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,667 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The campaign's local run on this device (Plan20 FIR-492, root
|
|
3
|
+
* `local-run-lifecycle.v1`).
|
|
4
|
+
*
|
|
5
|
+
* Shared services the plan launches once (not per drill) outlive any one drill,
|
|
6
|
+
* so root tracks them as one run per campaign and device:
|
|
7
|
+
* - `register` before this device's first offer in the campaign, with a fixed
|
|
8
|
+
* launch id per service, every service `planned`. It grants no launch: a shared
|
|
9
|
+
* service still starts only inside a drill root has prepared;
|
|
10
|
+
* - `observe` each change (first launch -> `running`, an end -> `reaped` with its
|
|
11
|
+
* exit code, or `unknown` when the end cannot be attested), confirmed before
|
|
12
|
+
* the drill that needs it reports readiness;
|
|
13
|
+
* - each drill's close reports these services as `owned_run`/`transport_detached`
|
|
14
|
+
* from the state root last confirmed;
|
|
15
|
+
* - `close` at shutdown, once every service was stopped, after root sealed every
|
|
16
|
+
* accepted drill of the campaign on this device (seals arrive asynchronously,
|
|
17
|
+
* so the close is retried for a bounded time while the device session is live).
|
|
18
|
+
*
|
|
19
|
+
* Root never infers a reap and keeps one run per campaign and device forever, so
|
|
20
|
+
* the run's identity, confirmed snapshot and any unconfirmed event are persisted
|
|
21
|
+
* per campaign and runner device in the private profile directory and re-sent
|
|
22
|
+
* byte-identical after a restart. Root accepts the run's events only signed by
|
|
23
|
+
* that device, so only a later start with the same runner can close it.
|
|
24
|
+
* A run left with services running by an earlier companion cannot be continued:
|
|
25
|
+
* those processes are reported `unknown` (never invented as reaped) and the
|
|
26
|
+
* companion stops. Nothing here starts or stops a process; the supervisor does.
|
|
27
|
+
*/
|
|
28
|
+
import { randomUUID } from 'node:crypto';
|
|
29
|
+
import { open, readFile, rename, rm } from 'node:fs/promises';
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
import { privateDirectory } from '../credentials.js';
|
|
32
|
+
import { CliError } from '../http.js';
|
|
33
|
+
import { LOCAL_RUN_MAX_SERVICES } from './contracts.js';
|
|
34
|
+
import { backoffMs, errorCode, isAbort, isTransient, retryTransient, sleep } from './control-plane.js';
|
|
35
|
+
export const LOCAL_RUN_ERRORS = {
|
|
36
|
+
/** Shared launched services need a selected campaign: root keeps their run per campaign. */
|
|
37
|
+
campaign_required: 'native_local_run_campaign_required',
|
|
38
|
+
/** Root runs shared services only for a campaign that runs one drill at a time. */
|
|
39
|
+
serial_required: 'native_local_run_serial_required',
|
|
40
|
+
/** A shared service whose state is not serial and reset per drill (fresh session or approved hook). */
|
|
41
|
+
reset_required: 'native_local_run_reset_required',
|
|
42
|
+
/** Root registers a run only while the campaign is allocating or queued. */
|
|
43
|
+
registration_closed: 'native_local_run_registration_closed',
|
|
44
|
+
/**
|
|
45
|
+
* Only the registering runner device can close the run, and a session-only runner's key lives only in
|
|
46
|
+
* its process, so shared services are refused for it before any runner is enrolled or anything starts.
|
|
47
|
+
*/
|
|
48
|
+
persistent_runner_required: 'native_local_run_persistent_runner_required',
|
|
49
|
+
/** The run cannot be continued from this machine (no record, a different plan, or processes left by an earlier companion). */
|
|
50
|
+
unrecoverable: 'native_local_run_unrecoverable',
|
|
51
|
+
/** The run is closed; this device takes no more drills of the campaign. */
|
|
52
|
+
closed: 'native_local_run_closed',
|
|
53
|
+
/** A shared service's end could not be attested; root keeps the run uncertain. */
|
|
54
|
+
uncertain: 'native_local_run_uncertain',
|
|
55
|
+
/** A shared service ended; it is never started again under the run's identity. */
|
|
56
|
+
service_ended: 'native_local_run_service_ended',
|
|
57
|
+
/** The run is not registered yet (or no longer reported), so a shared service cannot be reported truthfully. */
|
|
58
|
+
unregistered: 'native_local_run_unregistered',
|
|
59
|
+
};
|
|
60
|
+
const REGISTER_STATES = new Set(['allocating', 'queued']);
|
|
61
|
+
const STATE_SCHEMA = 'firedrill.native-local-run-state.v1';
|
|
62
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
63
|
+
const RUN_ALIAS = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
64
|
+
/** Root's seals arrive asynchronously; the run close is retried between these bounds. */
|
|
65
|
+
const CLOSE_RETRY_BASE_MS = 2_000;
|
|
66
|
+
const CLOSE_RETRY_CAP_MS = 15_000;
|
|
67
|
+
/** Shared services that belong to a campaign's local run: launched once (not per drill), not attached, not driver-owned. */
|
|
68
|
+
export function runOwnedAliases(plan) {
|
|
69
|
+
const driverAlias = plan.driver.profile === 'jsonl-process.v1' ? plan.driver.service_alias : null;
|
|
70
|
+
return plan.services.filter((service) => service.mode === 'launch' && !service.per_drill && service.alias !== driverAlias)
|
|
71
|
+
.map((service) => service.alias);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Root admits a shared service into a run only when every state domain on it is
|
|
75
|
+
* serial and reset per drill by a fresh session or an approved hook (and it has
|
|
76
|
+
* at least one). Checked before anything opens, with root's own rule.
|
|
77
|
+
*/
|
|
78
|
+
export function checkRunCompatible(descriptor, aliases) {
|
|
79
|
+
if (aliases.length > LOCAL_RUN_MAX_SERVICES) {
|
|
80
|
+
throw new CliError(LOCAL_RUN_ERRORS.reset_required, `The service runs at most ${LOCAL_RUN_MAX_SERVICES} shared services per campaign. Nothing was started.`, 2);
|
|
81
|
+
}
|
|
82
|
+
for (const alias of aliases) {
|
|
83
|
+
const domains = descriptor.state_domains.filter((domain) => domain.service_alias === alias);
|
|
84
|
+
const resettable = domains.length > 0 && RUN_ALIAS.test(alias) && domains.every((domain) => domain.concurrency === 'serial'
|
|
85
|
+
&& (domain.reset === 'fresh-session' || (domain.reset === 'approved-hook' && typeof domain.hook_sha256 === 'string' && domain.hook_sha256.length > 0)));
|
|
86
|
+
if (!resettable) {
|
|
87
|
+
throw new CliError(LOCAL_RUN_ERRORS.reset_required, `Shared service "${alias}" cannot be kept running across drills: each of its declared state domains must be serial and reset per drill by a fresh session or an approved hook. Make it per-drill in the plan, or declare its state. Nothing was started.`, 2);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export class LocalRun {
|
|
92
|
+
#options;
|
|
93
|
+
#aliases;
|
|
94
|
+
#path;
|
|
95
|
+
/** Where records were kept per campaign only, before they named the device. */
|
|
96
|
+
#legacyPath;
|
|
97
|
+
#state = null;
|
|
98
|
+
#chain = Promise.resolve();
|
|
99
|
+
/** Why this run serves no further drills on this device; null while it may. */
|
|
100
|
+
#retired = null;
|
|
101
|
+
constructor(options, path, legacyPath) {
|
|
102
|
+
this.#options = options;
|
|
103
|
+
this.#aliases = new Set(options.aliases);
|
|
104
|
+
this.#path = path;
|
|
105
|
+
this.#legacyPath = legacyPath;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Load what this machine knows about the run and reconcile it with root before
|
|
109
|
+
* any offer is taken. A fresh run is registered later, just before the first
|
|
110
|
+
* slot offer (`beforeOffer`).
|
|
111
|
+
*/
|
|
112
|
+
static async open(options, signal) {
|
|
113
|
+
if (!UUID.test(options.campaignId))
|
|
114
|
+
throw new CliError(LOCAL_RUN_ERRORS.campaign_required, 'Shared services need the campaign id exactly as Firedrill printed it. Nothing was started.', 2);
|
|
115
|
+
const deviceKey = typeof options.deviceKey === 'string' ? options.deviceKey.toLowerCase() : '';
|
|
116
|
+
if (!UUID.test(deviceKey))
|
|
117
|
+
throw new CliError('runner_receipt_invalid', 'Shared services need the runner device key exactly as Firedrill printed it. Nothing was started.', 2);
|
|
118
|
+
const directory = await privateDirectory();
|
|
119
|
+
const run = new LocalRun({ ...options, deviceKey }, join(directory, `native-local-run-${options.campaignId}-${deviceKey}.json`), join(directory, `native-local-run-${options.campaignId}.json`));
|
|
120
|
+
await run.#recover(signal);
|
|
121
|
+
return run;
|
|
122
|
+
}
|
|
123
|
+
/** The one way to close the run after this companion: root accepts its close only signed by this runner device. */
|
|
124
|
+
get closeLaterHint() {
|
|
125
|
+
return `Run the companion again later for campaign ${this.#options.campaignId} with --runner ${this.#options.deviceKey} to close it; `
|
|
126
|
+
+ 'only this runner device can.';
|
|
127
|
+
}
|
|
128
|
+
/** Why the run takes no further drills, or null. */
|
|
129
|
+
get retired() { return this.#retired; }
|
|
130
|
+
/** Root confirmed the run's registration and it can still take drills. */
|
|
131
|
+
get registered() { return !this.#retired && !!this.#state && this.#state.sequence >= 1 && !this.#state.closed; }
|
|
132
|
+
/** The launch id registered for a run-owned alias, once root confirmed the registration. */
|
|
133
|
+
launchId(alias) {
|
|
134
|
+
const state = this.#state;
|
|
135
|
+
return state && state.sequence >= 1 && this.#aliases.has(alias) ? state.launches[alias] ?? null : null;
|
|
136
|
+
}
|
|
137
|
+
beforeLaunch(alias) {
|
|
138
|
+
return this.#serial(async () => {
|
|
139
|
+
const state = this.#state;
|
|
140
|
+
if (!state || state.sequence < 1 || state.closed || this.#retired || !this.#aliases.has(alias)
|
|
141
|
+
|| state.launch_intents.includes(alias)
|
|
142
|
+
|| state.observed?.some((item) => item.service === alias && item.state !== 'planned'))
|
|
143
|
+
throw new CliError(LOCAL_RUN_ERRORS.uncertain, 'The original shared-service launch must be reconciled before another launch. Nothing was started.');
|
|
144
|
+
state.launch_intents.push(alias);
|
|
145
|
+
await this.#persist();
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
captureObserved(final = false) {
|
|
149
|
+
return this.#serial(async () => {
|
|
150
|
+
if (!this.#state || this.#state.closed)
|
|
151
|
+
return;
|
|
152
|
+
this.#state.observed = this.#snapshot(final);
|
|
153
|
+
await this.#persist();
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
notStarted(alias) {
|
|
157
|
+
return this.#serial(async () => {
|
|
158
|
+
const state = this.#state;
|
|
159
|
+
if (!state || !state.launch_intents.includes(alias)
|
|
160
|
+
|| (this.#options.observe().get(alias)?.state ?? 'planned') !== 'planned'
|
|
161
|
+
|| state.confirmed?.some((item) => item.service === alias && item.state !== 'planned')) {
|
|
162
|
+
throw new CliError(LOCAL_RUN_ERRORS.uncertain, 'The original launch cannot be recorded as never started.');
|
|
163
|
+
}
|
|
164
|
+
const observed = this.#snapshot(false);
|
|
165
|
+
state.observed = observed.map((item) => item.service === alias
|
|
166
|
+
? { ...item, state: 'not_started', exit_code: null } : item);
|
|
167
|
+
await this.#persist();
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
/** A run-owned alias root currently knows as running: its identity for a drill's readiness. */
|
|
171
|
+
runService = (alias) => {
|
|
172
|
+
const state = this.#state;
|
|
173
|
+
const entry = state?.confirmed?.find((item) => item.service === alias);
|
|
174
|
+
return state && entry?.state === 'running' ? { run_id: state.run_id, launch_id: entry.launch_id } : null;
|
|
175
|
+
};
|
|
176
|
+
closeEntry(alias, outcome) {
|
|
177
|
+
if (!this.#aliases.has(alias))
|
|
178
|
+
return null;
|
|
179
|
+
const state = this.#state;
|
|
180
|
+
const entry = state?.confirmed?.find((item) => item.service === alias);
|
|
181
|
+
if (!state || !entry)
|
|
182
|
+
return LOCAL_RUN_ERRORS.unregistered;
|
|
183
|
+
if (entry.state === 'running' || entry.state === 'reaped') {
|
|
184
|
+
return { service: alias, launch_id: entry.launch_id, run_id: state.run_id, ownership: 'owned_run', close_kind: 'transport_detached' };
|
|
185
|
+
}
|
|
186
|
+
if (entry.state === 'unknown')
|
|
187
|
+
return LOCAL_RUN_ERRORS.uncertain;
|
|
188
|
+
// Launched here but root never confirmed it: "not started" would contradict the launch once reported.
|
|
189
|
+
if (entry.state === 'planned' && (state.launch_intents.includes(alias)
|
|
190
|
+
|| (this.#options.observe().get(alias)?.state ?? 'planned') !== 'planned'))
|
|
191
|
+
return LOCAL_RUN_ERRORS.unregistered;
|
|
192
|
+
return outcome === 'completed' ? 'native_close_service_not_launched' : { service: alias, ownership: 'none', close_kind: 'not_started' };
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Before each slot offer: the run must be registered first (root refuses a
|
|
196
|
+
* registration once this device has any offer in the campaign). Throws when
|
|
197
|
+
* the run can take no further drills, so the companion stops and closes it.
|
|
198
|
+
*/
|
|
199
|
+
beforeOffer = (signal) => this.#serial(async () => {
|
|
200
|
+
if (this.#retired)
|
|
201
|
+
throw new CliError(this.#retired.code, this.#retired.message);
|
|
202
|
+
if (this.#state && this.#state.sequence < 1 && this.#state.pending?.action !== 'register')
|
|
203
|
+
this.#state = null;
|
|
204
|
+
const existing = this.#state;
|
|
205
|
+
if (existing && existing.sequence >= 1)
|
|
206
|
+
return true;
|
|
207
|
+
if (!existing) {
|
|
208
|
+
const policy = await this.#options.controlPlane.campaignPolicy(this.#options.campaignId, signal);
|
|
209
|
+
if (policy.max_parallel !== 1) {
|
|
210
|
+
throw new CliError(LOCAL_RUN_ERRORS.serial_required, 'This plan keeps shared services running across drills, which the service allows only for a campaign that runs one drill at a time. Make those services per-drill, or use a campaign with max parallel 1. No drill was requested.', 2);
|
|
211
|
+
}
|
|
212
|
+
if (!REGISTER_STATES.has(policy.state)) {
|
|
213
|
+
throw new CliError(LOCAL_RUN_ERRORS.registration_closed, `The campaign is ${policy.state}; its shared services can be registered only while it is allocating or queued. No drill was requested.`, 2);
|
|
214
|
+
}
|
|
215
|
+
const runId = randomUUID();
|
|
216
|
+
const launches = Object.fromEntries(this.#options.aliases.map((alias) => [alias, randomUUID()]));
|
|
217
|
+
const services = this.#options.aliases.map((alias) => ({ service: alias, launch_id: launches[alias], state: 'planned', exit_code: null }));
|
|
218
|
+
this.#state = { schema: STATE_SCHEMA, campaign_id: this.#options.campaignId, descriptor_sha256: this.#options.descriptorSha256,
|
|
219
|
+
run_id: runId, launches, confirmed: null, sequence: 0, pending: { run_id: runId, request_key: randomUUID(), sequence: 1, action: 'register', services },
|
|
220
|
+
closed: false, launch_intents: [], observed: null };
|
|
221
|
+
await this.#persist();
|
|
222
|
+
}
|
|
223
|
+
const state = this.#state;
|
|
224
|
+
try {
|
|
225
|
+
await this.#submit(state.pending, signal);
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
// An answered refusal stored nothing on root: forget the registration so a later start does not resend it.
|
|
229
|
+
if (refused(error)) {
|
|
230
|
+
this.#state = null;
|
|
231
|
+
await this.#persist();
|
|
232
|
+
}
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
this.#options.output('native_local_run_registered', { campaign_id: this.#options.campaignId, run_id: state.run_id, services: this.#options.aliases }, `Registered this campaign's shared services (${this.#options.aliases.join(', ')}) with the service. They start with the first drill.`);
|
|
236
|
+
return true;
|
|
237
|
+
});
|
|
238
|
+
/** Report each run-owned service's current state to root (nothing when unchanged); resolves once root confirmed it. */
|
|
239
|
+
sync(signal) {
|
|
240
|
+
return this.#serial(async () => {
|
|
241
|
+
const state = this.#state;
|
|
242
|
+
if (!state || state.sequence < 1 || state.closed)
|
|
243
|
+
return;
|
|
244
|
+
state.observed = this.#snapshot(false);
|
|
245
|
+
await this.#persist();
|
|
246
|
+
if (state.pending)
|
|
247
|
+
await this.#submit(state.pending, signal);
|
|
248
|
+
const next = this.#snapshot(false);
|
|
249
|
+
if (sameSnapshot(next, state.confirmed))
|
|
250
|
+
return;
|
|
251
|
+
await this.#send('observe', next, signal);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* At shutdown, after every service was stopped: close the run once root sealed
|
|
256
|
+
* every accepted drill of the campaign on this device. Waits (retrying the same
|
|
257
|
+
* event) while root reports drills pending, until `until` aborts. Returns
|
|
258
|
+
* whether the run is closed; a run that cannot be closed now is left for a
|
|
259
|
+
* later start of the companion with the same runner device to close.
|
|
260
|
+
*/
|
|
261
|
+
close(signal, until) {
|
|
262
|
+
return this.#serial(async () => {
|
|
263
|
+
const state = this.#state;
|
|
264
|
+
if (!state)
|
|
265
|
+
return true;
|
|
266
|
+
if (state.closed)
|
|
267
|
+
return true;
|
|
268
|
+
state.observed = this.#snapshot(true);
|
|
269
|
+
await this.#persist();
|
|
270
|
+
if (state.sequence < 1) {
|
|
271
|
+
// A registration that was never confirmed. Re-sending it could create a run only to close it, which
|
|
272
|
+
// would end the campaign on this device for good; reading root tells whether it landed instead.
|
|
273
|
+
const pending = state.pending;
|
|
274
|
+
if (!pending)
|
|
275
|
+
return true;
|
|
276
|
+
let receipt;
|
|
277
|
+
try {
|
|
278
|
+
receipt = await this.#options.controlPlane.readRun(this.#options.campaignId, signal);
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
// Not landed: the record stays, so a later start re-sends it byte-identical (or forgets it when refused).
|
|
284
|
+
if (!receipt)
|
|
285
|
+
return true;
|
|
286
|
+
if (receipt.run_id !== state.run_id || receipt.sequence !== pending.sequence)
|
|
287
|
+
return false;
|
|
288
|
+
state.confirmed = pending.services;
|
|
289
|
+
state.sequence = pending.sequence;
|
|
290
|
+
state.pending = null;
|
|
291
|
+
await this.#persist();
|
|
292
|
+
}
|
|
293
|
+
if (state.pending) {
|
|
294
|
+
try {
|
|
295
|
+
await this.#submit(state.pending, signal);
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
if (!refused(error))
|
|
299
|
+
return false;
|
|
300
|
+
// Refused: root stored nothing; the report is rebuilt from what root holds.
|
|
301
|
+
state.pending = null;
|
|
302
|
+
await this.#persist();
|
|
303
|
+
}
|
|
304
|
+
// The unconfirmed event was itself the close.
|
|
305
|
+
if (state.closed)
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
const final = this.#snapshot(true);
|
|
309
|
+
// How each service ended is recorded with root first: a close refused while drills are still
|
|
310
|
+
// being sealed stores nothing, and the next start must not find these services "running".
|
|
311
|
+
if (!sameSnapshot(final, state.confirmed)) {
|
|
312
|
+
try {
|
|
313
|
+
await this.#send('observe', final, signal);
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
this.#options.output('native_local_run_close_pending', { campaign_id: this.#options.campaignId, run_id: state.run_id, error_code: errorCode(error) }, `How the campaign's shared services ended was not confirmed by the service. ${this.closeLaterHint}`);
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (final.some((item) => item.state === 'unknown' || item.state === 'running')) {
|
|
321
|
+
// Root keeps the run uncertain until each end is attested; nothing is invented.
|
|
322
|
+
this.#options.output('native_local_run_uncertain', { campaign_id: this.#options.campaignId, run_id: state.run_id }, 'A shared service did not end in a way this client could attest, so the campaign\'s local run stays open and uncertain. It needs the service\'s help to close.');
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
const event = { run_id: state.run_id, request_key: randomUUID(), sequence: state.sequence + 1, action: 'close', services: final };
|
|
326
|
+
state.pending = event;
|
|
327
|
+
await this.#persist();
|
|
328
|
+
let announced = false;
|
|
329
|
+
for (let attempt = 0;; attempt++) {
|
|
330
|
+
try {
|
|
331
|
+
await this.#submit(event, signal);
|
|
332
|
+
this.#options.output('native_local_run_closed', { campaign_id: this.#options.campaignId, run_id: state.run_id }, 'Closed this campaign\'s local run: every shared service was stopped and reported.');
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
const code = errorCode(error);
|
|
337
|
+
const waiting = code === 'native_local_run_drills_pending';
|
|
338
|
+
if (isAbort(error, signal)) {
|
|
339
|
+
// The close may have landed: it stays recorded and a later start re-sends it byte-identical.
|
|
340
|
+
this.#options.output('native_local_run_close_pending', { campaign_id: this.#options.campaignId, run_id: state.run_id }, `The campaign's local run close was not confirmed. ${this.closeLaterHint}`);
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
if (!waiting && refused(error)) {
|
|
344
|
+
// Root stored nothing; every end is already recorded, so a later start only closes.
|
|
345
|
+
state.pending = null;
|
|
346
|
+
await this.#persist();
|
|
347
|
+
this.#options.output('native_local_run_close_refused', { campaign_id: this.#options.campaignId, error_code: code }, `The service refused to close this campaign's local run. ${this.closeLaterHint}`);
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
if (!waiting && !isTransient(error)) {
|
|
351
|
+
// An answer this client could not confirm: the close may have landed, so it stays recorded and is re-sent.
|
|
352
|
+
this.#options.output('native_local_run_close_pending', { campaign_id: this.#options.campaignId, run_id: state.run_id, error_code: code }, `The campaign's local run close was not confirmed. ${this.closeLaterHint}`);
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
if (waiting && !announced) {
|
|
356
|
+
announced = true;
|
|
357
|
+
this.#options.output('native_local_run_closing', { campaign_id: this.#options.campaignId }, 'Waiting for the service to seal this device\'s drills before it closes the campaign\'s local run.');
|
|
358
|
+
}
|
|
359
|
+
if (until.aborted || !await sleep(backoffMs(attempt, CLOSE_RETRY_BASE_MS, CLOSE_RETRY_CAP_MS), until)) {
|
|
360
|
+
// A refused attempt stored nothing (every end is already recorded); an unanswered one is re-sent unchanged by a later start.
|
|
361
|
+
if (waiting) {
|
|
362
|
+
state.pending = null;
|
|
363
|
+
await this.#persist();
|
|
364
|
+
}
|
|
365
|
+
this.#options.output('native_local_run_close_pending', { campaign_id: this.#options.campaignId, run_id: state.run_id }, `The campaign's local run is not closed yet (its drills are not all sealed). ${this.closeLaterHint}`);
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
// -------------------------------------------------------------------------
|
|
373
|
+
async #recover(signal) {
|
|
374
|
+
let state = await this.#load(this.#path);
|
|
375
|
+
const receipt = await retryTransient(() => this.#options.controlPlane.readRun(this.#options.campaignId, signal), { signal, attempts: 4 });
|
|
376
|
+
if (!state && receipt)
|
|
377
|
+
state = await this.#claimLegacy(receipt);
|
|
378
|
+
if (!state) {
|
|
379
|
+
if (!receipt)
|
|
380
|
+
return;
|
|
381
|
+
// This machine lost its record (or never kept one), but root holds the run's descriptor and service
|
|
382
|
+
// snapshot: for the same plan the run is taken over from that snapshot. No process is relaunched from it.
|
|
383
|
+
const adopted = this.#adopt(receipt, null);
|
|
384
|
+
if (!adopted) {
|
|
385
|
+
this.#retire(LOCAL_RUN_ERRORS.unrecoverable, 'This device already has a local run for this campaign, registered for a different plan (other shared services or descriptor), so it can be neither continued nor closed from here. Approve that plan again, or use another campaign.');
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
state = adopted;
|
|
389
|
+
this.#options.output('native_local_run_recovered', { campaign_id: this.#options.campaignId, run_id: state.run_id, sequence: state.sequence }, 'This machine had no record of the campaign\'s local run, so it continues from the service\'s record of its shared services.');
|
|
390
|
+
}
|
|
391
|
+
this.#state = state;
|
|
392
|
+
if (!receipt) {
|
|
393
|
+
if (state.sequence === 0)
|
|
394
|
+
return; // registration never confirmed: re-sent before the first offer
|
|
395
|
+
this.#retire(LOCAL_RUN_ERRORS.unrecoverable, 'This machine recorded a local run for this campaign that the service does not have for this device. Nothing was started.');
|
|
396
|
+
this.#state = null;
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (receipt.run_id !== state.run_id) {
|
|
400
|
+
this.#retire(LOCAL_RUN_ERRORS.unrecoverable, 'The service holds a different local run for this campaign and device than this machine recorded. Nothing was started.');
|
|
401
|
+
this.#state = null;
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
// An unconfirmed event either landed (root is at its sequence) or not (root is where this machine left off).
|
|
405
|
+
if (state.pending && receipt.sequence === state.pending.sequence) {
|
|
406
|
+
state.confirmed = state.pending.services;
|
|
407
|
+
state.sequence = state.pending.sequence;
|
|
408
|
+
if (state.pending.action === 'close')
|
|
409
|
+
state.closed = true;
|
|
410
|
+
state.pending = null;
|
|
411
|
+
}
|
|
412
|
+
else if (receipt.sequence === state.sequence) {
|
|
413
|
+
// Not landed. How the services ended must not be lost with it: an observe is re-sent unchanged below,
|
|
414
|
+
// and a close (root stored neither) becomes the observe of the same ends; the run is closed at the next stop.
|
|
415
|
+
const pending = state.pending;
|
|
416
|
+
state.pending = pending?.action === 'observe' ? pending
|
|
417
|
+
: pending?.action === 'close' && !sameSnapshot(pending.services, state.confirmed)
|
|
418
|
+
? { run_id: state.run_id, request_key: randomUUID(), sequence: state.sequence + 1, action: 'observe', services: pending.services }
|
|
419
|
+
: null;
|
|
420
|
+
}
|
|
421
|
+
else if (receipt.sequence > state.sequence) {
|
|
422
|
+
// Root is ahead of this machine (an event confirmed after this record was written): its snapshot
|
|
423
|
+
// of the same services and launches is authoritative, and any unconfirmed event here is superseded.
|
|
424
|
+
const adopted = this.#adopt(receipt, state);
|
|
425
|
+
if (!adopted) {
|
|
426
|
+
this.#retire(LOCAL_RUN_ERRORS.unrecoverable, 'The service\'s record of this campaign\'s local run is ahead of this machine\'s and names other services or launches. Nothing was started.');
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
state = adopted;
|
|
430
|
+
this.#state = state;
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
this.#retire(LOCAL_RUN_ERRORS.unrecoverable, 'The service\'s record of this campaign\'s local run is behind this machine\'s. Nothing was started.');
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (receipt.state === 'closed')
|
|
437
|
+
state.closed = true;
|
|
438
|
+
await this.#persist();
|
|
439
|
+
if (state.closed) {
|
|
440
|
+
this.#retire(LOCAL_RUN_ERRORS.closed, 'This campaign\'s local run on this device is closed, so it takes no more drills here. Use another campaign.');
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (state.pending) {
|
|
444
|
+
// Reported before the earlier companion stopped but not confirmed: confirm it before judging what is left.
|
|
445
|
+
// Unconfirmed again (no answer), the start fails and the record keeps it for the next one.
|
|
446
|
+
const pending = state.pending;
|
|
447
|
+
try {
|
|
448
|
+
await this.#serial(() => this.#submit(pending, signal));
|
|
449
|
+
}
|
|
450
|
+
catch (error) {
|
|
451
|
+
if (!refused(error))
|
|
452
|
+
throw error;
|
|
453
|
+
state.pending = null;
|
|
454
|
+
await this.#persist();
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const confirmed = this.#snapshot(false);
|
|
458
|
+
state.observed = confirmed;
|
|
459
|
+
await this.#persist();
|
|
460
|
+
if (confirmed.some((item) => item.state === 'running')) {
|
|
461
|
+
// Processes started by an earlier companion are not this one's to report as ended.
|
|
462
|
+
const orphaned = confirmed.map((item) => item.state === 'running' ? { ...item, state: 'unknown', exit_code: null } : item);
|
|
463
|
+
await this.#serial(() => this.#send('observe', orphaned, signal)).catch(() => undefined);
|
|
464
|
+
this.#retire(LOCAL_RUN_ERRORS.unrecoverable, 'Shared services have an unresolved original launch. Their state remains unknown and settlement cannot finish until truthful cleanup evidence is available. This campaign takes no more drills on this device.');
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (confirmed.some((item) => item.state === 'unknown')) {
|
|
468
|
+
this.#retire(LOCAL_RUN_ERRORS.uncertain, 'A shared service of this campaign ended without an attested exit, so the service keeps the local run uncertain and this device takes no more of its drills.');
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (confirmed.some((item) => item.state === 'reaped' || item.state === 'not_started')) {
|
|
472
|
+
this.#retire(LOCAL_RUN_ERRORS.service_ended, 'This campaign\'s shared services already ended, so this device takes no more of its drills. The companion only closes the local run.');
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* A record kept per campaign only (before records named the device) may be
|
|
477
|
+
* another runner's. It is this device's only when root holds the same run for
|
|
478
|
+
* this device (run ids are unique); it then moves to this device's record.
|
|
479
|
+
* Otherwise it is left untouched for the device that wrote it.
|
|
480
|
+
*/
|
|
481
|
+
async #claimLegacy(receipt) {
|
|
482
|
+
let legacy;
|
|
483
|
+
try {
|
|
484
|
+
legacy = await this.#load(this.#legacyPath);
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
if (!legacy || legacy.run_id !== receipt.run_id)
|
|
490
|
+
return null;
|
|
491
|
+
this.#state = legacy;
|
|
492
|
+
await this.#persist();
|
|
493
|
+
await rm(this.#legacyPath, { force: true });
|
|
494
|
+
return legacy;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* A record rebuilt from root's receipt: only for this plan's descriptor and
|
|
498
|
+
* exactly its shared services, and (with a record) the launches it fixed.
|
|
499
|
+
* Root's snapshot is the device's own earlier report, not proof of a process.
|
|
500
|
+
*/
|
|
501
|
+
#adopt(receipt, recorded) {
|
|
502
|
+
const byAlias = new Map(receipt.services.map((item) => [item.service, item]));
|
|
503
|
+
if (receipt.descriptor_digest !== this.#options.descriptorSha256 || byAlias.size !== this.#options.aliases.length
|
|
504
|
+
|| !this.#options.aliases.every((alias) => byAlias.has(alias))
|
|
505
|
+
|| (recorded && (recorded.run_id !== receipt.run_id
|
|
506
|
+
|| !this.#options.aliases.every((alias) => recorded.launches[alias] === byAlias.get(alias)?.launch_id))))
|
|
507
|
+
return null;
|
|
508
|
+
const confirmed = this.#options.aliases.map((alias) => byAlias.get(alias));
|
|
509
|
+
return { schema: STATE_SCHEMA, campaign_id: this.#options.campaignId, descriptor_sha256: this.#options.descriptorSha256,
|
|
510
|
+
run_id: receipt.run_id, launches: Object.fromEntries(confirmed.map((item) => [item.service, item.launch_id])),
|
|
511
|
+
confirmed, sequence: receipt.sequence, pending: null, closed: receipt.state === 'closed',
|
|
512
|
+
launch_intents: recorded?.launch_intents ?? confirmed.map((item) => item.service),
|
|
513
|
+
observed: recorded?.observed ?? confirmed };
|
|
514
|
+
}
|
|
515
|
+
/** The snapshot to report: what the supervisor observes, never contradicting what root already holds. */
|
|
516
|
+
#snapshot(final) {
|
|
517
|
+
const state = this.#state;
|
|
518
|
+
const observed = this.#options.observe();
|
|
519
|
+
const confirmed = new Map((state.confirmed ?? []).map((item) => [item.service, item]));
|
|
520
|
+
const services = this.#options.aliases.map((alias) => {
|
|
521
|
+
let seen = observed.get(alias) ?? { state: 'planned' };
|
|
522
|
+
const retained = state.observed?.find((item) => item.service === alias);
|
|
523
|
+
if (retained?.state === 'reaped' || retained?.state === 'not_started')
|
|
524
|
+
return retained;
|
|
525
|
+
if (seen.state === 'planned' && retained && retained.state !== 'planned') {
|
|
526
|
+
seen = { state: 'unknown' };
|
|
527
|
+
}
|
|
528
|
+
if (seen.state === 'planned' && state.launch_intents.includes(alias))
|
|
529
|
+
seen = { state: 'unknown' };
|
|
530
|
+
const prior = confirmed.get(alias)?.state ?? 'planned';
|
|
531
|
+
const launch = state.launches[alias];
|
|
532
|
+
// Reaped and never-started entries are immutable; an ended or lost process never becomes running again.
|
|
533
|
+
if (prior === 'reaped' || prior === 'not_started')
|
|
534
|
+
return confirmed.get(alias);
|
|
535
|
+
let next = seen.state === 'planned' && final ? 'not_started' : seen.state;
|
|
536
|
+
if ((prior === 'running' || prior === 'unknown') && (next === 'planned' || next === 'not_started'))
|
|
537
|
+
next = 'unknown';
|
|
538
|
+
if (prior === 'unknown' && next === 'running')
|
|
539
|
+
next = 'unknown';
|
|
540
|
+
return { service: alias, launch_id: launch, state: next, exit_code: next === 'reaped' && seen.state === 'reaped' ? seen.exit_code : null };
|
|
541
|
+
});
|
|
542
|
+
// While drills may still follow, an ended service retires the run (at shutdown every service ends by design).
|
|
543
|
+
for (const item of final ? [] : services) {
|
|
544
|
+
if ((item.state === 'reaped' || item.state === 'unknown') && !this.#retired) {
|
|
545
|
+
this.#retire(item.state === 'reaped' ? LOCAL_RUN_ERRORS.service_ended : LOCAL_RUN_ERRORS.uncertain, `Shared service ${item.service} ended, so this campaign takes no more drills on this device; its local run is closed when the companion stops.`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return services;
|
|
549
|
+
}
|
|
550
|
+
async #send(action, services, signal) {
|
|
551
|
+
const state = this.#state;
|
|
552
|
+
state.observed = services;
|
|
553
|
+
const event = { run_id: state.run_id, request_key: randomUUID(), sequence: state.sequence + 1, action, services };
|
|
554
|
+
state.pending = event;
|
|
555
|
+
await this.#persist();
|
|
556
|
+
try {
|
|
557
|
+
await this.#submit(event, signal);
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
// An answered refusal stored nothing: the next report is built afresh. Anything else may have landed and is re-sent unchanged.
|
|
561
|
+
if (refused(error)) {
|
|
562
|
+
state.pending = null;
|
|
563
|
+
await this.#persist();
|
|
564
|
+
}
|
|
565
|
+
throw error;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
/** Submit one event and record root's confirmation. */
|
|
569
|
+
async #submit(event, signal) {
|
|
570
|
+
const state = this.#state;
|
|
571
|
+
const receipt = await this.#options.controlPlane.submitRun(this.#options.campaignId, event, signal);
|
|
572
|
+
state.confirmed = event.services;
|
|
573
|
+
state.sequence = event.sequence;
|
|
574
|
+
state.pending = null;
|
|
575
|
+
if (event.action === 'close' || receipt.state === 'closed')
|
|
576
|
+
state.closed = true;
|
|
577
|
+
await this.#persist();
|
|
578
|
+
if (receipt.state === 'uncertain' && !this.#retired) {
|
|
579
|
+
this.#retire(LOCAL_RUN_ERRORS.uncertain, 'The service keeps this campaign\'s local run uncertain, so this device takes no more of its drills.');
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
#retire(code, message) {
|
|
583
|
+
if (this.#retired)
|
|
584
|
+
return;
|
|
585
|
+
this.#retired = { code, message };
|
|
586
|
+
this.#options.output('native_local_run_retired', { campaign_id: this.#options.campaignId, reason: code }, message);
|
|
587
|
+
}
|
|
588
|
+
#serial(work) {
|
|
589
|
+
const run = this.#chain.catch(() => undefined).then(work);
|
|
590
|
+
this.#chain = run;
|
|
591
|
+
return run;
|
|
592
|
+
}
|
|
593
|
+
async #load(path) {
|
|
594
|
+
let text;
|
|
595
|
+
try {
|
|
596
|
+
text = await readFile(path, 'utf8');
|
|
597
|
+
}
|
|
598
|
+
catch (error) {
|
|
599
|
+
if (error?.code === 'ENOENT')
|
|
600
|
+
return null;
|
|
601
|
+
throw new CliError(LOCAL_RUN_ERRORS.unrecoverable, 'This machine\'s record of the campaign\'s local run could not be read. Nothing was started.');
|
|
602
|
+
}
|
|
603
|
+
let value;
|
|
604
|
+
try {
|
|
605
|
+
value = JSON.parse(text);
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
value = null;
|
|
609
|
+
}
|
|
610
|
+
const launches = value?.launches && typeof value.launches === 'object' ? value.launches : null;
|
|
611
|
+
const aliases = launches ? Object.keys(launches).sort() : [];
|
|
612
|
+
if (!value || value.schema !== STATE_SCHEMA || value.campaign_id !== this.#options.campaignId || !UUID.test(value.run_id)
|
|
613
|
+
|| !Number.isSafeInteger(value.sequence) || value.sequence < 0 || typeof value.closed !== 'boolean' || !launches
|
|
614
|
+
|| !aliases.every((alias) => UUID.test(launches[alias]))) {
|
|
615
|
+
throw new CliError(LOCAL_RUN_ERRORS.unrecoverable, 'This machine\'s record of the campaign\'s local run is damaged, so the run cannot be continued. Nothing was started.');
|
|
616
|
+
}
|
|
617
|
+
if (value.descriptor_sha256 !== this.#options.descriptorSha256 || aliases.join('\0') !== [...this.#options.aliases].sort().join('\0')) {
|
|
618
|
+
throw new CliError(LOCAL_RUN_ERRORS.unrecoverable, 'This campaign\'s local run was registered for a different plan (other shared services or descriptor). Approve that plan again, or use another campaign. Nothing was started.', 2);
|
|
619
|
+
}
|
|
620
|
+
if (value.launch_intents === undefined)
|
|
621
|
+
value.launch_intents = value.sequence > 0 ? aliases : [];
|
|
622
|
+
if (value.observed === undefined)
|
|
623
|
+
value.observed = value.pending?.services ?? value.confirmed;
|
|
624
|
+
if (!Array.isArray(value.launch_intents) || value.launch_intents.some((alias) => !aliases.includes(alias))
|
|
625
|
+
|| (value.observed !== null && (!Array.isArray(value.observed) || value.observed.length !== aliases.length
|
|
626
|
+
|| new Set(value.observed.map((item) => item.service)).size !== aliases.length
|
|
627
|
+
|| value.observed.some((item) => !aliases.includes(item.service) || item.launch_id !== launches[item.service]
|
|
628
|
+
|| !['planned', 'running', 'unknown', 'reaped', 'not_started'].includes(item.state)
|
|
629
|
+
|| (item.state === 'reaped' ? !Number.isSafeInteger(item.exit_code) : item.exit_code !== null))))) {
|
|
630
|
+
throw new CliError(LOCAL_RUN_ERRORS.unrecoverable, 'The retained local process evidence is invalid. Nothing was started.');
|
|
631
|
+
}
|
|
632
|
+
return value;
|
|
633
|
+
}
|
|
634
|
+
/** Atomic, private write: a crash leaves the previous record or the new one, never half of one. No record: the file is removed. */
|
|
635
|
+
async #persist() {
|
|
636
|
+
if (!this.#state) {
|
|
637
|
+
await rm(this.#path, { force: true });
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
const temporary = `${this.#path}.${randomUUID()}.tmp`;
|
|
641
|
+
const file = await open(temporary, 'wx', 0o600);
|
|
642
|
+
try {
|
|
643
|
+
await file.writeFile(JSON.stringify(this.#state));
|
|
644
|
+
await file.sync();
|
|
645
|
+
}
|
|
646
|
+
finally {
|
|
647
|
+
await file.close();
|
|
648
|
+
}
|
|
649
|
+
await rename(temporary, this.#path);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* An answered refusal: root stored nothing (a 4xx that is not a rate or timing
|
|
654
|
+
* condition), or the event never left this client. Anything else (no answer, a
|
|
655
|
+
* 5xx, an answer this client could not confirm) may have landed.
|
|
656
|
+
*/
|
|
657
|
+
function refused(error) {
|
|
658
|
+
if (errorCode(error) === 'native_local_run_invalid')
|
|
659
|
+
return true;
|
|
660
|
+
return error instanceof CliError && error.httpStatus !== null && error.httpStatus >= 400 && error.httpStatus < 500 && !isTransient(error);
|
|
661
|
+
}
|
|
662
|
+
function sameSnapshot(a, b) {
|
|
663
|
+
if (!b || a.length !== b.length)
|
|
664
|
+
return false;
|
|
665
|
+
return a.every((item, index) => item.service === b[index].service && item.launch_id === b[index].launch_id
|
|
666
|
+
&& item.state === b[index].state && item.exit_code === b[index].exit_code);
|
|
667
|
+
}
|