@quickchatai/quickreview 1.1.0 → 1.3.1
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 +137 -57
- package/bin/qcr.js +1 -1
- package/config.example.toml +60 -14
- package/deploy/ansible/README.md +20 -12
- package/deploy/ansible/inventory.example.ini +1 -1
- package/deploy/collect-secrets.sh +1 -1
- package/deploy/qcr-update.sh +1 -1
- package/dist/extensions/cli/home.js +14 -0
- package/dist/extensions/cli/home.js.map +1 -0
- package/dist/extensions/cli/login-route.js +5 -0
- package/dist/extensions/cli/login-route.js.map +1 -0
- package/dist/extensions/cli/selfimprove.js +18 -0
- package/dist/extensions/cli/selfimprove.js.map +1 -0
- package/dist/extensions/home.js +350 -0
- package/dist/extensions/home.js.map +1 -0
- package/dist/extensions/login-route.js +180 -0
- package/dist/extensions/login-route.js.map +1 -0
- package/dist/extensions/onboarding.js +35 -11
- package/dist/extensions/onboarding.js.map +1 -1
- package/dist/extensions/selfimprove.js +49 -0
- package/dist/extensions/selfimprove.js.map +1 -0
- package/dist/extensions/setup-scan.js +23 -592
- package/dist/extensions/setup-scan.js.map +1 -1
- package/dist/extensions/setup.js +2 -47
- package/dist/extensions/setup.js.map +1 -1
- package/dist/src/archive/restore.js +18 -15
- package/dist/src/archive/restore.js.map +1 -1
- package/dist/src/botworker.js +13 -2
- package/dist/src/botworker.js.map +1 -1
- package/dist/src/channelfs.js +3 -26
- package/dist/src/channelfs.js.map +1 -1
- package/dist/src/ciretry.js +2 -1
- package/dist/src/ciretry.js.map +1 -1
- package/dist/src/cli.js +54 -61
- package/dist/src/cli.js.map +1 -1
- package/dist/src/cliops.js +3 -2
- package/dist/src/cliops.js.map +1 -1
- package/dist/src/commands.js +25 -2
- package/dist/src/commands.js.map +1 -1
- package/dist/src/config.js +32 -2
- package/dist/src/config.js.map +1 -1
- package/dist/src/daemon.js +50 -8
- package/dist/src/daemon.js.map +1 -1
- package/dist/src/debuground.js +1 -0
- package/dist/src/debuground.js.map +1 -1
- package/dist/src/debugroundpi.js +6 -1
- package/dist/src/debugroundpi.js.map +1 -1
- package/dist/src/dispatch.js +5 -4
- package/dist/src/dispatch.js.map +1 -1
- package/dist/src/engine/caserunner-sdk.js +15 -251
- package/dist/src/engine/caserunner-sdk.js.map +1 -1
- package/dist/src/engine/debugpi.js +17 -5
- package/dist/src/engine/debugpi.js.map +1 -1
- package/dist/src/execboundary.js +10 -3
- package/dist/src/execboundary.js.map +1 -1
- package/dist/src/github.js +51 -16
- package/dist/src/github.js.map +1 -1
- package/dist/src/githubtoken.js +346 -0
- package/dist/src/githubtoken.js.map +1 -0
- package/dist/src/home/adapters.js +349 -0
- package/dist/src/home/adapters.js.map +1 -0
- package/dist/src/home/advanced.js +318 -0
- package/dist/src/home/advanced.js.map +1 -0
- package/dist/src/home/advancedtools.js +57 -0
- package/dist/src/home/advancedtools.js.map +1 -0
- package/dist/src/home/autoreview.js +325 -0
- package/dist/src/home/autoreview.js.map +1 -0
- package/dist/src/home/daemonstatus.js +32 -0
- package/dist/src/home/daemonstatus.js.map +1 -0
- package/dist/src/home/devicelogin.js +61 -0
- package/dist/src/home/devicelogin.js.map +1 -0
- package/dist/src/home/launch.js +202 -0
- package/dist/src/home/launch.js.map +1 -0
- package/dist/src/home/loginowner.js +205 -0
- package/dist/src/home/loginowner.js.map +1 -0
- package/dist/src/home/memory.js +122 -0
- package/dist/src/home/memory.js.map +1 -0
- package/dist/src/home/model.js +811 -0
- package/dist/src/home/model.js.map +1 -0
- package/dist/src/home/panel.js +520 -0
- package/dist/src/home/panel.js.map +1 -0
- package/dist/src/home/ports.js +19 -0
- package/dist/src/home/ports.js.map +1 -0
- package/dist/src/home/review.js +163 -0
- package/dist/src/home/review.js.map +1 -0
- package/dist/src/home/reviews.js +478 -0
- package/dist/src/home/reviews.js.map +1 -0
- package/dist/src/home/setup.js +507 -0
- package/dist/src/home/setup.js.map +1 -0
- package/dist/src/home/setupadapters.js +214 -0
- package/dist/src/home/setupadapters.js.map +1 -0
- package/dist/src/home/setupcard.js +199 -0
- package/dist/src/home/setupcard.js.map +1 -0
- package/dist/src/home/setuptools.js +45 -0
- package/dist/src/home/setuptools.js.map +1 -0
- package/dist/src/home/spend.js +160 -0
- package/dist/src/home/spend.js.map +1 -0
- package/dist/src/home/switches.js +209 -0
- package/dist/src/home/switches.js.map +1 -0
- package/dist/src/home/switchon.js +439 -0
- package/dist/src/home/switchon.js.map +1 -0
- package/dist/src/home/toolnames.js +41 -0
- package/dist/src/home/toolnames.js.map +1 -0
- package/dist/src/home/tune.js +341 -0
- package/dist/src/home/tune.js.map +1 -0
- package/dist/src/home/words.js +7 -0
- package/dist/src/home/words.js.map +1 -0
- package/dist/src/homes.js +14 -0
- package/dist/src/homes.js.map +1 -1
- package/dist/src/install.js +1 -1
- package/dist/src/install.js.map +1 -1
- package/dist/src/ipc/workerspec.js +8 -0
- package/dist/src/ipc/workerspec.js.map +1 -1
- package/dist/src/live/publish.js +1 -1
- package/dist/src/live/publish.js.map +1 -1
- package/dist/src/livecli.js +1 -1
- package/dist/src/livecli.js.map +1 -1
- package/dist/src/loginhint.js +8 -3
- package/dist/src/loginhint.js.map +1 -1
- package/dist/src/loginrefresh/handover.js +121 -0
- package/dist/src/loginrefresh/handover.js.map +1 -0
- package/dist/src/loginrefresh/lock.js +54 -3
- package/dist/src/loginrefresh/lock.js.map +1 -1
- package/dist/src/loginrefresh/pirefresh.js +3 -0
- package/dist/src/loginrefresh/pirefresh.js.map +1 -1
- package/dist/src/loginrefresh/refresher.js +33 -5
- package/dist/src/loginrefresh/refresher.js.map +1 -1
- package/dist/src/loginrefresh/supervisor.js +18 -1
- package/dist/src/loginrefresh/supervisor.js.map +1 -1
- package/dist/src/npmverbs.js +46 -14
- package/dist/src/npmverbs.js.map +1 -1
- package/dist/src/onboard/choices.js +35 -4
- package/dist/src/onboard/choices.js.map +1 -1
- package/dist/src/onboard/engine.js +32 -306
- package/dist/src/onboard/engine.js.map +1 -1
- package/dist/src/onboard/pipin.js +22 -0
- package/dist/src/onboard/pipin.js.map +1 -0
- package/dist/src/onboard/stepforward.js +41 -0
- package/dist/src/onboard/stepforward.js.map +1 -0
- package/dist/src/onboard/steps/appslug.js +4 -1
- package/dist/src/onboard/steps/appslug.js.map +1 -1
- package/dist/src/onboard/steps/bringup.js +3 -2
- package/dist/src/onboard/steps/bringup.js.map +1 -1
- package/dist/src/onboard/steps/compose.js +29 -0
- package/dist/src/onboard/steps/compose.js.map +1 -1
- package/dist/src/onboard/steps/configfill.js +120 -39
- package/dist/src/onboard/steps/configfill.js.map +1 -1
- package/dist/src/onboard/steps/githubaccount.js +595 -0
- package/dist/src/onboard/steps/githubaccount.js.map +1 -0
- package/dist/src/onboard/steps/githubapp.js +65 -7
- package/dist/src/onboard/steps/githubapp.js.map +1 -1
- package/dist/src/onboard/steps/golive.js +2 -0
- package/dist/src/onboard/steps/golive.js.map +1 -1
- package/dist/src/onboard/steps/index.js +11 -0
- package/dist/src/onboard/steps/index.js.map +1 -1
- package/dist/src/onboard/steps/ingress.js +80 -25
- package/dist/src/onboard/steps/ingress.js.map +1 -1
- package/dist/src/onboard/steps/llmauth.js +103 -30
- package/dist/src/onboard/steps/llmauth.js.map +1 -1
- package/dist/src/onboard/steps/loginshare.js +213 -0
- package/dist/src/onboard/steps/loginshare.js.map +1 -0
- package/dist/src/onboard/steps/memory.js +67 -3
- package/dist/src/onboard/steps/memory.js.map +1 -1
- package/dist/src/onboard/steps/proof.js +366 -56
- package/dist/src/onboard/steps/proof.js.map +1 -1
- package/dist/src/onboard/steps/repoconfig.js +72 -0
- package/dist/src/onboard/steps/repoconfig.js.map +1 -0
- package/dist/src/onboard/steps/topology.js +6 -6
- package/dist/src/onboard/steps/topology.js.map +1 -1
- package/dist/src/onboard/steps/transport.js +9 -1
- package/dist/src/onboard/steps/transport.js.map +1 -1
- package/dist/src/onboard/steps/tuiroute.js +102 -0
- package/dist/src/onboard/steps/tuiroute.js.map +1 -0
- package/dist/src/onboard/steps/verifyup.js +17 -1
- package/dist/src/onboard/steps/verifyup.js.map +1 -1
- package/dist/src/onboard/toolnames.js +30 -0
- package/dist/src/onboard/toolnames.js.map +1 -0
- package/dist/src/operatorwrite.js +23 -0
- package/dist/src/operatorwrite.js.map +1 -0
- package/dist/src/outcomes.js +4 -1
- package/dist/src/outcomes.js.map +1 -1
- package/dist/src/piagentdir.js +13 -0
- package/dist/src/piagentdir.js.map +1 -0
- package/dist/src/pilogin.js +31 -16
- package/dist/src/pilogin.js.map +1 -1
- package/dist/src/posting-journal.js +2 -1
- package/dist/src/posting-journal.js.map +1 -1
- package/dist/src/posting-outbox.js +6 -2
- package/dist/src/posting-outbox.js.map +1 -1
- package/dist/src/posting-receipt.js +14 -1
- package/dist/src/posting-receipt.js.map +1 -1
- package/dist/src/qcrbin.js +16 -11
- package/dist/src/qcrbin.js.map +1 -1
- package/dist/src/recheck.js +2 -1
- package/dist/src/recheck.js.map +1 -1
- package/dist/src/reconcile.js +22 -2
- package/dist/src/reconcile.js.map +1 -1
- package/dist/src/reviewargs.js +122 -0
- package/dist/src/reviewargs.js.map +1 -0
- package/dist/src/reviewcli.js +7 -161
- package/dist/src/reviewcli.js.map +1 -1
- package/dist/src/reviewdetach.js +76 -11
- package/dist/src/reviewdetach.js.map +1 -1
- package/dist/src/reviewhistory.js +32 -6
- package/dist/src/reviewhistory.js.map +1 -1
- package/dist/src/reviewnow.js +45 -0
- package/dist/src/reviewnow.js.map +1 -0
- package/dist/src/scancli.js +39 -691
- package/dist/src/scancli.js.map +1 -1
- package/dist/src/selfimprove.js +66 -0
- package/dist/src/selfimprove.js.map +1 -0
- package/dist/src/setup/adapters.js +5 -223
- package/dist/src/setup/adapters.js.map +1 -1
- package/dist/src/setup/caserunner.js +15 -249
- package/dist/src/setup/caserunner.js.map +1 -1
- package/dist/src/setup/engine.js +32 -341
- package/dist/src/setup/engine.js.map +1 -1
- package/dist/src/setup/facts.js +3 -592
- package/dist/src/setup/facts.js.map +1 -1
- package/dist/src/setup/findings.js +3 -947
- package/dist/src/setup/findings.js.map +1 -1
- package/dist/src/setup/presentation.js +3 -8
- package/dist/src/setup/presentation.js.map +1 -1
- package/dist/src/setup/scan.js +3 -724
- package/dist/src/setup/scan.js.map +1 -1
- package/dist/src/setup/scanbuild.js +9 -92
- package/dist/src/setup/scanbuild.js.map +1 -1
- package/dist/src/setup/scanlease.js +3 -250
- package/dist/src/setup/scanlease.js.map +1 -1
- package/dist/src/setup/scanreport.js +3 -288
- package/dist/src/setup/scanreport.js.map +1 -1
- package/dist/src/setup/scanrun.js +3 -1732
- package/dist/src/setup/scanrun.js.map +1 -1
- package/dist/src/setup/scantree.js +3 -256
- package/dist/src/setup/scantree.js.map +1 -1
- package/dist/src/setup/stage2request.js +3 -258
- package/dist/src/setup/stage2request.js.map +1 -1
- package/dist/src/setup/unitcut.js +3 -436
- package/dist/src/setup/unitcut.js.map +1 -1
- package/dist/src/state.js +2 -0
- package/dist/src/state.js.map +1 -1
- package/dist/src/statemachine.js +4 -1
- package/dist/src/statemachine.js.map +1 -1
- package/dist/src/takeover.js +1 -0
- package/dist/src/takeover.js.map +1 -1
- package/dist/src/takeoverpi.js +15 -1
- package/dist/src/takeoverpi.js.map +1 -1
- package/dist/src/tui/app.js +49 -0
- package/dist/src/tui/app.js.map +1 -1
- package/dist/src/tui/board.js +88 -12
- package/dist/src/tui/board.js.map +1 -1
- package/dist/src/tui/cmdbar.js +4 -1
- package/dist/src/tui/cmdbar.js.map +1 -1
- package/dist/src/tui/companion.js +76 -11
- package/dist/src/tui/companion.js.map +1 -1
- package/dist/src/tui/connect.js +7 -1
- package/dist/src/tui/connect.js.map +1 -1
- package/dist/src/tui/deploytab.js +2 -2
- package/dist/src/tui/deploytab.js.map +1 -1
- package/dist/src/tui/filters.js +5 -2
- package/dist/src/tui/filters.js.map +1 -1
- package/dist/src/tui/ghread.js +61 -5
- package/dist/src/tui/ghread.js.map +1 -1
- package/dist/src/tui/index.js +109 -20
- package/dist/src/tui/index.js.map +1 -1
- package/dist/src/tui/memforward.js +39 -2
- package/dist/src/tui/memforward.js.map +1 -1
- package/dist/src/tui/memory.js +3 -16
- package/dist/src/tui/memory.js.map +1 -1
- package/dist/src/tui/memoryurl.js +22 -0
- package/dist/src/tui/memoryurl.js.map +1 -0
- package/dist/src/tui/model.js +25 -0
- package/dist/src/tui/model.js.map +1 -1
- package/dist/src/tui/prpause.js +7 -0
- package/dist/src/tui/prpause.js.map +1 -1
- package/dist/src/tui/remote.js +7 -1
- package/dist/src/tui/remote.js.map +1 -1
- package/dist/src/tui/route.js +143 -0
- package/dist/src/tui/route.js.map +1 -0
- package/dist/src/tui/setupstate.js +32 -0
- package/dist/src/tui/setupstate.js.map +1 -0
- package/dist/src/tui/sshtriage.js +102 -5
- package/dist/src/tui/sshtriage.js.map +1 -1
- package/dist/src/tui/theme.js +2 -1
- package/dist/src/tui/theme.js.map +1 -1
- package/dist/src/tui/unwedge.js +2 -12
- package/dist/src/tui/unwedge.js.map +1 -1
- package/dist/src/verdicts.js +17 -5
- package/dist/src/verdicts.js.map +1 -1
- package/dist/src/version.js +1 -1
- package/dist/src/version.js.map +1 -1
- package/dist/src/webhook.js +12 -1
- package/dist/src/webhook.js.map +1 -1
- package/dist/src/wire.js +34 -8
- package/dist/src/wire.js.map +1 -1
- package/dist/src/workerworld.js +11 -1
- package/dist/src/workerworld.js.map +1 -1
- package/docker/README.md +1 -1
- package/docker/companion/entrypoint.sh +20 -1
- package/docker/entrypoint.sh +57 -6
- package/docker-compose.yml +16 -1
- package/extensions/cli/home.ts +14 -0
- package/extensions/cli/login-route.ts +10 -0
- package/extensions/cli/selfimprove.ts +18 -0
- package/extensions/home.ts +484 -0
- package/extensions/login-route.ts +220 -0
- package/extensions/onboarding.ts +41 -11
- package/extensions/selfimprove.ts +65 -0
- package/extensions/setup-scan.ts +79 -766
- package/extensions/setup.ts +14 -70
- package/package.json +6 -4
- package/prompts/onboarding.md +58 -18
- package/schemas/review-result.v1.json +60 -0
- package/scripts/deploy.sh +8 -6
- package/scripts/mint-token.sh +25 -4
- package/scripts/qcr-host.sh +28 -5
- package/scripts/vm-ghcr-login.sh +7 -4
- package/src/archive/restore.ts +18 -21
- package/src/botworker.ts +13 -2
- package/src/channelfs.ts +3 -26
- package/src/ciretry.ts +2 -1
- package/src/cli.ts +59 -67
- package/src/cliops.ts +3 -2
- package/src/commands.ts +28 -2
- package/src/config.ts +62 -4
- package/src/daemon.ts +58 -7
- package/src/debuground.ts +3 -0
- package/src/debugroundpi.ts +12 -1
- package/src/dispatch.ts +5 -4
- package/src/engine/caserunner-sdk.ts +29 -314
- package/src/engine/debugpi.ts +17 -5
- package/src/execboundary.ts +16 -4
- package/src/github.ts +57 -15
- package/src/githubtoken.ts +435 -0
- package/src/home/adapters.ts +418 -0
- package/src/home/advanced.ts +421 -0
- package/src/home/advancedtools.ts +96 -0
- package/src/home/autoreview.ts +385 -0
- package/src/home/daemonstatus.ts +55 -0
- package/src/home/devicelogin.ts +107 -0
- package/src/home/launch.ts +257 -0
- package/src/home/loginowner.ts +248 -0
- package/src/home/memory.ts +176 -0
- package/src/home/model.ts +965 -0
- package/src/home/panel.ts +666 -0
- package/src/home/ports.ts +338 -0
- package/src/home/review.ts +206 -0
- package/src/home/reviews.ts +565 -0
- package/src/home/setup.ts +689 -0
- package/src/home/setupadapters.ts +217 -0
- package/src/home/setupcard.ts +288 -0
- package/src/home/setuptools.ts +57 -0
- package/src/home/spend.ts +194 -0
- package/src/home/switches.ts +293 -0
- package/src/home/switchon.ts +554 -0
- package/src/home/toolnames.ts +46 -0
- package/src/home/tune.ts +405 -0
- package/src/home/words.ts +8 -0
- package/src/homes.ts +17 -0
- package/src/install.ts +1 -1
- package/src/ipc/workerspec.ts +15 -0
- package/src/live/publish.ts +4 -1
- package/src/livecli.ts +6 -1
- package/src/loginhint.ts +8 -3
- package/src/loginrefresh/handover.ts +138 -0
- package/src/loginrefresh/lock.ts +72 -3
- package/src/loginrefresh/pirefresh.ts +3 -0
- package/src/loginrefresh/refresher.ts +34 -5
- package/src/loginrefresh/supervisor.ts +25 -2
- package/src/npmverbs.ts +55 -13
- package/src/onboard/choices.ts +40 -4
- package/src/onboard/engine.ts +70 -421
- package/src/onboard/pipin.ts +31 -0
- package/src/onboard/stepforward.ts +61 -0
- package/src/onboard/steps/appslug.ts +4 -1
- package/src/onboard/steps/bringup.ts +3 -2
- package/src/onboard/steps/compose.ts +31 -0
- package/src/onboard/steps/configfill.ts +144 -40
- package/src/onboard/steps/githubaccount.ts +716 -0
- package/src/onboard/steps/githubapp.ts +92 -8
- package/src/onboard/steps/golive.ts +2 -0
- package/src/onboard/steps/index.ts +11 -0
- package/src/onboard/steps/ingress.ts +98 -31
- package/src/onboard/steps/llmauth.ts +114 -29
- package/src/onboard/steps/loginshare.ts +258 -0
- package/src/onboard/steps/memory.ts +76 -3
- package/src/onboard/steps/proof.ts +473 -55
- package/src/onboard/steps/repoconfig.ts +85 -0
- package/src/onboard/steps/topology.ts +7 -6
- package/src/onboard/steps/transport.ts +15 -0
- package/src/onboard/steps/tuiroute.ts +119 -0
- package/src/onboard/steps/verifyup.ts +20 -1
- package/src/onboard/toolnames.ts +34 -0
- package/src/operatorwrite.ts +25 -0
- package/src/outcomes.ts +8 -1
- package/src/piagentdir.ts +12 -0
- package/src/pilogin.ts +32 -16
- package/src/posting-journal.ts +6 -1
- package/src/posting-outbox.ts +10 -2
- package/src/posting-receipt.ts +17 -0
- package/src/qcrbin.ts +16 -11
- package/src/recheck.ts +2 -1
- package/src/reconcile.ts +29 -3
- package/src/reviewargs.ts +119 -0
- package/src/reviewcli.ts +6 -167
- package/src/reviewdetach.ts +107 -13
- package/src/reviewhistory.ts +20 -6
- package/src/reviewnow.ts +76 -0
- package/src/scancli.ts +75 -826
- package/src/selfimprove.ts +90 -0
- package/src/setup/adapters.ts +13 -221
- package/src/setup/caserunner.ts +34 -273
- package/src/setup/engine.ts +62 -496
- package/src/setup/facts.ts +3 -627
- package/src/setup/findings.ts +3 -1264
- package/src/setup/presentation.ts +3 -18
- package/src/setup/scan.ts +3 -1033
- package/src/setup/scanbuild.ts +20 -114
- package/src/setup/scanlease.ts +3 -297
- package/src/setup/scanreport.ts +3 -373
- package/src/setup/scanrun.ts +3 -2268
- package/src/setup/scantree.ts +3 -259
- package/src/setup/stage2request.ts +3 -347
- package/src/setup/unitcut.ts +3 -523
- package/src/state.ts +43 -0
- package/src/statemachine.ts +4 -1
- package/src/takeover.ts +5 -0
- package/src/takeoverpi.ts +16 -2
- package/src/tui/app.ts +47 -0
- package/src/tui/board.ts +111 -14
- package/src/tui/cmdbar.ts +4 -1
- package/src/tui/companion.ts +80 -11
- package/src/tui/connect.ts +7 -1
- package/src/tui/deploytab.ts +2 -2
- package/src/tui/filters.ts +7 -3
- package/src/tui/ghread.ts +74 -7
- package/src/tui/index.ts +117 -23
- package/src/tui/memforward.ts +52 -3
- package/src/tui/memory.ts +3 -15
- package/src/tui/memoryurl.ts +22 -0
- package/src/tui/model.ts +36 -2
- package/src/tui/prpause.ts +11 -2
- package/src/tui/remote.ts +9 -1
- package/src/tui/route.ts +173 -0
- package/src/tui/setupstate.ts +56 -0
- package/src/tui/sshtriage.ts +127 -6
- package/src/tui/theme.ts +2 -1
- package/src/tui/unwedge.ts +2 -14
- package/src/verdicts.ts +23 -5
- package/src/version.ts +1 -1
- package/src/webhook.ts +10 -1
- package/src/wire.ts +42 -9
- package/src/workerworld.ts +16 -2
- package/dist/src/live/projection.js +0 -450
- package/dist/src/live/projection.js.map +0 -1
- package/dist/src/live/read.js +0 -60
- package/dist/src/live/read.js.map +0 -1
- package/dist/src/onboard/launch.js +0 -83
- package/dist/src/onboard/launch.js.map +0 -1
- package/dist/src/setup/ledger.js +0 -207
- package/dist/src/setup/ledger.js.map +0 -1
- package/dist/src/setup/stage2.js +0 -617
- package/dist/src/setup/stage2.js.map +0 -1
- package/dist/src/setup/textsniff.js +0 -89
- package/dist/src/setup/textsniff.js.map +0 -1
- package/src/live/projection.ts +0 -491
- package/src/live/read.ts +0 -86
- package/src/onboard/launch.ts +0 -115
- package/src/setup/ledger.ts +0 -324
- package/src/setup/stage2.ts +0 -926
- package/src/setup/textsniff.ts +0 -95
|
@@ -1,1733 +1,4 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
//
|
|
5
|
-
// Four promises, each structural:
|
|
6
|
-
// * NOTHING RUNS UNSEEN. `start` must quote the key `preview` handed out; a
|
|
7
|
-
// plan, sha, price or setting that moved since is refused, so the
|
|
8
|
-
// operator always saw the cases and the price of what runs.
|
|
9
|
-
// * THE FIRST SCAN CALIBRATES. With no measured price for this lens at
|
|
10
|
-
// these settings, a preview offers only the CALIBRATION run — one case
|
|
11
|
-
// from each of `[scan] calibration_units` units spread across the size
|
|
12
|
-
// range, stopped hard at `calibration_max_usd`. Its cases are real stage-1
|
|
13
|
-
// work (their findings and ledger rows count); what it adds is the price.
|
|
14
|
-
// Every later preview is priced from the ledger's actual costs.
|
|
15
|
-
// * HARD STOP AT THE ESTIMATE. The run's cap is the estimate the operator
|
|
16
|
-
// approved (the calibration cap, for a calibration run). A case starts
|
|
17
|
-
// only while what is spent, plus the estimates of the cases in flight,
|
|
18
|
-
// plus its own estimate stays within the cap; the moment spending reaches
|
|
19
|
-
// the cap — read LIVE, from the running cost the case runner reports as
|
|
20
|
-
// each case goes, not only when a case ends — every case still in flight
|
|
21
|
-
// is aborted and the run ends `capped`. A case that failed or was aborted
|
|
22
|
-
// counts what it spent (its estimate when the runner cannot say), so
|
|
23
|
-
// failures can never carry a run past the cap. What it did not read stays
|
|
24
|
-
// out of the ledger, so the next preview prices exactly the rest — and
|
|
25
|
-
// runs it first: stage-1 and delta cases are ordered coverage-first
|
|
26
|
-
// (scan.ts readTier: never read or cut short, then changed — stalest
|
|
27
|
-
// reading first — then the rest), so the stop never starves the same
|
|
28
|
-
// tail run after run. Stage-2 rows keep the proposal's order.
|
|
29
|
-
// * STOP AND RESUME BY THE LEDGER. `stop` aborts in-flight cases and starts
|
|
30
|
-
// no more. A run the companion was not there to finish (a TUI quit, an
|
|
31
|
-
// expired login) reads as `interrupted` — unless another process sharing
|
|
32
|
-
// the state home holds the driver lease for it (scanlease.ts: the
|
|
33
|
-
// companion, or a `qcr scan` driver), when it reads as running and no
|
|
34
|
-
// second run starts beside it. Either way, only FINISHED cases wrote
|
|
35
|
-
// ledger rows, so starting again reads only what is left.
|
|
36
|
-
//
|
|
37
|
-
// A delta re-scan is the same run over the files whose content
|
|
38
|
-
// changed since the last scan (scan.ts changedSince) AND every file no
|
|
39
|
-
// complete reading covers at its content — never read, cut short, unreadable
|
|
40
|
-
// before (the plan's `owed`, read unread-first): priced from the ledger like
|
|
41
|
-
// any stage-1 preview — so never before calibration — and
|
|
42
|
-
// stopped hard at its estimate. Deep passes never ride a delta; stage 2 runs
|
|
43
|
-
// only when asked. Every run, delta or not, ends by RECONCILING the findings
|
|
44
|
-
// store (findings.ts): exact matches were already recognised case by case;
|
|
45
|
-
// what a re-read file no longer reports is fixed, or handed to the companion
|
|
46
|
-
// as a match question when a fresh finding might be the same one.
|
|
47
|
-
//
|
|
48
|
-
// DEPTH (`[scan] depth`, profile over config.toml): a deep run builds the
|
|
49
|
-
// agents deep (scanbuild.ts — the deep suggest and dig addenda), lifts stage
|
|
50
|
-
// 1's suggestion cap to `SCAN_DEEP_MAX_SUGGESTIONS`, writes `depth: "deep"` on
|
|
51
|
-
// its ledger rows and reads coverage at its depth: a file the ledger holds
|
|
52
|
-
// only a standard reading of is read again by a deep run (ledger.ts). It is
|
|
53
|
-
// priced from readings at its depth, or — when none prices it — from readings
|
|
54
|
-
// at the other depth, and the preview says so (`rate_across_depth`, a row's
|
|
55
|
-
// `across_depth`). A stage-2 proposal records the depth it was made at — the
|
|
56
|
-
// propose call's `deep`, else `[scan] depth` — and a start re-plans and
|
|
57
|
-
// builds at that depth; a deep one adds the floor (stage2.ts). The depth is
|
|
58
|
-
// part of a deep plan's key, so a key never starts a run at another depth.
|
|
59
|
-
//
|
|
60
|
-
// Where a case runs: background pi subagents inside the companion (the case
|
|
61
|
-
// runner port), from the scan agent build — `pass-scan`, and the validator
|
|
62
|
-
// when `[scan] validator` is on, both built with the scan-mode anchor
|
|
63
|
-
// addendum. The tree they read is the reviewed checkout AT THE CHOSEN SHA,
|
|
64
|
-
// materialised read-only by the source port; the sha is recorded on every
|
|
65
|
-
// ledger row and finding.
|
|
66
|
-
//
|
|
67
|
-
// Stage 2 is the same run over a different plan: the companion
|
|
68
|
-
// PROPOSES rows (file set × deep lens × tier, cost — stage2.ts's rules),
|
|
69
|
-
// optionally fitted to a budget; the proposal is stored, and its key starts
|
|
70
|
-
// it — quoted by the companion on the operator's word, or by the TUI's click
|
|
71
|
-
// through the start channel (stage2request.ts). One case per row, the same
|
|
72
|
-
// hard stop at the approved estimate, the same ledger (keyed by the row's
|
|
73
|
-
// lens), the same findings store (filed under the unit owning each file).
|
|
74
|
-
//
|
|
75
|
-
// Persistence: `<state home>/scan/run.json` (the current run and a short
|
|
76
|
-
// history), `scan/suggestions.json` (stage 1's deep-lens suggestions, the
|
|
77
|
-
// input to stage 2), `scan/stage2.json` (the latest stage-2 proposal),
|
|
78
|
-
// beside the ledger and the findings store — and the TUI's stop request
|
|
79
|
-
// and its answer (`scan/stop.json`, `scan/stop-result.json` —
|
|
80
|
-
// scanreport.ts, which also owns the run record's name and parser).
|
|
81
|
-
import { posix } from "node:path";
|
|
82
|
-
import { VALIDATE_PASS } from "@quickchatai/pi-quickreview/src/include.js";
|
|
83
|
-
import { higherTier } from "@quickchatai/pi-quickreview/src/types.js";
|
|
84
|
-
import { FindingsStore, settled, } from "./findings.js";
|
|
85
|
-
import { casesFor, coveredBy, lastScanned, Ledger, measuredRate, newestRead, partialRanges, readings, } from "./ledger.js";
|
|
86
|
-
import { anchoredLines, calibrationCases, caseEstimate, changedSince, coverKey, evidenceQuotesAnchor, findingFingerprint, normalizeCode, parseScanAnswer, parseValidatorAnswer, planKey, planStage1, scanCasePrompt, scanValidatorPrompt, survivorSource, drainLanes, SCAN_DEEP_MAX_SUGGESTIONS, SCAN_PASS, } from "./scan.js";
|
|
87
|
-
import { planStage2, priceStage2Row, stage2Cases, stage2CasePrompt, stage2Leads, stage2Key, unitOwner, } from "./stage2.js";
|
|
88
|
-
import { keptRows, parseStage2File, parseStartRequest, parseStartResult, proposalDepth, STAGE2_FILE, STAGE2_RESULT_FILE, STAGE2_START_FILE, STAGE2_VERSION, } from "./stage2request.js";
|
|
89
|
-
import { LEASE_RENEW_MS } from "./scanlease.js";
|
|
90
|
-
import { parseRunFile, parseStopRequest, parseStopResult, RUN_FILE, RUN_VERSION, SCAN_STOP_FILE, SCAN_STOP_RESULT_FILE, } from "./scanreport.js";
|
|
91
|
-
// ---- did the lens read? ------------------------------------------------------------
|
|
92
|
-
/**
|
|
93
|
-
* Why a lens's answer is not a reading of its case, or null when it is. A
|
|
94
|
-
* lens can answer NONE without opening a file; such an answer must not
|
|
95
|
-
* record the files as read in the ledger, nor price the lens.
|
|
96
|
-
*
|
|
97
|
-
* `reads` is what the case runner saw the lens's file-read calls name
|
|
98
|
-
* (`CaseOutcome.reads`); absent means the runner cannot tell, and unknown is
|
|
99
|
-
* never judged as zero. A reading needs at least one focus file opened at
|
|
100
|
-
* its path under the tree root. The runner reports each read resolved to the
|
|
101
|
-
* file it opened, so a relative read counts exactly when it resolved inside
|
|
102
|
-
* the tree: the lens runs outside the tree (first-party cwd), and a relative
|
|
103
|
-
* `api/x.py` opened the app root's copy — another repository — which is no
|
|
104
|
-
* reading of the case. A read left relative (a runner that cannot resolve)
|
|
105
|
-
* is never credited, and a read of some other file (a CLAUDE.md, a caller)
|
|
106
|
-
* is context, not the case. Pure.
|
|
107
|
-
*/
|
|
108
|
-
export function unreadFocus(reads, focus, root) {
|
|
109
|
-
if (reads === undefined)
|
|
110
|
-
return null;
|
|
111
|
-
if (openedFocus(reads, focus, root).size > 0)
|
|
112
|
-
return null;
|
|
113
|
-
const n = focus.length;
|
|
114
|
-
return `the lens read none of its ${n} focus file${n === 1 ? "" : "s"} (${reads.length} file-read call${reads.length === 1 ? "" : "s"} in all)`;
|
|
115
|
-
}
|
|
116
|
-
/**
|
|
117
|
-
* The focus files a lens opened, by the same reading `unreadFocus` makes: a
|
|
118
|
-
* read resolved inside the tree root naming the file. Null when the runner
|
|
119
|
-
* cannot tell (`reads` absent). A focus file the lens never opened is no
|
|
120
|
-
* reading of THAT file, whatever it read of the others: it gets no ledger
|
|
121
|
-
* row, so it is read again and a reconcile never settles its findings fixed
|
|
122
|
-
* from a reading that never happened. Pure.
|
|
123
|
-
*/
|
|
124
|
-
export function openedFocus(reads, focus, root) {
|
|
125
|
-
if (reads === undefined)
|
|
126
|
-
return null;
|
|
127
|
-
const base = posix.normalize(`${root}/`);
|
|
128
|
-
const opened = new Set(reads
|
|
129
|
-
// pi strips one leading `@` from a path argument (its `stripAtPrefix`),
|
|
130
|
-
// so `@/tree/x.py` opened `/tree/x.py`.
|
|
131
|
-
.map((r) => (r.startsWith("@") ? r.slice(1) : r))
|
|
132
|
-
.filter((r) => r.startsWith("/"))
|
|
133
|
-
.map((r) => posix.normalize(r))
|
|
134
|
-
.filter((r) => r.startsWith(base))
|
|
135
|
-
.map((r) => r.slice(base.length)));
|
|
136
|
-
return new Set(focus.filter((f) => opened.has(posix.normalize(f))));
|
|
137
|
-
}
|
|
138
|
-
// ---- records -------------------------------------------------------------------
|
|
139
|
-
export { RUN_FILE, RUN_VERSION };
|
|
140
|
-
export const SUGGESTIONS_FILE = "scan/suggestions.json";
|
|
141
|
-
/** Under the state home: every case's event stream, one directory per run
|
|
142
|
-
* (`caserunner.ts` `transcriptFile`). */
|
|
143
|
-
export const SCAN_TRANSCRIPTS_DIR = "scan/transcripts";
|
|
144
|
-
/** Summaries of finished runs kept beside the current one. */
|
|
145
|
-
export const RUN_HISTORY = 20;
|
|
146
|
-
const EPS = 1e-9;
|
|
147
|
-
const clean = (err) => (err instanceof Error ? err.message : String(err));
|
|
148
|
-
/** The refusal while another process holds the driver lease. */
|
|
149
|
-
const heldElsewhere = (l) => l.run === null
|
|
150
|
-
? `a scan is starting in another process (pid ${l.pid} on ${l.host}) — wait for it, then check the status`
|
|
151
|
-
: `scan ${l.run} is running in another process (pid ${l.pid} on ${l.host}) — stop it there or wait for it`;
|
|
152
|
-
/** The validator's variant at the tier of the lens it judges — never raised:
|
|
153
|
-
* the scan validator is the skeptic, at the same tier. */
|
|
154
|
-
const validatorAgent = (tier) => `${VALIDATE_PASS}--${tier}`;
|
|
155
|
-
/** Why a candidate the skeptic's answer never mentioned is doubtful. */
|
|
156
|
-
export const UNMENTIONED_REASON = "the skeptic listed it neither as a survivor nor under DOUBTED — kept, not upheld";
|
|
157
|
-
/** The run record, read totally: an unreadable file is the error beside an
|
|
158
|
-
* empty record, never a throw (the portrait and the re-tune read it too). */
|
|
159
|
-
export function readRunFile(state) {
|
|
160
|
-
const empty = { version: RUN_VERSION, current: null, history: [] };
|
|
161
|
-
let text;
|
|
162
|
-
try {
|
|
163
|
-
text = state.read(RUN_FILE);
|
|
164
|
-
}
|
|
165
|
-
catch (err) {
|
|
166
|
-
return { file: empty, error: `${RUN_FILE} in ${state.location} cannot be read: ${clean(err)}` };
|
|
167
|
-
}
|
|
168
|
-
if (text === null)
|
|
169
|
-
return { file: empty, error: null };
|
|
170
|
-
try {
|
|
171
|
-
return { file: parseRunFile(text), error: null };
|
|
172
|
-
}
|
|
173
|
-
catch (err) {
|
|
174
|
-
return {
|
|
175
|
-
file: empty,
|
|
176
|
-
error: `${RUN_FILE} in ${state.location} is not a scan record this version reads (${clean(err)}) — nothing will overwrite it; move it aside to scan again`,
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
export function runSummary(run, state) {
|
|
181
|
-
return {
|
|
182
|
-
id: run.id,
|
|
183
|
-
mode: run.mode,
|
|
184
|
-
sha: run.sha,
|
|
185
|
-
state,
|
|
186
|
-
started_at: run.started_at,
|
|
187
|
-
finished_at: run.finished_at,
|
|
188
|
-
cases_done: run.cases.filter((c) => c.status === "done").length,
|
|
189
|
-
cases: run.cases.length,
|
|
190
|
-
findings: run.cases.reduce((n, c) => n + c.findings, 0),
|
|
191
|
-
spent_usd: run.spent_usd,
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
/**
|
|
195
|
-
* The scan runs that finished — complete, partial, stopped or capped —
|
|
196
|
-
* newest first (what a portrait refresh and a re-tune stand on). A
|
|
197
|
-
* run still `running` in the file is either live or was interrupted; neither
|
|
198
|
-
* is a scan whose findings are settled.
|
|
199
|
-
*/
|
|
200
|
-
export function finishedRuns(state) {
|
|
201
|
-
const { file, error } = readRunFile(state);
|
|
202
|
-
const out = [];
|
|
203
|
-
if (file.current && file.current.state !== "running")
|
|
204
|
-
out.push(runSummary(file.current, file.current.state));
|
|
205
|
-
for (const h of file.history) {
|
|
206
|
-
if (h.state !== "running" && h.state !== "interrupted" && !out.some((r) => r.id === h.id))
|
|
207
|
-
out.push(h);
|
|
208
|
-
}
|
|
209
|
-
out.sort((a, b) => (b.finished_at ?? b.started_at).localeCompare(a.finished_at ?? a.started_at));
|
|
210
|
-
return { runs: out, error };
|
|
211
|
-
}
|
|
212
|
-
export class ScanRunner {
|
|
213
|
-
ports;
|
|
214
|
-
active = null;
|
|
215
|
-
starting = false;
|
|
216
|
-
/** The commit a start still planning has opened: its tree is kept, like
|
|
217
|
-
* the active run's, from a preview or proposal planned meanwhile. */
|
|
218
|
-
startingSha = null;
|
|
219
|
-
consuming = false;
|
|
220
|
-
/** Renews the driver lease from a start's claim to its run's end. */
|
|
221
|
-
renewTimer = null;
|
|
222
|
-
/** The run the lease is renewed for (null while a start plans). */
|
|
223
|
-
leaseRun = null;
|
|
224
|
-
/** Runs whose driver lease was lost: stopped, and never written again —
|
|
225
|
-
* the run record is the new holder's. */
|
|
226
|
-
fenced = new Set();
|
|
227
|
-
ledger;
|
|
228
|
-
findings;
|
|
229
|
-
constructor(ports) {
|
|
230
|
-
this.ports = ports;
|
|
231
|
-
this.ledger = new Ledger(ports.state);
|
|
232
|
-
this.findings = new FindingsStore(ports.state);
|
|
233
|
-
}
|
|
234
|
-
// -- the run file --
|
|
235
|
-
load() {
|
|
236
|
-
return readRunFile(this.ports.state);
|
|
237
|
-
}
|
|
238
|
-
store(file) {
|
|
239
|
-
// A run that lost the lease owns the record no more.
|
|
240
|
-
if (file.current !== null && this.fenced.has(file.current.id))
|
|
241
|
-
return;
|
|
242
|
-
this.ports.state.write(RUN_FILE, `${JSON.stringify(file, null, 1)}\n`);
|
|
243
|
-
}
|
|
244
|
-
summary(run, state) {
|
|
245
|
-
return runSummary(run, state);
|
|
246
|
-
}
|
|
247
|
-
// -- the plan --
|
|
248
|
-
/** What every plan reads first: the settings, the saved cut, the commit
|
|
249
|
-
* `ref` names (default HEAD), its tree and the ledger. Opening a tree
|
|
250
|
-
* prunes the others, never the active run's nor a planning start's;
|
|
251
|
-
* `claim` (a start planning) marks this one as the starting start's. */
|
|
252
|
-
prerequisites(ref, claim) {
|
|
253
|
-
const other = this.ports.lease?.other();
|
|
254
|
-
if (other)
|
|
255
|
-
return { ok: false, errors: [heldElsewhere(other)] };
|
|
256
|
-
const s = this.ports.settings();
|
|
257
|
-
if (!s.ok)
|
|
258
|
-
return s;
|
|
259
|
-
const cut = this.ports.savedCut();
|
|
260
|
-
if (!cut.ok)
|
|
261
|
-
return cut;
|
|
262
|
-
const wanted = (ref ?? "").trim() || "HEAD";
|
|
263
|
-
const sha = this.ports.source.resolve(wanted);
|
|
264
|
-
if (sha === null)
|
|
265
|
-
return {
|
|
266
|
-
ok: false,
|
|
267
|
-
errors: [`${JSON.stringify(wanted)} names no commit in ${this.ports.source.location}`],
|
|
268
|
-
};
|
|
269
|
-
if (claim)
|
|
270
|
-
this.startingSha = sha;
|
|
271
|
-
const keep = [this.active?.sha, this.startingSha].filter((k) => k !== undefined && k !== null);
|
|
272
|
-
const opened = this.ports.source.open(sha, keep);
|
|
273
|
-
if (!opened.ok)
|
|
274
|
-
return {
|
|
275
|
-
ok: false,
|
|
276
|
-
errors: [`cannot read the tree at ${sha.slice(0, 12)}: ${opened.reason}`],
|
|
277
|
-
};
|
|
278
|
-
const { entries, error } = this.ledger.load();
|
|
279
|
-
if (error !== null)
|
|
280
|
-
return { ok: false, errors: [error] };
|
|
281
|
-
return { ok: true, settings: s.settings, cut: cut.cut, sha, tree: opened.tree, entries };
|
|
282
|
-
}
|
|
283
|
-
async plan(ref, delta = false, claim = false) {
|
|
284
|
-
const pre = this.prerequisites(ref, claim);
|
|
285
|
-
if (!pre.ok)
|
|
286
|
-
return pre;
|
|
287
|
-
const { sha, tree, entries, settings } = pre;
|
|
288
|
-
const cfg = settings.scan;
|
|
289
|
-
const depth = cfg.depth;
|
|
290
|
-
// A delta compares against the last scan: each file's newest content the
|
|
291
|
-
// ledger read, and the tree that scan read (listed, not materialised).
|
|
292
|
-
let basis = null;
|
|
293
|
-
if (delta) {
|
|
294
|
-
const last = lastScanned(entries, SCAN_PASS);
|
|
295
|
-
if (last === null)
|
|
296
|
-
return {
|
|
297
|
-
ok: false,
|
|
298
|
-
errors: ["no stage-1 scan to re-scan against — preview and run a full scan first"],
|
|
299
|
-
};
|
|
300
|
-
const listed = this.ports.source.list(last.sha);
|
|
301
|
-
if (listed === null) {
|
|
302
|
-
return {
|
|
303
|
-
ok: false,
|
|
304
|
-
errors: [
|
|
305
|
-
`cannot list the tree the last scan read (${last.sha.slice(0, 12)}) — a delta cannot tell which files are new`,
|
|
306
|
-
],
|
|
307
|
-
};
|
|
308
|
-
}
|
|
309
|
-
basis = {
|
|
310
|
-
since: last.sha,
|
|
311
|
-
read: newestRead(entries, SCAN_PASS),
|
|
312
|
-
baseline: new Map(listed.map((f) => [f.path, f.blob])),
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
|
-
const plan = planStage1({
|
|
316
|
-
cut: pre.cut,
|
|
317
|
-
files: tree.files,
|
|
318
|
-
linesOf: (p) => tree.lines(p),
|
|
319
|
-
whyUnreadable: (p) => tree.whyUnreadable?.(p) ?? null,
|
|
320
|
-
// Coverage at the run's depth: a deep run reads again what only a
|
|
321
|
-
// standard reading covers.
|
|
322
|
-
covered: coveredBy(entries, SCAN_PASS, depth),
|
|
323
|
-
partial: partialRanges(entries, SCAN_PASS, depth),
|
|
324
|
-
// Coverage first: never-read and cut-short files run before changed
|
|
325
|
-
// ones, so the hard stop never starves the same tail run after run.
|
|
326
|
-
history: new Map([...readings(entries, SCAN_PASS, depth)].map(([path, r]) => [
|
|
327
|
-
path,
|
|
328
|
-
{ blob: r.blob, complete: r.complete, at: r.newest.at },
|
|
329
|
-
])),
|
|
330
|
-
textOf: (p) => tree.read(p),
|
|
331
|
-
exclude: cfg.exclude,
|
|
332
|
-
budget: { maxFiles: cfg.case_max_files, maxLines: cfg.case_max_lines },
|
|
333
|
-
...(basis ? { delta: basis } : {}),
|
|
334
|
-
});
|
|
335
|
-
const key = {
|
|
336
|
-
lens: SCAN_PASS,
|
|
337
|
-
tier: cfg.tier,
|
|
338
|
-
effort: cfg.effort,
|
|
339
|
-
validator: cfg.validator,
|
|
340
|
-
};
|
|
341
|
-
// Priced at the run's depth; from readings at the other depth only when
|
|
342
|
-
// none at this one is priced yet (and the preview says so).
|
|
343
|
-
const own = measuredRate(entries, { ...key, depth });
|
|
344
|
-
const rate = own ?? measuredRate(entries, key);
|
|
345
|
-
const tally = casesFor(entries, key);
|
|
346
|
-
const measured = rate?.cases ?? 0;
|
|
347
|
-
const blockers = [];
|
|
348
|
-
// Calibrated once `calibration_units` priced cases stand behind the rate —
|
|
349
|
-
// or every case there is, on a repo smaller than that.
|
|
350
|
-
const calibrated = rate !== null && measured >= Math.min(cfg.calibration_units, measured + plan.cases.length);
|
|
351
|
-
let mode;
|
|
352
|
-
let cases;
|
|
353
|
-
let cap;
|
|
354
|
-
let estimate;
|
|
355
|
-
if (delta) {
|
|
356
|
-
// Priced like stage 1 — a delta never calibrates, and never guesses.
|
|
357
|
-
mode = "delta";
|
|
358
|
-
if (rate === null) {
|
|
359
|
-
blockers.push("no measured price for stage 1 at these settings — run the calibration (an ordinary preview) before a delta re-scan");
|
|
360
|
-
}
|
|
361
|
-
cases = plan.cases.map((c) => ({
|
|
362
|
-
...c,
|
|
363
|
-
estimate_usd: rate === null ? null : caseEstimate(c.lines, rate),
|
|
364
|
-
}));
|
|
365
|
-
estimate = rate === null ? null : cases.reduce((n, c) => n + (c.estimate_usd ?? 0), 0);
|
|
366
|
-
cap = estimate ?? 0;
|
|
367
|
-
}
|
|
368
|
-
else if (calibrated) {
|
|
369
|
-
mode = "stage1";
|
|
370
|
-
cases = plan.cases.map((c) => ({ ...c, estimate_usd: caseEstimate(c.lines, rate) }));
|
|
371
|
-
estimate = cases.reduce((n, c) => n + (c.estimate_usd ?? 0), 0);
|
|
372
|
-
cap = estimate;
|
|
373
|
-
}
|
|
374
|
-
else {
|
|
375
|
-
mode = "calibration";
|
|
376
|
-
if (tally.cases > 0 && tally.priced === 0) {
|
|
377
|
-
blockers.push(`${tally.cases} stage-1 case(s) finished at these settings but the case runner priced none of them — ` +
|
|
378
|
-
"a scan cannot be estimated, so none is started (check that the companion's pi reports usage cost)");
|
|
379
|
-
}
|
|
380
|
-
cases = calibrationCases(plan.cases, Math.max(1, cfg.calibration_units - measured)).map((c) => ({ ...c, estimate_usd: null }));
|
|
381
|
-
estimate = null;
|
|
382
|
-
cap = cfg.calibration_max_usd;
|
|
383
|
-
}
|
|
384
|
-
// Nothing to read, but the findings still owe a reconcile — a reported
|
|
385
|
-
// file deleted since, or a run that ended before its own reconcile: the
|
|
386
|
-
// run reads nothing and only settles them, so emptiness is no blocker.
|
|
387
|
-
const reconcileOnly = cases.length === 0 ? this.reconcileOwed(tree) : null;
|
|
388
|
-
if (cases.length === 0 && reconcileOnly === null) {
|
|
389
|
-
blockers.push(delta
|
|
390
|
-
? `nothing changed to read at ${sha.slice(0, 12)} since the last scan (${basis.since.slice(0, 12)}), and the ledger covers every other file at its content`
|
|
391
|
-
: plan.covered > 0
|
|
392
|
-
? `nothing left to read at ${sha.slice(0, 12)}: the ledger already covers every stage-1 file at this content`
|
|
393
|
-
: "nothing to read: no unit of the saved cut owns a readable, non-excluded file");
|
|
394
|
-
}
|
|
395
|
-
// A deep delta owes an unchanged file a standard reading already covers
|
|
396
|
-
// only because no deep one does: counted apart, so the preview says so.
|
|
397
|
-
const owedShallow = (b) => {
|
|
398
|
-
if (depth !== "deep")
|
|
399
|
-
return {};
|
|
400
|
-
const standard = coveredBy(entries, SCAN_PASS, "standard");
|
|
401
|
-
const seen = new Set();
|
|
402
|
-
for (const c of plan.cases)
|
|
403
|
-
for (const f of c.files)
|
|
404
|
-
if (!changedSince(f, b) && standard.has(coverKey(f.path, f.blob)))
|
|
405
|
-
seen.add(f.path);
|
|
406
|
-
return { owed_shallow: seen.size };
|
|
407
|
-
};
|
|
408
|
-
const runSettings = {
|
|
409
|
-
tier: cfg.tier,
|
|
410
|
-
effort: cfg.effort,
|
|
411
|
-
validator: cfg.validator,
|
|
412
|
-
validate_tier: cfg.validator ? cfg.tier : null,
|
|
413
|
-
...(depth === "deep" ? { depth } : {}),
|
|
414
|
-
};
|
|
415
|
-
const preview = {
|
|
416
|
-
key: planKey({
|
|
417
|
-
mode,
|
|
418
|
-
sha,
|
|
419
|
-
cases: cases.map((c) => [
|
|
420
|
-
c.id,
|
|
421
|
-
c.files.map((f) => `${f.path}@${f.blob}${f.range ? `:${f.range.start}-${f.range.end}` : ""}`),
|
|
422
|
-
]),
|
|
423
|
-
settings: runSettings,
|
|
424
|
-
cap: Math.round(cap * 1e6),
|
|
425
|
-
}),
|
|
426
|
-
mode,
|
|
427
|
-
sha,
|
|
428
|
-
cases,
|
|
429
|
-
files: cases.reduce((n, c) => n + c.files.length, 0),
|
|
430
|
-
lines: cases.reduce((n, c) => n + c.lines, 0),
|
|
431
|
-
estimate_usd: estimate,
|
|
432
|
-
cap_usd: cap,
|
|
433
|
-
rate,
|
|
434
|
-
depth,
|
|
435
|
-
rate_across_depth: own === null && rate !== null,
|
|
436
|
-
calibration: { measured, wanted: cfg.calibration_units },
|
|
437
|
-
remaining_cases: mode === "calibration" ? plan.cases.length - cases.length : 0,
|
|
438
|
-
excluded: plan.excluded,
|
|
439
|
-
covered: plan.covered,
|
|
440
|
-
unassigned: plan.unassigned,
|
|
441
|
-
unreadable: plan.unreadable,
|
|
442
|
-
skipped: plan.skipped,
|
|
443
|
-
settings: { ...runSettings, exclude: cfg.exclude, concurrency: cfg.concurrency },
|
|
444
|
-
delta: basis ? { since: basis.since, owed: plan.owed, ...owedShallow(basis) } : null,
|
|
445
|
-
...(reconcileOnly !== null ? { reconcile_only: reconcileOnly } : {}),
|
|
446
|
-
blockers,
|
|
447
|
-
};
|
|
448
|
-
return { ok: true, planned: { preview, tree, settings } };
|
|
449
|
-
}
|
|
450
|
-
/** Why a run that reads nothing must still run, or null: an open finding
|
|
451
|
-
* on a file the tree no longer holds (outside the pending match question,
|
|
452
|
-
* which waits for the companion's answer), or a last run that ended before
|
|
453
|
-
* its own reconcile (interrupted, or its reconcile failed). */
|
|
454
|
-
reconcileOwed(tree) {
|
|
455
|
-
const last = this.load().file.current;
|
|
456
|
-
if (last !== null && last.id !== this.active?.id && last.matching == null)
|
|
457
|
-
return `the last run (${last.id}) ended before its findings were reconciled`;
|
|
458
|
-
const { findings, error } = this.findings.load();
|
|
459
|
-
if (error !== null)
|
|
460
|
-
return null;
|
|
461
|
-
const asked = new Set((this.findings.question().question?.unreported ?? []).map((f) => f.fingerprint));
|
|
462
|
-
const paths = new Set(tree.files.map((f) => f.path));
|
|
463
|
-
const gone = findings.filter((f) => !settled(f) && !asked.has(f.fingerprint) && !paths.has(f.path)).length;
|
|
464
|
-
return gone > 0 ? `${gone} open finding(s) on file(s) no longer in the tree` : null;
|
|
465
|
-
}
|
|
466
|
-
// -- the four verbs --
|
|
467
|
-
/** What a scan would read and cost now at `sha` (default the checkout's
|
|
468
|
-
* HEAD), and the key a start must quote. */
|
|
469
|
-
async preview(args = {}) {
|
|
470
|
-
const p = await this.plan(args.sha, args.delta === true);
|
|
471
|
-
return p.ok ? { ok: true, preview: p.planned.preview } : p;
|
|
472
|
-
}
|
|
473
|
-
/** Start the previewed run in the background. Returns once it is recorded. */
|
|
474
|
-
async start(args) {
|
|
475
|
-
const busy = this.refuseBusy();
|
|
476
|
-
if (busy !== null)
|
|
477
|
-
return busy;
|
|
478
|
-
if (typeof args.key !== "string" || args.key === "") {
|
|
479
|
-
return {
|
|
480
|
-
ok: false,
|
|
481
|
-
errors: [
|
|
482
|
-
"no preview key — preview the scan, show the operator the cost, then start with its key",
|
|
483
|
-
],
|
|
484
|
-
};
|
|
485
|
-
}
|
|
486
|
-
const key = args.key;
|
|
487
|
-
return this.holdingStart(() => this.startPlanned(key, args.sha, args.delta === true));
|
|
488
|
-
}
|
|
489
|
-
/** Why no start may begin now: a run is active, or another start is still
|
|
490
|
-
* planning. Two starts in parallel (a model may issue tool calls
|
|
491
|
-
* concurrently) must not both pass the active check while the first is
|
|
492
|
-
* still planning: that would run — and pay for — the same scan twice. */
|
|
493
|
-
refuseBusy() {
|
|
494
|
-
if (this.active)
|
|
495
|
-
return {
|
|
496
|
-
ok: false,
|
|
497
|
-
errors: [`scan ${this.active.id} is still running — stop it or wait for it`],
|
|
498
|
-
};
|
|
499
|
-
if (this.starting)
|
|
500
|
-
return {
|
|
501
|
-
ok: false,
|
|
502
|
-
errors: ["a scan is already starting — wait for it, then check scan_status"],
|
|
503
|
-
};
|
|
504
|
-
return null;
|
|
505
|
-
}
|
|
506
|
-
/** Hold the starting slot across a start's planning and launch. */
|
|
507
|
-
async holdingStart(body) {
|
|
508
|
-
// The lease is claimed before planning, so of two processes starting at
|
|
509
|
-
// once only one plans and runs.
|
|
510
|
-
const lease = this.ports.lease;
|
|
511
|
-
if (lease && !lease.claim(null)) {
|
|
512
|
-
const other = lease.other();
|
|
513
|
-
return {
|
|
514
|
-
ok: false,
|
|
515
|
-
errors: [
|
|
516
|
-
other
|
|
517
|
-
? heldElsewhere(other)
|
|
518
|
-
: `the scan driver lease could not be claimed${lease.lastError ? `: ${lease.lastError}` : ""}`,
|
|
519
|
-
],
|
|
520
|
-
};
|
|
521
|
-
}
|
|
522
|
-
this.starting = true;
|
|
523
|
-
if (lease)
|
|
524
|
-
this.renewLease(null);
|
|
525
|
-
let started = false;
|
|
526
|
-
try {
|
|
527
|
-
const r = await body();
|
|
528
|
-
started = r.ok;
|
|
529
|
-
return r;
|
|
530
|
-
}
|
|
531
|
-
finally {
|
|
532
|
-
this.starting = false;
|
|
533
|
-
this.startingSha = null;
|
|
534
|
-
if (!started && !this.active) {
|
|
535
|
-
this.stopRenewing();
|
|
536
|
-
lease?.release();
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
/** Still holding the lease a start claimed, right before it launches? A
|
|
541
|
-
* planning that blocked past the lease's life may have lost it. */
|
|
542
|
-
leaseLost() {
|
|
543
|
-
const lease = this.ports.lease;
|
|
544
|
-
if (!lease || lease.renew(null))
|
|
545
|
-
return null;
|
|
546
|
-
const other = lease.other();
|
|
547
|
-
return {
|
|
548
|
-
ok: false,
|
|
549
|
-
errors: [
|
|
550
|
-
`the scan driver lease was lost while the start planned${other ? ` (${heldElsewhere(other)})` : ""} — preview again`,
|
|
551
|
-
],
|
|
552
|
-
};
|
|
553
|
-
}
|
|
554
|
-
/** Renew the lease for `run` every LEASE_RENEW_MS until stopRenewing. */
|
|
555
|
-
renewLease(run) {
|
|
556
|
-
this.leaseRun = run;
|
|
557
|
-
if (this.renewTimer)
|
|
558
|
-
return;
|
|
559
|
-
const timer = setInterval(() => this.renewTick(), LEASE_RENEW_MS);
|
|
560
|
-
timer.unref?.();
|
|
561
|
-
this.renewTimer = timer;
|
|
562
|
-
}
|
|
563
|
-
stopRenewing() {
|
|
564
|
-
if (this.renewTimer)
|
|
565
|
-
clearInterval(this.renewTimer);
|
|
566
|
-
this.renewTimer = null;
|
|
567
|
-
this.leaseRun = null;
|
|
568
|
-
}
|
|
569
|
-
/** One renewal. A lease found lost (this process was suspended past its
|
|
570
|
-
* life, and another took it over) stops the run it was held for: its
|
|
571
|
-
* in-flight cases are aborted and it writes the run record no more. */
|
|
572
|
-
renewTick() {
|
|
573
|
-
const lease = this.ports.lease;
|
|
574
|
-
if (!lease || lease.renew(this.leaseRun))
|
|
575
|
-
return;
|
|
576
|
-
const other = lease.other();
|
|
577
|
-
const why = `the scan driver lease was lost${other ? ` — ${heldElsewhere(other)}` : ""}`;
|
|
578
|
-
const active = this.active;
|
|
579
|
-
this.stopRenewing();
|
|
580
|
-
if (active && !this.fenced.has(active.id)) {
|
|
581
|
-
this.ports.log?.(`setup: ${why}; stopping scan ${active.id}, which no longer owns the run record`);
|
|
582
|
-
this.fenced.add(active.id);
|
|
583
|
-
active.abort.abort();
|
|
584
|
-
}
|
|
585
|
-
else
|
|
586
|
-
this.ports.log?.(`setup: ${why}`);
|
|
587
|
-
}
|
|
588
|
-
async startPlanned(key, sha, delta) {
|
|
589
|
-
const args = { key, sha };
|
|
590
|
-
const p = await this.plan(args.sha, delta, true);
|
|
591
|
-
if (!p.ok)
|
|
592
|
-
return p;
|
|
593
|
-
const { preview, tree, settings } = p.planned;
|
|
594
|
-
if (args.key !== preview.key) {
|
|
595
|
-
return {
|
|
596
|
-
ok: false,
|
|
597
|
-
errors: [
|
|
598
|
-
`the plan changed since that preview (key ${args.key}, now ${preview.key}) — preview again and show the operator the new cost`,
|
|
599
|
-
],
|
|
600
|
-
};
|
|
601
|
-
}
|
|
602
|
-
if (preview.blockers.length > 0)
|
|
603
|
-
return { ok: false, errors: preview.blockers };
|
|
604
|
-
const { file, error } = this.load();
|
|
605
|
-
if (error !== null)
|
|
606
|
-
return { ok: false, errors: [error] };
|
|
607
|
-
const built = this.ports.buildAgents(settings);
|
|
608
|
-
if (!built.ok)
|
|
609
|
-
return {
|
|
610
|
-
ok: false,
|
|
611
|
-
errors: built.errors.map((e) => `the scan agents could not be built: ${e}`),
|
|
612
|
-
};
|
|
613
|
-
const cfg = settings.scan;
|
|
614
|
-
const jobs = preview.cases.map((c) => ({
|
|
615
|
-
plan: c,
|
|
616
|
-
lens: SCAN_PASS,
|
|
617
|
-
tier: cfg.tier,
|
|
618
|
-
effort: cfg.effort,
|
|
619
|
-
prompt: (ctx) => scanCasePrompt(c, ctx),
|
|
620
|
-
label: `stage 1, unit ${c.unit}`,
|
|
621
|
-
unitOf: () => c.unit,
|
|
622
|
-
suggestions: true,
|
|
623
|
-
}));
|
|
624
|
-
const lost = this.leaseLost();
|
|
625
|
-
if (lost !== null)
|
|
626
|
-
return lost;
|
|
627
|
-
const run = this.launch(file, {
|
|
628
|
-
mode: preview.mode,
|
|
629
|
-
sha: preview.sha,
|
|
630
|
-
plan_key: preview.key,
|
|
631
|
-
cap_usd: preview.cap_usd,
|
|
632
|
-
estimate_usd: preview.estimate_usd,
|
|
633
|
-
settings: {
|
|
634
|
-
tier: preview.settings.tier,
|
|
635
|
-
effort: preview.settings.effort,
|
|
636
|
-
validator: preview.settings.validator,
|
|
637
|
-
validate_tier: preview.settings.validate_tier,
|
|
638
|
-
...(preview.settings.depth ? { depth: preview.settings.depth } : {}),
|
|
639
|
-
},
|
|
640
|
-
}, jobs, tree, settings);
|
|
641
|
-
return { ok: true, run };
|
|
642
|
-
}
|
|
643
|
-
/** Record a new run as current and drive its cases in the background. */
|
|
644
|
-
launch(file, head, jobs, tree, settings, onAccepted) {
|
|
645
|
-
const now = this.ports.clock();
|
|
646
|
-
const run = {
|
|
647
|
-
id: `scan-${now
|
|
648
|
-
.toISOString()
|
|
649
|
-
.replace(/[-:]/g, "")
|
|
650
|
-
.replace(/\.\d+Z$/, "Z")}`,
|
|
651
|
-
...head,
|
|
652
|
-
started_at: now.toISOString(),
|
|
653
|
-
finished_at: null,
|
|
654
|
-
state: "running",
|
|
655
|
-
spent_usd: 0,
|
|
656
|
-
unpriced_cases: 0,
|
|
657
|
-
cases: jobs.map(({ plan: c, ...job }) => ({
|
|
658
|
-
id: c.id,
|
|
659
|
-
unit: c.unit,
|
|
660
|
-
part: c.part,
|
|
661
|
-
parts: c.parts,
|
|
662
|
-
files: c.files.map((f) => f.path),
|
|
663
|
-
...(c.files.some((f) => f.range)
|
|
664
|
-
? { ranges: c.files.filter((f) => f.range).map((f) => ({ path: f.path, ...f.range })) }
|
|
665
|
-
: {}),
|
|
666
|
-
lines: c.lines,
|
|
667
|
-
estimate_usd: c.estimate_usd,
|
|
668
|
-
status: "pending",
|
|
669
|
-
cost_usd: null,
|
|
670
|
-
findings: 0,
|
|
671
|
-
doubted: 0,
|
|
672
|
-
dropped: 0,
|
|
673
|
-
suggestions: [],
|
|
674
|
-
error: null,
|
|
675
|
-
started_at: null,
|
|
676
|
-
finished_at: null,
|
|
677
|
-
...(head.mode === "stage2"
|
|
678
|
-
? { lens: job.lens, tier: job.tier, reason: job.reason, units: job.units }
|
|
679
|
-
: {}),
|
|
680
|
-
})),
|
|
681
|
-
problems: [],
|
|
682
|
-
matching: null,
|
|
683
|
-
};
|
|
684
|
-
// A run left `running` by a companion that is gone was interrupted.
|
|
685
|
-
if (file.current !== null) {
|
|
686
|
-
const prev = file.current;
|
|
687
|
-
file.history = [
|
|
688
|
-
this.summary(prev, prev.state === "running" ? "interrupted" : prev.state),
|
|
689
|
-
...file.history,
|
|
690
|
-
].slice(0, RUN_HISTORY);
|
|
691
|
-
}
|
|
692
|
-
file.current = run;
|
|
693
|
-
this.store(file);
|
|
694
|
-
try {
|
|
695
|
-
onAccepted?.(run);
|
|
696
|
-
}
|
|
697
|
-
catch (err) {
|
|
698
|
-
const reason = `the scan was not launched: its acceptance checkpoint failed (${clean(err)})`;
|
|
699
|
-
run.problems.push(reason);
|
|
700
|
-
const at = this.ports.clock().toISOString();
|
|
701
|
-
for (const c of run.cases) {
|
|
702
|
-
c.status = "stopped";
|
|
703
|
-
c.error = reason;
|
|
704
|
-
c.finished_at = at;
|
|
705
|
-
}
|
|
706
|
-
this.settle(file, run, "partial");
|
|
707
|
-
throw err;
|
|
708
|
-
}
|
|
709
|
-
const abort = new AbortController();
|
|
710
|
-
// Publish the handle before a case can synchronously request stop().
|
|
711
|
-
const done = Promise.resolve()
|
|
712
|
-
.then(() => this.drive(file, run, jobs, tree, settings, abort))
|
|
713
|
-
.catch((err) => {
|
|
714
|
-
// Belt: drive() records its own failures; this catches a bug in it.
|
|
715
|
-
run.problems.push(`the scan stopped on an internal error: ${clean(err)}`);
|
|
716
|
-
// Cases the failed drive never reached are over with the run.
|
|
717
|
-
const at = this.ports.clock().toISOString();
|
|
718
|
-
for (const c of run.cases) {
|
|
719
|
-
if (c.status !== "pending" && c.status !== "running")
|
|
720
|
-
continue;
|
|
721
|
-
c.status = "stopped";
|
|
722
|
-
c.finished_at = at;
|
|
723
|
-
}
|
|
724
|
-
this.settle(file, run, "partial");
|
|
725
|
-
this.ports.log?.(`setup: scan ${run.id} failed: ${clean(err)}`);
|
|
726
|
-
})
|
|
727
|
-
.finally(() => {
|
|
728
|
-
if (this.active?.id === run.id)
|
|
729
|
-
this.active = null;
|
|
730
|
-
});
|
|
731
|
-
this.active = { id: run.id, sha: run.sha, abort, done };
|
|
732
|
-
this.holdLease(run.id);
|
|
733
|
-
return run;
|
|
734
|
-
}
|
|
735
|
-
/** Hold the driver lease for `run` while it is driven: renewed on the
|
|
736
|
-
* start's timer, given up when it ends. */
|
|
737
|
-
holdLease(run) {
|
|
738
|
-
const lease = this.ports.lease;
|
|
739
|
-
if (!lease)
|
|
740
|
-
return;
|
|
741
|
-
lease.renew(run);
|
|
742
|
-
this.renewLease(run);
|
|
743
|
-
void this.active?.done.finally(() => {
|
|
744
|
-
if (this.active || this.starting)
|
|
745
|
-
return;
|
|
746
|
-
this.stopRenewing();
|
|
747
|
-
if (!this.fenced.has(run))
|
|
748
|
-
lease.release();
|
|
749
|
-
});
|
|
750
|
-
}
|
|
751
|
-
/** The latest run, the history and the ledger's totals. */
|
|
752
|
-
status() {
|
|
753
|
-
const { file, error } = this.load();
|
|
754
|
-
const problems = error === null ? [] : [error];
|
|
755
|
-
const ledger = this.ledger.load();
|
|
756
|
-
if (ledger.error !== null)
|
|
757
|
-
problems.push(ledger.error);
|
|
758
|
-
const run = file.current;
|
|
759
|
-
return {
|
|
760
|
-
ok: problems.length === 0,
|
|
761
|
-
problems,
|
|
762
|
-
run: run === null
|
|
763
|
-
? null
|
|
764
|
-
: {
|
|
765
|
-
...run,
|
|
766
|
-
display: run.state === "running" &&
|
|
767
|
-
this.active?.id !== run.id &&
|
|
768
|
-
this.ports.lease?.other()?.run !== run.id
|
|
769
|
-
? "interrupted"
|
|
770
|
-
: run.state,
|
|
771
|
-
},
|
|
772
|
-
active: this.active !== null,
|
|
773
|
-
history: file.history,
|
|
774
|
-
ledger: ledger.error !== null
|
|
775
|
-
? null
|
|
776
|
-
: {
|
|
777
|
-
// Distinct files (and file × lens pairs): a file read at two
|
|
778
|
-
// contents, two depths or in ranges is still one file.
|
|
779
|
-
files: new Set(ledger.entries.filter((e) => e.lens === SCAN_PASS).map((e) => e.path))
|
|
780
|
-
.size,
|
|
781
|
-
deep_files: new Set(ledger.entries
|
|
782
|
-
.filter((e) => e.lens !== SCAN_PASS)
|
|
783
|
-
.map((e) => `${e.lens}\0${e.path}`)).size,
|
|
784
|
-
cost_usd: ledger.entries.reduce((n, e) => n + (e.cost_usd ?? 0), 0),
|
|
785
|
-
},
|
|
786
|
-
};
|
|
787
|
-
}
|
|
788
|
-
/** Stop the running scan: in-flight cases are aborted, pending ones never
|
|
789
|
-
* start; what finished stays in the ledger, so a later start resumes. */
|
|
790
|
-
stop() {
|
|
791
|
-
if (!this.active)
|
|
792
|
-
return { ok: false, errors: ["no scan is running in this companion"] };
|
|
793
|
-
this.active.abort.abort();
|
|
794
|
-
return { ok: true, id: this.active.id };
|
|
795
|
-
}
|
|
796
|
-
/** Resolves when no scan is running (tests; a clean shutdown). */
|
|
797
|
-
async idle() {
|
|
798
|
-
await this.active?.done;
|
|
799
|
-
}
|
|
800
|
-
/** Stage 1's stored deep-lens suggestions (stage 2's input). */
|
|
801
|
-
suggestions() {
|
|
802
|
-
try {
|
|
803
|
-
const text = this.ports.state.read(SUGGESTIONS_FILE);
|
|
804
|
-
if (text === null)
|
|
805
|
-
return { suggestions: [], error: null };
|
|
806
|
-
const raw = JSON.parse(text);
|
|
807
|
-
if (raw.version !== RUN_VERSION || !Array.isArray(raw.suggestions))
|
|
808
|
-
throw new Error("not a version-1 suggestions file");
|
|
809
|
-
return { suggestions: raw.suggestions, error: null };
|
|
810
|
-
}
|
|
811
|
-
catch (err) {
|
|
812
|
-
return { suggestions: [], error: `${SUGGESTIONS_FILE} cannot be read: ${clean(err)}` };
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
// -- stage 2 --
|
|
816
|
-
/** The latest stored stage-2 proposal, none, or why it cannot be read. */
|
|
817
|
-
stage2Proposal() {
|
|
818
|
-
try {
|
|
819
|
-
const text = this.ports.state.read(STAGE2_FILE);
|
|
820
|
-
return { proposal: text === null ? null : parseStage2File(text).proposal, error: null };
|
|
821
|
-
}
|
|
822
|
-
catch (err) {
|
|
823
|
-
return { proposal: null, error: `${STAGE2_FILE} cannot be read: ${clean(err)}` };
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
/** Plan the companion's rows at `deep` (true / false), or at `[scan] depth`
|
|
827
|
-
* when it is undefined; the settings returned carry that depth, so the
|
|
828
|
-
* build and the run are at it too. */
|
|
829
|
-
async planStage2(request, budgetUsd, ref, deep, claim = false) {
|
|
830
|
-
const pre = this.prerequisites(ref, claim);
|
|
831
|
-
if (!pre.ok)
|
|
832
|
-
return pre;
|
|
833
|
-
const { sha, tree, entries } = pre;
|
|
834
|
-
const depth = deep === undefined ? pre.settings.scan.depth : deep ? "deep" : "standard";
|
|
835
|
-
const settings = { ...pre.settings, scan: { ...pre.settings.scan, depth } };
|
|
836
|
-
const cfg = settings.scan;
|
|
837
|
-
const plan = planStage2({
|
|
838
|
-
rows: request,
|
|
839
|
-
cut: pre.cut,
|
|
840
|
-
files: tree.files,
|
|
841
|
-
linesOf: (p) => tree.lines(p),
|
|
842
|
-
whyUnreadable: (p) => tree.whyUnreadable?.(p) ?? null,
|
|
843
|
-
textOf: (p) => tree.read(p),
|
|
844
|
-
exclude: cfg.exclude,
|
|
845
|
-
budget: { maxFiles: cfg.case_max_files, maxLines: cfg.case_max_lines },
|
|
846
|
-
lenses: settings.lenses,
|
|
847
|
-
riskPasses: settings.riskPasses ?? [],
|
|
848
|
-
defaultTier: cfg.tier,
|
|
849
|
-
covered: (lens) => coveredBy(entries, lens, depth),
|
|
850
|
-
partial: (lens) => partialRanges(entries, lens, depth),
|
|
851
|
-
price: (lens, tier, effort) => priceStage2Row(entries, cfg, lens, tier, effort, depth),
|
|
852
|
-
budgetUsd,
|
|
853
|
-
...(depth === "deep" ? { floor: cfg.deep_lenses } : {}),
|
|
854
|
-
});
|
|
855
|
-
if (!plan.ok)
|
|
856
|
-
return { ok: false, errors: plan.errors };
|
|
857
|
-
const runnable = plan.rows.filter((r) => r.status === "run");
|
|
858
|
-
const key = stage2Key({
|
|
859
|
-
sha,
|
|
860
|
-
rows: runnable.map((r) => [
|
|
861
|
-
r.id,
|
|
862
|
-
r.lens,
|
|
863
|
-
r.tier,
|
|
864
|
-
r.effort,
|
|
865
|
-
[...r.files, ...r.tests].map((f) => `${f.path}@${f.blob}`),
|
|
866
|
-
...(r.ranges ? [r.ranges.map((x) => `${x.start}-${x.end}`)] : []),
|
|
867
|
-
]),
|
|
868
|
-
validator: cfg.validator,
|
|
869
|
-
cap: Math.round(plan.total_usd * 1e6),
|
|
870
|
-
...(depth === "deep" ? { depth } : {}),
|
|
871
|
-
});
|
|
872
|
-
const proposal = {
|
|
873
|
-
key,
|
|
874
|
-
sha,
|
|
875
|
-
at: this.ports.clock().toISOString(),
|
|
876
|
-
request,
|
|
877
|
-
budget_usd: budgetUsd,
|
|
878
|
-
rows: plan.rows,
|
|
879
|
-
cap_usd: plan.total_usd,
|
|
880
|
-
runnable: plan.runnable,
|
|
881
|
-
over_budget_usd: plan.over_budget_usd,
|
|
882
|
-
validator: cfg.validator,
|
|
883
|
-
depth,
|
|
884
|
-
floor: plan.floor,
|
|
885
|
-
};
|
|
886
|
-
return { ok: true, proposal, tree, settings, cut: pre.cut };
|
|
887
|
-
}
|
|
888
|
-
/**
|
|
889
|
-
* Plan and STORE a stage-2 proposal from the companion's rows: checked,
|
|
890
|
-
* tests paired, priced from the ledger, fitted to `budget_usd` when given.
|
|
891
|
-
* `deep` (true / false) sets its depth, else `[scan] depth`; a deep one adds
|
|
892
|
-
* the floor after the companion's rows, which may then be none.
|
|
893
|
-
* Runs nothing. The stored proposal replaces the previous one — it is what
|
|
894
|
-
* the TUI shows and what a start's key must name.
|
|
895
|
-
*/
|
|
896
|
-
async proposeStage2(args) {
|
|
897
|
-
const rows = args.rows ?? [];
|
|
898
|
-
if (!Array.isArray(rows))
|
|
899
|
-
return { ok: false, errors: ["`rows` must be a list of { lens, files, tier?, reason }"] };
|
|
900
|
-
if (args.deep !== undefined && args.deep !== null && typeof args.deep !== "boolean")
|
|
901
|
-
return { ok: false, errors: ["`deep` must be true or false (or left out)"] };
|
|
902
|
-
const deep = typeof args.deep === "boolean" ? args.deep : undefined;
|
|
903
|
-
const request = [];
|
|
904
|
-
for (const [i, r] of rows.entries()) {
|
|
905
|
-
if (typeof r !== "object" || r === null || Array.isArray(r))
|
|
906
|
-
return { ok: false, errors: [`row-${i + 1}: not a { lens, files, tier?, reason } object`] };
|
|
907
|
-
const o = r;
|
|
908
|
-
request.push({
|
|
909
|
-
lens: typeof o.lens === "string" ? o.lens : "",
|
|
910
|
-
files: Array.isArray(o.files)
|
|
911
|
-
? o.files.filter((f) => typeof f === "string")
|
|
912
|
-
: typeof o.files === "string"
|
|
913
|
-
? o.files.split(",")
|
|
914
|
-
: [],
|
|
915
|
-
...(typeof o.tier === "string" && o.tier.trim() !== "" ? { tier: o.tier.trim() } : {}),
|
|
916
|
-
reason: typeof o.reason === "string" ? o.reason : "",
|
|
917
|
-
});
|
|
918
|
-
}
|
|
919
|
-
let budget = null;
|
|
920
|
-
if (args.budget_usd !== undefined && args.budget_usd !== null) {
|
|
921
|
-
if (typeof args.budget_usd !== "number" ||
|
|
922
|
-
!Number.isFinite(args.budget_usd) ||
|
|
923
|
-
args.budget_usd <= 0) {
|
|
924
|
-
return {
|
|
925
|
-
ok: false,
|
|
926
|
-
errors: ["`budget_usd` must be a positive number of dollars (or left out)"],
|
|
927
|
-
};
|
|
928
|
-
}
|
|
929
|
-
budget = args.budget_usd;
|
|
930
|
-
}
|
|
931
|
-
const p = await this.planStage2(request, budget, args.sha, deep);
|
|
932
|
-
if (!p.ok)
|
|
933
|
-
return p;
|
|
934
|
-
try {
|
|
935
|
-
this.ports.state.write(STAGE2_FILE, `${JSON.stringify({ version: STAGE2_VERSION, proposal: p.proposal }, null, 1)}\n`);
|
|
936
|
-
}
|
|
937
|
-
catch (err) {
|
|
938
|
-
return { ok: false, errors: [`the proposal could not be stored: ${clean(err)}`] };
|
|
939
|
-
}
|
|
940
|
-
return { ok: true, proposal: p.proposal };
|
|
941
|
-
}
|
|
942
|
-
/**
|
|
943
|
-
* Start the stored stage-2 proposal whose key is quoted — on the
|
|
944
|
-
* operator's word (the companion) or their click (the TUI, through
|
|
945
|
-
* `consumeStage2Request`). Re-planned first: a proposal that moved since it
|
|
946
|
-
* was shown is refused. Hard stop at its estimate — or, when `rows` keeps
|
|
947
|
-
* only some of its runnable rows (the TUI's plan pane), exactly
|
|
948
|
-
* those rows, stopped hard at their total.
|
|
949
|
-
*/
|
|
950
|
-
async startStage2(args, onAccepted) {
|
|
951
|
-
const busy = this.refuseBusy();
|
|
952
|
-
if (busy !== null)
|
|
953
|
-
return busy;
|
|
954
|
-
if (typeof args.key !== "string" || args.key === "") {
|
|
955
|
-
return {
|
|
956
|
-
ok: false,
|
|
957
|
-
errors: [
|
|
958
|
-
"no proposal key — propose stage 2, show the operator the rows and the cost, then start with its key",
|
|
959
|
-
],
|
|
960
|
-
};
|
|
961
|
-
}
|
|
962
|
-
let keep;
|
|
963
|
-
if (args.rows !== undefined && args.rows !== null) {
|
|
964
|
-
if (!Array.isArray(args.rows) ||
|
|
965
|
-
!args.rows.every((r) => typeof r === "string")) {
|
|
966
|
-
return {
|
|
967
|
-
ok: false,
|
|
968
|
-
errors: ["`rows` must be a list of row ids from the proposal (`row-<n>` / `floor-<n>`)"],
|
|
969
|
-
};
|
|
970
|
-
}
|
|
971
|
-
keep = args.rows;
|
|
972
|
-
}
|
|
973
|
-
const key = args.key;
|
|
974
|
-
return this.holdingStart(() => this.startStage2Planned(key, keep, onAccepted));
|
|
975
|
-
}
|
|
976
|
-
async startStage2Planned(key, keep, onAccepted) {
|
|
977
|
-
const stored = this.stage2Proposal();
|
|
978
|
-
if (stored.error !== null)
|
|
979
|
-
return { ok: false, errors: [stored.error] };
|
|
980
|
-
if (stored.proposal === null)
|
|
981
|
-
return { ok: false, errors: ["no stage 2 proposal — propose one first"] };
|
|
982
|
-
if (stored.proposal.key !== key) {
|
|
983
|
-
return {
|
|
984
|
-
ok: false,
|
|
985
|
-
errors: [
|
|
986
|
-
`${key} is not the latest stage 2 proposal (that is ${stored.proposal.key}) — show the operator the latest one`,
|
|
987
|
-
],
|
|
988
|
-
};
|
|
989
|
-
}
|
|
990
|
-
// At the depth it was made at, whatever `[scan] depth` says now.
|
|
991
|
-
const p = await this.planStage2(stored.proposal.request, stored.proposal.budget_usd, stored.proposal.sha, proposalDepth(stored.proposal) === "deep", true);
|
|
992
|
-
if (!p.ok)
|
|
993
|
-
return p;
|
|
994
|
-
if (p.proposal.key !== key) {
|
|
995
|
-
return {
|
|
996
|
-
ok: false,
|
|
997
|
-
errors: [
|
|
998
|
-
`the plan changed since the proposal (key ${key}, now ${p.proposal.key}: a file, a price or the settings moved) — propose again and show the operator the new cost`,
|
|
999
|
-
],
|
|
1000
|
-
};
|
|
1001
|
-
}
|
|
1002
|
-
if (!p.proposal.rows.some((r) => r.status === "run")) {
|
|
1003
|
-
return {
|
|
1004
|
-
ok: false,
|
|
1005
|
-
errors: ["nothing to run: every row is covered by the ledger or over the budget"],
|
|
1006
|
-
};
|
|
1007
|
-
}
|
|
1008
|
-
const kept = keptRows(p.proposal.rows, keep);
|
|
1009
|
-
if (!kept.ok)
|
|
1010
|
-
return kept;
|
|
1011
|
-
const rows = kept.rows;
|
|
1012
|
-
const cap = kept.cap_usd ?? p.proposal.cap_usd;
|
|
1013
|
-
const { file, error } = this.load();
|
|
1014
|
-
if (error !== null)
|
|
1015
|
-
return { ok: false, errors: [error] };
|
|
1016
|
-
const built = this.ports.buildAgents(p.settings);
|
|
1017
|
-
if (!built.ok)
|
|
1018
|
-
return {
|
|
1019
|
-
ok: false,
|
|
1020
|
-
errors: built.errors.map((e) => `the scan agents could not be built: ${e}`),
|
|
1021
|
-
};
|
|
1022
|
-
const unitOf = unitOwner(p.cut, p.tree.files.map((f) => f.path));
|
|
1023
|
-
// What stage 1 pointed each row's lens at in its files rides the case's
|
|
1024
|
-
// prompt. An unreadable suggestions file is a problem on the run, never a
|
|
1025
|
-
// refused start: the leads are help, not the plan.
|
|
1026
|
-
const leadSource = this.suggestions();
|
|
1027
|
-
const leadsOf = (row) => stage2Leads(row, leadSource.suggestions);
|
|
1028
|
-
// One case per row — or, for a row over a file too big for one case, one
|
|
1029
|
-
// per line range (`<row>#<i>`), its estimate the range's share.
|
|
1030
|
-
const jobs = rows.flatMap((row) => stage2Cases(row).map((c) => ({
|
|
1031
|
-
plan: {
|
|
1032
|
-
unit: row.units.join("+"),
|
|
1033
|
-
id: c.id,
|
|
1034
|
-
part: c.part,
|
|
1035
|
-
parts: c.parts,
|
|
1036
|
-
files: c.files,
|
|
1037
|
-
lines: c.lines,
|
|
1038
|
-
estimate_usd: row.lines > 0 ? (row.estimate_usd * c.lines) / row.lines : row.estimate_usd,
|
|
1039
|
-
},
|
|
1040
|
-
lens: row.lens,
|
|
1041
|
-
tier: row.tier,
|
|
1042
|
-
effort: row.effort,
|
|
1043
|
-
prompt: (ctx) => stage2CasePrompt(row, ctx, c.files, leadsOf(row)),
|
|
1044
|
-
label: `stage 2, ${c.id} (${row.lens})`,
|
|
1045
|
-
unitOf,
|
|
1046
|
-
suggestions: false,
|
|
1047
|
-
reason: row.reason,
|
|
1048
|
-
units: row.units,
|
|
1049
|
-
})));
|
|
1050
|
-
const cfg = p.settings.scan;
|
|
1051
|
-
const top = rows.reduce((t, r) => higherTier(t, r.tier), rows[0].tier);
|
|
1052
|
-
const lost = this.leaseLost();
|
|
1053
|
-
if (lost !== null)
|
|
1054
|
-
return lost;
|
|
1055
|
-
const run = this.launch(file, {
|
|
1056
|
-
mode: "stage2",
|
|
1057
|
-
sha: p.proposal.sha,
|
|
1058
|
-
plan_key: key,
|
|
1059
|
-
cap_usd: cap,
|
|
1060
|
-
estimate_usd: cap,
|
|
1061
|
-
settings: {
|
|
1062
|
-
tier: top,
|
|
1063
|
-
effort: "per row",
|
|
1064
|
-
validator: cfg.validator,
|
|
1065
|
-
validate_tier: cfg.validator ? top : null,
|
|
1066
|
-
...(cfg.depth === "deep" ? { depth: cfg.depth } : {}),
|
|
1067
|
-
},
|
|
1068
|
-
}, jobs, p.tree, p.settings, onAccepted);
|
|
1069
|
-
if (leadSource.error !== null) {
|
|
1070
|
-
run.problems.push(`stage-1 leads not carried into the cases: ${leadSource.error}`);
|
|
1071
|
-
this.store(file);
|
|
1072
|
-
}
|
|
1073
|
-
return { ok: true, run };
|
|
1074
|
-
}
|
|
1075
|
-
/**
|
|
1076
|
-
* Answer the TUI's start request, if there is one not yet answered: start
|
|
1077
|
-
* the proposal it names and write the outcome to `STAGE2_RESULT_FILE`.
|
|
1078
|
-
* Resolves null when there is nothing to answer. The companion's scan belt
|
|
1079
|
-
* polls this; one request is answered once.
|
|
1080
|
-
*/
|
|
1081
|
-
async consumeStage2Request() {
|
|
1082
|
-
if (this.consuming)
|
|
1083
|
-
return null;
|
|
1084
|
-
this.consuming = true;
|
|
1085
|
-
try {
|
|
1086
|
-
let text;
|
|
1087
|
-
try {
|
|
1088
|
-
text = this.ports.state.read(STAGE2_START_FILE);
|
|
1089
|
-
}
|
|
1090
|
-
catch (err) {
|
|
1091
|
-
this.ports.log?.(`setup: ${STAGE2_START_FILE} cannot be read: ${clean(err)}`);
|
|
1092
|
-
return null;
|
|
1093
|
-
}
|
|
1094
|
-
const req = text === null ? null : parseStartRequest(text);
|
|
1095
|
-
if (req === null)
|
|
1096
|
-
return null;
|
|
1097
|
-
let prev = null;
|
|
1098
|
-
try {
|
|
1099
|
-
prev = this.ports.state.read(STAGE2_RESULT_FILE);
|
|
1100
|
-
}
|
|
1101
|
-
catch {
|
|
1102
|
-
// Unreadable: answer again rather than never.
|
|
1103
|
-
}
|
|
1104
|
-
if (prev !== null && parseStartResult(prev)?.request === req.id)
|
|
1105
|
-
return null;
|
|
1106
|
-
const acceptedFile = `scan/stage2-starts/${encodeURIComponent(req.id)}.json`;
|
|
1107
|
-
let accepted = null;
|
|
1108
|
-
try {
|
|
1109
|
-
const saved = this.ports.state.read(acceptedFile);
|
|
1110
|
-
if (saved !== null) {
|
|
1111
|
-
accepted = parseStartResult(saved);
|
|
1112
|
-
if (accepted === null ||
|
|
1113
|
-
accepted.request !== req.id ||
|
|
1114
|
-
accepted.key !== req.key ||
|
|
1115
|
-
!accepted.ok)
|
|
1116
|
-
throw new Error("the accepted start record does not match this request");
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
catch (err) {
|
|
1120
|
-
this.ports.log?.(`setup: accepted stage 2 start cannot be read: ${clean(err)}`);
|
|
1121
|
-
return null;
|
|
1122
|
-
}
|
|
1123
|
-
let result;
|
|
1124
|
-
if (accepted !== null)
|
|
1125
|
-
result = accepted;
|
|
1126
|
-
else {
|
|
1127
|
-
try {
|
|
1128
|
-
const r = await this.startStage2({
|
|
1129
|
-
key: req.key,
|
|
1130
|
-
...(req.rows !== undefined ? { rows: req.rows } : {}),
|
|
1131
|
-
}, (run) => {
|
|
1132
|
-
accepted = {
|
|
1133
|
-
request: req.id,
|
|
1134
|
-
key: req.key,
|
|
1135
|
-
at: this.ports.clock().toISOString(),
|
|
1136
|
-
ok: true,
|
|
1137
|
-
run: run.id,
|
|
1138
|
-
errors: [],
|
|
1139
|
-
};
|
|
1140
|
-
// A successful start is recoverable before the first paid case.
|
|
1141
|
-
this.ports.state.write(acceptedFile, `${JSON.stringify(accepted)}\n`);
|
|
1142
|
-
});
|
|
1143
|
-
result = accepted ?? {
|
|
1144
|
-
request: req.id,
|
|
1145
|
-
key: req.key,
|
|
1146
|
-
at: this.ports.clock().toISOString(),
|
|
1147
|
-
ok: r.ok,
|
|
1148
|
-
run: r.ok ? r.run.id : null,
|
|
1149
|
-
errors: r.ok ? [] : r.errors,
|
|
1150
|
-
};
|
|
1151
|
-
}
|
|
1152
|
-
catch (err) {
|
|
1153
|
-
result = {
|
|
1154
|
-
request: req.id,
|
|
1155
|
-
key: req.key,
|
|
1156
|
-
at: this.ports.clock().toISOString(),
|
|
1157
|
-
ok: false,
|
|
1158
|
-
run: null,
|
|
1159
|
-
errors: [`the start was not launched: ${clean(err)}`],
|
|
1160
|
-
};
|
|
1161
|
-
}
|
|
1162
|
-
}
|
|
1163
|
-
try {
|
|
1164
|
-
this.ports.state.write(STAGE2_RESULT_FILE, `${JSON.stringify(result)}\n`);
|
|
1165
|
-
}
|
|
1166
|
-
catch (err) {
|
|
1167
|
-
this.ports.log?.(`setup: the stage 2 start result could not be written: ${clean(err)}`);
|
|
1168
|
-
}
|
|
1169
|
-
this.ports.log?.(`setup: the TUI asked to start stage 2 (${req.key}): ${result.ok ? `started ${result.run}` : result.errors.join("; ")}`);
|
|
1170
|
-
return result;
|
|
1171
|
-
}
|
|
1172
|
-
finally {
|
|
1173
|
-
this.consuming = false;
|
|
1174
|
-
}
|
|
1175
|
-
}
|
|
1176
|
-
/**
|
|
1177
|
-
* Answer the TUI's stop request (scanreport.ts), if there is one
|
|
1178
|
-
* not yet answered: stop the run it names when that is the run in flight
|
|
1179
|
-
* here, and write the outcome to `SCAN_STOP_RESULT_FILE`. A request naming
|
|
1180
|
-
* another run — or arriving when nothing runs in this companion (a run
|
|
1181
|
-
* recorded as running that nobody is driving is already interrupted) — is
|
|
1182
|
-
* refused, never aimed at whatever runs now. Answered once.
|
|
1183
|
-
*/
|
|
1184
|
-
consumeStopRequest() {
|
|
1185
|
-
let text;
|
|
1186
|
-
try {
|
|
1187
|
-
text = this.ports.state.read(SCAN_STOP_FILE);
|
|
1188
|
-
}
|
|
1189
|
-
catch (err) {
|
|
1190
|
-
this.ports.log?.(`setup: ${SCAN_STOP_FILE} cannot be read: ${clean(err)}`);
|
|
1191
|
-
return null;
|
|
1192
|
-
}
|
|
1193
|
-
const req = text === null ? null : parseStopRequest(text);
|
|
1194
|
-
if (req === null)
|
|
1195
|
-
return null;
|
|
1196
|
-
let prev = null;
|
|
1197
|
-
try {
|
|
1198
|
-
prev = this.ports.state.read(SCAN_STOP_RESULT_FILE);
|
|
1199
|
-
}
|
|
1200
|
-
catch {
|
|
1201
|
-
// Unreadable: answer again rather than never.
|
|
1202
|
-
}
|
|
1203
|
-
if (prev !== null && parseStopResult(prev)?.request === req.id)
|
|
1204
|
-
return null;
|
|
1205
|
-
// Another process drives that run: the stop is its holder's to answer.
|
|
1206
|
-
if (this.active?.id !== req.run && this.ports.lease?.other()?.run === req.run)
|
|
1207
|
-
return null;
|
|
1208
|
-
const inFlight = this.active?.id ?? null;
|
|
1209
|
-
const errors = [];
|
|
1210
|
-
if (!this.active)
|
|
1211
|
-
errors.push(`no scan is running in this companion — ${req.run} is not in flight, so there is nothing to stop (the ledger resumes it when started again)`);
|
|
1212
|
-
else if (this.active.id !== req.run)
|
|
1213
|
-
errors.push(`the scan in flight is ${this.active.id}, not ${req.run} — look at the report again`);
|
|
1214
|
-
else
|
|
1215
|
-
this.stop();
|
|
1216
|
-
const result = {
|
|
1217
|
-
request: req.id,
|
|
1218
|
-
run: req.run,
|
|
1219
|
-
at: this.ports.clock().toISOString(),
|
|
1220
|
-
ok: errors.length === 0,
|
|
1221
|
-
errors,
|
|
1222
|
-
in_flight: inFlight,
|
|
1223
|
-
};
|
|
1224
|
-
try {
|
|
1225
|
-
this.ports.state.write(SCAN_STOP_RESULT_FILE, `${JSON.stringify(result)}\n`);
|
|
1226
|
-
}
|
|
1227
|
-
catch (err) {
|
|
1228
|
-
this.ports.log?.(`setup: the scan stop result could not be written: ${clean(err)}`);
|
|
1229
|
-
}
|
|
1230
|
-
this.ports.log?.(`setup: the TUI asked to stop ${req.run}: ${result.ok ? "stopped" : errors.join("; ")}`);
|
|
1231
|
-
return result;
|
|
1232
|
-
}
|
|
1233
|
-
// -- the background run --
|
|
1234
|
-
async drive(file, run, jobs, tree, settings, abort) {
|
|
1235
|
-
const signal = abort.signal;
|
|
1236
|
-
let capHit = false;
|
|
1237
|
-
let reserved = 0;
|
|
1238
|
-
let next = 0;
|
|
1239
|
-
// What each in-flight case has spent SO FAR, as its runner reports it.
|
|
1240
|
-
// The hard stop reads these live: a case never runs on past the cap just
|
|
1241
|
-
// because it has not finished yet.
|
|
1242
|
-
const live = new Map();
|
|
1243
|
-
const liveTotal = () => [...live.values()].reduce((n, v) => n + v, 0);
|
|
1244
|
-
const onCost = (caseId, spentSoFar) => {
|
|
1245
|
-
live.set(caseId, spentSoFar);
|
|
1246
|
-
if (!signal.aborted &&
|
|
1247
|
-
(run.cap_usd > EPS
|
|
1248
|
-
? run.spent_usd + liveTotal() >= run.cap_usd - EPS
|
|
1249
|
-
: run.spent_usd + liveTotal() > EPS)) {
|
|
1250
|
-
capHit = true;
|
|
1251
|
-
abort.abort();
|
|
1252
|
-
}
|
|
1253
|
-
};
|
|
1254
|
-
// May a case with this estimate start? Within the cap counting what is in
|
|
1255
|
-
// flight; a case with no estimate (calibration) only while the cap is not
|
|
1256
|
-
// yet reached. A zero cap is a free run (the runner prices at zero): it admits cases only
|
|
1257
|
-
// while nothing has been spent, and stops on the first real spending.
|
|
1258
|
-
let unknownSpend = false;
|
|
1259
|
-
const within = (extra) => !unknownSpend &&
|
|
1260
|
-
((run.cap_usd <= EPS && run.spent_usd + reserved <= EPS) ||
|
|
1261
|
-
(run.cap_usd > EPS &&
|
|
1262
|
-
(extra > 0
|
|
1263
|
-
? run.spent_usd + reserved + extra <= run.cap_usd + EPS
|
|
1264
|
-
: run.spent_usd + reserved < run.cap_usd - EPS)));
|
|
1265
|
-
const lane = async () => {
|
|
1266
|
-
while (next < jobs.length && !signal.aborted) {
|
|
1267
|
-
const job = jobs[next];
|
|
1268
|
-
const plan = job.plan;
|
|
1269
|
-
const est = plan.estimate_usd ?? 0;
|
|
1270
|
-
if (!within(est)) {
|
|
1271
|
-
capHit = true;
|
|
1272
|
-
return;
|
|
1273
|
-
}
|
|
1274
|
-
next++;
|
|
1275
|
-
reserved += est;
|
|
1276
|
-
live.set(plan.id, 0);
|
|
1277
|
-
const spent = await this.runCase(file, run, job, tree, settings, signal, (v) => onCost(plan.id, v));
|
|
1278
|
-
live.delete(plan.id);
|
|
1279
|
-
reserved -= est;
|
|
1280
|
-
run.spent_usd += spent;
|
|
1281
|
-
// A case with no estimate (calibration) that ended with no total price
|
|
1282
|
-
// (a live report is only a floor) spent an UNKNOWN amount — never read
|
|
1283
|
-
// as free. Under
|
|
1284
|
-
// a cap, nothing more starts: the hard stop cannot be held blind.
|
|
1285
|
-
const done = run.cases.find((x) => x.id === plan.id);
|
|
1286
|
-
if (run.cap_usd > EPS &&
|
|
1287
|
-
!signal.aborted &&
|
|
1288
|
-
plan.estimate_usd === null &&
|
|
1289
|
-
done?.started_at != null &&
|
|
1290
|
-
done.cost_usd === null &&
|
|
1291
|
-
!unknownSpend) {
|
|
1292
|
-
unknownSpend = true;
|
|
1293
|
-
capHit = true;
|
|
1294
|
-
abort.abort();
|
|
1295
|
-
run.problems.push(`case ${plan.id} ended with no total price — what it spent is unknown, so no further case starts (the cap cannot be held without prices)`);
|
|
1296
|
-
}
|
|
1297
|
-
this.store(file);
|
|
1298
|
-
// The hard stop: once spending reaches the cap, nothing in flight may
|
|
1299
|
-
// add to it.
|
|
1300
|
-
if (run.cap_usd > EPS &&
|
|
1301
|
-
run.spent_usd >= run.cap_usd - EPS &&
|
|
1302
|
-
!signal.aborted &&
|
|
1303
|
-
run.cases.some((c) => c.status === "running")) {
|
|
1304
|
-
capHit = true;
|
|
1305
|
-
abort.abort();
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
};
|
|
1309
|
-
const lanes = Math.max(1, Math.min(settings.scan.concurrency, jobs.length));
|
|
1310
|
-
await drainLanes(lanes, lane, abort);
|
|
1311
|
-
// Never started: `skipped` when the cap kept it out, `stopped` when a stop did.
|
|
1312
|
-
for (const c of run.cases)
|
|
1313
|
-
if (c.status === "pending")
|
|
1314
|
-
c.status = capHit ? "skipped" : "stopped";
|
|
1315
|
-
// A run that lost its lease settles nothing: the findings are the new
|
|
1316
|
-
// holder's to reconcile.
|
|
1317
|
-
if (!this.fenced.has(run.id)) {
|
|
1318
|
-
this.reconcile(run, tree);
|
|
1319
|
-
await this.closeFixedTickets(run);
|
|
1320
|
-
}
|
|
1321
|
-
const allDone = run.cases.every((c) => c.status === "done");
|
|
1322
|
-
this.settle(file, run, allDone ? "complete" : capHit ? "capped" : signal.aborted ? "stopped" : "partial");
|
|
1323
|
-
}
|
|
1324
|
-
/** One case, start to finish. Resolves with what it cost (its estimate
|
|
1325
|
-
* when the runner could not price it); a runner failure is an outcome, so
|
|
1326
|
-
* it rejects only when the run record cannot be written. */
|
|
1327
|
-
async runCase(file, run, job, tree, settings, signal, onCost = () => { }) {
|
|
1328
|
-
const plan = job.plan;
|
|
1329
|
-
const c = run.cases.find((x) => x.id === plan.id);
|
|
1330
|
-
if (signal.aborted) {
|
|
1331
|
-
c.status = "stopped";
|
|
1332
|
-
return 0;
|
|
1333
|
-
}
|
|
1334
|
-
c.status = "running";
|
|
1335
|
-
c.started_at = this.ports.clock().toISOString();
|
|
1336
|
-
this.store(file);
|
|
1337
|
-
const ctx = { root: tree.root, sha: run.sha, caseId: `${run.id}/${plan.id}` };
|
|
1338
|
-
// What the case has spent: every call's cost, a FAILED call's included
|
|
1339
|
-
// (it spent money too). Null once any call could not say.
|
|
1340
|
-
let cost = 0;
|
|
1341
|
-
// The most the runner has reported so far — the floor of what an
|
|
1342
|
-
// unpriced case is counted at.
|
|
1343
|
-
let seen = 0;
|
|
1344
|
-
const addCost = (o) => {
|
|
1345
|
-
const spent = o.cost_usd === undefined ? null : o.cost_usd;
|
|
1346
|
-
cost = cost === null || spent === null ? null : cost + spent;
|
|
1347
|
-
};
|
|
1348
|
-
const report = (base) => (v) => {
|
|
1349
|
-
seen = Math.max(seen, base + v);
|
|
1350
|
-
onCost(seen);
|
|
1351
|
-
};
|
|
1352
|
-
const outcome = await this.runOne({
|
|
1353
|
-
id: ctx.caseId,
|
|
1354
|
-
unit: plan.unit,
|
|
1355
|
-
agent: `${job.lens}--${job.tier}`,
|
|
1356
|
-
files: c.files,
|
|
1357
|
-
prompt: job.prompt(ctx),
|
|
1358
|
-
}, signal, report(0));
|
|
1359
|
-
addCost(outcome);
|
|
1360
|
-
if (outcome.transcript !== undefined)
|
|
1361
|
-
c.transcript = outcome.transcript;
|
|
1362
|
-
if (outcome.ok && outcome.reads !== undefined)
|
|
1363
|
-
c.reads = outcome.reads.length;
|
|
1364
|
-
const finish = (status, error) => {
|
|
1365
|
-
c.status = status;
|
|
1366
|
-
c.error = error;
|
|
1367
|
-
c.cost_usd = cost;
|
|
1368
|
-
c.finished_at = this.ports.clock().toISOString();
|
|
1369
|
-
if (cost === null)
|
|
1370
|
-
run.unpriced_cases++;
|
|
1371
|
-
// Unpriced: counted at its estimate (or what the runner saw it spend,
|
|
1372
|
-
// if more) — never as free.
|
|
1373
|
-
return cost ?? Math.max(seen, plan.estimate_usd ?? 0);
|
|
1374
|
-
};
|
|
1375
|
-
if (!outcome.ok)
|
|
1376
|
-
return finish(signal.aborted ? "stopped" : "failed", outcome.reason);
|
|
1377
|
-
// A lens that opened none of its files did not read the case, whatever it
|
|
1378
|
-
// answered: never coverage (no ledger row, so it is read again), never a
|
|
1379
|
-
// price sample (the rate comes from ledger rows), and its findings are
|
|
1380
|
-
// not stored — they were not drawn from the code. Said on the run, the
|
|
1381
|
-
// way a failed validator is, so a cheap empty lens cannot pass as a
|
|
1382
|
-
// quiet one.
|
|
1383
|
-
const unread = unreadFocus(outcome.reads, plan.files.map((f) => f.path), tree.root);
|
|
1384
|
-
if (unread !== null) {
|
|
1385
|
-
run.problems.push(`case ${plan.id}: ${job.lens} — ${unread}; not a reading (not coverage, not a price sample) — it will be read again`);
|
|
1386
|
-
return finish("failed", unread);
|
|
1387
|
-
}
|
|
1388
|
-
// Opened some of its files and not others: the others are not read.
|
|
1389
|
-
const focusPaths = [...new Set(plan.files.map((f) => f.path))];
|
|
1390
|
-
const opened = openedFocus(outcome.reads, focusPaths, tree.root);
|
|
1391
|
-
const unopened = opened === null ? [] : focusPaths.filter((p) => !opened.has(p));
|
|
1392
|
-
if (unopened.length > 0) {
|
|
1393
|
-
c.unopened = unopened;
|
|
1394
|
-
run.problems.push(`case ${plan.id}: ${job.lens} opened ${focusPaths.length - unopened.length} of its ${focusPaths.length} focus files — not opened: ${unopened.join(", ")}; those are not coverage and will be read again`);
|
|
1395
|
-
}
|
|
1396
|
-
const inTree = new Set(tree.files.map((f) => f.path));
|
|
1397
|
-
// A file read in line ranges: this case reports only inside its range —
|
|
1398
|
-
// the neighbouring ranges are other cases' (scan.ts cutRanges).
|
|
1399
|
-
const rangeOf = new Map(plan.files.filter((f) => f.range).map((f) => [f.path, f.range]));
|
|
1400
|
-
const outside = (cand) => {
|
|
1401
|
-
const r = rangeOf.get(cand.path);
|
|
1402
|
-
return r !== undefined && (cand.end < r.start || cand.start > r.end);
|
|
1403
|
-
};
|
|
1404
|
-
const deep = settings.scan.depth === "deep";
|
|
1405
|
-
const answer = parseScanAnswer(outcome.output, {
|
|
1406
|
-
root: tree.root,
|
|
1407
|
-
...(deep && job.suggestions ? { maxSuggestions: SCAN_DEEP_MAX_SUGGESTIONS } : {}),
|
|
1408
|
-
});
|
|
1409
|
-
// Only findings or an explicit NONE is a reading. Prose with neither (a
|
|
1410
|
-
// tool error told in words, a broken grammar) is not "nothing found",
|
|
1411
|
-
// and must never enter the ledger as clean coverage — that reading is
|
|
1412
|
-
// what settles earlier findings fixed.
|
|
1413
|
-
if (answer.candidates.length === 0 && !answer.none) {
|
|
1414
|
-
return finish("failed", "the lens answered with no finding and no NONE line");
|
|
1415
|
-
}
|
|
1416
|
-
let dropped = answer.dropped + answer.suggestions_dropped;
|
|
1417
|
-
// A candidate — a lens's or a skeptic's survivor — stands on the code at
|
|
1418
|
-
// its anchor: null when it falls outside the case's range or its lines
|
|
1419
|
-
// are not in the tree (dropped).
|
|
1420
|
-
const anchor = (cand) => {
|
|
1421
|
-
if (outside(cand))
|
|
1422
|
-
return null;
|
|
1423
|
-
// A focus file the lens never opened was not read: nothing it reports
|
|
1424
|
-
// there was drawn from the code, whatever else the lens opened.
|
|
1425
|
-
if (opened !== null && focusPaths.includes(cand.path) && !opened.has(cand.path))
|
|
1426
|
-
return null;
|
|
1427
|
-
const text = inTree.has(cand.path) ? tree.read(cand.path) : null;
|
|
1428
|
-
const lines = text === null ? null : anchoredLines(text, cand.start, cand.end);
|
|
1429
|
-
if (lines === null)
|
|
1430
|
-
return null;
|
|
1431
|
-
return { code: normalizeCode(lines), quoted: evidenceQuotesAnchor(cand.evidence, lines) };
|
|
1432
|
-
};
|
|
1433
|
-
const anchored = [];
|
|
1434
|
-
for (const cand of answer.candidates) {
|
|
1435
|
-
const at = anchor(cand);
|
|
1436
|
-
if (at === null)
|
|
1437
|
-
dropped++;
|
|
1438
|
-
else
|
|
1439
|
-
anchored.push({ cand, ...at });
|
|
1440
|
-
}
|
|
1441
|
-
if (answer.candidates.length > 0 && anchored.length === 0) {
|
|
1442
|
-
c.dropped = dropped;
|
|
1443
|
-
return finish("failed", "none of the reported findings could be anchored to this case");
|
|
1444
|
-
}
|
|
1445
|
-
// The validator, when this scan runs one, is the skeptic: at the tier of
|
|
1446
|
-
// the lens it judges, it sorts every
|
|
1447
|
-
// candidate into a validity tier — and kills none. A survivor is
|
|
1448
|
-
// `confirmed` (a traced scenario) or `likely`; a DOUBTED line, a KILLED
|
|
1449
|
-
// line and a candidate its answer never mentions are all kept `doubtful`,
|
|
1450
|
-
// so no kill path of the frozen validator can make a finding vanish. A
|
|
1451
|
-
// validator that failed judges nothing: its findings are kept with no
|
|
1452
|
-
// tier (unknown), never a guessed one.
|
|
1453
|
-
let validated = false;
|
|
1454
|
-
let validatorOpened = null;
|
|
1455
|
-
let kept = anchored.map((a) => ({
|
|
1456
|
-
...a,
|
|
1457
|
-
validity: null,
|
|
1458
|
-
scenario: null,
|
|
1459
|
-
doubt: null,
|
|
1460
|
-
}));
|
|
1461
|
-
if (settings.scan.validator && anchored.length > 0) {
|
|
1462
|
-
const v = await this.runOne({
|
|
1463
|
-
id: `${ctx.caseId}/validate`,
|
|
1464
|
-
unit: plan.unit,
|
|
1465
|
-
agent: validatorAgent(job.tier),
|
|
1466
|
-
files: c.files,
|
|
1467
|
-
prompt: scanValidatorPrompt(plan, anchored.map((a) => a.cand), ctx, job.lens, job.label),
|
|
1468
|
-
}, signal, report(cost ?? seen));
|
|
1469
|
-
addCost(v);
|
|
1470
|
-
if (v.transcript !== undefined)
|
|
1471
|
-
c.validator_transcript = v.transcript;
|
|
1472
|
-
if (!v.ok && signal.aborted)
|
|
1473
|
-
return finish("stopped", v.reason);
|
|
1474
|
-
// A skeptic that opened none of the files its candidates name judged
|
|
1475
|
-
// nothing from the code: its tiers are not taken — unknown, not guessed.
|
|
1476
|
-
const candPaths = [...new Set(anchored.map((a) => a.cand.path))];
|
|
1477
|
-
const skepticOpened = v.ok ? openedFocus(v.reads, candPaths, tree.root) : null;
|
|
1478
|
-
validatorOpened = skepticOpened;
|
|
1479
|
-
const skepticUnread = skepticOpened !== null && skepticOpened.size === 0;
|
|
1480
|
-
const read = v.ok && !skepticUnread ? parseValidatorAnswer(v.output, tree.root) : null;
|
|
1481
|
-
if (read === null || !read.answered) {
|
|
1482
|
-
const why = !v.ok
|
|
1483
|
-
? v.reason
|
|
1484
|
-
: skepticUnread
|
|
1485
|
-
? `it opened none of the ${candPaths.length} file(s) its candidates name`
|
|
1486
|
-
: "it answered with no survivor, NONE, DOUBTED or KILLED";
|
|
1487
|
-
run.problems.push(`case ${plan.id}: the validator failed (${why}) — its findings are kept unvalidated`);
|
|
1488
|
-
}
|
|
1489
|
-
else {
|
|
1490
|
-
validated = true;
|
|
1491
|
-
const judged = [];
|
|
1492
|
-
// The candidate each survivor stands for — ONE per survivor: the same
|
|
1493
|
-
// file and overlapping lines, the same kind first. One survivor must
|
|
1494
|
-
// never stand for two candidates, or the one it did not mean (a
|
|
1495
|
-
// duplicate on the same lines, a neighbour of another kind) would be
|
|
1496
|
-
// neither upheld nor unmentioned — silently dropped, a kill.
|
|
1497
|
-
const upheld = new Set();
|
|
1498
|
-
const cands = anchored.map((a) => a.cand);
|
|
1499
|
-
for (const s of read.survivors) {
|
|
1500
|
-
const at = anchor(s);
|
|
1501
|
-
if (at === null) {
|
|
1502
|
-
dropped++;
|
|
1503
|
-
continue;
|
|
1504
|
-
}
|
|
1505
|
-
const meant = survivorSource(s, cands, upheld);
|
|
1506
|
-
if (meant)
|
|
1507
|
-
upheld.add(meant);
|
|
1508
|
-
judged.push({
|
|
1509
|
-
cand: s,
|
|
1510
|
-
...at,
|
|
1511
|
-
validity: s.validity,
|
|
1512
|
-
scenario: s.scenario,
|
|
1513
|
-
doubt: null,
|
|
1514
|
-
});
|
|
1515
|
-
}
|
|
1516
|
-
// A doubt — or, in a scan, a kill — marks the candidate it names.
|
|
1517
|
-
const doubted = new Set();
|
|
1518
|
-
for (const d of [...read.doubted, ...read.killed]) {
|
|
1519
|
-
const names = (a) => a.cand.path === d.path &&
|
|
1520
|
-
(d.line === null || (d.line >= a.cand.start && d.line <= a.cand.end)) &&
|
|
1521
|
-
!doubted.has(a.cand);
|
|
1522
|
-
// A candidate no survivor stands for first: two candidates on the
|
|
1523
|
-
// same lines (a duplicate the skeptic doubts beside the one it
|
|
1524
|
-
// upholds) must not have the doubt land on the upheld one — its
|
|
1525
|
-
// fingerprint would then turn the confirmed survivor doubtful and
|
|
1526
|
-
// leave the duplicate standing as "upheld".
|
|
1527
|
-
const hit = anchored.find((a) => names(a) && !upheld.has(a.cand)) ?? anchored.find(names);
|
|
1528
|
-
if (!hit)
|
|
1529
|
-
continue;
|
|
1530
|
-
doubted.add(hit.cand);
|
|
1531
|
-
judged.push({
|
|
1532
|
-
...hit,
|
|
1533
|
-
validity: "doubtful",
|
|
1534
|
-
scenario: null,
|
|
1535
|
-
doubt: { word: d.word, reason: d.reason, class: d.class },
|
|
1536
|
-
});
|
|
1537
|
-
}
|
|
1538
|
-
// Never mentioned — neither upheld nor doubted: kept, doubtful.
|
|
1539
|
-
for (const a of anchored) {
|
|
1540
|
-
if (upheld.has(a.cand) || doubted.has(a.cand))
|
|
1541
|
-
continue;
|
|
1542
|
-
judged.push({
|
|
1543
|
-
...a,
|
|
1544
|
-
validity: "doubtful",
|
|
1545
|
-
scenario: null,
|
|
1546
|
-
doubt: { word: "not-upheld", reason: UNMENTIONED_REASON, class: null },
|
|
1547
|
-
});
|
|
1548
|
-
}
|
|
1549
|
-
kept =
|
|
1550
|
-
skepticOpened === null
|
|
1551
|
-
? judged
|
|
1552
|
-
: [
|
|
1553
|
-
...judged.filter((a) => skepticOpened.has(a.cand.path)),
|
|
1554
|
-
...anchored
|
|
1555
|
-
.filter((a) => !skepticOpened.has(a.cand.path))
|
|
1556
|
-
.map((a) => ({ ...a, validity: null, scenario: null, doubt: null })),
|
|
1557
|
-
];
|
|
1558
|
-
if (skepticOpened !== null && skepticOpened.size < candPaths.length)
|
|
1559
|
-
run.problems.push(`case ${plan.id}: the validator did not open every candidate file — unread findings are kept unvalidated`);
|
|
1560
|
-
}
|
|
1561
|
-
}
|
|
1562
|
-
const blobOf = new Map(tree.files.map((f) => [f.path, f.blob]));
|
|
1563
|
-
const all = kept.map(({ cand, code, quoted, validity, scenario, doubt }) => ({
|
|
1564
|
-
fingerprint: findingFingerprint({ path: cand.path, lens: job.lens, kind: cand.kind, code }),
|
|
1565
|
-
unit: job.unitOf(cand.path),
|
|
1566
|
-
path: cand.path,
|
|
1567
|
-
blob: blobOf.get(cand.path) ?? null,
|
|
1568
|
-
start: cand.start,
|
|
1569
|
-
end: cand.end,
|
|
1570
|
-
lens: job.lens,
|
|
1571
|
-
kind: cand.kind,
|
|
1572
|
-
severity: cand.severity,
|
|
1573
|
-
confidence: cand.confidence,
|
|
1574
|
-
reachability: cand.reachability,
|
|
1575
|
-
capped_from: cand.capped_from,
|
|
1576
|
-
anchor_quoted: quoted,
|
|
1577
|
-
claim: cand.claim,
|
|
1578
|
-
evidence: cand.evidence,
|
|
1579
|
-
fix: cand.fix,
|
|
1580
|
-
detail: cand.detail,
|
|
1581
|
-
validated: validated && (validatorOpened === null || validatorOpened.has(cand.path)),
|
|
1582
|
-
doubt,
|
|
1583
|
-
validity,
|
|
1584
|
-
scenario,
|
|
1585
|
-
}));
|
|
1586
|
-
// One finding per fingerprint: the validator may list a candidate as a
|
|
1587
|
-
// survivor AND doubt it — the doubt wins, and it is counted once.
|
|
1588
|
-
const byFp = new Map();
|
|
1589
|
-
for (const r of all) {
|
|
1590
|
-
const had = byFp.get(r.fingerprint);
|
|
1591
|
-
if (!had || (had.doubt === null && r.doubt !== null))
|
|
1592
|
-
byFp.set(r.fingerprint, r);
|
|
1593
|
-
}
|
|
1594
|
-
const reported = [...byFp.values()];
|
|
1595
|
-
// Ownership is checked here, not left to the renewal timer: a driver
|
|
1596
|
-
// resumed after its lease was taken over publishes nothing.
|
|
1597
|
-
if (!this.fenced.has(run.id))
|
|
1598
|
-
this.renewTick();
|
|
1599
|
-
if (this.fenced.has(run.id))
|
|
1600
|
-
return finish("stopped", "the scan driver lease was lost");
|
|
1601
|
-
const at = this.ports.clock().toISOString();
|
|
1602
|
-
try {
|
|
1603
|
-
this.findings.report(reported, { run: run.id, sha: run.sha, at });
|
|
1604
|
-
}
|
|
1605
|
-
catch (err) {
|
|
1606
|
-
// Nothing recorded, so nothing enters the ledger: the case is read again.
|
|
1607
|
-
return finish("failed", `its findings could not be stored: ${clean(err)}`);
|
|
1608
|
-
}
|
|
1609
|
-
c.findings = reported.length;
|
|
1610
|
-
c.doubted = reported.filter((r) => r.doubt !== null).length;
|
|
1611
|
-
c.confirmed = reported.filter((r) => r.validity === "confirmed").length;
|
|
1612
|
-
c.likely = reported.filter((r) => r.validity === "likely").length;
|
|
1613
|
-
if (job.suggestions) {
|
|
1614
|
-
const suggestions = answer.suggestions
|
|
1615
|
-
.map((s) => ({ ...s, files: s.files.filter((p) => inTree.has(p)) }))
|
|
1616
|
-
.filter((s) => s.files.length > 0);
|
|
1617
|
-
dropped += answer.suggestions.length - suggestions.length;
|
|
1618
|
-
c.suggestions = suggestions;
|
|
1619
|
-
if (!this.storeSuggestions(run, plan.unit, suggestions, at)) {
|
|
1620
|
-
c.dropped = dropped;
|
|
1621
|
-
return finish("failed", "its deep-lens suggestions could not be stored; not coverage");
|
|
1622
|
-
}
|
|
1623
|
-
}
|
|
1624
|
-
c.dropped = dropped;
|
|
1625
|
-
// Last: the ledger. Only now is the case's reading on record.
|
|
1626
|
-
// Each row keeps its share of the WHOLE case's cost, so leaving out a file
|
|
1627
|
-
// the lens never opened leaves the rate per line what the case measured.
|
|
1628
|
-
const total = plan.files.reduce((n, f) => n + Math.max(f.lines, 1), 0);
|
|
1629
|
-
const notRead = new Set(unopened);
|
|
1630
|
-
const rows = plan.files
|
|
1631
|
-
.filter((f) => !notRead.has(f.path))
|
|
1632
|
-
.map((f) => ({
|
|
1633
|
-
path: f.path,
|
|
1634
|
-
blob: f.blob,
|
|
1635
|
-
lens: job.lens,
|
|
1636
|
-
...(f.range ? { range: f.range } : {}),
|
|
1637
|
-
tier: job.tier,
|
|
1638
|
-
effort: job.effort,
|
|
1639
|
-
validator: settings.scan.validator,
|
|
1640
|
-
...(deep ? { depth: "deep" } : {}),
|
|
1641
|
-
sha: run.sha,
|
|
1642
|
-
lines: f.lines,
|
|
1643
|
-
cost_usd: cost === null ? null : (cost * Math.max(f.lines, 1)) / total,
|
|
1644
|
-
run: run.id,
|
|
1645
|
-
case: plan.id,
|
|
1646
|
-
at,
|
|
1647
|
-
}));
|
|
1648
|
-
try {
|
|
1649
|
-
this.ledger.record(rows);
|
|
1650
|
-
}
|
|
1651
|
-
catch (err) {
|
|
1652
|
-
run.problems.push(`case ${plan.id}: the ledger refused its rows (${clean(err)}) — it will be read again`);
|
|
1653
|
-
return finish("failed", "not recorded in the coverage ledger");
|
|
1654
|
-
}
|
|
1655
|
-
return finish("done", null);
|
|
1656
|
-
}
|
|
1657
|
-
async runOne(spec, signal, onCost) {
|
|
1658
|
-
try {
|
|
1659
|
-
return await this.ports.cases.run(spec, signal, onCost);
|
|
1660
|
-
}
|
|
1661
|
-
catch (err) {
|
|
1662
|
-
return { ok: false, reason: `the case runner threw: ${clean(err)}`, cost_usd: null };
|
|
1663
|
-
}
|
|
1664
|
-
}
|
|
1665
|
-
storeSuggestions(run, unit, found, at) {
|
|
1666
|
-
if (found.length === 0)
|
|
1667
|
-
return true;
|
|
1668
|
-
const { suggestions, error } = this.suggestions();
|
|
1669
|
-
if (error !== null) {
|
|
1670
|
-
run.problems.push(`deep-lens suggestions not stored: ${error}`);
|
|
1671
|
-
return false;
|
|
1672
|
-
}
|
|
1673
|
-
const key = (s) => `${s.unit}\0${s.lens}\0${[...s.files].sort().join(",")}`;
|
|
1674
|
-
const byKey = new Map(suggestions.map((s) => [key(s), s]));
|
|
1675
|
-
for (const s of found) {
|
|
1676
|
-
const row = { ...s, unit, sha: run.sha, run: run.id, at };
|
|
1677
|
-
byKey.set(key(row), row);
|
|
1678
|
-
}
|
|
1679
|
-
try {
|
|
1680
|
-
this.ports.state.write(SUGGESTIONS_FILE, `${JSON.stringify({ version: RUN_VERSION, suggestions: [...byKey.values()] }, null, 1)}\n`);
|
|
1681
|
-
}
|
|
1682
|
-
catch (err) {
|
|
1683
|
-
run.problems.push(`deep-lens suggestions not stored: ${clean(err)}`);
|
|
1684
|
-
return false;
|
|
1685
|
-
}
|
|
1686
|
-
return true;
|
|
1687
|
-
}
|
|
1688
|
-
/** The run's end, whatever ended it: what re-read files no longer report
|
|
1689
|
-
* is fixed, or handed to the companion (findings.ts reconcile). Only
|
|
1690
|
-
* FINISHED cases wrote ledger rows, so only their reading counts. */
|
|
1691
|
-
reconcile(run, tree) {
|
|
1692
|
-
const { entries, error } = this.ledger.load();
|
|
1693
|
-
try {
|
|
1694
|
-
if (error !== null)
|
|
1695
|
-
throw new Error(error);
|
|
1696
|
-
run.matching = this.findings.reconcile({
|
|
1697
|
-
rows: entries,
|
|
1698
|
-
tree: new Set(tree.files.map((f) => f.path)),
|
|
1699
|
-
blobs: new Map(tree.files.map((f) => [f.path, f.blob])),
|
|
1700
|
-
run: { run: run.id, sha: run.sha, at: this.ports.clock().toISOString() },
|
|
1701
|
-
});
|
|
1702
|
-
}
|
|
1703
|
-
catch (err) {
|
|
1704
|
-
run.problems.push(`the findings were not reconciled (${clean(err)}) — the next run's end does it`);
|
|
1705
|
-
}
|
|
1706
|
-
}
|
|
1707
|
-
/** A promoted finding the reconcile fixed has its ticket closed with the
|
|
1708
|
-
* run's sha; a refused close is a problem on the run, retried next time. */
|
|
1709
|
-
async closeFixedTickets(run) {
|
|
1710
|
-
if (!this.ports.closeFixedTickets)
|
|
1711
|
-
return;
|
|
1712
|
-
try {
|
|
1713
|
-
const r = await this.ports.closeFixedTickets();
|
|
1714
|
-
run.tickets_closed = r.closed.length;
|
|
1715
|
-
for (const p of r.problems)
|
|
1716
|
-
run.problems.push(`ticket not closed: ${p}`);
|
|
1717
|
-
}
|
|
1718
|
-
catch (err) {
|
|
1719
|
-
run.problems.push(`the fixed findings' tickets were not closed (${clean(err)}) — the next run's end retries`);
|
|
1720
|
-
}
|
|
1721
|
-
}
|
|
1722
|
-
settle(file, run, state) {
|
|
1723
|
-
run.state = state;
|
|
1724
|
-
run.finished_at = this.ports.clock().toISOString();
|
|
1725
|
-
try {
|
|
1726
|
-
this.store(file);
|
|
1727
|
-
}
|
|
1728
|
-
catch (err) {
|
|
1729
|
-
this.ports.log?.(`setup: could not record scan ${run.id}: ${clean(err)}`);
|
|
1730
|
-
}
|
|
1731
|
-
}
|
|
1732
|
-
}
|
|
1
|
+
// Moved to the plugin (packages/pi-plugin/src/scan/scanrun.ts), the scan engine's
|
|
2
|
+
// home: this name stays for the app's importers.
|
|
3
|
+
export * from "@quickchatai/pi-quickreview/src/scan/scanrun.js";
|
|
1733
4
|
//# sourceMappingURL=scanrun.js.map
|