@wenathlan/devthink 2.0.13 → 2.0.15
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/CHANGELOG.md +27 -0
- package/accounts.ts +2087 -0
- package/agent.ts +2331 -0
- package/antigravity.ts +2139 -0
- package/apifreeze.ts +4146 -0
- package/auth.ts +642 -0
- package/background.ts +16837 -0
- package/bridge.ts +2080 -0
- package/build.ts +54 -0
- package/bun.ts +26 -0
- package/capture.ts +1614 -0
- package/checksecrets.ts +72 -0
- package/cjs.ts +9 -0
- package/cli.ts +3500 -0
- package/commands.ts +1101 -0
- package/companion.ts +450 -0
- package/compatibility.ts +37 -0
- package/config.ts +3137 -0
- package/constants.ts +943 -0
- package/core.ts +1473 -0
- package/crossbrowser.ts +1359 -0
- package/dashdone.ts +605 -0
- package/data.ts +943 -0
- package/database.ts +133 -0
- package/debug.ts +850 -0
- package/deno.ts +27 -0
- package/devthink.ts +707 -0
- package/dist/accounts.d.ts +409 -0
- package/dist/agent.d.ts +837 -0
- package/dist/antigravity.d.ts +380 -0
- package/dist/apifreeze.d.ts +113 -0
- package/dist/auth.d.ts +224 -0
- package/{bridge.d.ts → dist/bridge.d.ts} +1 -1
- package/dist/capture.d.ts +595 -0
- package/dist/cjs.d.ts +6 -0
- package/{cli.js → dist/cli.js} +2 -2
- package/dist/commands.d.ts +310 -0
- package/dist/compatibility.d.ts +17 -0
- package/dist/config.d.ts +1853 -0
- package/dist/constants.d.ts +637 -0
- package/dist/core.d.ts +361 -0
- package/dist/data.d.ts +330 -0
- package/dist/database.d.ts +24 -0
- package/dist/debug.d.ts +270 -0
- package/dist/engine.d.ts +74 -0
- package/dist/environments.d.ts +244 -0
- package/dist/evidence.d.ts +277 -0
- package/dist/export.d.ts +311 -0
- package/dist/fingerprint.d.ts +594 -0
- package/dist/flow.d.ts +300 -0
- package/dist/gates.d.ts +103 -0
- package/dist/identity.d.ts +51 -0
- package/dist/ids.d.ts +7 -0
- package/dist/llm.d.ts +652 -0
- package/dist/models.d.ts +1807 -0
- package/dist/net.d.ts +325 -0
- package/dist/neutral.d.ts +41 -0
- package/dist/oauth.d.ts +703 -0
- package/dist/page.d.ts +1166 -0
- package/dist/perf.d.ts +481 -0
- package/dist/plan.d.ts +134 -0
- package/dist/project.d.ts +395 -0
- package/dist/providers.d.ts +37 -0
- package/dist/quota.d.ts +453 -0
- package/dist/recovery.d.ts +621 -0
- package/dist/request.d.ts +1492 -0
- package/dist/run.d.ts +779 -0
- package/dist/runtime.d.ts +212 -0
- package/dist/search.d.ts +375 -0
- package/dist/security.d.ts +534 -0
- package/dist/serve.d.ts +136 -0
- package/dist/session.d.ts +296 -0
- package/dist/storage.d.ts +53 -0
- package/dist/streaming.d.ts +1119 -0
- package/dist/swarm.d.ts +328 -0
- package/dist/tools.d.ts +170 -0
- package/dist/types.d.ts +8357 -0
- package/dist/utils.d.ts +82 -0
- package/dist/version.d.ts +7 -0
- package/dist/versionregistry.d.ts +406 -0
- package/dist/views.d.ts +579 -0
- package/dist/workbenchsession.d.ts +69 -0
- package/dist/workflow.d.ts +649 -0
- package/docs/readme.md +92 -0
- package/docs/schemas/antigravity.json +192 -0
- package/engine.ts +1524 -0
- package/environments.ts +1005 -0
- package/evidence.ts +643 -0
- package/export.ts +889 -0
- package/fingerprint.ts +1458 -0
- package/flow.ts +1190 -0
- package/gates.ts +307 -0
- package/gateway.ts +34 -0
- package/hardening.ts +191 -0
- package/headless.ts +369 -0
- package/http.ts +2110 -0
- package/identity.ts +246 -0
- package/ids.ts +26 -0
- package/index.ts +369 -0
- package/llm.ts +2682 -0
- package/mcp.ts +1835 -0
- package/{memory.js → memory.ts} +3834 -2310
- package/models.ts +3805 -0
- package/modes.ts +43 -0
- package/net.ts +1214 -0
- package/neutral.ts +362 -0
- package/node.ts +26 -0
- package/oauth.ts +2216 -0
- package/offscreen.ts +170 -0
- package/pack.ts +956 -0
- package/package.json +175 -82
- package/page.ts +7824 -0
- package/pagebridge.ts +1116 -0
- package/perf.ts +957 -0
- package/plan.ts +672 -0
- package/plugin.ts +76 -0
- package/{policy.js → policy.ts} +5073 -4679
- package/prisma.config.ts +27 -0
- package/progress.ts +1308 -0
- package/project.ts +1291 -0
- package/protocol.ts +4187 -0
- package/providers.ts +246 -0
- package/quota.ts +1887 -0
- package/recovery.ts +2433 -0
- package/request.ts +4482 -0
- package/run.ts +2135 -0
- package/runtime.ts +526 -0
- package/search.ts +1760 -0
- package/security.ts +1673 -0
- package/serve.ts +419 -0
- package/server.ts +5538 -0
- package/session.ts +1162 -0
- package/storage.ts +182 -0
- package/streaming.ts +4101 -0
- package/swarm.ts +884 -0
- package/sync.ts +56 -0
- package/tests/agentcert.test.ts +128 -0
- package/tests/agentfleet.test.ts +214 -0
- package/tests/agentreview.test.ts +241 -0
- package/tests/agentstream.test.ts +335 -0
- package/tests/agentwork.test.ts +356 -0
- package/tests/apifreeze.test.ts +290 -0
- package/tests/apimap.test.ts +112 -0
- package/tests/approvalgate.test.ts +175 -0
- package/tests/artifactmanifest.test.ts +204 -0
- package/tests/attentionfeed.test.ts +163 -0
- package/tests/backgroundruns.test.ts +100 -0
- package/tests/batchscheduling.test.ts +131 -0
- package/tests/blackboard.test.ts +238 -0
- package/tests/bridge.test.ts +1088 -0
- package/tests/browsercoverage.test.ts +134 -0
- package/tests/browserpolyfills.test.ts +124 -0
- package/tests/build.mjs +1260 -0
- package/tests/clientauth.test.ts +223 -0
- package/tests/clitools.test.ts +578 -0
- package/tests/confirmgates.test.ts +190 -0
- package/tests/containerpack.test.ts +143 -0
- package/tests/controlflow.test.ts +2120 -0
- package/tests/coordination.test.ts +345 -0
- package/tests/copyscreen.test.ts +139 -0
- package/tests/core.test.ts +417 -0
- package/tests/costcert.test.ts +95 -0
- package/tests/crossbrowser.test.ts +115 -0
- package/tests/cspaudit.test.ts +79 -0
- package/tests/dashdone.test.ts +615 -0
- package/tests/datagrid.test.ts +137 -0
- package/tests/doccheck.test.ts +107 -0
- package/tests/dombatch.test.ts +109 -0
- package/tests/emulation.test.ts +282 -0
- package/tests/environments.test.ts +259 -0
- package/tests/evidenceviews.test.ts +88 -0
- package/tests/exporttools.test.ts +185 -0
- package/tests/firefoxprep.test.ts +134 -0
- package/tests/flowlibrary.test.ts +242 -0
- package/tests/flowrun.test.ts +303 -0
- package/tests/forensics.test.ts +439 -0
- package/tests/gateway.test.ts +939 -0
- package/tests/headlesslib.test.ts +171 -0
- package/tests/httpstream.test.ts +277 -0
- package/tests/immutablelog.test.ts +157 -0
- package/tests/inboundguard.test.ts +177 -0
- package/tests/lazyload.test.ts +72 -0
- package/tests/librarymodes.test.ts +623 -0
- package/tests/llm.test.ts +696 -0
- package/tests/manifest.test.ts +226 -0
- package/tests/maskinputs.test.ts +150 -0
- package/tests/matrixverify.test.ts +111 -0
- package/tests/mavenpack.test.ts +75 -0
- package/tests/mcpserver.test.ts +704 -0
- package/tests/memory.test.ts +4155 -0
- package/tests/memorycare.test.ts +230 -0
- package/tests/migrateplan.test.ts +349 -0
- package/tests/minimization.test.ts +414 -0
- package/tests/modelroute.test.ts +112 -0
- package/tests/multiagent.test.ts +440 -0
- package/tests/nativebridge.test.ts +807 -0
- package/tests/navigation.test.ts +284 -0
- package/tests/nugetpack.test.ts +83 -0
- package/tests/orchestration.test.ts +449 -0
- package/tests/originpolicy.test.ts +405 -0
- package/tests/outputcompare.test.ts +97 -0
- package/tests/package.test.ts +91 -0
- package/tests/pagebridge.test.ts +5909 -0
- package/tests/pentest.test.ts +75 -0
- package/tests/perfrecords.test.ts +257 -0
- package/tests/permdiff.test.ts +85 -0
- package/tests/phishguard.test.ts +128 -0
- package/tests/pickerviews.test.ts +155 -0
- package/tests/pipelines.test.ts +381 -0
- package/tests/planlint.test.ts +240 -0
- package/tests/planreview.test.ts +383 -0
- package/tests/platformtargets.test.ts +56 -0
- package/tests/policy.test.ts +8284 -0
- package/tests/portability.test.ts +96 -0
- package/tests/progress.test.ts +1036 -0
- package/tests/promptlibrary.test.ts +116 -0
- package/tests/protocol.test.ts +4793 -0
- package/tests/publishingpipeline.test.ts +314 -0
- package/tests/quickactions.test.ts +164 -0
- package/tests/readiness.test.ts +78 -0
- package/tests/recipes.test.ts +163 -0
- package/tests/redactshots.test.ts +216 -0
- package/tests/registernvidiakeys.ts +51 -0
- package/tests/relayserve.test.ts +304 -0
- package/tests/releasenotes.test.ts +276 -0
- package/tests/resilience.test.ts +467 -0
- package/tests/resourceaware.test.ts +187 -0
- package/tests/runbudget.test.ts +125 -0
- package/tests/runhistory.test.ts +299 -0
- package/tests/runreplay.test.ts +113 -0
- package/tests/runresume.test.ts +184 -0
- package/tests/runstate.test.ts +288 -0
- package/tests/runtimeadapters.test.ts +251 -0
- package/tests/safariskeleton.test.ts +115 -0
- package/tests/sandboxframe.test.ts +196 -0
- package/tests/sbom.test.ts +105 -0
- package/tests/secretvault.test.ts +188 -0
- package/tests/selcache.test.ts +111 -0
- package/tests/sessioninterface.test.ts +791 -0
- package/tests/sessions.test.ts +591 -0
- package/tests/siteprefs.test.ts +228 -0
- package/tests/snapshotdelta.test.ts +165 -0
- package/tests/statusviews.test.ts +237 -0
- package/tests/stepmeter.test.ts +200 -0
- package/tests/streamparse.test.ts +163 -0
- package/tests/stylepruning.test.ts +90 -0
- package/tests/surfaces.test.ts +282 -0
- package/tests/sweep.test.ts +78 -0
- package/tests/syncbridge.test.ts +164 -0
- package/tests/taskqueue.test.ts +206 -0
- package/tests/toolcalls.test.ts +336 -0
- package/tests/toolcatalog.test.ts +201 -0
- package/tests/torture-agent.test.ts +706 -0
- package/tests/torture-auth.test.ts +461 -0
- package/tests/torture-commands.test.ts +895 -0
- package/tests/torture-data.test.ts +923 -0
- package/tests/torture-environments.test.ts +285 -0
- package/tests/torture-export.test.ts +263 -0
- package/tests/torture-http.test.ts +1379 -0
- package/tests/torture-llm.test.ts +808 -0
- package/tests/torture-memory.test.ts +1084 -0
- package/tests/torture-plan.test.ts +829 -0
- package/tests/torture-run.test.ts +626 -0
- package/tests/torture-session.test.ts +928 -0
- package/tests/torture-swarm.test.ts +1245 -0
- package/tests/torture-tools.test.ts +964 -0
- package/tests/torture-workflow.test.ts +2354 -0
- package/tests/tourviews.test.ts +52 -0
- package/tests/transparency.test.ts +147 -0
- package/tests/trigger.test.ts +941 -0
- package/tests/v1sunset.test.ts +428 -0
- package/tests/virtlist.test.ts +94 -0
- package/tests/vision.test.ts +540 -0
- package/tests/vsixpack.test.ts +145 -0
- package/tests/webapi.test.ts +523 -0
- package/tests/workflow.test.ts +1785 -0
- package/tests/workfloweditor.test.ts +759 -0
- package/tests/xpipack.test.ts +128 -0
- package/tools.ts +734 -0
- package/types.ts +9908 -0
- package/umd.ts +9 -0
- package/utils.ts +249 -0
- package/version.ts +8 -0
- package/versionregistry.ts +1310 -0
- package/views.ts +2153 -0
- package/vitest.config.ts +12 -0
- package/web/App.tsx +78 -0
- package/web/README.md +47 -0
- package/web/capacitor.config.ts +46 -0
- package/web/const.ts +19 -0
- package/web/control.shell.tsx +66 -0
- package/web/dashboardpage.ts +554 -0
- package/web/db.ts +493 -0
- package/web/design.html +541 -0
- package/web/gatewayview/Gateway.tsx +345 -0
- package/web/gatewayview/config.ts +1042 -0
- package/web/gatewayview/definition.ts +174 -0
- package/web/icons.ts +15 -0
- package/{manifest.json → web/manifest.json} +1 -1
- package/web/optionspage.ts +691 -0
- package/web/popup.ts +1651 -0
- package/web/schema.prisma +176 -0
- package/web/sidepanel.ts +7941 -0
- package/web/sitemanifest.json +13 -0
- package/web/terminal-ui.tsx +622 -0
- package/web/transparencypage.ts +541 -0
- package/web/vite.config.ts +39 -0
- package/web/workspace.ts +23 -0
- package/workbenchmemory.ts +104 -0
- package/workbenchsession.ts +304 -0
- package/workflow.ts +4495 -0
- package/bridge.d.ts.map +0 -1
- package/bridge.js +0 -1877
- package/bun.d.ts.map +0 -1
- package/bun.js +0 -52329
- package/caps/background.json +0 -712
- package/caps/cli.json +0 -360
- package/caps/library.json +0 -3611
- package/caps/mcp.json +0 -126
- package/caps/pagebridge.json +0 -377
- package/caps/popup.json +0 -51
- package/caps/sidepanel.json +0 -522
- package/checksums.txt +0 -142
- package/cli.d.ts.map +0 -1
- package/companion.d.ts.map +0 -1
- package/companion.js +0 -389
- package/crossbrowser.d.ts.map +0 -1
- package/crossbrowser.js +0 -1191
- package/dashdone.d.ts.map +0 -1
- package/dashdone.js +0 -368
- package/deno.d.ts.map +0 -1
- package/deno.js +0 -52325
- package/devthink.umd.js +0 -52346
- package/fixtures/bun.ts +0 -7
- package/fixtures/cjs.js +0 -6
- package/fixtures/deno.ts +0 -6
- package/fixtures/example-org-pagestate.json +0 -22
- package/fixtures/importers/automa-workflow.json +0 -18
- package/fixtures/importers/selenium-side.json +0 -20
- package/fixtures/importers/tabular-plan.csv +0 -7
- package/fixtures/importers/uivision-macro.json +0 -10
- package/fixtures/importers/v1-plan.json +0 -13
- package/fixtures/mcp-client.mjs +0 -14
- package/fixtures/pages/checkout.html +0 -192
- package/fixtures/pages/dashboard.html +0 -110
- package/fixtures/pages/feed.html +0 -113
- package/fixtures/pages/product-grid.html +0 -150
- package/fixtures/plans/form-inventory-plan.json +0 -11
- package/fixtures/plans/release-notes-plan.json +0 -11
- package/fixtures/recipes/agentscompete.json +0 -11
- package/fixtures/recipes/agentsforms.json +0 -11
- package/fixtures/recipes/agentsmonitor.json +0 -11
- package/fixtures/recipes/agentsreview.json +0 -29
- package/fixtures/recipes/agentswarm.json +0 -11
- package/fixtures/recipes/fillcalendar.json +0 -12
- package/fixtures/recipes/fillcheckout.json +0 -13
- package/fixtures/recipes/filldependentdropdowns.json +0 -12
- package/fixtures/recipes/fillfromcsv.json +0 -13
- package/fixtures/recipes/fillloginform.json +0 -12
- package/fixtures/recipes/fillmultistepwizard.json +0 -13
- package/fixtures/recipes/fillregistration.json +0 -12
- package/fixtures/recipes/fillsearchfilters.json +0 -12
- package/fixtures/recipes/filltypeahead.json +0 -11
- package/fixtures/recipes/monitoralerts.json +0 -12
- package/fixtures/recipes/monitoravailability.json +0 -11
- package/fixtures/recipes/monitornetworkquiet.json +0 -12
- package/fixtures/recipes/monitorpagechanges.json +0 -11
- package/fixtures/recipes/monitorpricechange.json +0 -11
- package/fixtures/recipes/monitorschedule.json +0 -12
- package/fixtures/recipes/scrapedynamicprices.json +0 -13
- package/fixtures/recipes/scrapeimagegallery.json +0 -12
- package/fixtures/recipes/scrapeinfinitefeed.json +0 -13
- package/fixtures/recipes/scrapenestedlists.json +0 -12
- package/fixtures/recipes/scrapeproductgrid.json +0 -12
- package/fixtures/recipes/scrapereviews.json +0 -12
- package/fixtures/recipes/scrapesearchresults.json +0 -12
- package/fixtures/recipes/scrapetablepagination.json +0 -12
- package/fixtures/recipes/testaccessibility.json +0 -34
- package/fixtures/recipes/testconsoleerrors.json +0 -12
- package/fixtures/recipes/testdeeplinks.json +0 -12
- package/fixtures/recipes/testformvalidation.json +0 -13
- package/fixtures/recipes/testlayoutshift.json +0 -12
- package/fixtures/recipes/testlinkcheck.json +0 -12
- package/fixtures/recipes/testloadtiming.json +0 -12
- package/fixtures/recipes/testvisualdiff.json +0 -12
- package/fixtures/workflows/changelog-digest-workflow.json +0 -13
- package/fixtures/workflows/form-survey-workflow.json +0 -13
- package/gallery.json +0 -706
- package/hardening.d.ts.map +0 -1
- package/hardening.js +0 -188
- package/headless.d.ts.map +0 -1
- package/headless.js +0 -11522
- package/http.d.ts.map +0 -1
- package/http.js +0 -2165
- package/index.cjs +0 -54682
- package/index.d.ts.map +0 -1
- package/index.js +0 -52310
- package/index.neutral.js +0 -46343
- package/mcp.d.ts.map +0 -1
- package/mcp.js +0 -20515
- package/memory.d.ts.map +0 -1
- package/nativehost.template.json +0 -10
- package/node.cjs +0 -54686
- package/node.d.ts.map +0 -1
- package/pack.d.ts.map +0 -1
- package/pack.js +0 -805
- package/policy.d.ts.map +0 -1
- package/progress.d.ts.map +0 -1
- package/progress.js +0 -645
- package/protocol.d.ts.map +0 -1
- package/protocol.js +0 -8334
- package/schemas/audit.schema.json +0 -226
- package/schemas/capability.schema.json +0 -113
- package/schemas/envelope.schema.json +0 -162
- package/schemas/memory.schema.json +0 -51
- package/schemas/observation.schema.json +0 -113
- package/schemas/plan.schema.json +0 -208
- package/schemas/progress.schema.json +0 -126
- package/schemas/proposal.schema.json +0 -68
- package/schemas/session.schema.json +0 -82
- package/schemas/tool.schema.json +0 -94
- package/server.d.ts.map +0 -1
- package/server.js +0 -28877
- package/umd-example.html +0 -18
- package/umd.d.ts.map +0 -1
- /package/{bun.d.ts → dist/bun.d.ts} +0 -0
- /package/{cli.d.ts → dist/cli.d.ts} +0 -0
- /package/{companion.d.ts → dist/companion.d.ts} +0 -0
- /package/{crossbrowser.d.ts → dist/crossbrowser.d.ts} +0 -0
- /package/{dashdone.d.ts → dist/dashdone.d.ts} +0 -0
- /package/{deno.d.ts → dist/deno.d.ts} +0 -0
- /package/{hardening.d.ts → dist/hardening.d.ts} +0 -0
- /package/{headless.d.ts → dist/headless.d.ts} +0 -0
- /package/{http.d.ts → dist/http.d.ts} +0 -0
- /package/{index.d.ts → dist/index.d.ts} +0 -0
- /package/{mcp.d.ts → dist/mcp.d.ts} +0 -0
- /package/{memory.d.ts → dist/memory.d.ts} +0 -0
- /package/{node.d.ts → dist/node.d.ts} +0 -0
- /package/{pack.d.ts → dist/pack.d.ts} +0 -0
- /package/{policy.d.ts → dist/policy.d.ts} +0 -0
- /package/{progress.d.ts → dist/progress.d.ts} +0 -0
- /package/{protocol.d.ts → dist/protocol.d.ts} +0 -0
- /package/{server.d.ts → dist/server.d.ts} +0 -0
- /package/{umd.d.ts → dist/umd.d.ts} +0 -0
package/run.ts
ADDED
|
@@ -0,0 +1,2135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The run module of the 1.1.90 consolidation: every correlated variation of the run logic interned in this one file, so the module family carries one surface without duplicate variations.
|
|
3
|
+
* The correlation is the run lifecycle itself, ordered from the base outward: runstate holds the typed run state every other variation builds on (the keepalive heartbeat trail, the url history, the environment and turnaround provenance, the restart recovery, the zombie reaping, the storage level run lock, the integrity sealing and the quota pruning); resilience continues the same lifecycle at the runrecord level (the legal state machine, the offline queue with its replay, the checkpoints, the heartbeat staleness window, the zombie check beside the reap audit event and the rollback compensation the user chooses); runhistory keeps the per run url visits, the merged runtimeline stream, the tab isolation namespace and the session lock that serializes concurrent runs; runtimeline deepens the observation side of the same runs (console, error, rejection, long task and network failure capture with redaction, spam collapse, rotation and the console diff between two runs); runbudget guards the step and memory ceilings with the timeoutcancel bound; runresume, runreplay and runparallel optimize the identical lifecycle (the checkpoint resume with fingerprint revalidation, the audit walk replay of a sealed run and the parallel read lanes with carried selectors); and backgroundruns closes the family with the queue that keeps reviewed workflows executing with no open surface, holding the same keepalive signal of the base lifecycle.
|
|
4
|
+
* No window, depth, retention or ceiling is ever hardcoded anywhere in the family: every interval, tolerance, budget and bound stays the user's choice, and no recovery, replay, rollback or queue path ever bypasses the human review.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/* ── Merged from runstate.ts ── */
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
environmentkind,
|
|
11
|
+
environmentprovenance,
|
|
12
|
+
runlock,
|
|
13
|
+
runstaterecord,
|
|
14
|
+
sealedrunstate,
|
|
15
|
+
urlhistoryentry,
|
|
16
|
+
} from "./types.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Run state logic of the 1.1.60 family.
|
|
20
|
+
* Correlated rules for the typed run state live here: the keepalive lifecycle with its start and stop events, its heartbeat trail and its reattach after a service worker restart, the url history of every navigation inside the run, the environment and worker turnaround of every step, the restart recovery of the pending step, the zombie reaping of runs whose heartbeat fell silent, the storage level run lock that holds a session against concurrent runs, the serialization of steps that share one tab across parallel branches, the integrity sealing of persisted run state and the quota pruning under storage pressure.
|
|
21
|
+
* No interval, window or lock expiry is ever hardcoded: the heartbeat interval, the zombie tolerance and the lock expiry stay user choices, the run state persists on every heartbeat, and no recovery path ever bypasses the human review.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Opens one run state: the keepalive port opens, the start event records and the run waits for its first heartbeat. */
|
|
25
|
+
export function openrun(input: {
|
|
26
|
+
runid: string;
|
|
27
|
+
sessionid: string;
|
|
28
|
+
planid: string;
|
|
29
|
+
profileid: string;
|
|
30
|
+
interval: number;
|
|
31
|
+
now: number;
|
|
32
|
+
}): runstaterecord {
|
|
33
|
+
if (input.runid.trim() === "" || input.sessionid.trim() === "")
|
|
34
|
+
throw new Error("The run state needs its run and session ids.");
|
|
35
|
+
if (!Number.isFinite(input.interval) || input.interval <= 0)
|
|
36
|
+
throw new Error("The keepalive heartbeat interval stays a positive user value in milliseconds.");
|
|
37
|
+
return {
|
|
38
|
+
runid: input.runid,
|
|
39
|
+
sessionid: input.sessionid,
|
|
40
|
+
planid: input.planid,
|
|
41
|
+
profileid: input.profileid,
|
|
42
|
+
state: "active",
|
|
43
|
+
urlhistory: [],
|
|
44
|
+
environments: {},
|
|
45
|
+
turnarounds: {},
|
|
46
|
+
keepalive: {
|
|
47
|
+
runid: input.runid,
|
|
48
|
+
sessionid: input.sessionid,
|
|
49
|
+
state: "active",
|
|
50
|
+
startedat: input.now,
|
|
51
|
+
interval: input.interval,
|
|
52
|
+
beats: 0,
|
|
53
|
+
lastbeatat: input.now,
|
|
54
|
+
portopen: true,
|
|
55
|
+
events: [
|
|
56
|
+
{
|
|
57
|
+
kind: "start" as const,
|
|
58
|
+
at: input.now,
|
|
59
|
+
detail: `The keepalive port opens for the run ${input.runid} and beats every ${input.interval} milliseconds.`,
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
updatedat: input.now,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Emits one keepalive heartbeat: the beat counter grows, the last beat time moves and the event trail records the beat so the persisted state carries it into any restart recovery. */
|
|
68
|
+
export function beatrun(state: runstaterecord, now: number): runstaterecord {
|
|
69
|
+
if (state.keepalive.state !== "active")
|
|
70
|
+
throw new Error(`The run ${state.runid} is ${state.keepalive.state}; a stopped run emits no heartbeat.`);
|
|
71
|
+
return {
|
|
72
|
+
...state,
|
|
73
|
+
keepalive: {
|
|
74
|
+
...state.keepalive,
|
|
75
|
+
beats: state.keepalive.beats + 1,
|
|
76
|
+
lastbeatat: now,
|
|
77
|
+
events: [...state.keepalive.events, { kind: "heartbeat" as const, at: now }].slice(-200),
|
|
78
|
+
},
|
|
79
|
+
updatedat: now,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Closes one run at its terminal state: the keepalive port closes, the stop event records and the run state freezes for the audit trail. */
|
|
84
|
+
export function closerun(state: runstaterecord, now: number): runstaterecord {
|
|
85
|
+
if (state.keepalive.state === "stopped")
|
|
86
|
+
throw new Error(`The run ${state.runid} already stopped its keepalive port.`);
|
|
87
|
+
return {
|
|
88
|
+
...state,
|
|
89
|
+
state: "completed",
|
|
90
|
+
keepalive: {
|
|
91
|
+
...state.keepalive,
|
|
92
|
+
state: "stopped",
|
|
93
|
+
stoppedat: now,
|
|
94
|
+
portopen: false,
|
|
95
|
+
events: [
|
|
96
|
+
...state.keepalive.events,
|
|
97
|
+
{ kind: "stop" as const, at: now, detail: "The run reached a terminal state and the keepalive port closed." },
|
|
98
|
+
].slice(-200),
|
|
99
|
+
},
|
|
100
|
+
updatedat: now,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Reattaches the keepalive port after a service worker restart: the persisted state rehydrates, the reattach event records and the pending step stays exactly where the run stopped. */
|
|
105
|
+
export function reattachrun(state: runstaterecord, now: number): runstaterecord {
|
|
106
|
+
if (state.keepalive.state !== "active")
|
|
107
|
+
throw new Error(`The run ${state.runid} is ${state.keepalive.state}; a stopped run never reattaches.`);
|
|
108
|
+
return {
|
|
109
|
+
...state,
|
|
110
|
+
state: "recovered",
|
|
111
|
+
keepalive: {
|
|
112
|
+
...state.keepalive,
|
|
113
|
+
portopen: true,
|
|
114
|
+
events: [
|
|
115
|
+
...state.keepalive.events,
|
|
116
|
+
{
|
|
117
|
+
kind: "reattach" as const,
|
|
118
|
+
at: now,
|
|
119
|
+
detail: "The service worker restarted and the keepalive port reattached from the persisted run state.",
|
|
120
|
+
},
|
|
121
|
+
].slice(-200),
|
|
122
|
+
},
|
|
123
|
+
updatedat: now,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Records one navigation inside the run: every url the run visits lands its history entry so the audit reads the full route of the run. */
|
|
128
|
+
export function recordurl(state: runstaterecord, entry: { url: string; stepid: string; now: number }): runstaterecord {
|
|
129
|
+
if (entry.url.trim() === "") throw new Error("The url history entry needs its url.");
|
|
130
|
+
const record: urlhistoryentry = { url: entry.url, stepid: entry.stepid, at: entry.now };
|
|
131
|
+
return {
|
|
132
|
+
...state,
|
|
133
|
+
urlhistory: [
|
|
134
|
+
...state.urlhistory.filter((item) => !(item.url === record.url && item.stepid === record.stepid)),
|
|
135
|
+
record,
|
|
136
|
+
],
|
|
137
|
+
updatedat: entry.now,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Records the environment of one executed step with its provenance: the step id, the environment and the origin land in the run state beside the outcome. */
|
|
142
|
+
export function recordenvironment(
|
|
143
|
+
state: runstaterecord,
|
|
144
|
+
input: { stepid: string; environment: environmentkind; origin: string; now: number },
|
|
145
|
+
): runstaterecord {
|
|
146
|
+
if (input.stepid.trim() === "") throw new Error("The environment record needs its step id.");
|
|
147
|
+
const provenance: environmentprovenance = {
|
|
148
|
+
origin: input.origin,
|
|
149
|
+
stepid: input.stepid,
|
|
150
|
+
environment: input.environment,
|
|
151
|
+
};
|
|
152
|
+
return {
|
|
153
|
+
...state,
|
|
154
|
+
environments: { ...state.environments, [input.stepid]: input.environment },
|
|
155
|
+
lastprovenance: provenance,
|
|
156
|
+
updatedat: input.now,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Records the worker turnaround of one offloaded step: the milliseconds between the worker request and its answer land in the run state for later profiling. */
|
|
161
|
+
export function recordturnaround(
|
|
162
|
+
state: runstaterecord,
|
|
163
|
+
input: { stepid: string; milliseconds: number; now: number },
|
|
164
|
+
): runstaterecord {
|
|
165
|
+
if (input.stepid.trim() === "") throw new Error("The turnaround record needs its step id.");
|
|
166
|
+
if (!Number.isFinite(input.milliseconds) || input.milliseconds < 0)
|
|
167
|
+
throw new Error("The worker turnaround stays a non-negative duration in milliseconds.");
|
|
168
|
+
return { ...state, turnarounds: { ...state.turnarounds, [input.stepid]: input.milliseconds }, updatedat: input.now };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Marks the pending step of one run for restart recovery: the executor resumes exactly this step after the service worker restart, and no other step ever moves. */
|
|
172
|
+
export function markpending(state: runstaterecord, stepid: string | undefined, now: number): runstaterecord {
|
|
173
|
+
return {
|
|
174
|
+
...state,
|
|
175
|
+
...(stepid !== undefined && stepid.trim() !== "" ? { pendingstepid: stepid } : {}),
|
|
176
|
+
updatedat: now,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Reads the recovery plan of one interrupted run: the pending step names the single step the executor resumes, and a run without a pending step starts over through the same review instead of guessing. */
|
|
181
|
+
export function recoveryplan(state: runstaterecord): {
|
|
182
|
+
runid: string;
|
|
183
|
+
pendingstepid?: string;
|
|
184
|
+
recoverable: boolean;
|
|
185
|
+
reason: string;
|
|
186
|
+
} {
|
|
187
|
+
if (state.state === "completed")
|
|
188
|
+
return {
|
|
189
|
+
runid: state.runid,
|
|
190
|
+
recoverable: false,
|
|
191
|
+
reason: `The run ${state.runid} completed before the restart; nothing resumes.`,
|
|
192
|
+
};
|
|
193
|
+
if (state.state === "reaped")
|
|
194
|
+
return {
|
|
195
|
+
runid: state.runid,
|
|
196
|
+
recoverable: false,
|
|
197
|
+
reason: `The run ${state.runid} was reaped as a zombie; the user starts a fresh reviewed run.`,
|
|
198
|
+
};
|
|
199
|
+
if (state.pendingstepid === undefined || state.pendingstepid.trim() === "")
|
|
200
|
+
return {
|
|
201
|
+
runid: state.runid,
|
|
202
|
+
recoverable: false,
|
|
203
|
+
reason: `The run ${state.runid} carries no pending step; a fresh reviewed run starts over instead of guessing.`,
|
|
204
|
+
};
|
|
205
|
+
return {
|
|
206
|
+
runid: state.runid,
|
|
207
|
+
pendingstepid: state.pendingstepid,
|
|
208
|
+
recoverable: true,
|
|
209
|
+
reason: `The executor resumes the pending step ${state.pendingstepid} of the run ${state.runid} from the persisted run state.`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Sweeps the zombie runs whose heartbeat fell silent: a run whose last beat sits past the tolerated silent intervals reaps, while a live run keeps its state; the tolerance stays the user configured number of intervals. */
|
|
214
|
+
export function zombiesweep(input: { states: runstaterecord[]; now: number; interval: number; missedlimit: number }): {
|
|
215
|
+
states: runstaterecord[];
|
|
216
|
+
reaped: string[];
|
|
217
|
+
} {
|
|
218
|
+
if (!Number.isFinite(input.interval) || input.interval <= 0)
|
|
219
|
+
throw new Error("The zombie sweep needs its heartbeat interval as a positive user value.");
|
|
220
|
+
if (!Number.isInteger(input.missedlimit) || input.missedlimit < 1)
|
|
221
|
+
throw new Error("The zombie tolerance stays a positive whole number of silent intervals.");
|
|
222
|
+
const silentfor = input.interval * input.missedlimit;
|
|
223
|
+
const zombies = input.states.filter(
|
|
224
|
+
(state) => state.keepalive.state === "active" && input.now - state.keepalive.lastbeatat > silentfor,
|
|
225
|
+
);
|
|
226
|
+
if (zombies.length === 0) return { states: input.states, reaped: [] };
|
|
227
|
+
const ids = new Set(zombies.map((state) => state.runid));
|
|
228
|
+
return {
|
|
229
|
+
states: input.states.map((state) =>
|
|
230
|
+
ids.has(state.runid)
|
|
231
|
+
? {
|
|
232
|
+
...state,
|
|
233
|
+
state: "reaped" as const,
|
|
234
|
+
keepalive: {
|
|
235
|
+
...state.keepalive,
|
|
236
|
+
state: "stopped" as const,
|
|
237
|
+
portopen: false,
|
|
238
|
+
...(state.keepalive.stoppedat === undefined ? { stoppedat: input.now } : {}),
|
|
239
|
+
events: [
|
|
240
|
+
...state.keepalive.events,
|
|
241
|
+
{ kind: "stop" as const, at: input.now, detail: "The zombie reaper closed the silent run." },
|
|
242
|
+
],
|
|
243
|
+
},
|
|
244
|
+
}
|
|
245
|
+
: state,
|
|
246
|
+
),
|
|
247
|
+
reaped: [...ids],
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Acquires the storage level run lock of one session: a session with no live lock gains its run lock while a concurrent run of the same session refuses, so one session never carries two runs at once. */
|
|
252
|
+
export function acquirerunlock(input: {
|
|
253
|
+
locks: runlock[];
|
|
254
|
+
sessionid: string;
|
|
255
|
+
runid: string;
|
|
256
|
+
holder: string;
|
|
257
|
+
expiresat?: number;
|
|
258
|
+
now: number;
|
|
259
|
+
}): { locks: runlock[]; acquired: boolean; reason: string } {
|
|
260
|
+
if (input.sessionid.trim() === "" || input.runid.trim() === "")
|
|
261
|
+
throw new Error("The run lock needs its session and run ids.");
|
|
262
|
+
const live = input.locks.filter(
|
|
263
|
+
(lock) => lock.sessionid === input.sessionid && (lock.expiresat === undefined || lock.expiresat > input.now),
|
|
264
|
+
);
|
|
265
|
+
const held = live.find((lock) => lock.runid !== input.runid);
|
|
266
|
+
if (held)
|
|
267
|
+
return {
|
|
268
|
+
locks: input.locks,
|
|
269
|
+
acquired: false,
|
|
270
|
+
reason: `The session ${input.sessionid} already holds the run ${held.runid}; a session never carries two concurrent runs.`,
|
|
271
|
+
};
|
|
272
|
+
const own = input.locks.find((lock) => lock.sessionid === input.sessionid && lock.runid === input.runid);
|
|
273
|
+
if (own)
|
|
274
|
+
return {
|
|
275
|
+
locks: input.locks,
|
|
276
|
+
acquired: true,
|
|
277
|
+
reason: `The run ${input.runid} of the session ${input.sessionid} already holds its lock.`,
|
|
278
|
+
};
|
|
279
|
+
const lock: runlock = {
|
|
280
|
+
sessionid: input.sessionid,
|
|
281
|
+
runid: input.runid,
|
|
282
|
+
holder: input.holder,
|
|
283
|
+
acquiredat: input.now,
|
|
284
|
+
...(input.expiresat !== undefined ? { expiresat: input.expiresat } : {}),
|
|
285
|
+
};
|
|
286
|
+
return {
|
|
287
|
+
locks: [...input.locks.filter((entry) => entry.sessionid !== input.sessionid), lock],
|
|
288
|
+
acquired: true,
|
|
289
|
+
reason: `The run ${input.runid} locked the session ${input.sessionid} against concurrent runs.`,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Releases the storage level run lock of one session: the run that holds the lock frees it while another holder refuses. */
|
|
294
|
+
export function releaserunlock(input: { locks: runlock[]; sessionid: string; runid: string; now: number }): {
|
|
295
|
+
locks: runlock[];
|
|
296
|
+
released: boolean;
|
|
297
|
+
reason: string;
|
|
298
|
+
} {
|
|
299
|
+
const lock = input.locks.find((entry) => entry.sessionid === input.sessionid);
|
|
300
|
+
if (!lock || lock.runid !== input.runid)
|
|
301
|
+
return {
|
|
302
|
+
locks: input.locks,
|
|
303
|
+
released: false,
|
|
304
|
+
reason: `The run ${input.runid} holds no lock of the session ${input.sessionid}.`,
|
|
305
|
+
};
|
|
306
|
+
return {
|
|
307
|
+
locks: input.locks.filter((entry) => entry.sessionid !== input.sessionid),
|
|
308
|
+
released: true,
|
|
309
|
+
reason: `The run ${input.runid} released the run lock of the session ${input.sessionid}.`,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Expires the run locks past their user configured expiry: an expired lock frees its session while a lock without an expiry keeps holding until its run releases it. */
|
|
314
|
+
export function expirerunlocks(locks: runlock[], now: number): { locks: runlock[]; expired: string[] } {
|
|
315
|
+
const expired = locks.filter((lock) => lock.expiresat !== undefined && now > lock.expiresat);
|
|
316
|
+
if (expired.length === 0) return { locks, expired: [] };
|
|
317
|
+
const ids = new Set(expired.map((lock) => lock.sessionid));
|
|
318
|
+
return { locks: locks.filter((lock) => !ids.has(lock.sessionid)), expired: [...ids] };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Serializes the steps that share one tab across parallel branches: every branch keeps its inner order while the shared tab receives one deterministic lane, so two branches never touch one tab inside the same beat. */
|
|
322
|
+
export function serializesteps(input: {
|
|
323
|
+
branches: Array<{ branchid: string; steps: Array<{ stepid: string; tabid: number }> }>;
|
|
324
|
+
}): Array<{ branchid: string; stepid: string; tabid: number; order: number }> {
|
|
325
|
+
const shared = new Set<number>();
|
|
326
|
+
const counts = new Map<number, number>();
|
|
327
|
+
for (const branch of input.branches)
|
|
328
|
+
for (const step of branch.steps) counts.set(step.tabid, (counts.get(step.tabid) ?? 0) + 1);
|
|
329
|
+
for (const [tabid, count] of counts) if (count > 1) shared.add(tabid);
|
|
330
|
+
const order: Array<{ branchid: string; stepid: string; tabid: number; order: number }> = [];
|
|
331
|
+
let cursor = 0;
|
|
332
|
+
for (const branch of input.branches) {
|
|
333
|
+
for (const step of branch.steps) {
|
|
334
|
+
if (shared.has(step.tabid)) {
|
|
335
|
+
order.push({ branchid: branch.branchid, stepid: step.stepid, tabid: step.tabid, order: cursor });
|
|
336
|
+
cursor += 1;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
for (const branch of input.branches) {
|
|
341
|
+
for (const step of branch.steps) {
|
|
342
|
+
if (!shared.has(step.tabid)) {
|
|
343
|
+
order.push({ branchid: branch.branchid, stepid: step.stepid, tabid: step.tabid, order: cursor });
|
|
344
|
+
cursor += 1;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return order;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Seals one persisted run state with its sha-256 integrity digest: the payload and its digest travel together so a tampered run state at rest stays detectable before any recovery uses it. */
|
|
352
|
+
export async function sealrunstate(state: runstaterecord): Promise<sealedrunstate> {
|
|
353
|
+
const payload = JSON.stringify(state);
|
|
354
|
+
const digest = await sha256(payload);
|
|
355
|
+
return { payload, algorithm: "sha-256", digest, sealedat: state.updatedat };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Opens one sealed run state: the digest must match the payload before the recovery reads it, and a tampered or malformed seal refuses. */
|
|
359
|
+
export async function openseal(sealed: sealedrunstate): Promise<runstaterecord> {
|
|
360
|
+
const digest = await sha256(sealed.payload);
|
|
361
|
+
if (digest !== sealed.digest)
|
|
362
|
+
throw new Error(
|
|
363
|
+
"The sealed run state fails its integrity digest; a tampered run state never reaches the recovery.",
|
|
364
|
+
);
|
|
365
|
+
const parsed = JSON.parse(sealed.payload) as runstaterecord;
|
|
366
|
+
if (
|
|
367
|
+
parsed === null ||
|
|
368
|
+
typeof parsed !== "object" ||
|
|
369
|
+
typeof parsed.runid !== "string" ||
|
|
370
|
+
typeof parsed.sessionid !== "string"
|
|
371
|
+
)
|
|
372
|
+
throw new Error("The sealed run state carries no run record.");
|
|
373
|
+
return parsed;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function sha256(value: string): Promise<string> {
|
|
377
|
+
const bytes = new TextEncoder().encode(value);
|
|
378
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
379
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Prunes the stored run states under storage pressure: the oldest completed run states leave first while every active or recovered run keeps its state, and the pruned ids name what left. */
|
|
383
|
+
export function prunerunstates(input: { states: runstaterecord[]; used: number; ceiling: number }): {
|
|
384
|
+
states: runstaterecord[];
|
|
385
|
+
pruned: string[];
|
|
386
|
+
reason: string;
|
|
387
|
+
} {
|
|
388
|
+
if (!Number.isFinite(input.ceiling) || input.ceiling <= 0)
|
|
389
|
+
return {
|
|
390
|
+
states: input.states,
|
|
391
|
+
pruned: [],
|
|
392
|
+
reason: "No storage ceiling is configured, so the run states stay under the user choice alone.",
|
|
393
|
+
};
|
|
394
|
+
if (input.used <= input.ceiling)
|
|
395
|
+
return {
|
|
396
|
+
states: input.states,
|
|
397
|
+
pruned: [],
|
|
398
|
+
reason: `The run state storage sits at ${input.used} of the ${input.ceiling} bytes the user configured; no pressure exists.`,
|
|
399
|
+
};
|
|
400
|
+
const removable = input.states
|
|
401
|
+
.filter((state) => state.state === "completed" || state.state === "reaped")
|
|
402
|
+
.sort((one, two) => one.updatedat - two.updatedat);
|
|
403
|
+
const states = [...input.states];
|
|
404
|
+
const pruned: string[] = [];
|
|
405
|
+
for (const candidate of removable) {
|
|
406
|
+
pruned.push(candidate.runid);
|
|
407
|
+
const index = states.findIndex((state) => state.runid === candidate.runid);
|
|
408
|
+
if (index >= 0) states.splice(index, 1);
|
|
409
|
+
if (pruned.length >= Math.max(1, Math.ceil(input.states.length / 2))) break;
|
|
410
|
+
}
|
|
411
|
+
return {
|
|
412
|
+
states,
|
|
413
|
+
pruned,
|
|
414
|
+
reason: `The storage pressure at ${input.used} of ${input.ceiling} bytes pruned the ${pruned.length} oldest finished run state${pruned.length === 1 ? "" : "s"} while every active run keeps its state.`,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Exports the run states as one single audit record: every run, its url history, its environments, its turnarounds and its keepalive trail in one envelope the auditor reads at once. */
|
|
419
|
+
export function exportrunstate(
|
|
420
|
+
states: runstaterecord[],
|
|
421
|
+
now: number,
|
|
422
|
+
): { runs: number; urls: number; environments: number; offloaded: number; beats: number; exportedat: number } {
|
|
423
|
+
return {
|
|
424
|
+
runs: states.length,
|
|
425
|
+
urls: states.reduce((total, state) => total + state.urlhistory.length, 0),
|
|
426
|
+
environments: states.reduce((total, state) => total + Object.keys(state.environments).length, 0),
|
|
427
|
+
offloaded: states.reduce((total, state) => total + Object.values(state.turnarounds).length, 0),
|
|
428
|
+
beats: states.reduce((total, state) => total + state.keepalive.beats, 0),
|
|
429
|
+
exportedat: now,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/* ── Merged from resilience.ts ── */
|
|
434
|
+
|
|
435
|
+
import { iscomplete } from "./progress.js";
|
|
436
|
+
import type {
|
|
437
|
+
agentplan,
|
|
438
|
+
auditevent,
|
|
439
|
+
checkpointrecord,
|
|
440
|
+
heartbeatrecord,
|
|
441
|
+
planprogress,
|
|
442
|
+
rollbackitem,
|
|
443
|
+
runrecord,
|
|
444
|
+
runsettings,
|
|
445
|
+
runstate,
|
|
446
|
+
runtransition,
|
|
447
|
+
queuedtask,
|
|
448
|
+
toolstep,
|
|
449
|
+
} from "./types.js";
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Run lifecycle logic of the 1.1.70 family.
|
|
453
|
+
* The runstatemachine tracks every legal transition of a run so a service worker restart or a lost connection never loses work: approved plans queue offline with monotonic sequences, steps carry deterministic idempotencykeys so replays deduplicate, checkpoints capture the completed steps with their page digest, heartbeats time the live runs, zombies reap through the audit trail, and failed runs roll back through compensating steps the user explicitly chooses.
|
|
454
|
+
* No window, depth or retention is ever hardcoded: the heartbeat staleness window, the offline queue depth and the failed run retention stay user choices, and no recovery, replay or rollback path ever bypasses the human review.
|
|
455
|
+
*/
|
|
456
|
+
|
|
457
|
+
/** The documented heartbeat staleness window the roadmap names while the configured window stays the user's choice with no code ceiling. */
|
|
458
|
+
export const roadmapheartbeatwindow = 60_000;
|
|
459
|
+
|
|
460
|
+
/** The legal transitions of each runstate: queued runs start or cancel, running runs pause, complete, fail or cancel, paused and awaitingapproval runs resume or stop, and only a failed or cancelled run rolls back. */
|
|
461
|
+
export function runstatemachine(): Record<runstate, runstate[]> {
|
|
462
|
+
return {
|
|
463
|
+
queued: ["running", "awaitingapproval", "cancelled", "failed"],
|
|
464
|
+
running: ["paused", "awaitingapproval", "completed", "failed", "cancelled"],
|
|
465
|
+
paused: ["running", "cancelled", "failed"],
|
|
466
|
+
awaitingapproval: ["running", "paused", "cancelled", "failed"],
|
|
467
|
+
completed: [],
|
|
468
|
+
failed: ["rolledback"],
|
|
469
|
+
cancelled: ["rolledback"],
|
|
470
|
+
rolledback: [],
|
|
471
|
+
pending: ["running", "paused", "cancelled", "failed"],
|
|
472
|
+
done: [],
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Reads whether one run transition stays legal inside the state machine: every state allows only the pairs its lifecycle defines. */
|
|
477
|
+
export function transitionislegal(transition: runtransition): boolean {
|
|
478
|
+
return runstatemachine()[transition.from].includes(transition.to);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Moves one runrecord through a single legal transition or throws: an illegal transition never silently mutates the run state. */
|
|
482
|
+
export function advancerun(run: runrecord, to: runstate, now: number): runrecord {
|
|
483
|
+
if (!transitionislegal({ from: run.state, to }))
|
|
484
|
+
throw new Error(
|
|
485
|
+
`The run ${run.runid} cannot move from ${run.state} to ${to}; the run lifecycle allows only its legal transitions.`,
|
|
486
|
+
);
|
|
487
|
+
return { ...run, state: to, updatedat: now };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Creates one runrecord in the queued state for an approved plan: the run waits in the offlinequeue or the executor before it ever runs. */
|
|
491
|
+
export function initialrun(input: { planid: string; sessionid: string; now: number; runid?: string }): runrecord {
|
|
492
|
+
if (input.planid.trim() === "") throw new Error("The run record needs its plan id.");
|
|
493
|
+
if (input.sessionid.trim() === "") throw new Error("The run record needs its session id.");
|
|
494
|
+
return {
|
|
495
|
+
runid: input.runid ?? crypto.randomUUID(),
|
|
496
|
+
planid: input.planid,
|
|
497
|
+
sessionid: input.sessionid,
|
|
498
|
+
state: "queued",
|
|
499
|
+
createdat: input.now,
|
|
500
|
+
updatedat: input.now,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Derives the deterministic idempotencykey of one step from its plan and step identity so a replay of the same step deduplicates on the same key. */
|
|
505
|
+
export function stepkey(input: { planid: string; stepid: string; kind: string }): string {
|
|
506
|
+
if (input.planid.trim() === "" || input.stepid.trim() === "")
|
|
507
|
+
throw new Error("The step key needs its plan and step ids.");
|
|
508
|
+
if (input.kind.trim() === "") throw new Error("The step key needs its step kind.");
|
|
509
|
+
return `${input.planid}:${input.stepid}:${input.kind}`;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Returns the count of tasks waiting in the offlinequeue. */
|
|
513
|
+
export function queuesize(queue: queuedtask[]): number {
|
|
514
|
+
return queue.length;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Appends one approved plan to the offlinequeue with a monotonic sequence number so the replay drains the tasks in their enqueue order. */
|
|
518
|
+
export function enqueueoffline(
|
|
519
|
+
queue: queuedtask[],
|
|
520
|
+
input: { plan: agentplan; sessionid: string; now: number },
|
|
521
|
+
): queuedtask[] {
|
|
522
|
+
if (input.plan.state !== "approved")
|
|
523
|
+
throw new Error("Only an approved plan enters the offlinequeue; an unreviewed plan never queues.");
|
|
524
|
+
const sequence = queue.reduce((highest, task) => Math.max(highest, task.sequence), 0) + 1;
|
|
525
|
+
return [
|
|
526
|
+
...queue,
|
|
527
|
+
{
|
|
528
|
+
sequence,
|
|
529
|
+
planid: input.plan.id,
|
|
530
|
+
sessionid: input.sessionid,
|
|
531
|
+
plan: input.plan,
|
|
532
|
+
enqueuedat: input.now,
|
|
533
|
+
expiresat: input.plan.expiresat,
|
|
534
|
+
},
|
|
535
|
+
];
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Returns the next queuedtask in sequence order and the queue without it; an empty queue returns no task. */
|
|
539
|
+
export function dequeueoffline(queue: queuedtask[]): { queue: queuedtask[]; task?: queuedtask } {
|
|
540
|
+
if (queue.length === 0) return { queue };
|
|
541
|
+
const ordered = [...queue].sort((one, two) => one.sequence - two.sequence);
|
|
542
|
+
const [task, ...rest] = ordered;
|
|
543
|
+
if (task === undefined) return { queue: [...ordered] };
|
|
544
|
+
return { queue: rest, task };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Reads the replay plan of the offlinequeue once connectivity returns: every task drains in sequence order while the tasks whose plan window passed offline expire and fail instead of running. */
|
|
548
|
+
export function replayqueue(queue: queuedtask[], now: number): { ready: queuedtask[]; expired: queuedtask[] } {
|
|
549
|
+
const ordered = [...queue].sort((one, two) => one.sequence - two.sequence);
|
|
550
|
+
const ready = ordered.filter((task) => task.expiresat > now);
|
|
551
|
+
const expired = ordered.filter((task) => task.expiresat <= now);
|
|
552
|
+
return { ready, expired };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Captures one checkpoint of a run: the completed step ids, the step boundary, the page digest a resume revalidates and the timestamp. */
|
|
556
|
+
export function makecheckpoint(input: {
|
|
557
|
+
runid: string;
|
|
558
|
+
stepid: string;
|
|
559
|
+
completed: string[];
|
|
560
|
+
digest: string;
|
|
561
|
+
now: number;
|
|
562
|
+
}): checkpointrecord {
|
|
563
|
+
if (input.runid.trim() === "") throw new Error("The checkpoint needs its run id.");
|
|
564
|
+
if (input.stepid.trim() === "") throw new Error("The checkpoint needs its step id.");
|
|
565
|
+
if (input.digest.trim() === "")
|
|
566
|
+
throw new Error("The checkpoint needs its page digest; a digestless resume never revalidates the page.");
|
|
567
|
+
return {
|
|
568
|
+
runid: input.runid,
|
|
569
|
+
stepid: input.stepid,
|
|
570
|
+
completed: [...input.completed],
|
|
571
|
+
digest: input.digest,
|
|
572
|
+
createdat: input.now,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** Returns the steps that follow the last stored checkpoint so the resume continues at the first open step instead of repeating completed work. */
|
|
577
|
+
export function resumecheckpoint(input: { checkpoint: checkpointrecord | undefined; steps: string[] }): string[] {
|
|
578
|
+
if (input.checkpoint === undefined) return [...input.steps];
|
|
579
|
+
const done = new Set(input.checkpoint.completed);
|
|
580
|
+
return input.steps.filter((step) => !done.has(step));
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Verifies one checkpointrecord against its run and page digest: a checkpoint of another run or a page that changed since the capture never validates. */
|
|
584
|
+
export function checkpointisvalid(input: { checkpoint: checkpointrecord; runid: string; digest: string }): boolean {
|
|
585
|
+
return (
|
|
586
|
+
input.checkpoint.runid === input.runid &&
|
|
587
|
+
input.checkpoint.digest === input.digest &&
|
|
588
|
+
input.checkpoint.digest.trim() !== ""
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Refreshes the heartbeatrecord of a running run: the beat counter grows and the last beat time moves so the zombiecheck sees the run alive. */
|
|
593
|
+
export function runheartbeat(input: { runid: string; beat?: number; now: number }): heartbeatrecord {
|
|
594
|
+
if (input.runid.trim() === "") throw new Error("The heartbeat needs its run id.");
|
|
595
|
+
const previous = Number.isFinite(input.beat) ? (input.beat as number) : 0;
|
|
596
|
+
return { runid: input.runid, beat: previous + 1, at: input.now };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Reads whether the last heartbeat of a run sits older than the configured window: a stale heartbeat turns a running run into a zombie candidate. */
|
|
600
|
+
export function heartbeatisstale(input: {
|
|
601
|
+
heartbeat: heartbeatrecord | undefined;
|
|
602
|
+
now: number;
|
|
603
|
+
window: number;
|
|
604
|
+
}): boolean {
|
|
605
|
+
if (input.heartbeat === undefined) return true;
|
|
606
|
+
return input.now - input.heartbeat.at > input.window;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Loads the user configured heartbeat staleness window without a hard cap: an absent setting keeps the documented roadmap window while every configured value stays the user's choice. */
|
|
610
|
+
export function heartbeatwindowof(settings: runsettings | undefined): number {
|
|
611
|
+
return settings?.heartbeatwindow ?? roadmapheartbeatwindow;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Lists the running runs whose heartbeat is stale: every run past its staleness window names a zombie candidate the reaper may reap. */
|
|
615
|
+
export function zombiecheck(input: {
|
|
616
|
+
runs: runrecord[];
|
|
617
|
+
heartbeats: Record<string, heartbeatrecord>;
|
|
618
|
+
now: number;
|
|
619
|
+
window: number;
|
|
620
|
+
}): runrecord[] {
|
|
621
|
+
return input.runs.filter(
|
|
622
|
+
(run) =>
|
|
623
|
+
run.state === "running" &&
|
|
624
|
+
heartbeatisstale({ heartbeat: input.heartbeats[run.runid], now: input.now, window: input.window }),
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** Transitions one zombie run to failed and records its reap audit event: the reaped run keeps its record for the audit trail while it never executes again. */
|
|
629
|
+
export function reaprun(run: runrecord, now: number): { run: runrecord; event: auditevent } {
|
|
630
|
+
if (run.state !== "running")
|
|
631
|
+
throw new Error(`The run ${run.runid} is ${run.state}; only a running run with a silent heartbeat reaps.`);
|
|
632
|
+
const reaped: runrecord = { ...run, state: "failed", updatedat: now };
|
|
633
|
+
return {
|
|
634
|
+
run: reaped,
|
|
635
|
+
event: {
|
|
636
|
+
id: crypto.randomUUID(),
|
|
637
|
+
kind: "reap",
|
|
638
|
+
at: now,
|
|
639
|
+
summary: `The zombie reaper failed the run ${run.runid} whose heartbeat stayed silent past the user window; the user starts a fresh reviewed run instead of guessing.`,
|
|
640
|
+
sessionid: run.sessionid,
|
|
641
|
+
planid: run.planid,
|
|
642
|
+
},
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** Lists the mutating steps a run already executed: every executed step that is not read only names a side effect the rollback must compensate. */
|
|
647
|
+
export function sideeffects(steps: toolstep[], executedstepids: string[]): toolstep[] {
|
|
648
|
+
const executed = new Set(executedstepids);
|
|
649
|
+
return steps.filter((step) => executed.has(step.id) && step.risk !== "read");
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** The compensating action of each mutating kind in plain language; the kinds outside the table report their outcome for the user review because their effect stays applied. */
|
|
653
|
+
const compensationtable: Record<string, string> = {
|
|
654
|
+
navigate: "navigate back to the origin the run started from",
|
|
655
|
+
openlink: "close the opened link target and return to the origin the run started from",
|
|
656
|
+
followlink: "navigate back to the origin the run started from",
|
|
657
|
+
navlist: "close every tab the list navigation opened and return to the origin the run started from",
|
|
658
|
+
batchopen: "close every tab the batch navigation opened and return to the origin the run started from",
|
|
659
|
+
openprivate: "close the private window the navigation opened",
|
|
660
|
+
reload: "the navigation history stays as the run left it; the compensation reports the route for the user review",
|
|
661
|
+
reloadcache:
|
|
662
|
+
"the navigation history stays as the run left it; the compensation reports the route for the user review",
|
|
663
|
+
back: "the navigation history stays as the run left it; the compensation reports the route for the user review",
|
|
664
|
+
forward: "the navigation history stays as the run left it; the compensation reports the route for the user review",
|
|
665
|
+
type: "restore the field value the form carried before the typing",
|
|
666
|
+
setvalue: "restore the field value the form carried before the change",
|
|
667
|
+
appendtext: "restore the field value the form carried before the append",
|
|
668
|
+
typeedit: "restore the field value the form carried before the edit",
|
|
669
|
+
clear: "restore the cleared value from the session record",
|
|
670
|
+
check: "restore the checkbox state the form carried before the check",
|
|
671
|
+
uncheck: "restore the checkbox state the form carried before the uncheck",
|
|
672
|
+
toggle: "restore the control state the form carried before the toggle",
|
|
673
|
+
chooseradio: "restore the radio choice the form carried",
|
|
674
|
+
setslider: "restore the slider position the form carried",
|
|
675
|
+
setdate: "restore the date the form carried",
|
|
676
|
+
setcolor: "restore the color the form carried",
|
|
677
|
+
select: "restore the selection the form carried",
|
|
678
|
+
selectmulti: "restore the selections the form carried",
|
|
679
|
+
upload: "clear the file input of the uploaded file",
|
|
680
|
+
attachfile: "clear the file input of the attached file",
|
|
681
|
+
click: "the click stays executed; the compensation reports its outcome for the user review",
|
|
682
|
+
clickdeep: "the click stays executed; the compensation reports its outcome for the user review",
|
|
683
|
+
rightclick: "the click stays executed; the compensation reports its outcome for the user review",
|
|
684
|
+
doubleclick: "the click stays executed; the compensation reports its outcome for the user review",
|
|
685
|
+
submit: "the submission stays submitted; the compensation reports it for the user review",
|
|
686
|
+
submitform: "the submission stays submitted; the compensation reports it for the user review",
|
|
687
|
+
retryform: "the submission stays submitted; the compensation reports it for the user review",
|
|
688
|
+
submitsearch: "the search submission stays submitted; the compensation reports it for the user review",
|
|
689
|
+
writestorage: "remove the written storage key",
|
|
690
|
+
setattribute: "restore the attribute value the page carried",
|
|
691
|
+
removeattribute: "restore the removed attribute from the session record",
|
|
692
|
+
setcookies: "delete the cookies the step set",
|
|
693
|
+
clearcookies: "restore the cleared cookies from the session record",
|
|
694
|
+
blockrequest: "remove the block rule the step applied",
|
|
695
|
+
mockresponse: "remove the mock rule the step applied",
|
|
696
|
+
rewriteheaders: "remove the header rewrite rule the step applied",
|
|
697
|
+
routeproxy: "remove the proxy route the step applied",
|
|
698
|
+
overridescript: "remove the script override the step applied",
|
|
699
|
+
tabcreate: "close the created tab",
|
|
700
|
+
tabclose: "reopen the closed tab from the session record",
|
|
701
|
+
windowcreate: "close the created window",
|
|
702
|
+
downloadfile: "delete the downloaded file from the user downloads",
|
|
703
|
+
evaluate: "the evaluated script stays applied; the compensation reports its effect for the user review",
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
/** Builds the compensating steps of a failed run for its executed side effects; read only steps carry no compensation at all because they changed nothing. */
|
|
707
|
+
export function rollbackrun(input: {
|
|
708
|
+
runid: string;
|
|
709
|
+
steps: toolstep[];
|
|
710
|
+
executedstepids: string[];
|
|
711
|
+
origin: string;
|
|
712
|
+
now: number;
|
|
713
|
+
}): rollbackitem[] {
|
|
714
|
+
return sideeffects(input.steps, input.executedstepids).map((step) => ({
|
|
715
|
+
runid: input.runid,
|
|
716
|
+
stepid: step.id,
|
|
717
|
+
kind: step.kind,
|
|
718
|
+
origin: input.origin,
|
|
719
|
+
compensation:
|
|
720
|
+
compensationtable[step.kind] ??
|
|
721
|
+
`the ${step.kind} step stays executed; the compensation reports its outcome for the user review`,
|
|
722
|
+
at: input.now,
|
|
723
|
+
}));
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** Moves one run to cancelled and returns the rollback choice the cancel pairs with: the cancel itself never executes a compensation without the explicit user choice. */
|
|
727
|
+
export function cancelrunrecord(run: runrecord, now: number): runrecord {
|
|
728
|
+
return advancerun(run, "cancelled", now);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** Pairs one cancellation with its optional rollback execution: the choice rollback builds the compensating steps while the choice none stops the run with its state frozen for the audit trail. */
|
|
732
|
+
export function cancelrollback(input: {
|
|
733
|
+
run: runrecord;
|
|
734
|
+
steps: toolstep[];
|
|
735
|
+
executedstepids: string[];
|
|
736
|
+
origin: string;
|
|
737
|
+
choice: "rollback" | "none";
|
|
738
|
+
now: number;
|
|
739
|
+
}): { run: runrecord; compensations: rollbackitem[]; reason: string } {
|
|
740
|
+
const cancelled = cancelrunrecord(input.run, input.now);
|
|
741
|
+
if (input.choice === "none")
|
|
742
|
+
return {
|
|
743
|
+
run: cancelled,
|
|
744
|
+
compensations: [],
|
|
745
|
+
reason: `The run ${input.run.runid} cancelled without a rollback; the executed steps stay applied and the run state freezes for the audit trail.`,
|
|
746
|
+
};
|
|
747
|
+
const compensations = rollbackrun({
|
|
748
|
+
runid: input.run.runid,
|
|
749
|
+
steps: input.steps,
|
|
750
|
+
executedstepids: input.executedstepids,
|
|
751
|
+
origin: input.origin,
|
|
752
|
+
now: input.now,
|
|
753
|
+
});
|
|
754
|
+
const rolledback = advancerun(cancelled, "rolledback", input.now);
|
|
755
|
+
return {
|
|
756
|
+
run: rolledback,
|
|
757
|
+
compensations,
|
|
758
|
+
reason: `The run ${input.run.runid} cancelled with its rollback: ${compensations.length} compensating step${compensations.length === 1 ? "" : "s"} run only after the explicit user choice.`,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** Transitions one run to failed and freezes its progress snapshot so the failure keeps exactly the state it failed at. */
|
|
763
|
+
export function runfail(input: { run: runrecord; progress?: planprogress; now: number }): {
|
|
764
|
+
run: runrecord;
|
|
765
|
+
frozen?: planprogress;
|
|
766
|
+
} {
|
|
767
|
+
const failed = advancerun(input.run, "failed", input.now);
|
|
768
|
+
if (input.progress === undefined) return { run: failed };
|
|
769
|
+
return {
|
|
770
|
+
run: failed,
|
|
771
|
+
frozen: {
|
|
772
|
+
...input.progress,
|
|
773
|
+
...(input.progress.runid !== undefined ? { runid: input.progress.runid } : { runid: input.run.runid }),
|
|
774
|
+
updatedat: input.now,
|
|
775
|
+
},
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** Transitions one run to completed only when iscomplete reports the plan finished; an unfinished run never completes. */
|
|
780
|
+
export function runcomplete(input: {
|
|
781
|
+
run: runrecord;
|
|
782
|
+
plan: agentplan;
|
|
783
|
+
progress?: planprogress;
|
|
784
|
+
now: number;
|
|
785
|
+
}): runrecord {
|
|
786
|
+
if (!iscomplete(input.progress, input.plan))
|
|
787
|
+
throw new Error(
|
|
788
|
+
`The run ${input.run.runid} did not finish every step of its plan; an unfinished run never completes.`,
|
|
789
|
+
);
|
|
790
|
+
return advancerun(input.run, "completed", input.now);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** Rebuilds one runrecord after a service worker restart: the persisted plan restores its run with its original timestamps while an expired or unapproved plan rebuilds as failed because it never resumes. */
|
|
794
|
+
export function runfromplan(plan: agentplan, sessionid: string, now: number): runrecord {
|
|
795
|
+
if (plan.state !== "approved" || plan.expiresat <= now)
|
|
796
|
+
return { runid: plan.id, planid: plan.id, sessionid, state: "failed", createdat: plan.createdat, updatedat: now };
|
|
797
|
+
return { runid: plan.id, planid: plan.id, sessionid, state: "running", createdat: plan.createdat, updatedat: now };
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** Returns the summary of one run: its state, its step counts against its plan, its checkpoint position and its last update. */
|
|
801
|
+
export function runsummaryof(input: {
|
|
802
|
+
run: runrecord;
|
|
803
|
+
plan: agentplan;
|
|
804
|
+
progress?: planprogress;
|
|
805
|
+
checkpoint?: checkpointrecord;
|
|
806
|
+
}): { runid: string; state: runstate; steps: number; completed: number; checkpointstep?: string; updatedat: number } {
|
|
807
|
+
const completed = input.progress?.completedsteps.length ?? 0;
|
|
808
|
+
return {
|
|
809
|
+
runid: input.run.runid,
|
|
810
|
+
state: input.run.state,
|
|
811
|
+
steps: input.plan.steps.length,
|
|
812
|
+
completed,
|
|
813
|
+
...(input.checkpoint !== undefined ? { checkpointstep: input.checkpoint.stepid } : {}),
|
|
814
|
+
updatedat: input.run.updatedat,
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/* ── Merged from runhistory.ts ── */
|
|
819
|
+
|
|
820
|
+
import type { agentsession, lockrecord, runtimelineevent, tabstate, urlvisit } from "./types.js";
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Run history logic of the 1.1.71 state depth family.
|
|
824
|
+
* Every run keeps its own urlhistory and timeline: urlvisit entries append per navigation with consecutive duplicates folded, the runtimeline merges step results, audit events and url visits into one ordered stream with phase buckets, tabisolate namespaces the memory keys per tab so runs never share state, and the sessionlock serializes concurrent runs on one session while it names its holder and expires its abandoned locks.
|
|
825
|
+
* No window stays hardcoded: the lock expiry window reads the user setting with no code ceiling, urlhistory stays scoped per run and never merges runs, and no lock, visit or timeline path ever bypasses the review.
|
|
826
|
+
*/
|
|
827
|
+
|
|
828
|
+
/** The documented lock expiry window the roadmap names while the configured window stays the user's choice with no code ceiling. */
|
|
829
|
+
export const roadmaplockwindow = 120_000;
|
|
830
|
+
|
|
831
|
+
/** Builds one urlvisit of a navigation inside a run: the url, the page title, the time and the run it belongs to. */
|
|
832
|
+
export function visitof(input: { url: string; title?: string; runid: string; now: number }): urlvisit {
|
|
833
|
+
if (input.url.trim() === "") throw new Error("The url visit needs its url.");
|
|
834
|
+
if (input.runid.trim() === "") throw new Error("The url visit needs its run id.");
|
|
835
|
+
return {
|
|
836
|
+
url: input.url,
|
|
837
|
+
...(input.title !== undefined && input.title.trim() !== "" ? { title: input.title } : {}),
|
|
838
|
+
at: input.now,
|
|
839
|
+
runid: input.runid,
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** Appends one urlvisit to the urlhistory of a run: consecutive visits to the same url fold into one entry so the history reads as the real route, and the visits of one run never merge with another run. */
|
|
844
|
+
export function appendvisit(visits: urlvisit[], visit: urlvisit): urlvisit[] {
|
|
845
|
+
const last = visits.at(-1);
|
|
846
|
+
if (last !== undefined && last.url === visit.url && last.runid === visit.runid) {
|
|
847
|
+
const folded: urlvisit = { ...last, at: visit.at };
|
|
848
|
+
return [...visits.slice(0, -1), folded];
|
|
849
|
+
}
|
|
850
|
+
return [...visits, visit];
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** Builds one runtimeline event of a step result: the step identity, its outcome summary and its timestamp inside the run. */
|
|
854
|
+
export function stepeventof(input: {
|
|
855
|
+
runid: string;
|
|
856
|
+
stepid: string;
|
|
857
|
+
summary: string;
|
|
858
|
+
ok: boolean;
|
|
859
|
+
now: number;
|
|
860
|
+
}): runtimelineevent {
|
|
861
|
+
return {
|
|
862
|
+
at: input.now,
|
|
863
|
+
runid: input.runid,
|
|
864
|
+
source: "step",
|
|
865
|
+
summary: `${input.ok ? "completed" : "failed"}: ${input.summary}`,
|
|
866
|
+
stepid: input.stepid,
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** Builds one runtimeline event of an audit event so the timeline carries the audit trail beside the steps. */
|
|
871
|
+
export function auditeventof(event: auditevent): runtimelineevent {
|
|
872
|
+
return {
|
|
873
|
+
at: event.at,
|
|
874
|
+
runid: "",
|
|
875
|
+
source: "audit",
|
|
876
|
+
summary: event.summary,
|
|
877
|
+
...(event.stepid !== undefined ? { stepid: event.stepid } : {}),
|
|
878
|
+
kind: event.kind,
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** Builds one runtimeline event of a url visit so the timeline carries the route beside the steps. */
|
|
883
|
+
export function visiteventof(visit: urlvisit): runtimelineevent {
|
|
884
|
+
return {
|
|
885
|
+
at: visit.at,
|
|
886
|
+
runid: visit.runid,
|
|
887
|
+
source: "visit",
|
|
888
|
+
summary: `visited ${visit.url}${visit.title !== undefined ? ` (${visit.title})` : ""}`,
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/** Merges step results, audit events and url visits into one ordered runtimeline stream: every event sorts by its timestamp, the audit events carry their kind and the visits carry their route. */
|
|
893
|
+
export function runtimelineof(input: {
|
|
894
|
+
steps: runtimelineevent[];
|
|
895
|
+
audits: runtimelineevent[];
|
|
896
|
+
visits: urlvisit[];
|
|
897
|
+
runid: string;
|
|
898
|
+
}): runtimelineevent[] {
|
|
899
|
+
const scoped = input.audits.filter((event) => event.runid === input.runid || event.runid === "");
|
|
900
|
+
const merged = [
|
|
901
|
+
...input.steps.filter((event) => event.runid === input.runid),
|
|
902
|
+
...scoped,
|
|
903
|
+
...input.visits.filter((visit) => visit.runid === input.runid).map(visiteventof),
|
|
904
|
+
];
|
|
905
|
+
return merged.sort((one, two) => one.at - two.at);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/** Buckets one runtimeline stream by phase for the timeline view: every event lands in the phase its timestamp names while the order inside a phase stays the stream order. */
|
|
909
|
+
export function buckettimeline(events: runtimelineevent[]): Array<{ phase: string; events: runtimelineevent[] }> {
|
|
910
|
+
const buckets = new Map<string, runtimelineevent[]>();
|
|
911
|
+
for (const event of events) {
|
|
912
|
+
const phase =
|
|
913
|
+
event.source === "visit"
|
|
914
|
+
? "route"
|
|
915
|
+
: event.source === "audit"
|
|
916
|
+
? "audit"
|
|
917
|
+
: event.summary.startsWith("failed")
|
|
918
|
+
? "failures"
|
|
919
|
+
: "steps";
|
|
920
|
+
const bucket = buckets.get(phase) ?? [];
|
|
921
|
+
bucket.push(event);
|
|
922
|
+
buckets.set(phase, bucket);
|
|
923
|
+
}
|
|
924
|
+
return [...buckets.entries()].map(([phase, bucket]) => ({ phase, events: bucket }));
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** Derives the isolated namespace id of one tab: every memory key of the run lives under its tab namespace so runs never share state. */
|
|
928
|
+
export function tabnamespace(tabid: number): string {
|
|
929
|
+
return `tab:${tabid}`;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/** Names one memory key inside the isolated namespace of its tab; the shared key stays readable beside the namespace copy. */
|
|
933
|
+
export function tabkey(tabid: number, key: string): string {
|
|
934
|
+
if (key.trim() === "") throw new Error("The namespaced key needs its key.");
|
|
935
|
+
return `${tabnamespace(tabid)}:${key}`;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** Copies the shared config into the tab namespace on demand: the tabstate carries its namespace id, its isolated run id and the copied config snapshot. */
|
|
939
|
+
export function tabisolate(input: {
|
|
940
|
+
tabid: number;
|
|
941
|
+
config: Record<string, unknown>;
|
|
942
|
+
runid?: string;
|
|
943
|
+
now: number;
|
|
944
|
+
}): tabstate {
|
|
945
|
+
if (!Number.isFinite(input.tabid)) throw new Error("The tab isolation needs its tab id.");
|
|
946
|
+
return {
|
|
947
|
+
tabid: input.tabid,
|
|
948
|
+
namespace: tabnamespace(input.tabid),
|
|
949
|
+
config: { ...input.config },
|
|
950
|
+
...(input.runid !== undefined && input.runid.trim() !== "" ? { runid: input.runid } : {}),
|
|
951
|
+
at: input.now,
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** Acquires one lockrecord for a run on a session: the holder names the run, the acquisition time stamps it and the expiry window stays the user choice. */
|
|
956
|
+
export function acquiresessionlock(input: {
|
|
957
|
+
runid: string;
|
|
958
|
+
sessionid: string;
|
|
959
|
+
window: number;
|
|
960
|
+
now: number;
|
|
961
|
+
}): lockrecord {
|
|
962
|
+
if (input.runid.trim() === "") throw new Error("The lock needs its run id.");
|
|
963
|
+
if (input.sessionid.trim() === "") throw new Error("The lock needs its session id.");
|
|
964
|
+
return {
|
|
965
|
+
holder: input.runid,
|
|
966
|
+
runid: input.runid,
|
|
967
|
+
sessionid: input.sessionid,
|
|
968
|
+
acquiredat: input.now,
|
|
969
|
+
expiresat: input.now + input.window,
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** Reads whether one lock blocks a run: a lock of the same run never blocks itself, a live foreign lock blocks and names its holder while an expired lock blocks nothing because its window passed. */
|
|
974
|
+
export function lockblocks(
|
|
975
|
+
lock: lockrecord | undefined,
|
|
976
|
+
runid: string,
|
|
977
|
+
now: number,
|
|
978
|
+
): { blocked: boolean; reason: string } {
|
|
979
|
+
if (lock === undefined)
|
|
980
|
+
return {
|
|
981
|
+
blocked: false,
|
|
982
|
+
reason: "No sessionlock stands on the session; the run starts through the reviewed path.",
|
|
983
|
+
};
|
|
984
|
+
if (lock.runid === runid)
|
|
985
|
+
return {
|
|
986
|
+
blocked: false,
|
|
987
|
+
reason: `The sessionlock already belongs to the run ${runid}; the run holds its own lock.`,
|
|
988
|
+
};
|
|
989
|
+
if (lock.expiresat <= now)
|
|
990
|
+
return {
|
|
991
|
+
blocked: false,
|
|
992
|
+
reason: `The sessionlock of the run ${lock.holder} expired past its window; the stale lock leaves before a new run starts.`,
|
|
993
|
+
};
|
|
994
|
+
return {
|
|
995
|
+
blocked: true,
|
|
996
|
+
reason: `The sessionlock of the session ${lock.sessionid} stays held by the run ${lock.holder}; the concurrent run ${runid} waits because one session never runs two runs at once.`,
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** Reads whether one lock belongs to a run so the release path only clears its own lock. */
|
|
1001
|
+
export function lockheldby(lock: lockrecord | undefined, runid: string): boolean {
|
|
1002
|
+
return lock !== undefined && lock.runid === runid;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** Releases the lock of a completed, failed or cancelled run: the sessionlock clears so the next reviewed run may acquire it. */
|
|
1006
|
+
export function releasesessionlock(lock: lockrecord | undefined, runid: string): boolean {
|
|
1007
|
+
return lockheldby(lock, runid);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/** Loads the user configured lock expiry window without a hard cap: an absent setting keeps the documented roadmap window while every configured value stays the user's choice. */
|
|
1011
|
+
export function lockwindowof(settings: runsettings | undefined): number {
|
|
1012
|
+
return settings?.lockwindow ?? roadmaplockwindow;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** Lists the locks whose expiry window passed: every abandoned lock names an expiry candidate the startup pass clears before the zombiecheck runs. */
|
|
1016
|
+
export function stalelocks(locks: lockrecord[], now: number): lockrecord[] {
|
|
1017
|
+
return locks.filter((lock) => lock.expiresat <= now);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/** Attaches the acquired lockid to one agentsession so concurrent run protection travels with the session record. */
|
|
1021
|
+
export function sessionwithlock(session: agentsession, lockid: string | undefined): agentsession {
|
|
1022
|
+
if (lockid === undefined || lockid.trim() === "") return session;
|
|
1023
|
+
return { ...session, lockid };
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/** Summarizes the urlhistory of one run: the visit count, its distinct origins and its first and last visit times; a run without visits carries none. */
|
|
1027
|
+
export function visitsummary(visits: urlvisit[]): { count: number; origins: string[]; first?: number; last?: number } {
|
|
1028
|
+
const scoped = visits.filter((visit) => visit.url.trim() !== "");
|
|
1029
|
+
const origins = [
|
|
1030
|
+
...new Set(
|
|
1031
|
+
scoped
|
|
1032
|
+
.map((visit) => {
|
|
1033
|
+
try {
|
|
1034
|
+
return new URL(visit.url).origin;
|
|
1035
|
+
} catch {
|
|
1036
|
+
return "";
|
|
1037
|
+
}
|
|
1038
|
+
})
|
|
1039
|
+
.filter((origin) => origin !== ""),
|
|
1040
|
+
),
|
|
1041
|
+
];
|
|
1042
|
+
const first = scoped[0]?.at;
|
|
1043
|
+
const last = scoped.at(-1)?.at;
|
|
1044
|
+
return {
|
|
1045
|
+
count: scoped.length,
|
|
1046
|
+
origins,
|
|
1047
|
+
...(first !== undefined ? { first } : {}),
|
|
1048
|
+
...(last !== undefined ? { last } : {}),
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/** Reads the final url of a completed navigation step result so the urlhistory records the url the redirect chain landed on; a result without a final url reports the planned url unchanged. */
|
|
1053
|
+
export function finalurlof(input: { plannedurl: string; finalurl?: string }): string {
|
|
1054
|
+
if (input.finalurl !== undefined && input.finalurl.trim() !== "") return input.finalurl;
|
|
1055
|
+
return input.plannedurl;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/** Lists the navigation steps of a plan in their reviewed order: the urlhistory records one urlvisit for each completed navigation step. */
|
|
1059
|
+
export function navigationsteps(steps: toolstep[]): toolstep[] {
|
|
1060
|
+
return steps.filter((step) =>
|
|
1061
|
+
[
|
|
1062
|
+
"navigate",
|
|
1063
|
+
"openlink",
|
|
1064
|
+
"followlink",
|
|
1065
|
+
"spanav",
|
|
1066
|
+
"navlist",
|
|
1067
|
+
"batchopen",
|
|
1068
|
+
"openprivate",
|
|
1069
|
+
"reload",
|
|
1070
|
+
"reloadcache",
|
|
1071
|
+
"back",
|
|
1072
|
+
"forward",
|
|
1073
|
+
].includes(step.kind),
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/** Reads the runrecord of a run with its visits attached so the record carries its urlhistory beside its lifecycle state. */
|
|
1078
|
+
export function runwithvisits(run: runrecord, visits: urlvisit[]): runrecord {
|
|
1079
|
+
const scoped = visits.filter((visit) => visit.runid === run.runid);
|
|
1080
|
+
if (scoped.length === 0) return run;
|
|
1081
|
+
return { ...run, visits: scoped };
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/* ── Merged from runtimeline.ts ── */
|
|
1085
|
+
|
|
1086
|
+
import type {
|
|
1087
|
+
consolediff,
|
|
1088
|
+
consoleentry,
|
|
1089
|
+
errorrecord,
|
|
1090
|
+
exchangerecord,
|
|
1091
|
+
loglevel,
|
|
1092
|
+
loglevelset,
|
|
1093
|
+
longtaskentry,
|
|
1094
|
+
netfailureentry,
|
|
1095
|
+
rejectionrecord,
|
|
1096
|
+
rotationrule,
|
|
1097
|
+
spamrule,
|
|
1098
|
+
stackframe,
|
|
1099
|
+
timelineentry,
|
|
1100
|
+
timelinesource,
|
|
1101
|
+
} from "./types.js";
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* Run timeline logics for the 1.1.45 debugging family.
|
|
1105
|
+
* Every correlated rule for console capture with reviewed depth bounds and secret redaction, stack frame parsing, error, rejection and resource failure capture, long task attribution windows, the timeline binding to one run and its step ids, spam collapse with reviewed thresholds, log rotation without entry loss, level and source filters, the blocking duration per step window and the console diff between two runs lives in this file.
|
|
1106
|
+
* Console, error and task watching derives from page-injected listeners installed through the scripting api and the performance buffers, so no debugger permission exists anywhere in the manifest; redaction runs before any entry leaves the page bridge and captured text never carries reviewed secret patterns.
|
|
1107
|
+
*/
|
|
1108
|
+
|
|
1109
|
+
/** The debugging kinds, listed among the available capabilities of every proposal request. */
|
|
1110
|
+
export const timelinekinds: string[] = ["watchconsole", "watcherrors", "watchtasks"];
|
|
1111
|
+
|
|
1112
|
+
/** The reviewed level set, ordered from the most severe to the most verbose level. */
|
|
1113
|
+
export const loglevels: loglevel[] = ["error", "warn", "info", "log", "debug", "trace"];
|
|
1114
|
+
|
|
1115
|
+
/** The reviewed timeline source grammar: page console output, script errors, promise rejections, resource failures, long tasks, failed network requests and instrumented devtools protocol events. */
|
|
1116
|
+
export const timelinesources: timelinesource[] = [
|
|
1117
|
+
"console",
|
|
1118
|
+
"error",
|
|
1119
|
+
"rejection",
|
|
1120
|
+
"resource",
|
|
1121
|
+
"longtask",
|
|
1122
|
+
"network",
|
|
1123
|
+
"cdp",
|
|
1124
|
+
];
|
|
1125
|
+
|
|
1126
|
+
/** Ranks one level inside the reviewed level set; lower ranks are more severe. */
|
|
1127
|
+
export function levelrank(level: loglevel): number {
|
|
1128
|
+
return loglevels.indexOf(level);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/** Redacts reviewed secret patterns from console text before any entry leaves the page bridge; matched patterns never survive into a stored entry. */
|
|
1132
|
+
export function redactconsoletext(text: string, patterns: string[]): string {
|
|
1133
|
+
let redacted = text;
|
|
1134
|
+
for (const pattern of patterns) {
|
|
1135
|
+
if (!pattern) continue;
|
|
1136
|
+
while (redacted.includes(pattern)) redacted = redacted.replace(pattern, "[redacted]");
|
|
1137
|
+
}
|
|
1138
|
+
return redacted;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/** Classifies the kind tag of one console argument for the consoleentry argument kinds. */
|
|
1142
|
+
export function argkind(value: unknown): string {
|
|
1143
|
+
if (value === null) return "null";
|
|
1144
|
+
if (Array.isArray(value)) return "array";
|
|
1145
|
+
if (value instanceof Error) return "error";
|
|
1146
|
+
switch (typeof value) {
|
|
1147
|
+
case "string":
|
|
1148
|
+
return "string";
|
|
1149
|
+
case "number":
|
|
1150
|
+
return "number";
|
|
1151
|
+
case "boolean":
|
|
1152
|
+
return "boolean";
|
|
1153
|
+
case "bigint":
|
|
1154
|
+
return "bigint";
|
|
1155
|
+
case "symbol":
|
|
1156
|
+
return "symbol";
|
|
1157
|
+
case "function":
|
|
1158
|
+
return "function";
|
|
1159
|
+
case "undefined":
|
|
1160
|
+
return "undefined";
|
|
1161
|
+
default:
|
|
1162
|
+
return "object";
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/** Serializes one console argument through the reviewed depth bound; deeper objects collapse to their constructor tag so the bound stays a user choice with no code ceiling. */
|
|
1167
|
+
export function serializearg(value: unknown, depth: number): string {
|
|
1168
|
+
const render = (item: unknown, remaining: number): string => {
|
|
1169
|
+
if (item instanceof Error) return `${item.name}: ${item.message}`;
|
|
1170
|
+
if (typeof item === "string") return item;
|
|
1171
|
+
if (typeof item === "function") return `[function ${item.name || "anonymous"}]`;
|
|
1172
|
+
if (typeof item === "bigint") return `${item}n`;
|
|
1173
|
+
if (typeof item === "symbol") return item.toString();
|
|
1174
|
+
if (item === null || item === undefined || typeof item !== "object") return String(item);
|
|
1175
|
+
if (remaining <= 0) {
|
|
1176
|
+
const tag = Array.isArray(item)
|
|
1177
|
+
? "Array"
|
|
1178
|
+
: ((item as { constructor?: { name?: string } }).constructor?.name ?? "Object");
|
|
1179
|
+
return `[${tag}]`;
|
|
1180
|
+
}
|
|
1181
|
+
if (Array.isArray(item)) return `[${item.map((entry) => render(entry, remaining - 1)).join(", ")}]`;
|
|
1182
|
+
const record = item as Record<string, unknown>;
|
|
1183
|
+
return `{${Object.keys(record)
|
|
1184
|
+
.map((key) => `${key}: ${render(record[key], remaining - 1)}`)
|
|
1185
|
+
.join(", ")}}`;
|
|
1186
|
+
};
|
|
1187
|
+
return render(value, Math.max(0, depth));
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/** Builds one captured console entry from a forwarded console call: the level, the redacted text of every serialized argument and the argument kinds, with redaction applied before the entry exists. */
|
|
1191
|
+
export function consolecapture(input: {
|
|
1192
|
+
level: loglevel;
|
|
1193
|
+
args: unknown[];
|
|
1194
|
+
depth: number;
|
|
1195
|
+
redact: string[];
|
|
1196
|
+
}): consoleentry {
|
|
1197
|
+
const parts = input.args.map((arg) => serializearg(arg, input.depth));
|
|
1198
|
+
return {
|
|
1199
|
+
level: input.level,
|
|
1200
|
+
text: redactconsoletext(parts.join(" "), input.redact),
|
|
1201
|
+
argkinds: input.args.map((arg) => argkind(arg)),
|
|
1202
|
+
repeat: 1,
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/** Parses one stack trace text into stack frames with function names, urls, lines and columns; unparseable lines are skipped instead of crashing capture. */
|
|
1207
|
+
export function stackframes(stacktext: string): stackframe[] {
|
|
1208
|
+
const frames: stackframe[] = [];
|
|
1209
|
+
for (const row of stacktext.split("\n")) {
|
|
1210
|
+
const trimmed = row.trim();
|
|
1211
|
+
if (!trimmed.startsWith("at ")) continue;
|
|
1212
|
+
const body = trimmed.slice(3).trim();
|
|
1213
|
+
const location = body.match(/\(([^()]*:\d+:\d+)\)$/) ?? body.match(/^(.*:\d+:\d+)$/);
|
|
1214
|
+
const located = location?.[1];
|
|
1215
|
+
if (!located) continue;
|
|
1216
|
+
const segments = located.split(":");
|
|
1217
|
+
const column = Number.parseInt(segments.pop() ?? "", 10);
|
|
1218
|
+
const lineno = Number.parseInt(segments.pop() ?? "", 10);
|
|
1219
|
+
const url = segments.join(":");
|
|
1220
|
+
if (!Number.isFinite(lineno) || lineno < 0) continue;
|
|
1221
|
+
const name = body.endsWith(`(${located})`) ? body.slice(0, body.length - located.length - 2).trim() : "";
|
|
1222
|
+
frames.push({
|
|
1223
|
+
...(name ? { functionname: name } : {}),
|
|
1224
|
+
url,
|
|
1225
|
+
line: lineno,
|
|
1226
|
+
...(Number.isFinite(column) ? { column } : {}),
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
return frames;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/** Builds one captured javascript error record body from an error event: the redacted message, the parsed stack frames, the source url and the line. */
|
|
1233
|
+
export function errorcapture(input: {
|
|
1234
|
+
message: string;
|
|
1235
|
+
sourceurl: string;
|
|
1236
|
+
line: number;
|
|
1237
|
+
stacktext?: string;
|
|
1238
|
+
redact: string[];
|
|
1239
|
+
}): Pick<errorrecord, "message" | "frames" | "sourceurl" | "line"> {
|
|
1240
|
+
return {
|
|
1241
|
+
message: redactconsoletext(input.message, input.redact),
|
|
1242
|
+
frames: input.stacktext !== undefined ? stackframes(input.stacktext) : [],
|
|
1243
|
+
sourceurl: input.sourceurl,
|
|
1244
|
+
line: input.line,
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/** Builds one captured unhandled rejection record body from the rejection reason text and its stack frames, with the reason redacted before capture. */
|
|
1249
|
+
export function rejectioncapture(input: {
|
|
1250
|
+
reason: string;
|
|
1251
|
+
stacktext?: string;
|
|
1252
|
+
redact: string[];
|
|
1253
|
+
}): Pick<rejectionrecord, "reason" | "frames"> {
|
|
1254
|
+
return {
|
|
1255
|
+
reason: redactconsoletext(input.reason, input.redact),
|
|
1256
|
+
frames: input.stacktext !== undefined ? stackframes(input.stacktext) : [],
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/** Builds the long task entry bodies of one watch window from performance longtask entries with their attribution names; the reviewed threshold filters entries below the user configured duration. */
|
|
1261
|
+
export function longtaskcapture(input: {
|
|
1262
|
+
entries: Array<{ starttime: number; duration: number; attributions: string[] }>;
|
|
1263
|
+
threshold: number;
|
|
1264
|
+
}): Array<Pick<longtaskentry, "duration" | "starttime" | "attributions">> {
|
|
1265
|
+
return input.entries
|
|
1266
|
+
.filter((entry) => entry.duration >= input.threshold)
|
|
1267
|
+
.map((entry) => ({
|
|
1268
|
+
duration: Math.round(entry.duration),
|
|
1269
|
+
starttime: Math.round(entry.starttime),
|
|
1270
|
+
attributions: [...entry.attributions],
|
|
1271
|
+
}));
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/** One run timeline bound to its run and step ids. */
|
|
1275
|
+
export interface timeline {
|
|
1276
|
+
runid: string;
|
|
1277
|
+
origin: string;
|
|
1278
|
+
stepids: string[];
|
|
1279
|
+
attachedat: number;
|
|
1280
|
+
entries: timelineentry[];
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/** Binds one timeline to the run and its step ids; capture stays scoped to that run and every entry carries the run correlation id of its step. */
|
|
1284
|
+
export function attachtimeline(input: { runid: string; origin: string; stepids: string[]; now: number }): timeline {
|
|
1285
|
+
return { runid: input.runid, origin: input.origin, stepids: [...input.stepids], attachedat: input.now, entries: [] };
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/** Applies the reviewed level set to timeline entries: per step level floors drop entries more verbose than the floor and source filters drop entries from unreviewed sources. */
|
|
1289
|
+
export function filterentries(entries: timelineentry[], levelset: loglevelset): timelineentry[] {
|
|
1290
|
+
return entries.filter((entry) => {
|
|
1291
|
+
const floor = levelset.floors?.[entry.stepid] ?? levelset.floors?.["*"];
|
|
1292
|
+
if (floor !== undefined && levelrank(entry.level) > levelrank(floor)) return false;
|
|
1293
|
+
if (levelset.sources !== undefined && levelset.sources.length > 0 && !levelset.sources.includes(entry.source))
|
|
1294
|
+
return false;
|
|
1295
|
+
return true;
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/** Collapses repeated identical messages inside the reviewed spam window into counts and flags the patterns that exceed the reviewed collapse threshold. */
|
|
1300
|
+
export function spamdetect(
|
|
1301
|
+
entries: timelineentry[],
|
|
1302
|
+
rule: spamrule,
|
|
1303
|
+
): { entries: Array<timelineentry & { repeat: number }>; flagged: Array<{ message: string; count: number }> } {
|
|
1304
|
+
const collapsed: Array<timelineentry & { repeat: number }> = [];
|
|
1305
|
+
const counts = new Map<string, number>();
|
|
1306
|
+
for (const entry of entries) {
|
|
1307
|
+
if (rule.pattern !== "" && !entry.message.includes(rule.pattern)) {
|
|
1308
|
+
collapsed.push({ ...entry, repeat: 1 });
|
|
1309
|
+
continue;
|
|
1310
|
+
}
|
|
1311
|
+
const key = `${entry.level}|${entry.source}|${entry.message}`;
|
|
1312
|
+
const previous = collapsed[collapsed.length - 1];
|
|
1313
|
+
if (
|
|
1314
|
+
previous &&
|
|
1315
|
+
previous.repeat !== undefined &&
|
|
1316
|
+
`${previous.level}|${previous.source}|${previous.message}` === key &&
|
|
1317
|
+
entry.time - previous.time <= rule.windowsize
|
|
1318
|
+
) {
|
|
1319
|
+
previous.repeat += 1;
|
|
1320
|
+
continue;
|
|
1321
|
+
}
|
|
1322
|
+
collapsed.push({ ...entry, repeat: 1 });
|
|
1323
|
+
}
|
|
1324
|
+
for (const entry of collapsed) {
|
|
1325
|
+
if (entry.repeat > 1) counts.set(`${entry.level}|${entry.source}|${entry.message}`, entry.repeat);
|
|
1326
|
+
}
|
|
1327
|
+
const flagged = [...counts.entries()]
|
|
1328
|
+
.filter(([, count]) => count > rule.collapse)
|
|
1329
|
+
.map(([key, count]) => ({ message: key.split("|").slice(2).join("|"), count }));
|
|
1330
|
+
return { entries: collapsed, flagged };
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/** Rotates the timeline of one run: the newest entries stay inside the reviewed max entries window while every overflow entry moves to the rotation target store without data loss. */
|
|
1334
|
+
export function rotatelogs(
|
|
1335
|
+
entries: timelineentry[],
|
|
1336
|
+
rule: rotationrule,
|
|
1337
|
+
): { kept: timelineentry[]; overflow: timelineentry[] } {
|
|
1338
|
+
if (entries.length <= rule.maxentries) return { kept: [...entries], overflow: [] };
|
|
1339
|
+
const kept = entries.slice(entries.length - rule.maxentries);
|
|
1340
|
+
const overflow = entries.slice(0, entries.length - rule.maxentries);
|
|
1341
|
+
return { kept, overflow };
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/** Counts the timeline entries per level for the response envelope timeline block. */
|
|
1345
|
+
export function timelinecounts(entries: timelineentry[]): Record<string, number> {
|
|
1346
|
+
const counts: Record<string, number> = {};
|
|
1347
|
+
for (const level of loglevels) counts[level] = 0;
|
|
1348
|
+
for (const entry of entries) counts[entry.level] = (counts[entry.level] ?? 0) + 1;
|
|
1349
|
+
return counts;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/** Sums the blocking duration of long task entries inside one step window; the window stays a reviewed value with no code ceiling. */
|
|
1353
|
+
export function blockingduration(
|
|
1354
|
+
tasks: Array<{ starttime: number; duration: number }>,
|
|
1355
|
+
stepid: string,
|
|
1356
|
+
window: { startedat: number; endedat: number },
|
|
1357
|
+
): { stepid: string; blocking: number; tasks: number } {
|
|
1358
|
+
const inside = tasks.filter((task) => task.starttime >= window.startedat && task.starttime <= window.endedat);
|
|
1359
|
+
return { stepid, blocking: inside.reduce((total, task) => total + task.duration, 0), tasks: inside.length };
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/** Marks one failed request of the run in the timeline from its observed exchange: the url, status, error class and correlation id. */
|
|
1363
|
+
export function netfailureentryof(input: { id: string; exchange: exchangerecord; at: number }): netfailureentry | null {
|
|
1364
|
+
const exchange = input.exchange;
|
|
1365
|
+
if (exchange.errorclass === undefined && exchange.status < 400) return null;
|
|
1366
|
+
return {
|
|
1367
|
+
id: input.id,
|
|
1368
|
+
runid: exchange.runid,
|
|
1369
|
+
stepid: exchange.stepid,
|
|
1370
|
+
url: exchange.url,
|
|
1371
|
+
status: exchange.status,
|
|
1372
|
+
errorclass: exchange.errorclass ?? "httperror",
|
|
1373
|
+
correlationid: exchange.correlationid,
|
|
1374
|
+
at: input.at,
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
/** Decides whether a watcher detached early because the run tab navigated inside its watch window: navigation timestamps inside the window detach the watcher at the navigation time. */
|
|
1379
|
+
export function watcherdetached(input: { startedat: number; lifetime: number; navigations: number[] }): {
|
|
1380
|
+
detached: boolean;
|
|
1381
|
+
at?: number;
|
|
1382
|
+
} {
|
|
1383
|
+
for (const navigation of input.navigations) {
|
|
1384
|
+
if (navigation >= input.startedat && navigation <= input.startedat + input.lifetime)
|
|
1385
|
+
return { detached: true, at: navigation };
|
|
1386
|
+
}
|
|
1387
|
+
return { detached: false };
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/** Compares the console output of two runs and classifies every line as added, removed or repeated; repeated lines carry their repeat counts. */
|
|
1391
|
+
export function consolediff(input: {
|
|
1392
|
+
baseid: string;
|
|
1393
|
+
targetid: string;
|
|
1394
|
+
baselines: string[];
|
|
1395
|
+
targetlines: string[];
|
|
1396
|
+
now: number;
|
|
1397
|
+
}): consolediff {
|
|
1398
|
+
const base = input.baselines;
|
|
1399
|
+
const target = input.targetlines;
|
|
1400
|
+
const basemap = new Map<string, number>();
|
|
1401
|
+
for (const line of base) basemap.set(line, (basemap.get(line) ?? 0) + 1);
|
|
1402
|
+
const targetmap = new Map<string, number>();
|
|
1403
|
+
for (const line of target) targetmap.set(line, (targetmap.get(line) ?? 0) + 1);
|
|
1404
|
+
const lines = [];
|
|
1405
|
+
const added: string[] = [];
|
|
1406
|
+
const removed: string[] = [];
|
|
1407
|
+
const repeated: string[] = [];
|
|
1408
|
+
for (const [line, count] of targetmap) {
|
|
1409
|
+
const basecount = basemap.get(line) ?? 0;
|
|
1410
|
+
if (basecount === 0) {
|
|
1411
|
+
for (let index = 0; index < count; index += 1) {
|
|
1412
|
+
lines.push({ kind: "added" as const, text: line });
|
|
1413
|
+
added.push(line);
|
|
1414
|
+
}
|
|
1415
|
+
continue;
|
|
1416
|
+
}
|
|
1417
|
+
const share = Math.min(basecount, count);
|
|
1418
|
+
for (let index = 0; index < share; index += 1) {
|
|
1419
|
+
lines.push({ kind: "repeated" as const, text: line, count: share });
|
|
1420
|
+
repeated.push(line);
|
|
1421
|
+
}
|
|
1422
|
+
for (let index = share; index < count; index += 1) {
|
|
1423
|
+
lines.push({ kind: "added" as const, text: line });
|
|
1424
|
+
added.push(line);
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
for (const [line, count] of basemap) {
|
|
1428
|
+
const targetcount = targetmap.get(line) ?? 0;
|
|
1429
|
+
const missing = Math.max(0, count - targetcount);
|
|
1430
|
+
for (let index = 0; index < missing; index += 1) {
|
|
1431
|
+
lines.push({ kind: "removed" as const, text: line });
|
|
1432
|
+
removed.push(line);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
return {
|
|
1436
|
+
base: input.baseid,
|
|
1437
|
+
target: input.targetid,
|
|
1438
|
+
lines,
|
|
1439
|
+
added: added.length,
|
|
1440
|
+
removed: removed.length,
|
|
1441
|
+
repeated: repeated.length,
|
|
1442
|
+
at: input.now,
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/* ── Merged from runbudget.ts ── */
|
|
1447
|
+
|
|
1448
|
+
import type { budgetalert, runbudgetrecord, timeoutcancelevent, timeoutcancelpolicy } from "./types.js";
|
|
1449
|
+
import { randomid } from "./memory.js";
|
|
1450
|
+
|
|
1451
|
+
/**
|
|
1452
|
+
* Runbudget logic of the 1.1.69 family.
|
|
1453
|
+
* Every run tracks its step usage against the user chosen step budget and its memory pressure through the worker telemetry, the budgetalerts fire at the user chosen thresholds with their severity levels while the critical threshold pauses the run pending a user choice instead of refusing it, and the timeoutcancel aborts a slow step at the user chosen bound with the cancel event recorded in the immutable log beside the step outcome.
|
|
1454
|
+
* Every budget, threshold and bound stays a user choice: the tracker measures and never caps, and the pause waits for the user instead of dropping work.
|
|
1455
|
+
*/
|
|
1456
|
+
|
|
1457
|
+
/** Builds one runbudget record of a run: the step usage against the user chosen step budget beside the memory pressure the worker telemetry reported. */
|
|
1458
|
+
export function runbudgetof(input: {
|
|
1459
|
+
runid: string;
|
|
1460
|
+
steps: number;
|
|
1461
|
+
stepbudget?: number;
|
|
1462
|
+
pressure: number;
|
|
1463
|
+
memorybudget?: number;
|
|
1464
|
+
now: number;
|
|
1465
|
+
}): runbudgetrecord {
|
|
1466
|
+
if (input.runid.trim() === "") throw new Error("The runbudget record needs its run id.");
|
|
1467
|
+
const pressure = Number.isFinite(input.pressure) ? Math.min(Math.max(input.pressure, 0), 1) : 0;
|
|
1468
|
+
return {
|
|
1469
|
+
runid: input.runid,
|
|
1470
|
+
steps: Math.max(input.steps, 0),
|
|
1471
|
+
...(input.stepbudget !== undefined ? { stepbudget: input.stepbudget } : {}),
|
|
1472
|
+
pressure,
|
|
1473
|
+
...(input.memorybudget !== undefined ? { memorybudget: input.memorybudget } : {}),
|
|
1474
|
+
at: input.now,
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/** Reads the budgetalerts of one runbudget record: the warning threshold fires its warning while the critical threshold fires its critical alert that pauses the run pending a user choice; an absent threshold keeps the alert informational only. */
|
|
1479
|
+
export function budgetalertstate(input: {
|
|
1480
|
+
budget: runbudgetrecord;
|
|
1481
|
+
thresholds?: { warning?: number; critical?: number };
|
|
1482
|
+
}): budgetalert[] {
|
|
1483
|
+
const alerts: budgetalert[] = [];
|
|
1484
|
+
const stepsbudget = input.budget.stepbudget;
|
|
1485
|
+
if (stepsbudget !== undefined && stepsbudget > 0) {
|
|
1486
|
+
const ratio = input.budget.steps / stepsbudget;
|
|
1487
|
+
const alert = budgetalertof({
|
|
1488
|
+
runid: input.budget.runid,
|
|
1489
|
+
kind: "steps",
|
|
1490
|
+
ratio,
|
|
1491
|
+
...(input.thresholds !== undefined ? { thresholds: input.thresholds } : {}),
|
|
1492
|
+
at: input.budget.at,
|
|
1493
|
+
});
|
|
1494
|
+
if (alert !== undefined) alerts.push(alert);
|
|
1495
|
+
}
|
|
1496
|
+
const memorybudget = input.budget.memorybudget;
|
|
1497
|
+
if (memorybudget !== undefined && memorybudget > 0) {
|
|
1498
|
+
const ratio = input.budget.pressure / memorybudget;
|
|
1499
|
+
const alert = budgetalertof({
|
|
1500
|
+
runid: input.budget.runid,
|
|
1501
|
+
kind: "memory",
|
|
1502
|
+
ratio,
|
|
1503
|
+
...(input.thresholds !== undefined ? { thresholds: input.thresholds } : {}),
|
|
1504
|
+
at: input.budget.at,
|
|
1505
|
+
});
|
|
1506
|
+
if (alert !== undefined) alerts.push(alert);
|
|
1507
|
+
}
|
|
1508
|
+
return alerts;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/** Reads the single alert of one budget ratio at its reached severity level; the critical level pauses the run pending a user choice. */
|
|
1512
|
+
function budgetalertof(input: {
|
|
1513
|
+
runid: string;
|
|
1514
|
+
kind: "steps" | "memory";
|
|
1515
|
+
ratio: number;
|
|
1516
|
+
thresholds?: { warning?: number; critical?: number };
|
|
1517
|
+
at: number;
|
|
1518
|
+
}): budgetalert | undefined {
|
|
1519
|
+
const critical = input.thresholds?.critical;
|
|
1520
|
+
const warning = input.thresholds?.warning;
|
|
1521
|
+
if (critical !== undefined && critical > 0 && input.ratio >= critical)
|
|
1522
|
+
return { runid: input.runid, level: "critical", kind: input.kind, ratio: input.ratio, paused: true, at: input.at };
|
|
1523
|
+
if (warning !== undefined && warning > 0 && input.ratio >= warning)
|
|
1524
|
+
return { runid: input.runid, level: "warning", kind: input.kind, ratio: input.ratio, paused: false, at: input.at };
|
|
1525
|
+
return undefined;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
/** Reads whether one run pauses for its budgetalerts: only a critical alert pauses the run pending a user choice while every warning stays informational. */
|
|
1529
|
+
export function runpausesforalerts(alerts: budgetalert[]): boolean {
|
|
1530
|
+
return alerts.some((alert) => alert.paused);
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/** Reads the timeoutcancel decision of one step: the step aborts at the user chosen bound while an absent bound never aborts a step because the bound stays a user choice. */
|
|
1534
|
+
export function timeoutcanceldecision(input: { elapsed: number; bound?: number }): { abort: boolean; reason: string } {
|
|
1535
|
+
if (input.bound === undefined)
|
|
1536
|
+
return {
|
|
1537
|
+
abort: false,
|
|
1538
|
+
reason:
|
|
1539
|
+
"The user set no timeout bound and the engine sets none; the step runs to its end inside the reviewed plan.",
|
|
1540
|
+
};
|
|
1541
|
+
if (!Number.isFinite(input.elapsed))
|
|
1542
|
+
return {
|
|
1543
|
+
abort: false,
|
|
1544
|
+
reason: "The step reported no finite elapsed time; the executor never aborts on a telemetry gap.",
|
|
1545
|
+
};
|
|
1546
|
+
if (input.elapsed <= input.bound)
|
|
1547
|
+
return {
|
|
1548
|
+
abort: false,
|
|
1549
|
+
reason: `The step ran ${input.elapsed} milliseconds inside the user timeout bound of ${input.bound} milliseconds.`,
|
|
1550
|
+
};
|
|
1551
|
+
return {
|
|
1552
|
+
abort: true,
|
|
1553
|
+
reason: `The step ran ${input.elapsed} milliseconds past the user timeout bound of ${input.bound} milliseconds; the executor aborts it and records the cancel event beside the step outcome.`,
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
/** Builds one timeoutcancel event: the aborted step, its bound, its elapsed time and the immutable log entry the cancel wrote beside the step outcome. */
|
|
1558
|
+
export function timeoutcanceleventof(input: {
|
|
1559
|
+
runid: string;
|
|
1560
|
+
stepid: string;
|
|
1561
|
+
bound: number;
|
|
1562
|
+
elapsed: number;
|
|
1563
|
+
now: number;
|
|
1564
|
+
logged: boolean;
|
|
1565
|
+
}): timeoutcancelevent {
|
|
1566
|
+
if (input.runid.trim() === "") throw new Error("The timeoutcancel event needs its run id.");
|
|
1567
|
+
if (input.stepid.trim() === "") throw new Error("The timeoutcancel event needs its step id.");
|
|
1568
|
+
return {
|
|
1569
|
+
id: randomid(),
|
|
1570
|
+
runid: input.runid,
|
|
1571
|
+
stepid: input.stepid,
|
|
1572
|
+
bound: Math.max(input.bound, 0),
|
|
1573
|
+
elapsed: Math.max(input.elapsed, 0),
|
|
1574
|
+
at: input.now,
|
|
1575
|
+
logged: input.logged,
|
|
1576
|
+
};
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/** Reads the timeoutcancel policies of one plan: one policy per step carrying the user chosen bound, and an absent bound keeps every policy unbounded. */
|
|
1580
|
+
export function timeoutcancelpolicies(input: { steps: string[]; bound?: number }): timeoutcancelpolicy[] {
|
|
1581
|
+
return input.steps.map((stepid) => ({ stepid, ...(input.bound !== undefined ? { bound: input.bound } : {}) }));
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
/* ── Merged from runresume.ts ── */
|
|
1585
|
+
|
|
1586
|
+
import type { navdedupeentry, resumepoint, runcacheentry, stepprefetchhint } from "./types.js";
|
|
1587
|
+
|
|
1588
|
+
/**
|
|
1589
|
+
* Runresume logic of the 1.1.69 family.
|
|
1590
|
+
* The efficientresume checkpoints every step boundary with its cursor and its page digest, skips the completed steps after a restart and revalidates the page fingerprint before it resumes, the navdedupe skips navigations to the already active url and folds repeated planned visits into one navigation, the runcache stores fetched resources per run keyed by their digest, serves the repeat fetches of the same run and clears at the run end unless the user pins the profile cache, and the stepprefetch derives its likely next pages and likely next selectors from the plan structure alone.
|
|
1591
|
+
* The resume, the dedupe and the prefetch optimize the run only: they never skip a review and never fetch a page the reviewed plan never named.
|
|
1592
|
+
*/
|
|
1593
|
+
|
|
1594
|
+
/** Builds one efficientresume checkpoint: the step boundary with its cursor and the digest of the page fingerprint the resume revalidates. */
|
|
1595
|
+
export function resumepointof(input: {
|
|
1596
|
+
runid: string;
|
|
1597
|
+
stepid: string;
|
|
1598
|
+
cursor: number;
|
|
1599
|
+
digest: string;
|
|
1600
|
+
now: number;
|
|
1601
|
+
}): resumepoint {
|
|
1602
|
+
if (input.runid.trim() === "") throw new Error("The resume checkpoint needs its run id.");
|
|
1603
|
+
if (input.stepid.trim() === "") throw new Error("The resume checkpoint needs its step id.");
|
|
1604
|
+
if (input.digest.trim() === "")
|
|
1605
|
+
throw new Error("The resume checkpoint needs its page digest; a digestless resume never revalidates the page.");
|
|
1606
|
+
return {
|
|
1607
|
+
runid: input.runid,
|
|
1608
|
+
stepid: input.stepid,
|
|
1609
|
+
cursor: Math.max(input.cursor, 0),
|
|
1610
|
+
digest: input.digest,
|
|
1611
|
+
at: input.now,
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
/** Reads the skip plan after a restart: the steps with a checkpoint skip while the resume continues at the first step without one. */
|
|
1616
|
+
export function skipcompletedsteps(input: { checkpoints: resumepoint[]; steps: string[] }): {
|
|
1617
|
+
skip: string[];
|
|
1618
|
+
resumeat?: string;
|
|
1619
|
+
} {
|
|
1620
|
+
const done = new Set(input.checkpoints.map((checkpoint) => checkpoint.stepid));
|
|
1621
|
+
const skip = input.steps.filter((step) => done.has(step));
|
|
1622
|
+
const resumeat = input.steps.find((step) => !done.has(step));
|
|
1623
|
+
return { skip, ...(resumeat !== undefined ? { resumeat } : {}) };
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
/** Revalidates the page fingerprint before one resume: a matching digest resumes while a changed fingerprint refuses so the resume never continues against a different page. */
|
|
1627
|
+
export function revalidatefingerprint(input: { checkpoint: resumepoint; fingerprint: string }): {
|
|
1628
|
+
ok: boolean;
|
|
1629
|
+
reason: string;
|
|
1630
|
+
} {
|
|
1631
|
+
if (input.checkpoint.digest === input.fingerprint)
|
|
1632
|
+
return {
|
|
1633
|
+
ok: true,
|
|
1634
|
+
reason: `The page fingerprint matches the checkpoint digest of the step ${input.checkpoint.stepid}; the resume continues at cursor ${input.checkpoint.cursor}.`,
|
|
1635
|
+
};
|
|
1636
|
+
return {
|
|
1637
|
+
ok: false,
|
|
1638
|
+
reason: `The page fingerprint changed since the checkpoint of the step ${input.checkpoint.stepid}; the resume refuses so the run never continues against a different page.`,
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/** Reads the navdedupe verdict of one planned navigation: a navigation to the already active url skips while every other navigation runs. */
|
|
1643
|
+
export function navdedupeverdict(input: { url: string; activeurl: string }): { skip: boolean; reason: string } {
|
|
1644
|
+
if (input.url.trim() === "")
|
|
1645
|
+
return { skip: false, reason: "The planned navigation carries no url; the navigation runs as reviewed." };
|
|
1646
|
+
if (input.url === input.activeurl)
|
|
1647
|
+
return {
|
|
1648
|
+
skip: true,
|
|
1649
|
+
reason: `The planned navigation to ${input.url} targets the already active url; the navdedupe skips the duplicate navigation while the step outcome records the skip.`,
|
|
1650
|
+
};
|
|
1651
|
+
return {
|
|
1652
|
+
skip: false,
|
|
1653
|
+
reason: `The planned navigation to ${input.url} targets a new url; the navigation runs as reviewed.`,
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
/** Folds repeated planned visits into one navigation per url: every step that plans the same url merges into one navdedupe entry. */
|
|
1658
|
+
export function foldplannedvisits(steps: Array<{ id: string; url?: string }>): navdedupeentry[] {
|
|
1659
|
+
const folded = new Map<string, navdedupeentry>();
|
|
1660
|
+
for (const step of steps) {
|
|
1661
|
+
if (step.url === undefined || step.url.trim() === "") continue;
|
|
1662
|
+
const entry = folded.get(step.url);
|
|
1663
|
+
if (entry === undefined) {
|
|
1664
|
+
folded.set(step.url, { url: step.url, stepids: [step.id] });
|
|
1665
|
+
continue;
|
|
1666
|
+
}
|
|
1667
|
+
entry.stepids.push(step.id);
|
|
1668
|
+
}
|
|
1669
|
+
return [...folded.values()];
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
/** Reads the digest key of one fetched resource within one run so the runcache stores and serves it by digest. */
|
|
1673
|
+
export function runcachedigest(input: { runid: string; resource: string }): string {
|
|
1674
|
+
if (input.runid.trim() === "") throw new Error("The runcache digest needs its run id.");
|
|
1675
|
+
if (input.resource.trim() === "") throw new Error("The runcache digest needs its resource.");
|
|
1676
|
+
return `${input.runid}:${input.resource}`;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/** Serves one repeat fetch from the runcache: a stored entry of the same run and the same resource answers the fetch while no stored entry keeps the fetch running. */
|
|
1680
|
+
export function runcachehit(
|
|
1681
|
+
entries: runcacheentry[],
|
|
1682
|
+
input: { runid: string; resource: string },
|
|
1683
|
+
): runcacheentry | undefined {
|
|
1684
|
+
return entries.find((entry) => entry.runid === input.runid && entry.resource === input.resource);
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
/** Reads the runcache sweep of one run end: the entries clear at the run end while the pinned entries of the user profile cache survive. */
|
|
1688
|
+
export function runcachesweep(
|
|
1689
|
+
entries: runcacheentry[],
|
|
1690
|
+
input: { runid: string; pin: boolean },
|
|
1691
|
+
): { kept: runcacheentry[]; cleared: number } {
|
|
1692
|
+
if (input.pin) return { kept: [...entries], cleared: 0 };
|
|
1693
|
+
const kept = entries.filter((entry) => entry.runid !== input.runid || entry.pinned);
|
|
1694
|
+
return { kept, cleared: entries.length - kept.length };
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
/** Derives the stepprefetch hints from the plan structure alone: every step names its likely next page from the next planned navigation and its likely next selectors from the targets of the steps that follow, so the warming never reaches a page the reviewed plan never named. */
|
|
1698
|
+
export function stepprefetchhints(plan: {
|
|
1699
|
+
steps: Array<{ id: string; kind: string; value?: string; target?: string }>;
|
|
1700
|
+
}): stepprefetchhint[] {
|
|
1701
|
+
return plan.steps.map((step, index) => {
|
|
1702
|
+
const rest = plan.steps.slice(index + 1);
|
|
1703
|
+
const nav = rest.find(
|
|
1704
|
+
(candidate) =>
|
|
1705
|
+
["navigate", "openlink", "followlink"].includes(candidate.kind) &&
|
|
1706
|
+
candidate.value !== undefined &&
|
|
1707
|
+
candidate.value.trim() !== "",
|
|
1708
|
+
);
|
|
1709
|
+
const selectors = rest
|
|
1710
|
+
.map((candidate) => candidate.target)
|
|
1711
|
+
.filter((target): target is string => target !== undefined && target.trim() !== "");
|
|
1712
|
+
return {
|
|
1713
|
+
stepid: step.id,
|
|
1714
|
+
...(nav?.value !== undefined ? { page: nav.value } : {}),
|
|
1715
|
+
selectors: [...new Set(selectors)].slice(0, 5),
|
|
1716
|
+
};
|
|
1717
|
+
});
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
/* ── Merged from runreplay.ts ── */
|
|
1721
|
+
|
|
1722
|
+
import type { immutablelogentry, runreplaysession, runreplaystep } from "./types.js";
|
|
1723
|
+
|
|
1724
|
+
/**
|
|
1725
|
+
* Runreplay logic of the 1.1.66 family.
|
|
1726
|
+
* The audit walk of one sealed run lives here: the replay opens only a sealed run whose chain verified, derives one replay step per recorded log entry with its gate resolutions and its restored observation and capture refs, and the viewer moves forward, backward and to any chosen step while every viewer action records in the audit trail.
|
|
1727
|
+
* The replay never re-executes anything: it reads the immutable log and the stored captures only, so a replay of a run never touches the page.
|
|
1728
|
+
*/
|
|
1729
|
+
|
|
1730
|
+
/** Derives the observation version and the capture id of one log entry from its summary provenance when the entry carries them; an entry without refs restores its text alone. */
|
|
1731
|
+
function restoredrefsof(entry: immutablelogentry): { observationversion?: number; captureid?: string } {
|
|
1732
|
+
const observation = /observation (\d+)/i.exec(entry.summary);
|
|
1733
|
+
const capture = /capture ([a-z0-9-]+)/i.exec(entry.summary);
|
|
1734
|
+
return {
|
|
1735
|
+
...(observation !== null ? { observationversion: Number(observation[1]) } : {}),
|
|
1736
|
+
...(capture !== null ? { captureid: capture[1] } : {}),
|
|
1737
|
+
};
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
/** Builds one replay step of a log entry with its gate resolutions: the step annotates the gate the run waited at when the entry carries one. */
|
|
1741
|
+
export function replaystepof(
|
|
1742
|
+
entry: immutablelogentry,
|
|
1743
|
+
index: number,
|
|
1744
|
+
gates: Array<{ gateid: string; kind: string; resolution: string; at: number }>,
|
|
1745
|
+
): runreplaystep {
|
|
1746
|
+
const refs = restoredrefsof(entry);
|
|
1747
|
+
return {
|
|
1748
|
+
stepid: entry.stepid ?? entry.id,
|
|
1749
|
+
index,
|
|
1750
|
+
summary: entry.summary,
|
|
1751
|
+
...(refs.observationversion !== undefined ? { observationversion: refs.observationversion } : {}),
|
|
1752
|
+
...(refs.captureid !== undefined ? { captureid: refs.captureid } : {}),
|
|
1753
|
+
gateresolutions: entry.stepid === undefined ? [] : gates.filter((gate) => gate.gateid === entry.stepid),
|
|
1754
|
+
};
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
/** Builds one runreplay session of a sealed run: one replay step per verified log entry in order, the cursor starts before the first step and the viewer actions start empty. */
|
|
1758
|
+
export function runreplaysessionof(input: {
|
|
1759
|
+
runid: string;
|
|
1760
|
+
entries: immutablelogentry[];
|
|
1761
|
+
gates?: Array<{ gateid: string; kind: string; resolution: string; at: number }>;
|
|
1762
|
+
now: number;
|
|
1763
|
+
}): runreplaysession {
|
|
1764
|
+
if (input.runid.trim() === "") throw new Error("The runreplay session needs its recorded run id.");
|
|
1765
|
+
if (input.entries.length === 0)
|
|
1766
|
+
throw new Error("The runreplay session needs at least one verified log entry to walk.");
|
|
1767
|
+
const steps = input.entries.map((entry, index) => replaystepof(entry, index, input.gates ?? []));
|
|
1768
|
+
return {
|
|
1769
|
+
id: `replay:${input.runid}:${input.now}`,
|
|
1770
|
+
runid: input.runid,
|
|
1771
|
+
cursor: 0,
|
|
1772
|
+
playing: false,
|
|
1773
|
+
steps,
|
|
1774
|
+
openedat: input.now,
|
|
1775
|
+
actions: [],
|
|
1776
|
+
};
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
/** Moves the replay cursor one step forward or backward; the cursor stays inside the step list because a replay walks its recorded chain only. */
|
|
1780
|
+
export function replaymove(
|
|
1781
|
+
session: runreplaysession,
|
|
1782
|
+
direction: "forward" | "backward",
|
|
1783
|
+
now: number,
|
|
1784
|
+
): runreplaysession {
|
|
1785
|
+
const next =
|
|
1786
|
+
direction === "forward" ? Math.min(session.steps.length - 1, session.cursor + 1) : Math.max(0, session.cursor - 1);
|
|
1787
|
+
const step = session.steps[next];
|
|
1788
|
+
return {
|
|
1789
|
+
...session,
|
|
1790
|
+
cursor: next,
|
|
1791
|
+
playing: false,
|
|
1792
|
+
actions: [
|
|
1793
|
+
...session.actions,
|
|
1794
|
+
{ kind: "step" as const, at: now, ...(step !== undefined ? { stepid: step.stepid } : {}) },
|
|
1795
|
+
],
|
|
1796
|
+
};
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
/** Jumps the replay cursor to one chosen step of the recorded chain; a step outside the chain refuses the jump. */
|
|
1800
|
+
export function replayjump(session: runreplaysession, stepid: string, now: number): runreplaysession {
|
|
1801
|
+
const index = session.steps.findIndex((step) => step.stepid === stepid);
|
|
1802
|
+
if (index === -1) throw new Error(`The runreplay knows no ${stepid} step in the recorded chain of ${session.runid}.`);
|
|
1803
|
+
return {
|
|
1804
|
+
...session,
|
|
1805
|
+
cursor: index,
|
|
1806
|
+
playing: false,
|
|
1807
|
+
actions: [...session.actions, { kind: "jump" as const, stepid, at: now }],
|
|
1808
|
+
};
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
/** Toggles the replay play state: playing advances the viewer through the recorded steps while the pause holds the cursor exactly where it stands. */
|
|
1812
|
+
export function replayplay(session: runreplaysession, playing: boolean, now: number): runreplaysession {
|
|
1813
|
+
return {
|
|
1814
|
+
...session,
|
|
1815
|
+
playing,
|
|
1816
|
+
actions: [...session.actions, { kind: playing ? ("play" as const) : ("pause" as const), at: now }],
|
|
1817
|
+
};
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
/** Records one viewer action of the replay session for the audit trail: every play, pause, step and jump keeps its time. */
|
|
1821
|
+
export function replayviewaction(
|
|
1822
|
+
session: runreplaysession,
|
|
1823
|
+
action: { kind: "play" | "pause" | "step" | "jump"; stepid?: string; at: number },
|
|
1824
|
+
): runreplaysession {
|
|
1825
|
+
return {
|
|
1826
|
+
...session,
|
|
1827
|
+
actions: [
|
|
1828
|
+
...session.actions,
|
|
1829
|
+
{ kind: action.kind, ...(action.stepid !== undefined ? { stepid: action.stepid } : {}), at: action.at },
|
|
1830
|
+
],
|
|
1831
|
+
};
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
/** Reads the restored view of the replayed step: the log summary it stands on beside the observation and the capture the step restored from the stored captures of the run. */
|
|
1835
|
+
export function replayrestoredview(step: runreplaystep): {
|
|
1836
|
+
stepid: string;
|
|
1837
|
+
index: number;
|
|
1838
|
+
summary: string;
|
|
1839
|
+
observationversion?: number;
|
|
1840
|
+
captureid?: string;
|
|
1841
|
+
gateresolutions: Array<{ gateid: string; kind: string; resolution: string; at: number }>;
|
|
1842
|
+
} {
|
|
1843
|
+
return {
|
|
1844
|
+
stepid: step.stepid,
|
|
1845
|
+
index: step.index,
|
|
1846
|
+
summary: step.summary,
|
|
1847
|
+
...(step.observationversion !== undefined ? { observationversion: step.observationversion } : {}),
|
|
1848
|
+
...(step.captureid !== undefined ? { captureid: step.captureid } : {}),
|
|
1849
|
+
gateresolutions: step.gateresolutions,
|
|
1850
|
+
};
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
/** Reads the replay cursor of one viewed run the memory keeps per run, so a reopened replay stands where the viewer left it. */
|
|
1854
|
+
export function replaycursorof(session: runreplaysession): { runid: string; cursor: number; playing: boolean } {
|
|
1855
|
+
return { runid: session.runid, cursor: session.cursor, playing: session.playing };
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
/* ── Merged from runparallel.ts ── */
|
|
1859
|
+
|
|
1860
|
+
import type { readparallelgroup, sessionreusegrant, warmselectorrecord } from "./types.js";
|
|
1861
|
+
|
|
1862
|
+
/**
|
|
1863
|
+
* Runparallel logic of the 1.1.69 family.
|
|
1864
|
+
* The readparallel groups the independent page reads of one run into parallel lanes and joins the lanes before the dependent steps, the warmselectors carry the validated selectors between adjacent steps and revalidate on a fingerprint change only, and the sessionreuse attaches an authenticated profile to a run on its explicit per profile consent while it isolates the cookies per task through separate containers.
|
|
1865
|
+
* The parallel lanes, the carried selectors and the reused sessions optimize the run only: every read, every resolution and every attached profile stays inside the reviewed plan and its consent gates.
|
|
1866
|
+
*/
|
|
1867
|
+
|
|
1868
|
+
/** Groups the independent page reads of one run into parallel lanes: every read runs in its own lane while the user chosen lane ceiling keeps the waves bounded, and an absent ceiling keeps every lane open. */
|
|
1869
|
+
export function readparallelgroupof(input: {
|
|
1870
|
+
runid: string;
|
|
1871
|
+
reads: Array<{ stepid: string; origin: string }>;
|
|
1872
|
+
lanes?: number;
|
|
1873
|
+
}): readparallelgroup {
|
|
1874
|
+
if (input.runid.trim() === "") throw new Error("The readparallel group needs its run id.");
|
|
1875
|
+
const lanes = input.lanes !== undefined && input.lanes > 0 ? input.reads.slice(0, input.lanes) : [...input.reads];
|
|
1876
|
+
return {
|
|
1877
|
+
runid: input.runid,
|
|
1878
|
+
lanes: lanes.map((read) => ({ stepid: read.stepid, origin: read.origin })),
|
|
1879
|
+
joined: false,
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
/** Joins one readparallel group before its dependent steps: every lane completes before the dependent step runs. */
|
|
1884
|
+
export function joinlanes(group: readparallelgroup): readparallelgroup {
|
|
1885
|
+
return { ...group, joined: true };
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
/** Reads the lanes of one readparallel group that still run, so the join waits on the open lanes alone. */
|
|
1889
|
+
export function openlanes(group: readparallelgroup): Array<{ stepid: string; origin: string }> {
|
|
1890
|
+
return group.lanes.filter((lane) => lane.stepid !== "");
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
/** Carries one validated selector to the adjacent step: the record holds the resolution and the page fingerprint the resolution stays valid under. */
|
|
1894
|
+
export function carrywarmselector(input: {
|
|
1895
|
+
selector: string;
|
|
1896
|
+
resolution: string;
|
|
1897
|
+
fingerprint: string;
|
|
1898
|
+
from: string;
|
|
1899
|
+
}): warmselectorrecord {
|
|
1900
|
+
if (input.selector.trim() === "") throw new Error("The warmselector record needs its selector.");
|
|
1901
|
+
if (input.fingerprint.trim() === "")
|
|
1902
|
+
throw new Error("The warmselector record needs its page fingerprint; a fingerprintless carry never revalidates.");
|
|
1903
|
+
return {
|
|
1904
|
+
selector: input.selector,
|
|
1905
|
+
resolution: input.resolution,
|
|
1906
|
+
fingerprint: input.fingerprint,
|
|
1907
|
+
carriedfrom: input.from,
|
|
1908
|
+
};
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
/** Reads whether one carried selector stays valid: the carry holds while the page fingerprint stays unchanged, and only a fingerprint change revalidates the selector. */
|
|
1912
|
+
export function warmselectorcarryvalid(input: { record: warmselectorrecord; fingerprint: string }): {
|
|
1913
|
+
carry: boolean;
|
|
1914
|
+
reason: string;
|
|
1915
|
+
} {
|
|
1916
|
+
if (input.record.fingerprint === input.fingerprint)
|
|
1917
|
+
return {
|
|
1918
|
+
carry: true,
|
|
1919
|
+
reason: `The page fingerprint stayed unchanged since the step ${input.record.carriedfrom}; the carried resolution of ${input.record.selector} holds and needs no revalidation.`,
|
|
1920
|
+
};
|
|
1921
|
+
return {
|
|
1922
|
+
carry: false,
|
|
1923
|
+
reason: `The page fingerprint changed since the step ${input.record.carriedfrom}; the carried resolution of ${input.record.selector} revalidates before the dispatch.`,
|
|
1924
|
+
};
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
/** Builds one sessionreuse grant: the authenticated profile attaches to a run only through its explicit per profile consent prompt. */
|
|
1928
|
+
export function sessionreusegrantof(input: {
|
|
1929
|
+
profile: string;
|
|
1930
|
+
container: string;
|
|
1931
|
+
promptid: string;
|
|
1932
|
+
consentedat: number;
|
|
1933
|
+
runid?: string;
|
|
1934
|
+
}): sessionreusegrant {
|
|
1935
|
+
if (input.profile.trim() === "") throw new Error("The sessionreuse grant needs its profile.");
|
|
1936
|
+
if (input.promptid.trim() === "")
|
|
1937
|
+
throw new Error("The sessionreuse grant needs its consent prompt id; a promptless grant attaches no profile.");
|
|
1938
|
+
return {
|
|
1939
|
+
profile: input.profile,
|
|
1940
|
+
container: input.container,
|
|
1941
|
+
promptid: input.promptid,
|
|
1942
|
+
consentedat: input.consentedat,
|
|
1943
|
+
...(input.runid !== undefined ? { runid: input.runid } : {}),
|
|
1944
|
+
};
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
/** Reads the container name of one task under one profile so the sessionreuse isolates the cookies per task through separate containers. */
|
|
1948
|
+
export function sessioncontainerof(input: { profile: string; taskid: string }): string {
|
|
1949
|
+
if (input.profile.trim() === "") throw new Error("The session container needs its profile.");
|
|
1950
|
+
if (input.taskid.trim() === "") throw new Error("The session container needs its task id.");
|
|
1951
|
+
return `${input.profile}:${input.taskid}`;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
/* ── Merged from backgroundruns.ts ── */
|
|
1955
|
+
|
|
1956
|
+
import type { backgroundqueueentry } from "./types.js";
|
|
1957
|
+
import { backgroundrungate } from "./policy.js";
|
|
1958
|
+
|
|
1959
|
+
/**
|
|
1960
|
+
* Background run queue logic of the 1.1.66 family.
|
|
1961
|
+
* The queue that keeps workflows executing with no open surface lives here: each entry holds one reviewed workflow the user queued, the queue hands the entries to the executor one at a time in their queued order, a running entry holds the keepalive signal of 1.1.60 for its whole duration, the interrupted entries of a restart requeue for the recovery, and every state transition feeds the attentionfeed through the statusbadge.
|
|
1962
|
+
* The queue never starts an unreviewed workflow: a queued entry needs its approved review state exactly like a foreground run.
|
|
1963
|
+
*/
|
|
1964
|
+
|
|
1965
|
+
/** Builds one background queue entry: the workflow stays queued until the executor picks it while no keepalive signal is held yet. */
|
|
1966
|
+
export function backgroundqueueentryof(input: {
|
|
1967
|
+
workflowid: string;
|
|
1968
|
+
summary: string;
|
|
1969
|
+
now: number;
|
|
1970
|
+
}): backgroundqueueentry {
|
|
1971
|
+
if (input.workflowid.trim() === "") throw new Error("The background queue entry needs its workflow id.");
|
|
1972
|
+
return {
|
|
1973
|
+
id: `background:${input.workflowid}:${input.now}`,
|
|
1974
|
+
workflowid: input.workflowid,
|
|
1975
|
+
state: "queued",
|
|
1976
|
+
keepaliveheld: false,
|
|
1977
|
+
queuedat: input.now,
|
|
1978
|
+
summary: input.summary,
|
|
1979
|
+
};
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
/** Enqueues one workflow for a background run: the queue keeps its order and one workflow may wait more than once. */
|
|
1983
|
+
export function enqueuebackgroundrun(input: {
|
|
1984
|
+
queue: backgroundqueueentry[];
|
|
1985
|
+
workflowid: string;
|
|
1986
|
+
summary: string;
|
|
1987
|
+
now: number;
|
|
1988
|
+
}): backgroundqueueentry[] {
|
|
1989
|
+
return [
|
|
1990
|
+
...input.queue,
|
|
1991
|
+
backgroundqueueentryof({ workflowid: input.workflowid, summary: input.summary, now: input.now }),
|
|
1992
|
+
];
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
/** Reads the next queued entry of the queue in its queued order; an empty queue returns undefined. */
|
|
1996
|
+
export function nextbackgroundrun(queue: backgroundqueueentry[]): backgroundqueueentry | undefined {
|
|
1997
|
+
return [...queue].filter((entry) => entry.state === "queued").sort((a, b) => a.queuedat - b.queuedat)[0];
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
/** Begins one background run: the entry moves to running and holds the keepalive signal for its whole duration behind the background run gate. */
|
|
2001
|
+
export function beginbackgroundrun(
|
|
2002
|
+
entry: backgroundqueueentry,
|
|
2003
|
+
now: number,
|
|
2004
|
+
reviewed: boolean,
|
|
2005
|
+
): { entry: backgroundqueueentry; gate: { allowed: boolean; reason: string } } {
|
|
2006
|
+
const gate = backgroundrungate({ reviewed, keepaliveheld: true });
|
|
2007
|
+
return {
|
|
2008
|
+
entry: gate.allowed ? { ...entry, state: "running" as const, keepaliveheld: true, startedat: now } : entry,
|
|
2009
|
+
gate: { allowed: gate.allowed, reason: gate.reason ?? "" },
|
|
2010
|
+
};
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
/** Finishes one background run: the entry reaches its terminal state and releases the keepalive hold it carried. */
|
|
2014
|
+
export function finishbackgroundrun(
|
|
2015
|
+
entry: backgroundqueueentry,
|
|
2016
|
+
state: "done" | "failed",
|
|
2017
|
+
now: number,
|
|
2018
|
+
): backgroundqueueentry {
|
|
2019
|
+
return { ...entry, state, keepaliveheld: false, endedat: now };
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
/** Reads the restart recovery state of the queue: every running entry of an interrupted worker requeues while the terminal entries keep their state for the audit. */
|
|
2023
|
+
export function resumebackgroundqueue(
|
|
2024
|
+
queue: backgroundqueueentry[],
|
|
2025
|
+
now: number,
|
|
2026
|
+
): { queue: backgroundqueueentry[]; requeued: string[] } {
|
|
2027
|
+
const requeued: string[] = [];
|
|
2028
|
+
const next = queue.map((entry) => {
|
|
2029
|
+
if (entry.state !== "running") return entry;
|
|
2030
|
+
requeued.push(entry.id);
|
|
2031
|
+
const { startedat, ...rest } = entry;
|
|
2032
|
+
void startedat;
|
|
2033
|
+
return { ...rest, state: "queued" as const, keepaliveheld: false };
|
|
2034
|
+
});
|
|
2035
|
+
void now;
|
|
2036
|
+
return { queue: next, requeued };
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
/** Reads the attention descriptor of one background queue entry: a failed entry feeds the attentionfeed while a queued entry defers until the executor turn frees. */
|
|
2040
|
+
export function backgroundrunattention(
|
|
2041
|
+
entry: backgroundqueueentry,
|
|
2042
|
+
origin: string,
|
|
2043
|
+
): { cause: "failure" | "deferral"; runid: string; origin: string; summary: string } | undefined {
|
|
2044
|
+
if (entry.state === "failed")
|
|
2045
|
+
return {
|
|
2046
|
+
cause: "failure",
|
|
2047
|
+
runid: entry.id,
|
|
2048
|
+
origin,
|
|
2049
|
+
summary: `The background run of ${entry.workflowid} failed: ${entry.summary}`,
|
|
2050
|
+
};
|
|
2051
|
+
if (entry.state === "queued")
|
|
2052
|
+
return {
|
|
2053
|
+
cause: "deferral",
|
|
2054
|
+
runid: entry.id,
|
|
2055
|
+
origin,
|
|
2056
|
+
summary: `The background run of ${entry.workflowid} waits for its executor turn.`,
|
|
2057
|
+
};
|
|
2058
|
+
return undefined;
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
/** Reads the dashboard view of the background queue: one row per entry with its state and its progress by the workflow step count. */
|
|
2062
|
+
export function backgroundrunsview(
|
|
2063
|
+
queue: backgroundqueueentry[],
|
|
2064
|
+
): Array<{
|
|
2065
|
+
id: string;
|
|
2066
|
+
workflowid: string;
|
|
2067
|
+
state: string;
|
|
2068
|
+
keepaliveheld: boolean;
|
|
2069
|
+
queuedat: number;
|
|
2070
|
+
startedat?: number;
|
|
2071
|
+
endedat?: number;
|
|
2072
|
+
summary: string;
|
|
2073
|
+
progress: string;
|
|
2074
|
+
}> {
|
|
2075
|
+
return queue.map((entry) => ({
|
|
2076
|
+
id: entry.id,
|
|
2077
|
+
workflowid: entry.workflowid,
|
|
2078
|
+
state: entry.state,
|
|
2079
|
+
keepaliveheld: entry.keepaliveheld,
|
|
2080
|
+
queuedat: entry.queuedat,
|
|
2081
|
+
...(entry.startedat !== undefined ? { startedat: entry.startedat } : {}),
|
|
2082
|
+
...(entry.endedat !== undefined ? { endedat: entry.endedat } : {}),
|
|
2083
|
+
summary: entry.summary,
|
|
2084
|
+
progress:
|
|
2085
|
+
entry.state === "running"
|
|
2086
|
+
? `Running with the keepalive signal held since ${entry.startedat ?? entry.queuedat}`
|
|
2087
|
+
: entry.state === "queued"
|
|
2088
|
+
? "Queued for the next executor turn"
|
|
2089
|
+
: entry.state === "done"
|
|
2090
|
+
? "Completed in the background"
|
|
2091
|
+
: "Failed; the attentionfeed carries the cause",
|
|
2092
|
+
}));
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
/** Reads the recenttray rows of the finished background runs: the tray grows its background section from the terminal entries of the queue. */
|
|
2096
|
+
export function backgroundtrayrows(
|
|
2097
|
+
queue: backgroundqueueentry[],
|
|
2098
|
+
origin: string,
|
|
2099
|
+
): Array<{
|
|
2100
|
+
runid: string;
|
|
2101
|
+
origin: string;
|
|
2102
|
+
outcome: "running" | "completed" | "halted" | "failed";
|
|
2103
|
+
title: string;
|
|
2104
|
+
at: number;
|
|
2105
|
+
resumable: boolean;
|
|
2106
|
+
reopenable: boolean;
|
|
2107
|
+
}> {
|
|
2108
|
+
return queue
|
|
2109
|
+
.filter((entry) => entry.state === "done" || entry.state === "failed")
|
|
2110
|
+
.map((entry) => ({
|
|
2111
|
+
runid: entry.id,
|
|
2112
|
+
origin,
|
|
2113
|
+
outcome: entry.state === "done" ? "completed" : "failed",
|
|
2114
|
+
title: `Background run of ${entry.workflowid}`,
|
|
2115
|
+
at: entry.endedat ?? entry.queuedat,
|
|
2116
|
+
resumable: false,
|
|
2117
|
+
reopenable: true,
|
|
2118
|
+
}));
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
/** Cancels one queued entry of the background queue: a running entry keeps its executor path and only a queued entry leaves through the queue. */
|
|
2122
|
+
export function cancelbackgroundentry(
|
|
2123
|
+
queue: backgroundqueueentry[],
|
|
2124
|
+
id: string,
|
|
2125
|
+
): { queue: backgroundqueueentry[]; cancelled: string[] } {
|
|
2126
|
+
const cancelled: string[] = [];
|
|
2127
|
+
const next = queue.filter((entry) => {
|
|
2128
|
+
if (entry.id === id && entry.state === "queued") {
|
|
2129
|
+
cancelled.push(entry.id);
|
|
2130
|
+
return false;
|
|
2131
|
+
}
|
|
2132
|
+
return true;
|
|
2133
|
+
});
|
|
2134
|
+
return { queue: next, cancelled };
|
|
2135
|
+
}
|