firedrill-run 2.2.5 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +176 -22
- package/bin/firedrill.mjs +29 -5
- package/dist/assembly.json +13 -0
- package/dist/cli/runtime/candidate_copy.py +395 -0
- package/dist/cli/runtime/source_snapshot.py +391 -0
- package/dist/cli/src/administration.js +295 -0
- package/dist/cli/src/agent-changes.js +496 -0
- package/dist/cli/src/assessment-requests.js +67 -0
- package/dist/cli/src/auth.js +506 -0
- package/dist/cli/src/billing-commerce-contracts.js +1 -0
- package/dist/cli/src/billing-usage.js +227 -0
- package/dist/cli/src/billing-wallet-contracts.js +1 -0
- package/dist/cli/src/billing.js +170 -0
- package/dist/cli/src/bootstrap-process-lock.js +136 -0
- package/dist/cli/src/browser-links.js +14 -0
- package/dist/cli/src/campaign-controls.js +122 -0
- package/dist/cli/src/campaign-diagnoses.js +118 -0
- package/dist/cli/src/campaign-reports.js +1215 -0
- package/dist/cli/src/campaign-world.js +156 -0
- package/dist/cli/src/candidate-copy.js +376 -0
- package/dist/cli/src/catalog-contracts.js +1 -0
- package/dist/cli/src/ci-access-contracts.js +1 -0
- package/dist/cli/src/ci-configuration-contracts.js +1 -0
- package/dist/cli/src/ci-coverage.js +64 -0
- package/dist/cli/src/ci-delivery-contracts.js +1 -0
- package/dist/cli/src/ci-result-contracts.js +1 -0
- package/dist/cli/src/ci-run-contracts.js +1 -0
- package/dist/cli/src/ci-setup-proposals.js +52 -0
- package/dist/cli/src/company-policies.js +229 -0
- package/dist/cli/src/completion.js +79 -0
- package/dist/cli/src/configuration-intents.js +277 -0
- package/dist/cli/src/configuration.js +369 -0
- package/dist/cli/src/continuous-simulation.js +277 -0
- package/dist/cli/src/coordinated-checkpoints.js +133 -0
- package/dist/cli/src/credential-mode.js +139 -0
- package/dist/cli/src/credentials.js +331 -0
- package/dist/cli/src/credit-format.js +8 -0
- package/dist/cli/src/dataset-contracts.js +1 -0
- package/dist/cli/src/dataset-export.js +154 -0
- package/dist/cli/src/dataset-reads.js +37 -0
- package/dist/cli/src/dependency-preparation.js +239 -0
- package/dist/cli/src/director-tool-setup.js +104 -0
- package/dist/cli/src/driver-review.js +225 -0
- package/dist/cli/src/evaluation-authoring.js +315 -0
- package/dist/cli/src/evidence-bundles.js +367 -0
- package/dist/cli/src/execution-proposals.js +36 -0
- package/dist/cli/src/http.js +302 -0
- package/dist/cli/src/local-container-boundary.js +38 -0
- package/dist/cli/src/local-container-policy.js +201 -0
- package/dist/cli/src/local-execution-setup.js +81 -0
- package/dist/cli/src/local-source-basis.js +73 -0
- package/dist/cli/src/local-source.js +66 -0
- package/dist/cli/src/local-value-store.js +91 -0
- package/dist/cli/src/local-values.js +163 -0
- package/dist/cli/src/login-organizations.js +70 -0
- package/dist/cli/src/main.js +1627 -0
- package/dist/cli/src/mcp-connection.js +109 -0
- package/dist/cli/src/native/bindings.js +137 -0
- package/dist/cli/src/native/canonical.js +370 -0
- package/dist/cli/src/native/cloud-resources.js +942 -0
- package/dist/cli/src/native/companion-channel.js +421 -0
- package/dist/cli/src/native/companion-ipc.js +473 -0
- package/dist/cli/src/native/contracts.js +218 -0
- package/dist/cli/src/native/control-plane.js +1354 -0
- package/dist/cli/src/native/credit-approval.js +15 -0
- package/dist/cli/src/native/dependency-proxy.js +1006 -0
- package/dist/cli/src/native/drill-cleanup.js +115 -0
- package/dist/cli/src/native/drill-runner.js +1562 -0
- package/dist/cli/src/native/drivers/action-trace.js +210 -0
- package/dist/cli/src/native/drivers/function-wrapper.js +152 -0
- package/dist/cli/src/native/drivers/http.js +442 -0
- package/dist/cli/src/native/drivers/index.js +47 -0
- package/dist/cli/src/native/drivers/jsonl-process.js +538 -0
- package/dist/cli/src/native/drivers/playwright.js +600 -0
- package/dist/cli/src/native/drivers/signed-event.js +244 -0
- package/dist/cli/src/native/drivers/types.js +578 -0
- package/dist/cli/src/native/evidence-filter.js +255 -0
- package/dist/cli/src/native/isolated-access.js +241 -0
- package/dist/cli/src/native/library-sharing.js +220 -0
- package/dist/cli/src/native/local-run.js +667 -0
- package/dist/cli/src/native/local-secrets.js +19 -0
- package/dist/cli/src/native/mcp-config.js +123 -0
- package/dist/cli/src/native/metadata-requests.js +120 -0
- package/dist/cli/src/native/observations.js +1159 -0
- package/dist/cli/src/native/owned-groups.js +46 -0
- package/dist/cli/src/native/plan.js +594 -0
- package/dist/cli/src/native/png.js +159 -0
- package/dist/cli/src/native/ports.js +43 -0
- package/dist/cli/src/native/readiness.js +139 -0
- package/dist/cli/src/native/reassessments.js +206 -0
- package/dist/cli/src/native/redaction.js +235 -0
- package/dist/cli/src/native/registration.js +155 -0
- package/dist/cli/src/native/roots.js +110 -0
- package/dist/cli/src/native/run-cleanup.js +199 -0
- package/dist/cli/src/native/scratch.js +117 -0
- package/dist/cli/src/native/screenshots.js +60 -0
- package/dist/cli/src/native/session.js +556 -0
- package/dist/cli/src/native/standalone-proxy.js +580 -0
- package/dist/cli/src/native/standalone-renewals.js +183 -0
- package/dist/cli/src/native/standalone-state.js +193 -0
- package/dist/cli/src/native/standalone-tools.js +187 -0
- package/dist/cli/src/native/supervisor.js +831 -0
- package/dist/cli/src/native/target-auth.js +161 -0
- package/dist/cli/src/native/target-state.js +330 -0
- package/dist/cli/src/native/tool-reuse.js +364 -0
- package/dist/cli/src/native/tool-setups.js +336 -0
- package/dist/cli/src/native/world-reuse.js +288 -0
- package/dist/cli/src/onboarding.js +23 -0
- package/dist/cli/src/priority-requests.js +70 -0
- package/dist/cli/src/priority.js +223 -0
- package/dist/cli/src/prompts.js +184 -0
- package/dist/cli/src/real-operations.js +221 -0
- package/dist/cli/src/recipe-intents.js +58 -0
- package/dist/cli/src/recorded-context-intents.js +59 -0
- package/dist/cli/src/recorded-context.js +248 -0
- package/dist/cli/src/recordings.js +69 -0
- package/dist/cli/src/registration-guide.js +39 -0
- package/dist/cli/src/report-exports.js +213 -0
- package/dist/cli/src/request-input.js +47 -0
- package/dist/cli/src/resource-lifecycle.js +141 -0
- package/dist/cli/src/revision-requests.js +55 -0
- package/dist/cli/src/runner-abandon.js +53 -0
- package/dist/cli/src/runner-campaign-controls.js +262 -0
- package/dist/cli/src/runner-claim-reads.js +36 -0
- package/dist/cli/src/runner-completion.js +65 -0
- package/dist/cli/src/runner-configuration.js +546 -0
- package/dist/cli/src/runner-container.js +794 -0
- package/dist/cli/src/runner-evidence-erasure.js +351 -0
- package/dist/cli/src/runner-evidence-privacy.js +37 -0
- package/dist/cli/src/runner-evidence-retention.js +161 -0
- package/dist/cli/src/runner-evidence.js +326 -0
- package/dist/cli/src/runner-execute.js +92 -0
- package/dist/cli/src/runner-execution-deadline.js +25 -0
- package/dist/cli/src/runner-grants.js +307 -0
- package/dist/cli/src/runner-identity.js +615 -0
- package/dist/cli/src/runner-inputs.js +308 -0
- package/dist/cli/src/runner-jobs.js +49 -0
- package/dist/cli/src/runner-model-transport.js +392 -0
- package/dist/cli/src/runner-ownership.js +332 -0
- package/dist/cli/src/runner-process-lock.js +175 -0
- package/dist/cli/src/runner-runtime.js +396 -0
- package/dist/cli/src/runner-session-channel.js +377 -0
- package/dist/cli/src/runner-trial-completion.js +34 -0
- package/dist/cli/src/runner-trial-journal.js +358 -0
- package/dist/cli/src/runner-trial-live.js +393 -0
- package/dist/cli/src/runner-trial-protocol.js +289 -0
- package/dist/cli/src/runner-trial-sync.js +110 -0
- package/dist/cli/src/runner-trial-transfer.js +107 -0
- package/dist/cli/src/runner-voice.js +40 -0
- package/dist/cli/src/runner-world-claim.js +21 -0
- package/dist/cli/src/runner-world-collector.js +159 -0
- package/dist/cli/src/runner-world-command-evidence.js +487 -0
- package/dist/cli/src/runner-world-commands.js +268 -0
- package/dist/cli/src/runner-world-completion.js +76 -0
- package/dist/cli/src/runner-world-erasure.js +95 -0
- package/dist/cli/src/runner-world-evidence-sync.js +91 -0
- package/dist/cli/src/runner-world-journal.js +510 -0
- package/dist/cli/src/runner-world-native-filters.js +134 -0
- package/dist/cli/src/runner-world-observations.js +50 -0
- package/dist/cli/src/runner-world-session-protocol.js +134 -0
- package/dist/cli/src/saved-records.js +40 -0
- package/dist/cli/src/scenario-authoring.js +212 -0
- package/dist/cli/src/scenario-discovery.js +216 -0
- package/dist/cli/src/scenario-paths.js +68 -0
- package/dist/cli/src/scenario-requests.js +4 -0
- package/dist/cli/src/service-receipts.js +105 -0
- package/dist/cli/src/simple/authoring.js +270 -0
- package/dist/cli/src/simple/credit-approval.js +16 -0
- package/dist/cli/src/simple/help.js +98 -0
- package/dist/cli/src/simple/init.js +96 -0
- package/dist/cli/src/simple/library-sharing.js +414 -0
- package/dist/cli/src/simple/metadata-requests.js +46 -0
- package/dist/cli/src/simple/native-report.js +841 -0
- package/dist/cli/src/simple/native-tools.js +780 -0
- package/dist/cli/src/simple/plan-apply.js +506 -0
- package/dist/cli/src/simple/reassessment.js +323 -0
- package/dist/cli/src/simple/register.js +123 -0
- package/dist/cli/src/simple/run.js +122 -0
- package/dist/cli/src/simple/status.js +49 -0
- package/dist/cli/src/simple/tool-renewal.js +308 -0
- package/dist/cli/src/simple/tool-reuse.js +526 -0
- package/dist/cli/src/simple/tool-start-catalog.js +100 -0
- package/dist/cli/src/simple/tool-state.js +426 -0
- package/dist/cli/src/simple/tools-author.js +222 -0
- package/dist/cli/src/simple/watch.js +28 -0
- package/dist/cli/src/simple/world-reuse.js +586 -0
- package/dist/cli/src/simulation-intents.js +406 -0
- package/dist/cli/src/simulation-run.js +1058 -0
- package/dist/cli/src/source-batch.js +322 -0
- package/dist/cli/src/source-connection.js +451 -0
- package/dist/cli/src/source-runtime.js +346 -0
- package/dist/cli/src/source-selection-intents.js +75 -0
- package/dist/cli/src/source-sets.js +214 -0
- package/dist/cli/src/target-models.js +109 -0
- package/dist/cli/src/tool-catalog.js +224 -0
- package/dist/cli/src/tool-command-contracts.js +1 -0
- package/dist/cli/src/tool-command-export.js +245 -0
- package/dist/cli/src/tool-connection-contracts.js +1 -0
- package/dist/cli/src/tool-connection-reads.js +23 -0
- package/dist/cli/src/tool-contributions.js +58 -0
- package/dist/cli/src/tool-data.js +116 -0
- package/dist/cli/src/tool-datasets.js +176 -0
- package/dist/cli/src/tool-group-contracts.js +1 -0
- package/dist/cli/src/tool-group-reads.js +26 -0
- package/dist/cli/src/tool-group-state.js +147 -0
- package/dist/cli/src/tool-groups.js +311 -0
- package/dist/cli/src/tool-instances.js +186 -0
- package/dist/cli/src/tool-lifecycle-contracts.js +1 -0
- package/dist/cli/src/tool-lifecycle-reads.js +30 -0
- package/dist/cli/src/tool-lifecycle.js +323 -0
- package/dist/cli/src/tool-package-contracts.js +1 -0
- package/dist/cli/src/tool-package-reads.js +41 -0
- package/dist/cli/src/tool-records.js +53 -0
- package/dist/cli/src/tool-runtime.js +96 -0
- package/dist/cli/src/tool-state-contracts.js +1 -0
- package/dist/cli/src/tool-state-reads.js +31 -0
- package/dist/cli/src/tools.js +306 -0
- package/dist/cli/src/trial-actions.js +93 -0
- package/dist/cli/src/trial-checkpoints.js +133 -0
- package/dist/cli/src/trial-checks.js +70 -0
- package/dist/cli/src/trial-state.js +197 -0
- package/dist/cli/src/vault.js +266 -0
- package/dist/cli/src/workspace-registration.js +383 -0
- package/dist/cli/src/workspace-team-contracts.js +1 -0
- package/dist/cli/src/world-access.js +231 -0
- package/dist/cli/src/world-agent.js +4 -0
- package/dist/cli/src/world-checkpoint-scopes.js +15 -0
- package/dist/cli/src/world-contracts.js +6 -0
- package/dist/cli/src/world-creation.js +201 -0
- package/dist/cli/src/world-datasets.js +207 -0
- package/dist/cli/src/world-inspection.js +257 -0
- package/dist/cli/src/world-recipes.js +103 -0
- package/dist/cli/src/world-requests.js +241 -0
- package/dist/cli/src/world-session-checkpoints.js +196 -0
- package/dist/cli/src/world-session-lifecycle.js +207 -0
- package/dist/cli/src/world-session-requests.js +215 -0
- package/dist/cli/src/world-session-state.js +145 -0
- package/dist/cli/src/world-session-voice.js +66 -0
- package/dist/cli/src/world-sessions.js +288 -0
- package/dist/cli/src/world-stream.js +99 -0
- package/dist/cli/src/world-time-report.js +58 -0
- package/dist/contracts/agent-change-contracts.js +75 -0
- package/dist/contracts/agent-execution-contracts.js +38 -0
- package/dist/contracts/application-state-contracts.js +26 -0
- package/dist/contracts/assessment-aggregate-contracts.js +15 -0
- package/dist/contracts/assessment-contracts.js +74 -0
- package/dist/contracts/billing-commerce-contracts.js +107 -0
- package/dist/contracts/billing-wallet-contracts.js +43 -0
- package/dist/contracts/campaign-lifecycle.js +1 -0
- package/dist/contracts/campaign-world-contracts.js +80 -0
- package/dist/contracts/catalog-contracts.js +73 -0
- package/dist/contracts/ci-access-contracts.js +68 -0
- package/dist/contracts/ci-configuration-contracts.js +88 -0
- package/dist/contracts/ci-coverage-contracts.js +65 -0
- package/dist/contracts/ci-coverage-labels.js +25 -0
- package/dist/contracts/ci-delivery-contracts.js +61 -0
- package/dist/contracts/ci-result-contracts.js +103 -0
- package/dist/contracts/ci-run-contracts.js +51 -0
- package/dist/contracts/ci-setup-contracts.js +88 -0
- package/dist/contracts/clip-contracts.js +32 -0
- package/dist/contracts/comparison-contracts.js +53 -0
- package/dist/contracts/composite-contracts.js +34 -0
- package/dist/contracts/context-contracts.js +76 -0
- package/dist/contracts/continuation-report-contracts.js +1 -0
- package/dist/contracts/continuity-requirements.js +8 -0
- package/dist/contracts/coordinated-checkpoint-contracts.js +34 -0
- package/dist/contracts/dataset-contracts.js +88 -0
- package/dist/contracts/dependency-decision-contracts.js +36 -0
- package/dist/contracts/dependency-protocol-contracts.js +43 -0
- package/dist/contracts/director-execution.js +51 -0
- package/dist/contracts/driver-review-contracts.js +99 -0
- package/dist/contracts/evidence-contracts.js +65 -0
- package/dist/contracts/evidence-coverage-contracts.js +10 -0
- package/dist/contracts/export-contracts.js +44 -0
- package/dist/contracts/github-directory-contracts.js +96 -0
- package/dist/contracts/insight-contracts.js +11 -0
- package/dist/contracts/inspection-contracts.js +1 -0
- package/dist/contracts/local-claim-contracts.js +39 -0
- package/dist/contracts/local-configuration-contracts.js +7 -0
- package/dist/contracts/local-evidence-contracts.js +32 -0
- package/dist/contracts/local-grant-contracts.js +83 -0
- package/dist/contracts/local-input-contracts.js +19 -0
- package/dist/contracts/local-runner-contracts.js +42 -0
- package/dist/contracts/local-trial-live-contracts.js +16 -0
- package/dist/contracts/local-value-source-contracts.js +92 -0
- package/dist/contracts/local-world-native-contracts.js +8 -0
- package/dist/contracts/mcp-contracts.js +42 -0
- package/dist/contracts/media-contracts.js +28 -0
- package/dist/contracts/native-check-contracts.js +31 -0
- package/dist/contracts/native-identity-contracts.js +52 -0
- package/dist/contracts/onboarding-contracts.js +32 -0
- package/dist/contracts/performance-contracts.js +85 -0
- package/dist/contracts/planning-contracts.js +81 -0
- package/dist/contracts/real-operation-contracts.js +30 -0
- package/dist/contracts/recommendation-contracts.js +54 -0
- package/dist/contracts/report-contracts.js +1 -0
- package/dist/contracts/resource-lifecycle-contracts.js +57 -0
- package/dist/contracts/revision-contracts.js +12 -0
- package/dist/contracts/revision-request-contracts.js +39 -0
- package/dist/contracts/saved-record-contracts.js +76 -0
- package/dist/contracts/scenario-draft-comparison.js +91 -0
- package/dist/contracts/scenario-world-contracts.js +14 -0
- package/dist/contracts/service-contracts.js +1 -0
- package/dist/contracts/session-command-contracts.js +56 -0
- package/dist/contracts/session-contracts.js +65 -0
- package/dist/contracts/session-state-contracts.js +67 -0
- package/dist/contracts/session-voice-contracts.js +45 -0
- package/dist/contracts/source-set-contracts.js +26 -0
- package/dist/contracts/source-upload-contracts.js +22 -0
- package/dist/contracts/tool-command-contracts.js +57 -0
- package/dist/contracts/tool-connection-contracts.js +87 -0
- package/dist/contracts/tool-contribution-contracts.js +35 -0
- package/dist/contracts/tool-group-contracts.js +53 -0
- package/dist/contracts/tool-group-state-contracts.js +63 -0
- package/dist/contracts/tool-lifecycle-contracts.js +94 -0
- package/dist/contracts/tool-package-contracts.js +91 -0
- package/dist/contracts/tool-protocol-contracts.js +71 -0
- package/dist/contracts/tool-runtime-billing-contracts.js +28 -0
- package/dist/contracts/tool-setup-proposal.js +59 -0
- package/dist/contracts/tool-state-contracts.js +79 -0
- package/dist/contracts/trial-state-contracts.js +30 -0
- package/dist/contracts/typescript/agent-change.js +1 -0
- package/dist/contracts/typescript/assessment-aggregate.js +1 -0
- package/dist/contracts/typescript/assessment.js +1 -0
- package/dist/contracts/typescript/billing-commerce.js +1 -0
- package/dist/contracts/typescript/campaigns.js +1 -0
- package/dist/contracts/typescript/catalog.js +1 -0
- package/dist/contracts/typescript/ci-access.js +1 -0
- package/dist/contracts/typescript/ci-configuration.js +1 -0
- package/dist/contracts/typescript/ci-delivery.js +1 -0
- package/dist/contracts/typescript/ci-result.js +1 -0
- package/dist/contracts/typescript/ci-runs.js +1 -0
- package/dist/contracts/typescript/clips.js +1 -0
- package/dist/contracts/typescript/comparison.js +1 -0
- package/dist/contracts/typescript/composite.js +1 -0
- package/dist/contracts/typescript/connections.js +1 -0
- package/dist/contracts/typescript/context.js +1 -0
- package/dist/contracts/typescript/datasets.js +1 -0
- package/dist/contracts/typescript/evidence-coverage.js +1 -0
- package/dist/contracts/typescript/export.js +1 -0
- package/dist/contracts/typescript/github-directory.js +1 -0
- package/dist/contracts/typescript/insight.js +1 -0
- package/dist/contracts/typescript/inspection.js +1 -0
- package/dist/contracts/typescript/intake.js +1 -0
- package/dist/contracts/typescript/local-claim.js +1 -0
- package/dist/contracts/typescript/local-configuration.js +1 -0
- package/dist/contracts/typescript/local-evidence.js +1 -0
- package/dist/contracts/typescript/local-grant.js +1 -0
- package/dist/contracts/typescript/local-input.js +1 -0
- package/dist/contracts/typescript/local-runner.js +1 -0
- package/dist/contracts/typescript/local-trial-live.js +1 -0
- package/dist/contracts/typescript/local-value-source.js +1 -0
- package/dist/contracts/typescript/local-world-native.js +1 -0
- package/dist/contracts/typescript/mcp.js +1 -0
- package/dist/contracts/typescript/media.js +1 -0
- package/dist/contracts/typescript/native-check.js +1 -0
- package/dist/contracts/typescript/native-identity.js +1 -0
- package/dist/contracts/typescript/onboarding.js +1 -0
- package/dist/contracts/typescript/performance.js +1 -0
- package/dist/contracts/typescript/planning.js +1 -0
- package/dist/contracts/typescript/preparation.js +1 -0
- package/dist/contracts/typescript/real-operation.js +1 -0
- package/dist/contracts/typescript/recommendation.js +1 -0
- package/dist/contracts/typescript/reports.js +1 -0
- package/dist/contracts/typescript/resource-lifecycle.js +1 -0
- package/dist/contracts/typescript/saved-record.js +1 -0
- package/dist/contracts/typescript/scenarios.js +1 -0
- package/dist/contracts/typescript/services.js +1 -0
- package/dist/contracts/typescript/session-command.js +1 -0
- package/dist/contracts/typescript/session-state.js +1 -0
- package/dist/contracts/typescript/session-voice.js +1 -0
- package/dist/contracts/typescript/session.js +1 -0
- package/dist/contracts/typescript/tool-command.js +1 -0
- package/dist/contracts/typescript/tool-connection.js +1 -0
- package/dist/contracts/typescript/tool-contribution.js +1 -0
- package/dist/contracts/typescript/tool-groups.js +1 -0
- package/dist/contracts/typescript/tool-lifecycle.js +1 -0
- package/dist/contracts/typescript/tool-package.js +1 -0
- package/dist/contracts/typescript/tool-state.js +1 -0
- package/dist/contracts/typescript/trial-state.js +1 -0
- package/dist/contracts/typescript/vault.js +1 -0
- package/dist/contracts/typescript/workspace-team.js +1 -0
- package/dist/contracts/typescript/world-stream.js +1 -0
- package/dist/contracts/workspace-team-contracts.js +55 -0
- package/dist/contracts/world-command.js +65 -0
- package/dist/contracts/world-lifetime-contracts.js +74 -0
- package/dist/contracts/world-recipe-contracts.js +27 -0
- package/dist/contracts/world-stream-contracts.js +27 -0
- package/dist/package.json +1 -0
- package/package.json +16 -16
- package/LICENSE.md +0 -23
- package/PACKAGING.json +0 -50
|
@@ -0,0 +1,1159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded in-memory observation buffer (Plan20 W09, spec 3.5).
|
|
3
|
+
*
|
|
4
|
+
* Converts driver observations into immutable artifacts and ObservationBatch v1
|
|
5
|
+
* envelopes for one assignment attempt:
|
|
6
|
+
* - every observation becomes one JSON artifact (canonical JSON, bounded,
|
|
7
|
+
* sha256-hashed). A binary attachment is described, not uploaded (below);
|
|
8
|
+
* - events get NATIVE_ID-shaped ids and contiguous sequences after the cloud's
|
|
9
|
+
* last acknowledgement, with `elapsed_ms` relative to the drill start and
|
|
10
|
+
* non-decreasing (root validation requires that within a batch);
|
|
11
|
+
* - artifacts are uploaded BEFORE the batch that references them;
|
|
12
|
+
* - batches hold at most 128 events and match observation-batch.v1 exactly;
|
|
13
|
+
* - at most `maxBufferedBytes` of not-yet-uploaded artifact bytes are held;
|
|
14
|
+
* `room()` is the backpressure point and `add()` drops (and reports) instead
|
|
15
|
+
* of exceeding the cap;
|
|
16
|
+
* - transient failures are retried with capped backoff; the cloud's returned
|
|
17
|
+
* acknowledgement is authoritative: anything at or below it is dropped from
|
|
18
|
+
* memory, anything above it is re-sent, and a regressed acknowledgement is
|
|
19
|
+
* adopted (resequencing only events the cloud never accepted).
|
|
20
|
+
*
|
|
21
|
+
* There is no durable local journal. Bytes dropped for capacity, lost on a
|
|
22
|
+
* regressed acknowledgement, or left unacknowledged after the final flush make
|
|
23
|
+
* the evidence incomplete; they are reported, never regenerated.
|
|
24
|
+
*
|
|
25
|
+
* Root evidence rules applied here:
|
|
26
|
+
* - each observation is one declared JSON record (<= 256 KiB) whose role is the
|
|
27
|
+
* event kind; root assigns the artifact id after declaration and the event
|
|
28
|
+
* references it; at most 4096 records and 64 MiB per assignment;
|
|
29
|
+
* - content passes the client-filtered-v1 profile (evidence-filter.ts) first;
|
|
30
|
+
* - a `conversation.observed` event carries exactly the target-message
|
|
31
|
+
* document cloud participants read;
|
|
32
|
+
* - binary attachments are refused by root, so they are counted as
|
|
33
|
+
* unsupported (not uploaded) without making the evidence incomplete;
|
|
34
|
+
* - approved captures are their own `state.observed` records: masked PNG
|
|
35
|
+
* screenshots and interval frames (role `screenshot.observed`) and sanitized
|
|
36
|
+
* action trace segments (role `trace.observed`, exactly root's document).
|
|
37
|
+
* They are optional, so each leaves room for the step's own records;
|
|
38
|
+
* - one record root refuses (privacy or capacity) is dropped and reported;
|
|
39
|
+
* the rest of the evidence keeps flowing.
|
|
40
|
+
*/
|
|
41
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
42
|
+
import { CliError } from '../http.js';
|
|
43
|
+
import { canonicalJson, sha256Hex } from './canonical.js';
|
|
44
|
+
import { CAPTURE_SCHEMA, CAPTURE_UNAVAILABLE_REASONS, EVIDENCE_MAX_RECORDS, EVIDENCE_MAX_TOTAL_BYTES, EVIDENCE_RECORD_MAX_BYTES, FRAME_OMISSION_REASONS, MAX_FRAMES_PER_STEP, NATIVE_ID, OBSERVATION_BATCH_MAX_EVENTS, UUID_PATTERN, OBSERVATION_KINDS, SHA256_HEX, TARGET_MESSAGE_MAX_CHARS, TARGET_RESULT_MAX_BYTES, TARGET_RESULT_SCAN_CHARS, TRACE_OPERATIONS, TRACE_OUTCOMES, TRACE_PARTIAL_REASONS, TRACE_PROFILE, TRACE_SEGMENT_MAX_EVENTS, TRACE_SEGMENT_SCHEMA, } from './contracts.js';
|
|
45
|
+
import { PNG_MAX_BYTES, isMetadataFreePng } from './png.js';
|
|
46
|
+
import { backoffMs, errorCode, isTransient, retryTransient, sleep } from './control-plane.js';
|
|
47
|
+
import { evidenceNodes, filterEvidenceValue } from './evidence-filter.js';
|
|
48
|
+
/** Root refusals that end one record, not the whole evidence stream. */
|
|
49
|
+
const RECORD_REFUSALS = new Set(['native_evidence_privacy_refused', 'native_artifact_capacity', 'native_artifact_invalid']);
|
|
50
|
+
/**
|
|
51
|
+
* The record's request key can no longer be used: its upload window closed
|
|
52
|
+
* (`expired`), or root already ended it — expiry cleanup ran, or a refusal whose
|
|
53
|
+
* answer was lost (`unavailable`). A new request key re-declares the same bytes.
|
|
54
|
+
*/
|
|
55
|
+
const RECORD_RENEWABLE = new Set(['native_artifact_expired', 'native_artifact_unavailable']);
|
|
56
|
+
const MAX_RECORD_RENEWALS = 3;
|
|
57
|
+
/** Smaller string caps tried, in order, when a record's filtered JSON exceeds the per-record limit. */
|
|
58
|
+
const SHRINK_STRING_CHARS = [4_000, 1_000, 256];
|
|
59
|
+
export const OBSERVATION_ARTIFACT_MEDIA_TYPE = 'application/json';
|
|
60
|
+
const ARTIFACT_VERSION = 1;
|
|
61
|
+
const MAX_SAFE = Number.MAX_SAFE_INTEGER;
|
|
62
|
+
/** Room kept for the drill's signed close record (one record, at most 64 KiB) within root's per-assignment caps. */
|
|
63
|
+
const CLOSE_RESERVE_RECORDS = 1;
|
|
64
|
+
const CLOSE_RESERVE_BYTES = 65_536;
|
|
65
|
+
/**
|
|
66
|
+
* Screenshots are optional evidence: together they may use at most this share of
|
|
67
|
+
* the drill's byte budget, so the replies and results root completes each action
|
|
68
|
+
* with always keep their room.
|
|
69
|
+
*/
|
|
70
|
+
const SCREENSHOT_BUDGET_SHARE = 0.5;
|
|
71
|
+
/**
|
|
72
|
+
* Room kept for the step's reply or result after an optional capture record:
|
|
73
|
+
* a target reply of 16,000 characters, each at most six bytes once escaped,
|
|
74
|
+
* plus its fields (a task or event result is smaller, at most 32,000 bytes).
|
|
75
|
+
*/
|
|
76
|
+
const STEP_RESULT_RESERVE_BYTES = 98_304;
|
|
77
|
+
/** Records kept after an optional capture record: the observation naming it and the step's reply or result. */
|
|
78
|
+
const STEP_RESERVE_RECORDS = 2;
|
|
79
|
+
/** Room an interval frame keeps for the step's own screenshot outcome: at least its `capture.unavailable` record. */
|
|
80
|
+
const CAPTURE_RECORD_RESERVE_BYTES = 2_048;
|
|
81
|
+
/** Accounting overhead per pending event (ids, numbers, bookkeeping). */
|
|
82
|
+
const EVENT_OVERHEAD_BYTES = 256;
|
|
83
|
+
const MEDIA_TYPE = /^[a-z0-9][a-z0-9.+-]{0,63}\/[a-z0-9][a-z0-9.+-]{0,63}$/i;
|
|
84
|
+
const KINDS = new Set(OBSERVATION_KINDS);
|
|
85
|
+
function nativeId(prefix) {
|
|
86
|
+
return `${prefix}${randomBytes(16).toString('hex')}`;
|
|
87
|
+
}
|
|
88
|
+
const isFrameReason = (value) => FRAME_OMISSION_REASONS.includes(value);
|
|
89
|
+
/** The driver's frames, bounded: at most MAX_FRAMES_PER_STEP shots (any more are omitted for `step_limit`), known reasons only. */
|
|
90
|
+
function boundedFrames(frames) {
|
|
91
|
+
const shots = Array.isArray(frames?.shots) ? frames.shots : [];
|
|
92
|
+
const extra = Math.max(0, shots.length - MAX_FRAMES_PER_STEP);
|
|
93
|
+
const reasons = new Set((Array.isArray(frames?.reasons) ? frames.reasons : []).filter(isFrameReason));
|
|
94
|
+
if (extra)
|
|
95
|
+
reasons.add('step_limit');
|
|
96
|
+
const counted = typeof frames?.omitted === 'number' && Number.isSafeInteger(frames.omitted) && frames.omitted > 0 ? frames.omitted : 0;
|
|
97
|
+
return { shots: shots.slice(0, MAX_FRAMES_PER_STEP), omitted: Math.min(MAX_SAFE, counted + extra),
|
|
98
|
+
reasons: FRAME_OMISSION_REASONS.filter((reason) => reasons.has(reason)) };
|
|
99
|
+
}
|
|
100
|
+
/** What the step observation says about its interval frames. */
|
|
101
|
+
function frameReference(ids, omitted, reasons) {
|
|
102
|
+
return { frame_event_ids: ids, frames_omitted: omitted, frame_omission_reasons: [...reasons] };
|
|
103
|
+
}
|
|
104
|
+
function positiveInt(value, fallback, max = MAX_SAFE) {
|
|
105
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? Math.min(value, max) : fallback;
|
|
106
|
+
}
|
|
107
|
+
function assignmentInvalid() {
|
|
108
|
+
return new CliError('native_assignment_invalid', 'Observations were not recorded: the assignment identity, lease or acknowledgement is malformed.');
|
|
109
|
+
}
|
|
110
|
+
function checkAssignment(assignment) {
|
|
111
|
+
const id = (value) => typeof value === 'string' && NATIVE_ID.test(value);
|
|
112
|
+
const int = (value, min) => typeof value === 'number' && Number.isSafeInteger(value) && value >= min;
|
|
113
|
+
if (!id(assignment.assignment_id) || !id(assignment.attempt_id) || !int(assignment.lease_epoch, 1)
|
|
114
|
+
|| !int(assignment.state_generation, 1) || !int(assignment.observation_ack, 0)
|
|
115
|
+
|| (assignment.observation_elapsed_ms !== undefined && !int(assignment.observation_elapsed_ms, 0)))
|
|
116
|
+
throw assignmentInvalid();
|
|
117
|
+
}
|
|
118
|
+
export class ObservationBuffer {
|
|
119
|
+
#options;
|
|
120
|
+
#assignment;
|
|
121
|
+
#maxBuffered;
|
|
122
|
+
#maxArtifact;
|
|
123
|
+
#maxTotal;
|
|
124
|
+
#flushDelay;
|
|
125
|
+
#attempts;
|
|
126
|
+
#pending = [];
|
|
127
|
+
#acked;
|
|
128
|
+
#assigned;
|
|
129
|
+
#lastElapsed = 0;
|
|
130
|
+
#buffered = 0;
|
|
131
|
+
#uploadedBytes = 0;
|
|
132
|
+
#acceptedBytes = 0;
|
|
133
|
+
#droppedObservations = 0;
|
|
134
|
+
#droppedBytes = 0;
|
|
135
|
+
#omitted = 0;
|
|
136
|
+
#unsupportedAttachments = 0;
|
|
137
|
+
#screenshots = 0;
|
|
138
|
+
#frames = 0;
|
|
139
|
+
#traceSegments = 0;
|
|
140
|
+
/** Bytes of screenshots and frames together, held to SCREENSHOT_BUDGET_SHARE of the drill's budget. */
|
|
141
|
+
#screenshotBytes = 0;
|
|
142
|
+
#captureUnavailable = 0;
|
|
143
|
+
#filtered = 0;
|
|
144
|
+
#records = 0;
|
|
145
|
+
#lostSequences = 0;
|
|
146
|
+
#inconsistent = false;
|
|
147
|
+
#closed = false;
|
|
148
|
+
#pump = null;
|
|
149
|
+
#rerun = false;
|
|
150
|
+
#timer = null;
|
|
151
|
+
#failure = null;
|
|
152
|
+
#permanentFailure = false;
|
|
153
|
+
#roomWaiters = new Set();
|
|
154
|
+
/**
|
|
155
|
+
* The batch in flight. Its id, events and previous_ack are pinned until the
|
|
156
|
+
* cloud's acknowledgement moves: a re-send (after a transient failure or an
|
|
157
|
+
* unchanged acknowledgement) carries the same batch_id and the same events,
|
|
158
|
+
* even if more observations arrived meanwhile.
|
|
159
|
+
*/
|
|
160
|
+
#batch = null;
|
|
161
|
+
#lostStepEvidence = 0;
|
|
162
|
+
#wholeDependencyCaptureLost = 0;
|
|
163
|
+
/** The final outcome event and its batch, pinned so a retry re-sends the same batch. */
|
|
164
|
+
#outcome = null;
|
|
165
|
+
/** Event ids the cloud acknowledged (at most EVIDENCE_MAX_RECORDS), for `delivered`. */
|
|
166
|
+
#acknowledgedIds = new Set();
|
|
167
|
+
/** Committed records of acknowledged events added with `keep`. */
|
|
168
|
+
#kept = new Map();
|
|
169
|
+
/** True while the readiness event's sequence is reserved: nothing else is sent. */
|
|
170
|
+
#holding = false;
|
|
171
|
+
/** The readiness event and its batch, pinned so a retry re-sends the same batch. */
|
|
172
|
+
#readiness = null;
|
|
173
|
+
constructor(options) {
|
|
174
|
+
checkAssignment(options.assignment);
|
|
175
|
+
this.#options = options;
|
|
176
|
+
this.#assignment = options.assignment;
|
|
177
|
+
this.#maxBuffered = positiveInt(options.maxBufferedBytes, 32 * 1024 * 1024);
|
|
178
|
+
this.#maxArtifact = positiveInt(options.maxArtifactBytes, EVIDENCE_RECORD_MAX_BYTES, Math.min(this.#maxBuffered, EVIDENCE_RECORD_MAX_BYTES));
|
|
179
|
+
const budget = options.assignment.budgets?.max_observation_bytes;
|
|
180
|
+
this.#maxTotal = positiveInt(budget, EVIDENCE_MAX_TOTAL_BYTES, EVIDENCE_MAX_TOTAL_BYTES);
|
|
181
|
+
this.#flushDelay = Math.max(0, Math.min(options.flushDelayMs ?? 250, 10_000));
|
|
182
|
+
this.#attempts = positiveInt(options.attempts, 5, 20);
|
|
183
|
+
this.#acked = options.assignment.observation_ack;
|
|
184
|
+
this.#assigned = options.assignment.observation_ack;
|
|
185
|
+
// A resumed attempt continues from root's acknowledged elapsed floor; root refuses lower values.
|
|
186
|
+
this.#lastElapsed = options.assignment.observation_elapsed_ms ?? 0;
|
|
187
|
+
}
|
|
188
|
+
/** True once delivery stopped for a non-transient reason; further observations will not be delivered. */
|
|
189
|
+
get failed() { return this.#permanentFailure; }
|
|
190
|
+
get bufferedBytes() { return this.#buffered; }
|
|
191
|
+
/**
|
|
192
|
+
* Accept one observation. Never throws and never blocks: capacity overruns and
|
|
193
|
+
* malformed observations are dropped and reported as incomplete evidence.
|
|
194
|
+
* Returns the event id it will be sent under, or null when it was dropped.
|
|
195
|
+
*/
|
|
196
|
+
add(observation, keep = false) {
|
|
197
|
+
if (this.#closed || this.#permanentFailure) {
|
|
198
|
+
if (observation?.dependency_exchange !== undefined)
|
|
199
|
+
this.#wholeDependencyCaptureLost++;
|
|
200
|
+
this.#drop(0);
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
if (!observation || typeof observation !== 'object' || !KINDS.has(observation.kind)) {
|
|
204
|
+
if (observation?.dependency_exchange !== undefined)
|
|
205
|
+
this.#wholeDependencyCaptureLost++;
|
|
206
|
+
this.#drop(0);
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
if (this.#assigned >= MAX_SAFE || this.#records >= EVIDENCE_MAX_RECORDS - CLOSE_RESERVE_RECORDS) {
|
|
210
|
+
if (observation.dependency_exchange !== undefined)
|
|
211
|
+
this.#wholeDependencyCaptureLost++;
|
|
212
|
+
this.#drop(0);
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
let json;
|
|
216
|
+
let role;
|
|
217
|
+
// Optional evidence keeps room (bytes and records) for what the step still has to record.
|
|
218
|
+
let reserve = 0, reserveRecords = 0;
|
|
219
|
+
if (observation.dependency_exchange !== undefined) {
|
|
220
|
+
json = observation.kind === 'action.observed' ? this.#encodeDependencyExchange(observation.dependency_exchange) : null;
|
|
221
|
+
if (!json) {
|
|
222
|
+
this.#wholeDependencyCaptureLost++;
|
|
223
|
+
this.#drop(0);
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
role = 'dependency.exchange.observed';
|
|
227
|
+
reserve = Math.min(this.#maxArtifact, STEP_RESULT_RESERVE_BYTES) + EVENT_OVERHEAD_BYTES;
|
|
228
|
+
reserveRecords = STEP_RESERVE_RECORDS;
|
|
229
|
+
}
|
|
230
|
+
else if (observation.target_message !== undefined) {
|
|
231
|
+
json = observation.kind === 'conversation.observed' ? this.#encodeTargetMessage(observation.target_message) : null;
|
|
232
|
+
if (!json) {
|
|
233
|
+
this.#drop(0);
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
else if (observation.target_result !== undefined) {
|
|
238
|
+
json = observation.kind === 'action.observed' ? this.#encodeTargetResult(observation.target_result) : null;
|
|
239
|
+
if (!json) {
|
|
240
|
+
this.#drop(0);
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
else if (observation.trace_segment !== undefined) {
|
|
245
|
+
// Exactly root's segment document; a segment that cannot be one is dropped, which root reads as a gap.
|
|
246
|
+
json = observation.kind === 'state.observed' ? this.#encodeTraceSegment(observation.trace_segment) : null;
|
|
247
|
+
if (!json) {
|
|
248
|
+
this.#drop(0);
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
role = 'trace.observed';
|
|
252
|
+
reserve = Math.min(this.#maxArtifact, STEP_RESULT_RESERVE_BYTES) + EVENT_OVERHEAD_BYTES;
|
|
253
|
+
reserveRecords = 1;
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
let attachmentRef = null;
|
|
257
|
+
// The reference the content's value budget is sized for: the longest one when captures were measured with it.
|
|
258
|
+
let sizedFor = null;
|
|
259
|
+
if (observation.capture !== undefined) {
|
|
260
|
+
if (observation.kind === 'action.observed') {
|
|
261
|
+
// Each capture is its own state.observed record, sent just before this one, which names it: the
|
|
262
|
+
// approved interval frames in capture order, then the step's screenshot, or a capture.unavailable
|
|
263
|
+
// record when it has no image. Captures are optional, so they may only use room left after this
|
|
264
|
+
// record (measured with its longest reference) and the step's reply or result, which root completes
|
|
265
|
+
// the action with; a frame also leaves room for the step's screenshot outcome.
|
|
266
|
+
const capture = observation.capture;
|
|
267
|
+
const frames = capture.frames === undefined ? null : boundedFrames(capture.frames);
|
|
268
|
+
const longest = { capture_unavailable_event_id: nativeId('ev'),
|
|
269
|
+
...(frames ? frameReference(frames.shots.map(() => nativeId('ev')), MAX_SAFE, FRAME_OMISSION_REASONS) : {}) };
|
|
270
|
+
sizedFor = longest;
|
|
271
|
+
const captureReserve = this.#encode(observation.kind, observation.content, longest, false).byteLength
|
|
272
|
+
+ Math.min(this.#maxArtifact, STEP_RESULT_RESERVE_BYTES) + 2 * EVENT_OVERHEAD_BYTES;
|
|
273
|
+
const framed = frames ? this.#addFrames(frames, captureReserve + CAPTURE_RECORD_RESERVE_BYTES + EVENT_OVERHEAD_BYTES) : null;
|
|
274
|
+
attachmentRef = { ...this.#addCapture(observation.at_ms, capture, captureReserve), ...(framed ?? {}) };
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
attachmentRef = { omitted: 'screenshot_not_recorded' };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const attachment = observation.capture === undefined ? observation.attachment : undefined;
|
|
281
|
+
if (attachment !== undefined) {
|
|
282
|
+
const bytes = attachment?.bytes, mediaType = attachment?.media_type;
|
|
283
|
+
// The media type is recorded as text, so it must pass the same filter unchanged.
|
|
284
|
+
if (!(bytes instanceof Uint8Array) || typeof mediaType !== 'string' || !MEDIA_TYPE.test(mediaType)
|
|
285
|
+
|| filterEvidenceValue(mediaType.toLowerCase(), 2).value !== mediaType.toLowerCase()) {
|
|
286
|
+
attachmentRef = { omitted: 'malformed' };
|
|
287
|
+
this.#omitted++;
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
// Root accepts JSON and text evidence only; binary capture waits for its masking profile.
|
|
291
|
+
attachmentRef = { omitted: 'unsupported_media', media_type: mediaType.toLowerCase(), bytes: bytes.byteLength };
|
|
292
|
+
this.#unsupportedAttachments++;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// The real reference never holds more values than the longest, so the content filters exactly as measured.
|
|
296
|
+
json = this.#encode(observation.kind, observation.content, attachmentRef, true, sizedFor ?? attachmentRef);
|
|
297
|
+
}
|
|
298
|
+
if (!this.#fits(json.byteLength + reserve) || this.#records >= EVIDENCE_MAX_RECORDS - CLOSE_RESERVE_RECORDS - reserveRecords) {
|
|
299
|
+
// Not even the bounded JSON fits: drop the whole observation, never partially record it.
|
|
300
|
+
if (observation.dependency_exchange !== undefined)
|
|
301
|
+
this.#wholeDependencyCaptureLost++;
|
|
302
|
+
this.#drop(json.byteLength);
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
const artifact = {
|
|
306
|
+
artifact_id: null, screenshot: null, role, request_key: randomUUID(), renewals: 0, sha256: sha256Hex(json), size: json.byteLength, bytes: json,
|
|
307
|
+
};
|
|
308
|
+
const event = {
|
|
309
|
+
event_id: nativeId('ev'), sequence: ++this.#assigned, kind: observation.kind,
|
|
310
|
+
elapsed_ms: this.#elapsed(observation.at_ms), artifact_id: '', artifact_sha256: artifact.sha256,
|
|
311
|
+
};
|
|
312
|
+
this.#records++;
|
|
313
|
+
this.#buffered += artifact.size + EVENT_OVERHEAD_BYTES;
|
|
314
|
+
this.#acceptedBytes += artifact.size;
|
|
315
|
+
this.#pending.push({ event, artifact, keep });
|
|
316
|
+
if (role === 'trace.observed')
|
|
317
|
+
this.#traceSegments++;
|
|
318
|
+
this.#schedule(this.#pending.length >= OBSERVATION_BATCH_MAX_EVENTS);
|
|
319
|
+
return event.event_id;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Queue the approved interval frames, oldest first, each its own masked PNG
|
|
323
|
+
* record at its own capture time, and return what the step observation says
|
|
324
|
+
* about them. A frame that is malformed or does not fit is counted as omitted
|
|
325
|
+
* with its reason: frames are optional, so a missing one is reported in that
|
|
326
|
+
* reference and never makes the evidence incomplete.
|
|
327
|
+
*/
|
|
328
|
+
#addFrames(frames, reserve) {
|
|
329
|
+
const ids = [];
|
|
330
|
+
let omitted = frames.omitted;
|
|
331
|
+
const reasons = new Set(frames.reasons);
|
|
332
|
+
for (const shot of frames.shots) {
|
|
333
|
+
const added = this.#addScreenshot(shot?.at_ms, shot, reserve, STEP_RESERVE_RECORDS + 1, true);
|
|
334
|
+
if (added.event_id !== null) {
|
|
335
|
+
ids.push(added.event_id);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
omitted = Math.min(MAX_SAFE, omitted + 1);
|
|
339
|
+
reasons.add(added.reason);
|
|
340
|
+
}
|
|
341
|
+
return frameReference(ids, omitted, FRAME_OMISSION_REASONS.filter((reason) => reasons.has(reason)));
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Queue an approved capture's record and return the reference the observation
|
|
345
|
+
* that captured it carries: the screenshot event, or a `capture.unavailable`
|
|
346
|
+
* record when there is no image. When not even that small record fits, the
|
|
347
|
+
* overflow already counted stands and no capture record is invented.
|
|
348
|
+
*/
|
|
349
|
+
#addCapture(atMs, capture, reserve) {
|
|
350
|
+
let reason;
|
|
351
|
+
if ('png' in capture) {
|
|
352
|
+
const added = this.#addScreenshot(atMs, capture, reserve);
|
|
353
|
+
if (added.event_id !== null)
|
|
354
|
+
return { screenshot_event_id: added.event_id };
|
|
355
|
+
reason = added.reason;
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
reason = capture.unavailable;
|
|
359
|
+
}
|
|
360
|
+
const id = this.#addCaptureUnavailable(atMs, capture, reason, reserve);
|
|
361
|
+
return id ? { capture_unavailable_event_id: id } : { omitted: 'screenshot_not_recorded' };
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Queue one bounded `state.observed` `capture.unavailable` record: ids, the
|
|
365
|
+
* policy digest and the reason, never error text or page content. Returns its
|
|
366
|
+
* event id, or null when it is malformed (counted as omitted) or does not fit
|
|
367
|
+
* (counted as dropped).
|
|
368
|
+
*/
|
|
369
|
+
#addCaptureUnavailable(atMs, capture, reason, reserve) {
|
|
370
|
+
if (!CAPTURE_UNAVAILABLE_REASONS.includes(reason) || typeof capture?.masking_policy_sha256 !== 'string'
|
|
371
|
+
|| !SHA256_HEX.test(capture.masking_policy_sha256) || typeof capture.step_id !== 'string' || typeof capture.session_key !== 'string') {
|
|
372
|
+
this.#omitted++;
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
if (this.#assigned >= MAX_SAFE || this.#records >= EVIDENCE_MAX_RECORDS - CLOSE_RESERVE_RECORDS - STEP_RESERVE_RECORDS) {
|
|
376
|
+
this.#drop(0);
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
const json = this.#encode('state.observed', {
|
|
380
|
+
type: 'capture.unavailable', schema: CAPTURE_SCHEMA, capture: 'screenshot', step_id: capture.step_id,
|
|
381
|
+
session_key: capture.session_key, masking_policy_sha256: capture.masking_policy_sha256, reason,
|
|
382
|
+
}, null);
|
|
383
|
+
if (!this.#fits(json.byteLength + reserve)) {
|
|
384
|
+
this.#drop(json.byteLength);
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
const artifact = {
|
|
388
|
+
artifact_id: null, screenshot: null, request_key: randomUUID(), renewals: 0, sha256: sha256Hex(json), size: json.byteLength, bytes: json,
|
|
389
|
+
};
|
|
390
|
+
const event = {
|
|
391
|
+
event_id: nativeId('ev'), sequence: ++this.#assigned, kind: 'state.observed',
|
|
392
|
+
elapsed_ms: this.#elapsed(atMs), artifact_id: '', artifact_sha256: artifact.sha256,
|
|
393
|
+
};
|
|
394
|
+
this.#records++;
|
|
395
|
+
this.#buffered += artifact.size + EVENT_OVERHEAD_BYTES;
|
|
396
|
+
this.#acceptedBytes += artifact.size;
|
|
397
|
+
this.#pending.push({ event, artifact, keep: false });
|
|
398
|
+
this.#captureUnavailable++;
|
|
399
|
+
return event.event_id;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Queue an approved masked screenshot or interval frame as its own
|
|
403
|
+
* `state.observed` event whose record is the PNG (artifact role
|
|
404
|
+
* `screenshot.observed`), which root's captured-frame playback reads by
|
|
405
|
+
* elapsed time. Returns its event id, or the reason it was not queued. For the
|
|
406
|
+
* step's screenshot, malformed is counted as omitted and not fitting as
|
|
407
|
+
* dropped; a frame (`frame`) is reported only in the step's frame reference.
|
|
408
|
+
*/
|
|
409
|
+
#addScreenshot(atMs, shot, reserve, reserveRecords = STEP_RESERVE_RECORDS, frame = false) {
|
|
410
|
+
const png = shot?.png;
|
|
411
|
+
if (!(png instanceof Uint8Array) || !isMetadataFreePng(png) || png.byteLength > PNG_MAX_BYTES
|
|
412
|
+
|| typeof shot.masking_policy_sha256 !== 'string' || !SHA256_HEX.test(shot.masking_policy_sha256)) {
|
|
413
|
+
if (!frame)
|
|
414
|
+
this.#omitted++;
|
|
415
|
+
return { event_id: null, reason: 'image_unsupported' };
|
|
416
|
+
}
|
|
417
|
+
// Past the screenshot share the step records why instead (a capture.unavailable record): nothing is lost.
|
|
418
|
+
if (this.#screenshotBytes + png.byteLength > Math.floor(this.#maxTotal * SCREENSHOT_BUDGET_SHARE)) {
|
|
419
|
+
return { event_id: null, reason: 'evidence_capacity' };
|
|
420
|
+
}
|
|
421
|
+
if (this.#assigned >= MAX_SAFE || this.#records >= EVIDENCE_MAX_RECORDS - CLOSE_RESERVE_RECORDS - reserveRecords
|
|
422
|
+
|| !this.#fits(png.byteLength + reserve)) {
|
|
423
|
+
if (!frame)
|
|
424
|
+
this.#drop(png.byteLength);
|
|
425
|
+
return { event_id: null, reason: 'evidence_capacity' };
|
|
426
|
+
}
|
|
427
|
+
const artifact = {
|
|
428
|
+
artifact_id: null, request_key: randomUUID(), renewals: 0, sha256: sha256Hex(png), size: png.byteLength, bytes: png,
|
|
429
|
+
screenshot: { consent: true, source: 'playwright', masking_applied: true, masking_policy_sha256: shot.masking_policy_sha256 },
|
|
430
|
+
};
|
|
431
|
+
const event = {
|
|
432
|
+
event_id: nativeId('ev'), sequence: ++this.#assigned, kind: 'state.observed',
|
|
433
|
+
elapsed_ms: this.#elapsed(atMs), artifact_id: '', artifact_sha256: artifact.sha256,
|
|
434
|
+
};
|
|
435
|
+
this.#records++;
|
|
436
|
+
this.#buffered += artifact.size + EVENT_OVERHEAD_BYTES;
|
|
437
|
+
this.#acceptedBytes += artifact.size;
|
|
438
|
+
this.#pending.push({ event, artifact, keep: false });
|
|
439
|
+
if (frame)
|
|
440
|
+
this.#frames++;
|
|
441
|
+
else
|
|
442
|
+
this.#screenshots++;
|
|
443
|
+
this.#screenshotBytes += png.byteLength;
|
|
444
|
+
return { event_id: event.event_id };
|
|
445
|
+
}
|
|
446
|
+
/** The committed record of an acknowledged event added with `keep`, or null (not yet acknowledged, refused or dropped). */
|
|
447
|
+
record(eventId) {
|
|
448
|
+
return this.#kept.get(eventId) ?? null;
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Record that a step's own observations never reached the buffer (the driver
|
|
452
|
+
* did not return them before the runner's bound). The count is unknown, so
|
|
453
|
+
* the evidence is reported incomplete; nothing is regenerated.
|
|
454
|
+
*/
|
|
455
|
+
noteLostStepEvidence() { this.#lostStepEvidence++; }
|
|
456
|
+
/**
|
|
457
|
+
* Backpressure: resolves when buffered bytes are at or below half the cap,
|
|
458
|
+
* delivery failed, the buffer closed, the transport signal aborted, or the
|
|
459
|
+
* caller's `wake` signal fired (graceful stop, lease expiry).
|
|
460
|
+
*/
|
|
461
|
+
async room(wake) {
|
|
462
|
+
const low = Math.floor(this.#maxBuffered / 2);
|
|
463
|
+
const signals = wake ? [this.#options.signal, wake] : [this.#options.signal];
|
|
464
|
+
const woken = () => signals.some((signal) => signal.aborted);
|
|
465
|
+
if (this.#buffered <= low || this.#permanentFailure || woken())
|
|
466
|
+
return;
|
|
467
|
+
this.#schedule(true);
|
|
468
|
+
await new Promise((resolve) => {
|
|
469
|
+
const check = () => {
|
|
470
|
+
if (this.#buffered <= low || this.#permanentFailure || woken() || this.#closed) {
|
|
471
|
+
this.#roomWaiters.delete(check);
|
|
472
|
+
for (const signal of signals)
|
|
473
|
+
signal.removeEventListener('abort', check);
|
|
474
|
+
resolve();
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
this.#roomWaiters.add(check);
|
|
478
|
+
for (const signal of signals)
|
|
479
|
+
signal.addEventListener('abort', check, { once: true });
|
|
480
|
+
check();
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Resolves true once the cloud acknowledged event `eventId` (root completes a
|
|
485
|
+
* target action only with an acknowledged reply event); false when the event
|
|
486
|
+
* was refused or dropped, delivery failed for good, the buffer finished
|
|
487
|
+
* without it, or the transport or `wake` aborted first. Never throws.
|
|
488
|
+
*/
|
|
489
|
+
async delivered(eventId, wake) {
|
|
490
|
+
const signals = wake ? [this.#options.signal, wake] : [this.#options.signal];
|
|
491
|
+
const state = () => {
|
|
492
|
+
if (this.#acknowledgedIds.has(eventId))
|
|
493
|
+
return true;
|
|
494
|
+
if (!this.#pending.some((item) => item.event.event_id === eventId))
|
|
495
|
+
return false;
|
|
496
|
+
if (this.#permanentFailure || signals.some((signal) => signal.aborted) || (this.#closed && !this.#pump))
|
|
497
|
+
return false;
|
|
498
|
+
return null;
|
|
499
|
+
};
|
|
500
|
+
const now = state();
|
|
501
|
+
if (now !== null)
|
|
502
|
+
return now;
|
|
503
|
+
this.#schedule(true);
|
|
504
|
+
return new Promise((resolve) => {
|
|
505
|
+
const check = () => {
|
|
506
|
+
const value = state();
|
|
507
|
+
if (value === null)
|
|
508
|
+
return;
|
|
509
|
+
this.#roomWaiters.delete(check);
|
|
510
|
+
for (const signal of signals)
|
|
511
|
+
signal.removeEventListener('abort', check);
|
|
512
|
+
resolve(value);
|
|
513
|
+
};
|
|
514
|
+
this.#roomWaiters.add(check);
|
|
515
|
+
for (const signal of signals)
|
|
516
|
+
signal.addEventListener('abort', check, { once: true });
|
|
517
|
+
check();
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
/** Send everything buffered now (does not close). Resolves when the attempt ends; never throws. */
|
|
521
|
+
async flush() {
|
|
522
|
+
this.#schedule(true);
|
|
523
|
+
while (this.#pump)
|
|
524
|
+
await this.#pump;
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Stop accepting observations, deliver what is buffered (bounded retries), and
|
|
528
|
+
* return the evidence summary. Safe to call more than once.
|
|
529
|
+
*/
|
|
530
|
+
async close() {
|
|
531
|
+
this.#closed = true;
|
|
532
|
+
if (this.#timer) {
|
|
533
|
+
clearTimeout(this.#timer);
|
|
534
|
+
this.#timer = null;
|
|
535
|
+
}
|
|
536
|
+
while (this.#pump)
|
|
537
|
+
await this.#pump;
|
|
538
|
+
if (this.#pending.length > 0 && !this.#permanentFailure && !this.#options.signal.aborted) {
|
|
539
|
+
this.#failure = null;
|
|
540
|
+
this.#startPump();
|
|
541
|
+
while (this.#pump)
|
|
542
|
+
await this.#pump;
|
|
543
|
+
}
|
|
544
|
+
this.#notifyRoom();
|
|
545
|
+
return this.summary();
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Send the drill's single `driver.started` event, whose signed record must name
|
|
549
|
+
* the event's own sequence: everything buffered is delivered first, later
|
|
550
|
+
* observations are held back, `build(sequence)` signs and commits the readiness
|
|
551
|
+
* record for exactly that sequence, and the event referencing it is sent alone
|
|
552
|
+
* as the next batch. Observations added meanwhile follow it. Resolves null once
|
|
553
|
+
* root acknowledged it, otherwise the code of why not (`build` may return one).
|
|
554
|
+
* A repeated call after acknowledgement resolves null. Never throws.
|
|
555
|
+
*/
|
|
556
|
+
async submitReadiness(build) {
|
|
557
|
+
if (this.#readiness && this.#acknowledgedIds.has(this.#readiness.event_id))
|
|
558
|
+
return null;
|
|
559
|
+
if (this.#closed || this.#permanentFailure || this.#holding)
|
|
560
|
+
return this.summary().failure_code ?? 'native_observation_delivery_failed';
|
|
561
|
+
// Deliver what is buffered, then hold delivery with nothing pending.
|
|
562
|
+
for (let round = 0;; round++) {
|
|
563
|
+
await this.flush();
|
|
564
|
+
if (this.#permanentFailure)
|
|
565
|
+
return this.summary().failure_code ?? 'native_observation_delivery_failed';
|
|
566
|
+
this.#holding = true;
|
|
567
|
+
while (this.#pump)
|
|
568
|
+
await this.#pump;
|
|
569
|
+
if (this.#pending.length === 0)
|
|
570
|
+
break;
|
|
571
|
+
this.#holding = false;
|
|
572
|
+
if (round >= 4 || this.#options.signal.aborted)
|
|
573
|
+
return this.summary().failure_code ?? 'native_observation_delivery_failed';
|
|
574
|
+
}
|
|
575
|
+
try {
|
|
576
|
+
const sequence = this.#acked + 1;
|
|
577
|
+
// Reserved now: observations added while the record is built follow it, in sequence and in time.
|
|
578
|
+
const elapsed = this.#elapsed(Date.now());
|
|
579
|
+
this.#assigned = sequence;
|
|
580
|
+
const built = await build(sequence);
|
|
581
|
+
if (typeof built === 'string')
|
|
582
|
+
return built;
|
|
583
|
+
if (!NATIVE_ID.test(built.artifact_id) || !SHA256_HEX.test(built.sha256) || !Number.isSafeInteger(built.size) || built.size < 1) {
|
|
584
|
+
return 'native_readiness_invalid';
|
|
585
|
+
}
|
|
586
|
+
this.#records++;
|
|
587
|
+
this.#acceptedBytes += built.size;
|
|
588
|
+
const event = { event_id: nativeId('ev'), sequence, kind: 'driver.started', elapsed_ms: elapsed,
|
|
589
|
+
artifact_id: built.artifact_id, artifact_sha256: built.sha256 };
|
|
590
|
+
const batch = {
|
|
591
|
+
version: 1, assignment_id: this.#assignment.assignment_id, attempt_id: this.#assignment.attempt_id,
|
|
592
|
+
lease_epoch: this.#assignment.lease_epoch, state_generation: this.#assignment.state_generation,
|
|
593
|
+
batch_id: nativeId('ob'), previous_ack: this.#acked, events: [event],
|
|
594
|
+
};
|
|
595
|
+
try {
|
|
596
|
+
checkBatch(batch);
|
|
597
|
+
}
|
|
598
|
+
catch (error) {
|
|
599
|
+
return errorCode(error) ?? 'native_observation_batch_invalid';
|
|
600
|
+
}
|
|
601
|
+
this.#readiness = { batch, event_id: event.event_id };
|
|
602
|
+
return await this.#sendSingle(batch, event.event_id);
|
|
603
|
+
}
|
|
604
|
+
catch (error) {
|
|
605
|
+
return errorCode(error) ?? 'native_observation_delivery_failed';
|
|
606
|
+
}
|
|
607
|
+
finally {
|
|
608
|
+
// Unsent, the reservation is released: held observations move up to follow the acknowledgement.
|
|
609
|
+
if (!(this.#readiness && this.#acknowledgedIds.has(this.#readiness.event_id)))
|
|
610
|
+
this.#readiness = null;
|
|
611
|
+
this.#resequence(this.#acked);
|
|
612
|
+
this.#holding = false;
|
|
613
|
+
this.#schedule(true);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* After close() delivered everything: send the drill's final driver outcome
|
|
618
|
+
* event as the next sequence, referencing an already committed record (the
|
|
619
|
+
* signed execution close), and wait for root to acknowledge it. Root admits a
|
|
620
|
+
* close only after this event is acknowledged, and no observation follows it.
|
|
621
|
+
* Resolves null once acknowledged, otherwise the code of why not. A repeated
|
|
622
|
+
* call re-sends the same batch. Never throws.
|
|
623
|
+
*/
|
|
624
|
+
async submitOutcome(kind, record) {
|
|
625
|
+
while (this.#pump)
|
|
626
|
+
await this.#pump;
|
|
627
|
+
if (this.#outcome && this.#acknowledgedIds.has(this.#outcome.event_id))
|
|
628
|
+
return null;
|
|
629
|
+
if (!this.#closed || this.#pending.length > 0 || this.#permanentFailure || this.#failure !== null) {
|
|
630
|
+
return this.summary().failure_code ?? 'native_observation_delivery_failed';
|
|
631
|
+
}
|
|
632
|
+
if (!this.#outcome) {
|
|
633
|
+
if (!KINDS.has(kind) || record.observation_sequence !== this.#acked + 1 || !NATIVE_ID.test(record.artifact_id)
|
|
634
|
+
|| !SHA256_HEX.test(record.sha256))
|
|
635
|
+
return 'native_close_invalid';
|
|
636
|
+
const event = {
|
|
637
|
+
event_id: nativeId('ev'), sequence: record.observation_sequence, kind, elapsed_ms: this.#elapsed(Date.now()),
|
|
638
|
+
artifact_id: record.artifact_id, artifact_sha256: record.sha256,
|
|
639
|
+
};
|
|
640
|
+
const batch = {
|
|
641
|
+
version: 1, assignment_id: this.#assignment.assignment_id, attempt_id: this.#assignment.attempt_id,
|
|
642
|
+
lease_epoch: this.#assignment.lease_epoch, state_generation: this.#assignment.state_generation,
|
|
643
|
+
batch_id: nativeId('ob'), previous_ack: this.#acked, events: [event],
|
|
644
|
+
};
|
|
645
|
+
try {
|
|
646
|
+
checkBatch(batch);
|
|
647
|
+
}
|
|
648
|
+
catch (error) {
|
|
649
|
+
return errorCode(error) ?? 'native_observation_batch_invalid';
|
|
650
|
+
}
|
|
651
|
+
this.#outcome = { batch, event_id: event.event_id };
|
|
652
|
+
this.#assigned = event.sequence;
|
|
653
|
+
}
|
|
654
|
+
return this.#sendSingle(this.#outcome.batch, this.#outcome.event_id);
|
|
655
|
+
}
|
|
656
|
+
/** Send one pinned single-event batch until root acknowledges exactly its event. Resolves null, or why not. */
|
|
657
|
+
async #sendSingle(batch, eventId) {
|
|
658
|
+
const sequence = batch.events[0].sequence;
|
|
659
|
+
const signal = this.#options.signal;
|
|
660
|
+
const retry = { signal, attempts: this.#attempts, onTransient: (error) => this.#options.onTransient?.(error) };
|
|
661
|
+
for (let stalled = 1; stalled <= 3; stalled++) {
|
|
662
|
+
let ack;
|
|
663
|
+
try {
|
|
664
|
+
ack = (await retryTransient(() => this.#options.controlPlane.submitObservations(batch, signal), retry))?.acknowledged;
|
|
665
|
+
}
|
|
666
|
+
catch (error) {
|
|
667
|
+
return errorCode(error) ?? 'native_observation_delivery_failed';
|
|
668
|
+
}
|
|
669
|
+
if (ack === sequence) {
|
|
670
|
+
this.#acked = sequence;
|
|
671
|
+
this.#acknowledgedIds.add(eventId);
|
|
672
|
+
return null;
|
|
673
|
+
}
|
|
674
|
+
// Anything but the unchanged cursor means root holds a different stream than this buffer: never guess.
|
|
675
|
+
if (ack !== batch.previous_ack) {
|
|
676
|
+
this.#inconsistent = true;
|
|
677
|
+
return 'native_observation_delivery_failed';
|
|
678
|
+
}
|
|
679
|
+
if (!await sleep(backoffMs(stalled, 500, 5_000), signal))
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
return 'native_observation_delivery_failed';
|
|
683
|
+
}
|
|
684
|
+
dependencyCaptureUnavailable() {
|
|
685
|
+
this.#wholeDependencyCaptureLost++;
|
|
686
|
+
this.#omitted++;
|
|
687
|
+
const content = { type: 'dependency.capture_unavailable', provenance: 'customer_device', reason: 'capture_unavailable' };
|
|
688
|
+
const bytes = this.#encode('action.observed', content, null, false).byteLength;
|
|
689
|
+
const reserve = Math.min(this.#maxArtifact, STEP_RESULT_RESERVE_BYTES) + EVENT_OVERHEAD_BYTES;
|
|
690
|
+
if (!this.#fits(bytes + reserve) || this.#records >= EVIDENCE_MAX_RECORDS - CLOSE_RESERVE_RECORDS - STEP_RESERVE_RECORDS) {
|
|
691
|
+
this.#drop(bytes);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
this.add({ kind: 'action.observed', at_ms: Date.now(), content });
|
|
695
|
+
}
|
|
696
|
+
summary() {
|
|
697
|
+
const unacknowledged = this.#pending.length;
|
|
698
|
+
return {
|
|
699
|
+
last_sequence: this.#acked,
|
|
700
|
+
assigned_sequence: this.#assigned,
|
|
701
|
+
dependency_capture_records_complete: unacknowledged === 0 && this.#failure === null && this.#droppedObservations === 0
|
|
702
|
+
&& this.#droppedBytes === 0 && this.#lostSequences === 0 && !this.#inconsistent
|
|
703
|
+
&& this.#lostStepEvidence === 0 && this.#wholeDependencyCaptureLost === 0,
|
|
704
|
+
evidence_complete: unacknowledged === 0 && this.#failure === null && this.#droppedObservations === 0
|
|
705
|
+
&& this.#droppedBytes === 0 && this.#omitted === 0 && this.#lostSequences === 0 && !this.#inconsistent
|
|
706
|
+
&& this.#lostStepEvidence === 0,
|
|
707
|
+
dropped_observations: this.#droppedObservations,
|
|
708
|
+
dropped_bytes: this.#droppedBytes,
|
|
709
|
+
omitted_contents: this.#omitted,
|
|
710
|
+
unsupported_attachments: this.#unsupportedAttachments,
|
|
711
|
+
screenshots: this.#screenshots,
|
|
712
|
+
frames: this.#frames,
|
|
713
|
+
trace_segments: this.#traceSegments,
|
|
714
|
+
capture_unavailable: this.#captureUnavailable,
|
|
715
|
+
filtered_values: this.#filtered,
|
|
716
|
+
unacknowledged,
|
|
717
|
+
uploaded_bytes: this.#uploadedBytes,
|
|
718
|
+
failure_code: this.#failure === null ? null : (errorCode(this.#failure) ?? 'native_observation_delivery_failed'),
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
// -------------------------------------------------------------------------
|
|
722
|
+
// Encoding and accounting.
|
|
723
|
+
/** Exactly root's target-message document, or null when the reply cannot be one. */
|
|
724
|
+
#encodeTargetMessage(message) {
|
|
725
|
+
if (!message || message.schema !== 'firedrill.native-target-message.v2' || message.role !== 'assistant'
|
|
726
|
+
|| typeof message.action_id !== 'string' || !UUID_PATTERN.test(message.action_id) || typeof message.content !== 'string')
|
|
727
|
+
return null;
|
|
728
|
+
// A string at depth 1 of the document; the filter keeps it within 16000 characters.
|
|
729
|
+
const filtered = filterEvidenceValue(message.content, 1);
|
|
730
|
+
this.#filtered += filtered.stats.removed + filtered.stats.rewritten;
|
|
731
|
+
const content = filtered.value;
|
|
732
|
+
if (typeof content !== 'string' || content.length < 1 || content.length > TARGET_MESSAGE_MAX_CHARS)
|
|
733
|
+
return null;
|
|
734
|
+
const bytes = Buffer.from(canonicalJson({ schema: message.schema, action_id: message.action_id, role: 'assistant', content }), 'utf8');
|
|
735
|
+
return bytes.byteLength <= this.#maxArtifact ? bytes : null;
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Root's `firedrill.native-target-result.v2`: exactly the v1 fields plus
|
|
739
|
+
* `output_coverage`. The driver's result stays v1; only this artifact adds
|
|
740
|
+
* coverage, from what this encoder did and never from the output's own keys.
|
|
741
|
+
* The target's output is filtered like any evidence (root refuses
|
|
742
|
+
* credentials): `redacted` when the filter removed or rewrote anything,
|
|
743
|
+
* otherwise `complete`. An output that would not fit root's 64 KiB result is
|
|
744
|
+
* `omitted` with a null output, never cut into a different value. Outcome
|
|
745
|
+
* and completion are kept exactly as the driver recorded them.
|
|
746
|
+
*/
|
|
747
|
+
#encodeTargetResult(result) {
|
|
748
|
+
const completion = result?.completion;
|
|
749
|
+
const messageFailure = result?.kind === 'participant_message' || result?.kind === 'scenario_message';
|
|
750
|
+
if (!result || result.schema !== 'firedrill.native-target-result.v1' || typeof result.action_id !== 'string'
|
|
751
|
+
|| !UUID_PATTERN.test(result.action_id) || typeof result.input_digest !== 'string' || !/^[a-f0-9]{64}$/.test(result.input_digest)
|
|
752
|
+
|| (result.kind !== 'task' && result.kind !== 'event' && !messageFailure) || !['completed', 'failed', 'cancelled'].includes(result.outcome)
|
|
753
|
+
|| (messageFailure && (result.outcome !== 'failed' || typeof result.claim_key !== 'string' || !UUID_PATTERN.test(result.claim_key)))
|
|
754
|
+
|| !completion || (result.kind === 'task' || messageFailure
|
|
755
|
+
? completion.kind !== 'target_returned' || completion.delivery_id !== null
|
|
756
|
+
: (completion.kind !== 'delivery_acknowledged' && completion.kind !== 'correlated_result') || completion.delivery_id !== result.action_id)) {
|
|
757
|
+
return null;
|
|
758
|
+
}
|
|
759
|
+
const filtered = filterEvidenceValue(result.output ?? null, 1);
|
|
760
|
+
const changed = filtered.stats.removed + filtered.stats.rewritten;
|
|
761
|
+
this.#filtered += changed;
|
|
762
|
+
const document = (output, coverage) => Buffer.from(canonicalJson({
|
|
763
|
+
schema: messageFailure ? 'firedrill.native-target-result.v3' : 'firedrill.native-target-result.v2', action_id: result.action_id,
|
|
764
|
+
...(messageFailure ? { claim_key: result.claim_key } : {}),
|
|
765
|
+
input_digest: result.input_digest, kind: result.kind, outcome: result.outcome, output, output_coverage: coverage,
|
|
766
|
+
completion: { kind: completion.kind, delivery_id: completion.delivery_id }
|
|
767
|
+
}), 'utf8');
|
|
768
|
+
// canonicalJson escapes like Python's ensure_ascii json.dumps, so these bytes are exactly the characters root's
|
|
769
|
+
// credential scan reads (its first 32,000). Root re-measures the parsed body with default separators (a space
|
|
770
|
+
// after each structural ',' and ':'), which stays under twice this size: within its 64 KiB bound.
|
|
771
|
+
const limit = Math.min(this.#maxArtifact, TARGET_RESULT_MAX_BYTES, TARGET_RESULT_SCAN_CHARS);
|
|
772
|
+
// Missing producer provenance cannot be upgraded to complete merely because
|
|
773
|
+
// this final filter found nothing else to remove. Preserve the original v1
|
|
774
|
+
// evidence contract so the server reports legacy_unknown.
|
|
775
|
+
const coverage = result.output_coverage;
|
|
776
|
+
if (!messageFailure && coverage !== 'complete' && coverage !== 'redacted' && coverage !== 'omitted') {
|
|
777
|
+
const legacy = Buffer.from(canonicalJson({ schema: result.schema, action_id: result.action_id,
|
|
778
|
+
input_digest: result.input_digest, kind: result.kind, outcome: result.outcome, output: filtered.value,
|
|
779
|
+
completion: { kind: completion.kind, delivery_id: completion.delivery_id } }), 'utf8');
|
|
780
|
+
return legacy.byteLength <= limit ? legacy : document(null, 'omitted');
|
|
781
|
+
}
|
|
782
|
+
let bytes = coverage === 'omitted' ? document(null, 'omitted')
|
|
783
|
+
: document(filtered.value, changed > 0 || coverage === 'redacted' ? 'redacted' : 'complete');
|
|
784
|
+
if (bytes.byteLength > limit)
|
|
785
|
+
bytes = document(null, 'omitted');
|
|
786
|
+
return bytes.byteLength <= limit ? bytes : null;
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Root's `firedrill.native-trace-segment.v1`, exactly its eight fields, or
|
|
790
|
+
* null when the segment cannot be one. Times become drill-relative elapsed
|
|
791
|
+
* milliseconds on the evidence clock (the drill start), kept non-decreasing
|
|
792
|
+
* and within the segment's interval. Only closed values are written: ids,
|
|
793
|
+
* counts, times and the operation, outcome and coverage enums.
|
|
794
|
+
*/
|
|
795
|
+
#encodeTraceSegment(segment) {
|
|
796
|
+
const events = segment?.events;
|
|
797
|
+
const terminal = segment?.terminal;
|
|
798
|
+
if (!segment || typeof segment.capture_id !== 'string' || !UUID_PATTERN.test(segment.capture_id)
|
|
799
|
+
|| typeof segment.segment_index !== 'number' || !Number.isSafeInteger(segment.segment_index) || segment.segment_index < 0
|
|
800
|
+
|| !Array.isArray(events) || events.length > TRACE_SEGMENT_MAX_EVENTS
|
|
801
|
+
|| (terminal !== null && !(terminal?.coverage === 'complete' && terminal.reason === null)
|
|
802
|
+
&& !(terminal?.coverage === 'partial' && TRACE_PARTIAL_REASONS.includes(terminal.reason))))
|
|
803
|
+
return null;
|
|
804
|
+
const relative = (atMs) => Math.min(MAX_SAFE, Math.max(0, Math.floor((typeof atMs === 'number' && Number.isFinite(atMs) ? atMs : Date.now()) - this.#options.startedAtMs)));
|
|
805
|
+
const start = relative(segment.start_at_ms);
|
|
806
|
+
let elapsed = start, sequence = 0;
|
|
807
|
+
const written = [];
|
|
808
|
+
for (const event of events) {
|
|
809
|
+
if (!event || typeof event.sequence !== 'number' || !Number.isSafeInteger(event.sequence) || event.sequence < 1
|
|
810
|
+
|| (sequence > 0 && event.sequence !== sequence + 1)
|
|
811
|
+
|| !TRACE_OPERATIONS.includes(event.operation) || !TRACE_OUTCOMES.includes(event.outcome))
|
|
812
|
+
return null;
|
|
813
|
+
sequence = event.sequence;
|
|
814
|
+
elapsed = Math.max(elapsed, relative(event.at_ms));
|
|
815
|
+
written.push({ sequence, elapsed_ms: elapsed, operation: event.operation, outcome: event.outcome });
|
|
816
|
+
}
|
|
817
|
+
const bytes = Buffer.from(canonicalJson({
|
|
818
|
+
schema: TRACE_SEGMENT_SCHEMA, capture_id: segment.capture_id, segment_index: segment.segment_index,
|
|
819
|
+
start_elapsed_ms: start, end_elapsed_ms: Math.max(elapsed, relative(segment.end_at_ms)),
|
|
820
|
+
capture: { consent: true, source: 'playwright', profile: TRACE_PROFILE }, events: written,
|
|
821
|
+
terminal: terminal === null ? null : { coverage: terminal.coverage, reason: terminal.reason },
|
|
822
|
+
}), 'utf8');
|
|
823
|
+
return bytes.byteLength <= this.#maxArtifact ? bytes : null;
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* `count` false measures only: the same bytes, without filtering or omission accounting.
|
|
827
|
+
* `sizedFor` is the attachment reference the content's value budget leaves room for
|
|
828
|
+
* (at least as many values as `attachment`), so the whole record stays within root's count.
|
|
829
|
+
*/
|
|
830
|
+
#encodeDependencyExchange(exchange) {
|
|
831
|
+
try {
|
|
832
|
+
if (exchange.schema !== 'firedrill.native-dependency-exchange.v1'
|
|
833
|
+
|| !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(exchange.exchange_id)
|
|
834
|
+
|| !/^[a-f0-9]{64}$/.test(exchange.binding_digest)
|
|
835
|
+
|| typeof exchange.dependency_alias !== 'string' || !/^[a-z][a-z0-9_-]{0,63}$/.test(exchange.dependency_alias)
|
|
836
|
+
|| typeof exchange.service_key !== 'string' || exchange.service_key.length < 1 || exchange.service_key.length > 200
|
|
837
|
+
|| typeof exchange.operation !== 'string' || !/^[A-Za-z0-9_.:/-]{1,128}$/.test(exchange.operation)
|
|
838
|
+
|| !['http', 'mcp'].includes(exchange.protocol)
|
|
839
|
+
|| !['returned', 'refused', 'pending', 'unknown'].includes(exchange.outcome)
|
|
840
|
+
|| !['json', 'text', 'unavailable'].includes(exchange.response_format)
|
|
841
|
+
|| ![exchange.request_coverage, exchange.response_coverage].every(value => ['captured', 'redacted', 'truncated', 'unsupported', 'unavailable'].includes(value))
|
|
842
|
+
|| exchange.http_status !== null && (!Number.isInteger(exchange.http_status) || exchange.http_status < 100 || exchange.http_status > 599)
|
|
843
|
+
|| exchange.is_error !== null && typeof exchange.is_error !== 'boolean'
|
|
844
|
+
|| !Array.isArray(exchange.omission_reasons) || exchange.omission_reasons.length > 5
|
|
845
|
+
|| exchange.omission_reasons.some(reason => !['privacy_filtered', 'size_limit', 'unsupported_content', 'no_response', 'capture_unavailable'].includes(reason)))
|
|
846
|
+
return null;
|
|
847
|
+
const request = filterEvidenceValue(exchange.request, 1, undefined, 32);
|
|
848
|
+
const response = filterEvidenceValue(exchange.response, 1, undefined, 32 + evidenceNodes(request.value));
|
|
849
|
+
if (!request.value || typeof request.value !== 'object' || Array.isArray(request.value))
|
|
850
|
+
return null;
|
|
851
|
+
const requestChanged = request.stats.removed + request.stats.rewritten > 0;
|
|
852
|
+
const responseChanged = response.stats.removed + response.stats.rewritten > 0;
|
|
853
|
+
const requestTruncated = (request.stats.truncated ?? 0) > 0;
|
|
854
|
+
const responseTruncated = (response.stats.truncated ?? 0) > 0;
|
|
855
|
+
const document = { schema: exchange.schema, exchange_id: exchange.exchange_id,
|
|
856
|
+
dependency_alias: exchange.dependency_alias, service_key: exchange.service_key, operation: exchange.operation,
|
|
857
|
+
binding_digest: exchange.binding_digest, protocol: exchange.protocol, outcome: exchange.outcome,
|
|
858
|
+
http_status: exchange.http_status, is_error: exchange.is_error, response_format: exchange.response_format,
|
|
859
|
+
request: request.value, response: response.value,
|
|
860
|
+
request_coverage: requestTruncated ? 'truncated' : requestChanged && exchange.request_coverage === 'captured' ? 'redacted' : exchange.request_coverage,
|
|
861
|
+
response_coverage: responseTruncated ? 'truncated' : responseChanged && exchange.response_coverage === 'captured' ? 'redacted' : exchange.response_coverage,
|
|
862
|
+
omission_reasons: [...new Set([...exchange.omission_reasons,
|
|
863
|
+
...(requestChanged || responseChanged ? ['privacy_filtered'] : []),
|
|
864
|
+
...(requestTruncated || responseTruncated ? ['size_limit'] : [])])] };
|
|
865
|
+
this.#filtered += request.stats.removed + request.stats.rewritten + response.stats.removed + response.stats.rewritten;
|
|
866
|
+
if (document.request_coverage !== 'captured' || document.response_coverage !== 'captured')
|
|
867
|
+
this.#omitted++;
|
|
868
|
+
let bytes = Buffer.from(canonicalJson(document), 'utf8');
|
|
869
|
+
if (bytes.byteLength > Math.min(131072, this.#maxArtifact)) {
|
|
870
|
+
this.#omitted++;
|
|
871
|
+
bytes = Buffer.from(canonicalJson({ ...document, request: {}, response: null, response_format: 'unavailable',
|
|
872
|
+
request_coverage: 'truncated', response_coverage: 'unavailable',
|
|
873
|
+
omission_reasons: [...new Set([...document.omission_reasons, 'size_limit'])] }), 'utf8');
|
|
874
|
+
}
|
|
875
|
+
return bytes.byteLength <= Math.min(131072, this.#maxArtifact) ? bytes : null;
|
|
876
|
+
}
|
|
877
|
+
catch {
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
#encode(kind, content, attachment, count = true, sizedFor = attachment) {
|
|
882
|
+
const envelope = (body) => Buffer.from(canonicalJson({ version: ARTIFACT_VERSION, kind, content: body, attachment }), 'utf8');
|
|
883
|
+
let bytes;
|
|
884
|
+
try {
|
|
885
|
+
if (!content || typeof content !== 'object' || Array.isArray(content))
|
|
886
|
+
throw new Error('content');
|
|
887
|
+
// client-filtered-v1: content sits at depth 1 of the evidence document, beside the
|
|
888
|
+
// envelope's own values (the document, version and kind) and the attachment reference.
|
|
889
|
+
const outside = 3 + Math.max(evidenceNodes(attachment), evidenceNodes(sizedFor));
|
|
890
|
+
let filtered = filterEvidenceValue(content, 1, undefined, outside);
|
|
891
|
+
bytes = envelope(filtered.value);
|
|
892
|
+
// ASCII escaping can grow text up to six times; shorten strings before giving up on the record.
|
|
893
|
+
for (const cap of SHRINK_STRING_CHARS) {
|
|
894
|
+
if (bytes.byteLength <= this.#maxArtifact)
|
|
895
|
+
break;
|
|
896
|
+
filtered = filterEvidenceValue(content, 1, cap, outside);
|
|
897
|
+
bytes = envelope(filtered.value);
|
|
898
|
+
}
|
|
899
|
+
if (count)
|
|
900
|
+
this.#filtered += filtered.stats.removed + filtered.stats.rewritten;
|
|
901
|
+
}
|
|
902
|
+
catch {
|
|
903
|
+
if (count)
|
|
904
|
+
this.#omitted++;
|
|
905
|
+
return envelope({ omitted: 'unserializable' });
|
|
906
|
+
}
|
|
907
|
+
if (bytes.byteLength > this.#maxArtifact) {
|
|
908
|
+
if (count) {
|
|
909
|
+
this.#omitted++;
|
|
910
|
+
this.#droppedBytes += bytes.byteLength;
|
|
911
|
+
}
|
|
912
|
+
return envelope({ omitted: 'capacity', bytes: bytes.byteLength });
|
|
913
|
+
}
|
|
914
|
+
return bytes;
|
|
915
|
+
}
|
|
916
|
+
#fits(size) {
|
|
917
|
+
return this.#buffered + size + EVENT_OVERHEAD_BYTES <= this.#maxBuffered
|
|
918
|
+
&& this.#acceptedBytes + size <= Math.max(0, this.#maxTotal - CLOSE_RESERVE_BYTES);
|
|
919
|
+
}
|
|
920
|
+
#drop(bytes) {
|
|
921
|
+
this.#droppedObservations++;
|
|
922
|
+
this.#droppedBytes += bytes;
|
|
923
|
+
}
|
|
924
|
+
#elapsed(atMs) {
|
|
925
|
+
const now = Date.now();
|
|
926
|
+
const at = typeof atMs === 'number' && Number.isFinite(atMs) && atMs <= now + 60_000 ? atMs : now;
|
|
927
|
+
const elapsed = Math.min(MAX_SAFE, Math.max(0, Math.floor(at - this.#options.startedAtMs)));
|
|
928
|
+
this.#lastElapsed = Math.max(this.#lastElapsed, elapsed);
|
|
929
|
+
return this.#lastElapsed;
|
|
930
|
+
}
|
|
931
|
+
#release(bytes) {
|
|
932
|
+
this.#buffered = Math.max(0, this.#buffered - bytes);
|
|
933
|
+
this.#notifyRoom();
|
|
934
|
+
}
|
|
935
|
+
#notifyRoom() {
|
|
936
|
+
for (const check of [...this.#roomWaiters])
|
|
937
|
+
check();
|
|
938
|
+
}
|
|
939
|
+
// -------------------------------------------------------------------------
|
|
940
|
+
// Delivery.
|
|
941
|
+
#schedule(immediate) {
|
|
942
|
+
if (this.#permanentFailure || this.#pending.length === 0 || this.#holding)
|
|
943
|
+
return;
|
|
944
|
+
if (this.#pump) {
|
|
945
|
+
this.#rerun = true;
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
if (immediate) {
|
|
949
|
+
if (this.#timer) {
|
|
950
|
+
clearTimeout(this.#timer);
|
|
951
|
+
this.#timer = null;
|
|
952
|
+
}
|
|
953
|
+
this.#startPump();
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
if (!this.#timer) {
|
|
957
|
+
this.#timer = setTimeout(() => { this.#timer = null; this.#startPump(); }, this.#flushDelay);
|
|
958
|
+
this.#timer.unref?.();
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
#startPump() {
|
|
962
|
+
if (this.#pump || this.#pending.length === 0 || this.#permanentFailure || this.#holding)
|
|
963
|
+
return;
|
|
964
|
+
this.#pump = this.#deliver().finally(() => {
|
|
965
|
+
this.#pump = null;
|
|
966
|
+
if (this.#rerun && !this.#closed) {
|
|
967
|
+
this.#rerun = false;
|
|
968
|
+
this.#schedule(true);
|
|
969
|
+
}
|
|
970
|
+
this.#notifyRoom();
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
async #deliver() {
|
|
974
|
+
let stalled = 0;
|
|
975
|
+
while (this.#pending.length > 0 && !this.#options.signal.aborted) {
|
|
976
|
+
try {
|
|
977
|
+
const progressed = await this.#sendOne();
|
|
978
|
+
stalled = progressed ? 0 : stalled + 1;
|
|
979
|
+
if (stalled >= 3) {
|
|
980
|
+
throw new CliError('native_observation_delivery_failed', 'Observations were not acknowledged after repeated submissions.');
|
|
981
|
+
}
|
|
982
|
+
if (!progressed && !await sleep(backoffMs(stalled, 500, 5_000), this.#options.signal))
|
|
983
|
+
return;
|
|
984
|
+
this.#failure = null;
|
|
985
|
+
}
|
|
986
|
+
catch (error) {
|
|
987
|
+
this.#failure = error;
|
|
988
|
+
if (!isTransient(error)) {
|
|
989
|
+
this.#permanentFailure = true;
|
|
990
|
+
this.#notifyRoom();
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
// Retries exhausted on a transient condition: try again later unless closing.
|
|
994
|
+
if (!this.#closed && !this.#timer) {
|
|
995
|
+
this.#timer = setTimeout(() => { this.#timer = null; this.#startPump(); }, 15_000);
|
|
996
|
+
this.#timer.unref?.();
|
|
997
|
+
}
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
/** Upload artifacts for the next slice, submit its batch and reconcile the acknowledgement. Returns whether the ack advanced. */
|
|
1003
|
+
async #sendOne() {
|
|
1004
|
+
const signal = this.#options.signal;
|
|
1005
|
+
const retry = { signal, attempts: this.#attempts, onTransient: (error) => this.#options.onTransient?.(error) };
|
|
1006
|
+
// Re-send the pinned batch unchanged while the acknowledgement has not moved.
|
|
1007
|
+
const pinned = this.#batch && this.#batch.previous === this.#acked && this.#pending.length >= this.#batch.count
|
|
1008
|
+
&& this.#pending[0].event.sequence === this.#batch.first ? this.#batch : null;
|
|
1009
|
+
if (!pinned)
|
|
1010
|
+
this.#batch = null;
|
|
1011
|
+
const slice = this.#pending.slice(0, pinned ? pinned.count : OBSERVATION_BATCH_MAX_EVENTS);
|
|
1012
|
+
// Records are committed before the batch that references them. Events of a
|
|
1013
|
+
// pinned batch were all committed before it was first sent.
|
|
1014
|
+
for (const item of slice) {
|
|
1015
|
+
const artifact = item.artifact;
|
|
1016
|
+
if (!artifact.bytes)
|
|
1017
|
+
continue;
|
|
1018
|
+
const bytes = artifact.bytes;
|
|
1019
|
+
let committed;
|
|
1020
|
+
try {
|
|
1021
|
+
const capture = artifact.screenshot;
|
|
1022
|
+
committed = await retryTransient(() => this.#options.controlPlane.uploadArtifact(this.#assignment, capture
|
|
1023
|
+
? { request_key: artifact.request_key, role: 'screenshot.observed', media_type: 'image/png', capture, sha256: artifact.sha256, bytes }
|
|
1024
|
+
: artifact.role
|
|
1025
|
+
? { request_key: artifact.request_key, role: artifact.role, media_type: OBSERVATION_ARTIFACT_MEDIA_TYPE, sha256: artifact.sha256, bytes }
|
|
1026
|
+
: { request_key: artifact.request_key, role: item.event.kind, media_type: OBSERVATION_ARTIFACT_MEDIA_TYPE, sha256: artifact.sha256, bytes }, signal), retry);
|
|
1027
|
+
}
|
|
1028
|
+
catch (error) {
|
|
1029
|
+
const code = errorCode(error);
|
|
1030
|
+
if (code !== null && RECORD_RENEWABLE.has(code) && artifact.renewals < MAX_RECORD_RENEWALS) {
|
|
1031
|
+
// Root closed this record's upload window and asks for a new request key. Root counts
|
|
1032
|
+
// every declared record against its caps, so the re-declaration counts again here too.
|
|
1033
|
+
artifact.renewals++;
|
|
1034
|
+
artifact.request_key = randomUUID();
|
|
1035
|
+
this.#records++;
|
|
1036
|
+
this.#acceptedBytes += artifact.size;
|
|
1037
|
+
return true;
|
|
1038
|
+
}
|
|
1039
|
+
if (code !== null && (RECORD_REFUSALS.has(code) || RECORD_RENEWABLE.has(code))) {
|
|
1040
|
+
this.#refuseRecord(item);
|
|
1041
|
+
return true;
|
|
1042
|
+
}
|
|
1043
|
+
throw error;
|
|
1044
|
+
}
|
|
1045
|
+
if (typeof committed?.artifact_id !== 'string' || !NATIVE_ID.test(committed.artifact_id)) {
|
|
1046
|
+
throw new CliError('native_observation_delivery_failed', 'The evidence record receipt was malformed. No evidence was regenerated.');
|
|
1047
|
+
}
|
|
1048
|
+
artifact.artifact_id = committed.artifact_id;
|
|
1049
|
+
item.event.artifact_id = committed.artifact_id;
|
|
1050
|
+
artifact.bytes = null;
|
|
1051
|
+
this.#uploadedBytes += artifact.size;
|
|
1052
|
+
this.#release(artifact.size);
|
|
1053
|
+
}
|
|
1054
|
+
const first = slice[0].event.sequence;
|
|
1055
|
+
const last = slice[slice.length - 1].event.sequence;
|
|
1056
|
+
if (first !== this.#acked + 1) {
|
|
1057
|
+
// Invariant guard: pending must start right after the acknowledgement.
|
|
1058
|
+
this.#resequence(this.#acked);
|
|
1059
|
+
this.#inconsistent = true;
|
|
1060
|
+
return true;
|
|
1061
|
+
}
|
|
1062
|
+
const previous = this.#acked;
|
|
1063
|
+
if (!this.#batch)
|
|
1064
|
+
this.#batch = { id: nativeId('ob'), first, count: slice.length, previous };
|
|
1065
|
+
const batch = {
|
|
1066
|
+
version: 1,
|
|
1067
|
+
assignment_id: this.#assignment.assignment_id,
|
|
1068
|
+
attempt_id: this.#assignment.attempt_id,
|
|
1069
|
+
lease_epoch: this.#assignment.lease_epoch,
|
|
1070
|
+
state_generation: this.#assignment.state_generation,
|
|
1071
|
+
batch_id: this.#batch.id,
|
|
1072
|
+
previous_ack: previous,
|
|
1073
|
+
events: slice.map((item) => ({ ...item.event })),
|
|
1074
|
+
};
|
|
1075
|
+
checkBatch(batch);
|
|
1076
|
+
const response = await retryTransient(() => this.#options.controlPlane.submitObservations(batch, signal), retry);
|
|
1077
|
+
const ack = response?.acknowledged;
|
|
1078
|
+
if (typeof ack !== 'number' || !Number.isSafeInteger(ack) || ack < 0) {
|
|
1079
|
+
throw new CliError('native_observation_delivery_failed', 'The observation acknowledgement was malformed. No evidence was regenerated.');
|
|
1080
|
+
}
|
|
1081
|
+
return this.#reconcile(ack, last);
|
|
1082
|
+
}
|
|
1083
|
+
/** Adopt the cloud acknowledgement for a batch ending at `last`. Returns whether it advanced. */
|
|
1084
|
+
#reconcile(ack, last) {
|
|
1085
|
+
if (ack === this.#acked)
|
|
1086
|
+
return false;
|
|
1087
|
+
this.#batch = null;
|
|
1088
|
+
if (ack < this.#acked) {
|
|
1089
|
+
// The cloud holds less than it acknowledged before. Those events left memory
|
|
1090
|
+
// and cannot be re-sent: report the loss and renumber only unaccepted events.
|
|
1091
|
+
this.#lostSequences += this.#acked - ack;
|
|
1092
|
+
this.#acked = ack;
|
|
1093
|
+
this.#resequence(ack);
|
|
1094
|
+
return true;
|
|
1095
|
+
}
|
|
1096
|
+
// Drop everything the cloud now holds. Events above `last` were never sent, so an
|
|
1097
|
+
// acknowledgement beyond the batch is inconsistent: keep those events and continue after it.
|
|
1098
|
+
const covered = Math.min(ack, last);
|
|
1099
|
+
let released = 0;
|
|
1100
|
+
while (this.#pending.length > 0 && this.#pending[0].event.sequence <= covered) {
|
|
1101
|
+
const item = this.#pending.shift();
|
|
1102
|
+
this.#acknowledgedIds.add(item.event.event_id);
|
|
1103
|
+
if (item.keep)
|
|
1104
|
+
this.#kept.set(item.event.event_id, { artifact_id: item.event.artifact_id, sha256: item.event.artifact_sha256 });
|
|
1105
|
+
released += EVENT_OVERHEAD_BYTES + (item.artifact.bytes ? item.artifact.size : 0);
|
|
1106
|
+
}
|
|
1107
|
+
this.#acked = ack;
|
|
1108
|
+
if (ack > last) {
|
|
1109
|
+
this.#inconsistent = true;
|
|
1110
|
+
this.#resequence(ack);
|
|
1111
|
+
}
|
|
1112
|
+
this.#release(released);
|
|
1113
|
+
return true;
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* Root refused one record (privacy profile, capacity) or its upload window
|
|
1117
|
+
* ended for good. Its event never left this process, so it is removed and
|
|
1118
|
+
* the later never-sent events are renumbered; the loss is reported.
|
|
1119
|
+
*/
|
|
1120
|
+
#refuseRecord(item) {
|
|
1121
|
+
const index = this.#pending.indexOf(item);
|
|
1122
|
+
if (index === -1)
|
|
1123
|
+
return;
|
|
1124
|
+
this.#pending.splice(index, 1);
|
|
1125
|
+
this.#drop(item.artifact.size);
|
|
1126
|
+
this.#release(EVENT_OVERHEAD_BYTES + (item.artifact.bytes ? item.artifact.size : 0));
|
|
1127
|
+
item.artifact.bytes = null;
|
|
1128
|
+
this.#resequence(this.#acked);
|
|
1129
|
+
}
|
|
1130
|
+
/** Renumber pending (never-accepted) events contiguously after `after`, keeping order and ids. */
|
|
1131
|
+
#resequence(after) {
|
|
1132
|
+
this.#batch = null;
|
|
1133
|
+
let next = after;
|
|
1134
|
+
for (const item of this.#pending)
|
|
1135
|
+
item.event.sequence = ++next;
|
|
1136
|
+
this.#assigned = next;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
/** Local mirror of observation-batch.v1 shape and continuity rules (root validates authoritatively). */
|
|
1140
|
+
function checkBatch(batch) {
|
|
1141
|
+
const invalid = () => new CliError('native_observation_batch_invalid', 'The observation batch was not sent: it does not match observation-batch.v1.');
|
|
1142
|
+
const int = (value, min) => Number.isSafeInteger(value) && value >= min && value <= MAX_SAFE;
|
|
1143
|
+
if (!NATIVE_ID.test(batch.assignment_id) || !NATIVE_ID.test(batch.attempt_id) || !NATIVE_ID.test(batch.batch_id)
|
|
1144
|
+
|| !int(batch.lease_epoch, 1) || !int(batch.state_generation, 1) || !int(batch.previous_ack, 0)
|
|
1145
|
+
|| batch.events.length < 1 || batch.events.length > OBSERVATION_BATCH_MAX_EVENTS)
|
|
1146
|
+
throw invalid();
|
|
1147
|
+
let expected = batch.previous_ack + 1;
|
|
1148
|
+
let elapsed = -1;
|
|
1149
|
+
const seen = new Set();
|
|
1150
|
+
for (const event of batch.events) {
|
|
1151
|
+
if (event.sequence !== expected || !int(event.sequence, 1) || !NATIVE_ID.test(event.event_id) || seen.has(event.event_id)
|
|
1152
|
+
|| !KINDS.has(event.kind) || !int(event.elapsed_ms, 0) || event.elapsed_ms < elapsed
|
|
1153
|
+
|| !NATIVE_ID.test(event.artifact_id) || !SHA256_HEX.test(event.artifact_sha256))
|
|
1154
|
+
throw invalid();
|
|
1155
|
+
seen.add(event.event_id);
|
|
1156
|
+
elapsed = event.elapsed_ms;
|
|
1157
|
+
expected++;
|
|
1158
|
+
}
|
|
1159
|
+
}
|