also looking at this
feat: run configured modeled cargo delivery and phase failures
#97
38 files
+2114
-30
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -275,6 +275,19 @@ jobs: | ||
| 275 | 275 | test "$actual" -eq "$expected" |
| 276 | 276 | bun -e 'const [file, scenario] = process.argv.slice(1); const raw = await Bun.file(file).text(); const s = JSON.parse(raw); if (Buffer.byteLength(raw) > 16384 || s.scenario !== scenario || s.status !== (scenario === "link-loss" ? "unqualified" : "characterized") || s.physicalFlightEnabled !== false || s.boardOutputsInhibited !== true || Object.values(s.capabilities).some(Boolean) || s.final.landingPerformed !== false) throw new Error("Invalid compiled cargo characterization")' "$RUNNER_TEMP/cargo-$scenario.json" "$scenario" |
| 277 | 277 | done |
| 278 | - name: Qualify copied cargo delivery, custody and phase failures | |
| 279 | run: bun scripts/qualify-cargo-cli.ts "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone" "$RUNNER_TEMP/cargo-delivery-cli" | |
| 280 | - uses: actions/upload-artifact@v4 | |
| 281 | if: always() | |
| 282 | with: | |
| 283 | name: cargo-delivery-cli-evidence | |
| 284 | if-no-files-found: ignore | |
| 285 | retention-days: 7 | |
| 286 | path: | | |
| 287 | ${{ runner.temp }}/cargo-delivery-cli/*.stdout | |
| 288 | ${{ runner.temp }}/cargo-delivery-cli/*.stderr | |
| 289 | ${{ runner.temp }}/cargo-delivery-cli/receipt.json | |
| 290 | ${{ runner.temp }}/cargo-delivery-cli/request.json | |
| 278 | 291 | - name: Qualify compiled environmental datasets outside the checkout |
| 279 | 292 | working-directory: ${{ runner.temp }} |
| 280 | 293 | shell: bash |
| @@ -114,9 +114,19 @@ our existing multirotor sensor, estimator, control and authority loop. | ||
| 114 | 114 | ./buddy cargo:load-bench --scenario link-loss |
| 115 | 115 | ``` |
| 116 | 116 | |
| 117 | Link loss is deliberately unqualified and exits 1. This is a [bounded physics prerequisite](docs/cargo-load.md) | |
| 118 | for [#25](https://github.com/stacksjs/drone/issues/25); delivery, physical release, custody and endurance | |
| 119 | qualification remain open. | |
| 117 | Link loss in this [bounded load characterization](docs/cargo-load.md) is deliberately unqualified | |
| 118 | and exits 1. Its capability flags remain separate from the modeled delivery mission: | |
| 119 | ||
| 120 | ```sh | |
| 121 | ./buddy cargo:mission examples/cargo-delivery.json | |
| 122 | ./buddy cargo:mission examples/cargo-delivery.json --scenario after-release-host-loss | |
| 123 | ``` | |
| 124 | ||
| 125 | The [cargo delivery guide](docs/cargo-delivery.md) covers explicit mass/CG, receiver, alternate | |
| 126 | and power declarations, captured release/custody, redundant links and post-release failures. | |
| 127 | Exit 2 retains a configured mission that did not complete a matched delivery and carrier landing. | |
| 128 | Distinct-vehicle fleet handover, electrical/measured endurance and physical/operational | |
| 129 | qualification remain open in [#25](https://github.com/stacksjs/drone/issues/25). | |
| 120 | 130 | |
| 121 | 131 | ## Agriculture sensing |
| 122 | 132 | |
| @@ -0,0 +1,25 @@ | ||
| 1 | import { defineCommand } from '@stacksjs/cli' | |
| 2 | import { printAsync, printErrorAsync } from '../../resources/functions/cli/output' | |
| 3 | import { cargoDeliveryScenarios, cargoDeliverySummary, simulateCargoDelivery } from '../../resources/functions/drone/cargo/delivery' | |
| 4 | import { readMissionFile } from '../../resources/functions/drone/missions/files' | |
| 5 | ||
| 6 | export default defineCommand({ | |
| 7 | name: 'cargo:mission <request>', | |
| 8 | description: 'Execute an explicitly configured modeled cargo mission without physical dispatch', | |
| 9 | options: { | |
| 10 | '--scenario <name>': { description: cargoDeliveryScenarios.join(', '), default: 'nominal' }, | |
| 11 | }, | |
| 12 | async handle(options, path) { | |
| 13 | try { | |
| 14 | const input = await readMissionFile(path, { maxBytes: 16384, preserveBom: true }) | |
| 15 | const summary = cargoDeliverySummary(simulateCargoDelivery({ configuration: JSON.parse(input), scenario: options.scenario })) | |
| 16 | if (!summary.demonstrationMatched || summary.status !== 'completed' || summary.delivery.outcome !== 'received' || !summary.landingContact) process.exitCode = 2 | |
| 17 | await printAsync(JSON.stringify(summary)) | |
| 18 | } | |
| 19 | catch { | |
| 20 | process.exitCode = 1 | |
| 21 | try { await printErrorAsync('Cargo mission simulation unavailable: invalid declaration, rejected route or output failure') } | |
| 22 | catch { /* Failed output remains failure even if its diagnostic pipe is closed. */ } | |
| 23 | } | |
| 24 | }, | |
| 25 | }) | |
| @@ -12,6 +12,7 @@ import AuthoritySimulate from '../app/Commands/AuthoritySimulate' | ||
| 12 | 12 | import ControlsSimulate from '../app/Commands/ControlsSimulate' |
| 13 | 13 | import CargoLoadBench from '../app/Commands/CargoLoadBench' |
| 14 | 14 | import CargoDeliver from '../app/Commands/CargoDeliver' |
| 15 | import CargoMission from '../app/Commands/CargoMission' | |
| 15 | 16 | |
| 16 | 17 | import PayloadsBench from '../app/Commands/PayloadsBench' |
| 17 | 18 | import PayloadsCapture from '../app/Commands/PayloadsCapture' |
| @@ -94,6 +95,7 @@ EstimatorReplay(app) | ||
| 94 | 95 | ControlsSimulate(app) |
| 95 | 96 | CargoLoadBench(app) |
| 96 | 97 | CargoDeliver(app) |
| 98 | CargoMission(app) | |
| 97 | 99 | AuthoritySimulate(app) |
| 98 | 100 | DriversBench(app) |
| 99 | 101 | PayloadsBench(app) |
| @@ -107,6 +107,7 @@ const config: BunPressOptions = { | ||
| 107 | 107 | { text: 'Owned prescribed application', link: '/application' }, |
| 108 | 108 | { text: 'Synthetic airborne environmental sampling', link: '/environment' }, |
| 109 | 109 | { text: 'Simulated carried-load characterization', link: '/cargo-load' }, |
| 110 | { text: 'Modeled cargo delivery and custody', link: '/cargo-delivery' }, | |
| 110 | 111 | { text: 'Simulated POI camera reframe', link: '/cinema' }, |
| 111 | 112 | ], |
| 112 | 113 | }, |
| @@ -0,0 +1,49 @@ | ||
| 1 | # 0034: Cargo missions use captured custody and the shared vehicle runtime | |
| 2 | ||
| 3 | ## Decision | |
| 4 | ||
| 5 | Build cargo delivery on `VehicleMissionPlan`, `VehiclePayloadPort`, the owned quad-X | |
| 6 | plant and the existing raw sensor/estimator/control/authority/watchdog loop. Keep | |
| 7 | point-mass dynamics and independent parcel motion in the model, and put release and | |
| 8 | custody policy behind a separate modeled coordinator. Plant truth cannot mutate | |
| 9 | policy custody directly. | |
| 10 | ||
| 11 | The public simulator accepts an explicit configuration for load/CG, receiver, release | |
| 12 | height, alternate and declared power budget. A pure planner validates the complete | |
| 13 | normal/alternate graph before allocating a world. Native Buddy commands provide the | |
| 14 | application entrypoint; the standalone CLI registers the same command implementation. | |
| 15 | No framework package abstraction is introduced for cargo-specific policy. | |
| 16 | ||
| 17 | Release is a single scoped operation after qualified captured dwell. Intent, mechanism | |
| 18 | open/jam, receiver receipt and carrier completion are distinct observations. Unknown | |
| 19 | dispatch remains uncertain. Terminal uncertainty cancels before discarding its event; | |
| 20 | authority loss and close retain the same cancellation path. Receipt requires matching | |
| 21 | event/container/site/recipient evidence and a qualified finite capture-time ACK pose. | |
| 22 | ||
| 23 | The shared mission engine's optional cargo payload failure edge is a finite, one-way | |
| 24 | alternate approach and landing. Its budget includes that path. It cannot retry release, | |
| 25 | replace the authority fence or bypass a source/authority failure. | |
| 26 | ||
| 27 | Two captured link sources belong to the same vehicle/boot/session scope. Switching | |
| 28 | between them does not transfer mission authority or parcel custody to another vehicle. | |
| 29 | Foreign/replayed/stale packets and complete source loss cannot silently renew authority. | |
| 30 | ||
| 31 | Post-release and post-receipt fault fixtures bind to observed policy events. Independent | |
| 32 | settling continues only board/plant physics after host processing stops. Later physical | |
| 33 | parcel contact cannot create a host receipt, and an earlier receipt cannot fabricate | |
| 34 | carrier landing completion. | |
| 35 | ||
| 36 | ## Limits and further work | |
| 37 | ||
| 38 | The actual executable world remains a flat local fixture with the existing synthetic | |
| 39 | GNSS origin and initial-north heading assumption. Its point parcel has no drag or | |
| 40 | collision scene. The current power calculation is a declared ceiling budget, separate | |
| 41 | from measured or modeled electrical consumption. Physical and authenticated-identity | |
| 42 | flags remain false. | |
| 43 | ||
| 44 | Distinct-vehicle fleet handover requires actual separate vehicle/boot/session sources, | |
| 45 | fresh authority and custody observations, explicit transfer/acceptance and failure | |
| 46 | behavior. It is not implemented by changing a scope label or copying a successful | |
| 47 | summary. Durable cargo/runtime reconciliation, physical release/receiver validation and | |
| 48 | the separate BVLOS operational evidence case remain issue #25 work. The | |
| 49 | [cargo guide](../cargo-delivery.md) records the current command and library contracts. | |
| @@ -38,6 +38,25 @@ forgetting a lease, not a guaranteed real-time deadline. Do not automatically re | ||
| 38 | 38 | without independently checking operator authority. `close()` is idempotent. The SITL example has |
| 39 | 39 | a hard 30-second duration and a deliberately artificial target. |
| 40 | 40 | |
| 41 | ## Modeled cargo missions | |
| 42 | ||
| 43 | The cargo modules expose the owned runtime directly, without an external-autopilot dependency: | |
| 44 | ||
| 45 | - `planCargoDelivery(configuration)` in `resources/functions/drone/cargo/plan.ts` captures a | |
| 46 | complete declaration and validates the normal/alternate mission graph and declared reserve. | |
| 47 | - `simulateCargoDelivery({ configuration, scenario? })` in `cargo/delivery.ts` under the same | |
| 48 | drone folder runs its actual loaded rigid body through the raw sensors, estimator, mission, | |
| 49 | controls, authority and watchdog. Omitting configuration selects only the closed named fixture. | |
| 50 | - `cargoDeliverySummary(run)` produces a bounded compact projection, preserving actual mission, | |
| 51 | custody and host-loss outcomes. A matched failure demonstration is not delivery completion. | |
| 52 | - `createCargoDeliveryCoordinator(declaration, modeledMechanism)` in `cargo/coordinator.ts` | |
| 53 | implements `VehiclePayloadPort`, captured readiness and idempotent cancellation. Its adapter | |
| 54 | contract is synchronous and explicitly modeled, with no built-in hardware I/O. | |
| 55 | ||
| 56 | The [cargo guide](cargo-delivery.md) defines the flat synthetic origin, finite load/route bounds, | |
| 57 | one-release and receipt semantics, phase-bound faults, native CLI exit codes and remaining | |
| 58 | fleet/physical/BVLOS work. Budgets are declared power ceilings, not electrical measurements. | |
| 59 | ||
| 41 | 60 | ## Target sources |
| 42 | 61 | |
| 43 | 62 | `createGpsTargetSource({ targetId, origin, maxSpeedMps?, now? })` converts trusted phone or GNSS fixes |
| @@ -0,0 +1,184 @@ | ||
| 1 | # Modeled cargo delivery | |
| 2 | ||
| 3 | The owned quadcopter runtime can execute a delivery mission with a carried point mass, | |
| 4 | captured release observations, receiver confirmation, a finite alternate landing and two | |
| 5 | modeled communication sources. The raw IMU/GNSS feed, estimator, controls, authority and | |
| 6 | independent output watchdog are the same paths used by the other owned vehicle profiles. | |
| 7 | ArduPilot and PX4 are optional drivers and are not used by this simulation. | |
| 8 | ||
| 9 | This is software simulation. Physical outputs remain inhibited and identity is not | |
| 10 | authenticated. A modeled receiver receipt does not establish a real recipient, hardware | |
| 11 | release, package condition, safe aircraft operation or authorization for BVLOS transport. | |
| 12 | ||
| 13 | ## Run a declared mission | |
| 14 | ||
| 15 | ```sh | |
| 16 | ./buddy cargo:mission examples/cargo-delivery.json | |
| 17 | ./buddy cargo:mission examples/cargo-delivery.json --scenario site-blocked | |
| 18 | ./buddy cargo:mission examples/cargo-delivery.json --scenario after-release-host-loss | |
| 19 | ``` | |
| 20 | ||
| 21 | The standalone `drone cargo:mission` command has the same arguments. The request file is | |
| 22 | required. It contains a complete `CargoDeliveryConfiguration`, as shown in the repository's | |
| 23 | [fictional example](https://github.com/stacksjs/drone/blob/main/examples/cargo-delivery.json). | |
| 24 | There is no inferred destination, parcel, alternate, power declaration or farm location. | |
| 25 | ||
| 26 | | Field | Declaration | | |
| 27 | |---|---| | |
| 28 | | `version`, `id` | Version `1` and an ASCII mission identifier, at most 40 characters | | |
| 29 | | `delivery.origin` | The synthetic feed's fixed WGS84 origin: latitude/longitude `0,0`, ellipsoid height `100` m | | |
| 30 | | `delivery.site` | Explicit site, recipient and container identifiers, `positionM` and capture radius | | |
| 31 | | `delivery.releaseHeightM` | Height above the receiver plane, from 0.3 to 3 m | | |
| 32 | | `load` | Point mass greater than zero and at most 0.25 kg; body-forward offset from -0.1 to 0.1 m | | |
| 33 | | `alternateM` | An explicit LOCAL_NED landing point on the modeled zero-down ground plane | | |
| 34 | | `power` | `availableWh`, conservative `maxW` and `reservePercent` | | |
| 35 | ||
| 36 | Down is positive. The receiver and alternate down coordinate must be zero. The carrier | |
| 37 | starts and returns at the local origin. Its modeled structural datum rests 0.1 m above | |
| 38 | the ground; qualified contact, rather than an invented exact zero position, completes | |
| 39 | landing. Sites and routes must fit the existing 12 m horizontal and -5 to 0 m down fence. | |
| 40 | This flat local world does not implement real terrain, arbitrary geodetic origins or | |
| 41 | long-range navigation. | |
| 42 | ||
| 43 | Site radius is 0.1 to 2 m. Site and recipient identifiers use lowercase ASCII letters, | |
| 44 | digits and hyphens, beginning with a letter, at most 64 characters; the container is | |
| 45 | limited to 40 characters by the carried-body model. Records reject extra fields, | |
| 46 | accessors and missing values. Tuples require three finite own data elements. | |
| 47 | ||
| 48 | The complete owned mission validator checks normal and alternate graph duration and | |
| 49 | reserve before a plant is allocated. The graph includes takeoff, destination, one | |
| 50 | material-drop request, return/landing, and a one-way alternate approach/landing after | |
| 51 | payload failure. No failure path retries release. Its fixed task deadlines give a worst | |
| 52 | case of 81,520 ms. The declared power ceiling must be at least 600 W; native mission | |
| 53 | capacity, reserve and power ceilings still apply. | |
| 54 | ||
| 55 | The example's 150 g parcel is 0.04 m aft of the structural datum. Its destination is | |
| 56 | `[2, 1, 0]`, release height 1.2 m, alternate `[-1, 1, 0]`, and budget 50 Wh/700 W/35% | |
| 57 | reserve. These are modeled declarations, not a recommended physical aircraft load. | |
| 58 | ||
| 59 | ## Outcomes and command delivery | |
| 60 | ||
| 61 | `cargo:mission` returns one compact JSON document, at most 16,384 UTF8 bytes including | |
| 62 | its final LF. It retains the admitted configuration, actual outcome, load, parcel, | |
| 63 | custody, link events, reserve budget and final board/authority observations. | |
| 64 | ||
| 65 | - Exit 0 requires a matched scenario, received custody and actual modeled carrier | |
| 66 | landing completion with neutral outputs. | |
| 67 | - Exit 2 retains a modeled diversion, blocked/incomplete mission, host loss or an | |
| 68 | unmatched scenario. Received custody can coexist with a later carrier failure. | |
| 69 | - Exit 1 covers invalid input, rejected admission, unavailable results and output | |
| 70 | failures. The handler attempts one fixed diagnostic without caller data. | |
| 71 | ||
| 72 | The request is a regular file bounded to 16,384 bytes, decoded with strict UTF8. A final | |
| 73 | symlink is refused; parent paths are followed. Reading is not path authorization or an | |
| 74 | atomic snapshot of concurrent file mutation. Accept results only after both streams | |
| 75 | reach EOF and the actual exit status is known. A failed output write can leave a prefix. | |
| 76 | JSON parsing uses native last-key-wins semantics. The CLI does not persist a full runtime | |
| 77 | journal, canonical signed request or authenticated output. | |
| 78 | ||
| 79 | The existing `cargo:deliver --scenario <name>` command remains a set of closed | |
| 80 | demonstrations with its original 100 g forward load. It exits 0 for matched fault | |
| 81 | demonstrations as well as the matched nominal route; it does not mean a parcel was | |
| 82 | delivered. Invalid input, unmatched demonstrations and output failures exit 1. | |
| 83 | The separate [load bench](cargo-load.md) retains its original characterization contract. | |
| 84 | ||
| 85 | ## Release and custody | |
| 86 | ||
| 87 | The shared payload port observes qualified delayed measurements, not current plant | |
| 88 | truth. Release requires current authority, matching site/recipient evidence, attached | |
| 89 | load, stable geometry and 26 distinct fresh captures. Both received observations and | |
| 90 | source captures must span at least 500 ms. Retained packets, retiming, clock/reference | |
| 91 | changes, stale gyros or scheduler gaps break readiness. Attitude, position, covariance, | |
| 92 | issue-time position, task, reference and bounded command lifetime are checked. | |
| 93 | ||
| 94 | The mechanism is dispatched at most once. Command intent, captured open/jammed state, | |
| 95 | receiver confirmation and carrier completion remain different observations. A policy | |
| 96 | refusal cannot become received custody through ambient receipt fields. Dispatch failure | |
| 97 | remains uncertain; uncertain/rejected terminal acknowledgements cancel the scoped | |
| 98 | operation before clearing it. Authority loss and close also cancel it idempotently. | |
| 99 | ||
| 100 | The model separates the actual point mass without resetting the carrier's motion, | |
| 101 | estimator or controller. The parcel follows a ballistic point trajectory to a plane. | |
| 102 | A receiver within the declared radius produces a fictional delayed receipt, which is | |
| 103 | then captured and transported. This model has no parcel drag, wind drift, deformation, | |
| 104 | collision scene, receiver authentication or measured release mechanism. | |
| 105 | ||
| 106 | ## Links and failures | |
| 107 | ||
| 108 | `createRedundantVehicleLinks` selects primary then backup for one captured vehicle, | |
| 109 | boot and session scope. It rejects foreign scope, replay, stale/future captures and | |
| 110 | invalid clock uncertainty. Age includes uncertainty and preserves the original capture | |
| 111 | epoch. Loss of both sources latches, including across an unobserved gap. A late packet | |
| 112 | does not silently restore authority. Labels are correlations, not authentication. | |
| 113 | The cargo fixture remains one carrier with boot 55/session 123; failover between its | |
| 114 | links is not fleet handover. | |
| 115 | ||
| 116 | Named cases include recipient absence, site blockage, mechanism jam, receipt loss, | |
| 117 | foreign receipt, primary/both-link loss, independent containment and faults during | |
| 118 | the alternate. Position, IMU, vehicle-sensor, RC, kill and host-loss cases also remain. | |
| 119 | The command's help lists every exact name. | |
| 120 | ||
| 121 | `after-release-` cases cover host, position, IMU, vehicle-sensor, RC, kill and both-link | |
| 122 | loss. `after-receipt-` cases cover host, kill and position loss. Injection starts 20 ms | |
| 123 | after the policy first observes the matching mechanism-open or received-custody state, | |
| 124 | rather than at a guessed mission time. `faultInjection` retains the observation, source capture, event, actual | |
| 125 | trigger and final physical epochs. An unreached anchor stays null and unmatched. | |
| 126 | ||
| 127 | These phase cases add a 1,000 ms independent settling probe after host execution ends. | |
| 128 | Only the board and physical plant continue; sensors, estimator, mission, payload and | |
| 129 | authority do not run. A parcel can reach the receiver in that interval while custody | |
| 130 | stays uncertain because no host received its receipt. A prior confirmed receipt remains | |
| 131 | received if the carrier later fails. Ground contact in a settling probe is not controlled | |
| 132 | carrier landing. After host loss the reported authority is its last stale snapshot, | |
| 133 | while the independent board expires its output lease. | |
| 134 | ||
| 135 | ## Library interface and energy limits | |
| 136 | ||
| 137 | Import `planCargoDelivery` from `resources/functions/drone/cargo/plan.ts`, | |
| 138 | `simulateCargoDelivery` and `cargoDeliverySummary` from `cargo/delivery.ts`, and | |
| 139 | `createCargoDeliveryCoordinator` from `cargo/coordinator.ts` under the same drone folder. | |
| 140 | The simulator accepts `{ configuration, scenario? }`; omitting configuration selects | |
| 141 | only the named demonstration fixture. The planner requires a complete declaration. | |
| 142 | The coordinator implements the existing `VehiclePayloadPort` over an explicitly modeled, | |
| 143 | synchronous mechanism adapter and returns detached custody/readiness snapshots. | |
| 144 | Full simulation results retain the complete runtime path and replayable mission trace. | |
| 145 | ||
| 146 | Energy is the existing declared maximum-power budget, including the worst normal or | |
| 147 | alternate graph and reserve. It is not a motor/battery electrical model, measured Wh, | |
| 148 | temperature limit, voltage-sag prediction or Pi performance result. Changing mass changes | |
| 149 | the actual modeled dynamics; it does not identify an electrical load law. A successful | |
| 150 | budget is conditional on that ceiling and is not measured endurance qualification. | |
| 151 | ||
| 152 | ## BVLOS operations evidence | |
| 153 | ||
| 154 | Country and intended operation must be established separately from library support. | |
| 155 | Sources below were checked October 24, 2026; inspect their current requirements for the | |
| 156 | actual operation. This software issues no authorization. | |
| 157 | ||
| 158 | The FAA's [Part 107 waiver guidance](https://www.faa.gov/uas/commercial_operators/part_107_waivers) | |
| 159 | identifies BVLOS and multiple-aircraft operation as distinct waiver subjects. Its | |
| 160 | [package-delivery guidance](https://www.faa.gov/uas/advanced_operations/package_delivery_drone) | |
| 161 | describes Part 135 certification, airspace authorization and applicable exemption/waiver | |
| 162 | work for BVLOS package delivery, with operating-area, communication and collision-avoidance | |
| 163 | evidence. A source-code license or simulated receipt supplies none of those approvals. | |
| 164 | ||
| 165 | EASA's [specific-category guidance](https://www.easa.europa.eu/en/the-agency/faqs/operational-authorisation-requirements-specific-category) | |
| 166 | distinguishes applicable standard-scenario declarations from national-authority operational | |
| 167 | authorizations supported by risk assessment, including SORA/PDRA paths and separate LUC | |
| 168 | privileges. A declaration route has its own aircraft and operational conditions. | |
| 169 | ||
| 170 | Before any proposed long-range operation, the operator's evidence case must bind the actual | |
| 171 | aircraft, crew, route, containment/alternate volumes, ground risks and authority approvals. | |
| 172 | It must address qualified detect-and-avoid and non-cooperative traffic, communication and | |
| 173 | navigation coverage/latency/loss, measured endurance and weather, release hardware and | |
| 174 | recipient/site procedures, fleet responsibility transfer, emergency response, maintenance | |
| 175 | and retained actual logs. These are pending engineering/operations deliverables here. | |
| 176 | Two modeled links, selected-person camera tracking and a range obstacle simulation do not | |
| 177 | establish an airspace detect-and-avoid system. | |
| 178 | ||
| 179 | ## Remaining issue scope | |
| 180 | ||
| 181 | [#25](https://github.com/stacksjs/drone/issues/25) remains open for actual distinct-vehicle | |
| 182 | fleet handover, richer endurance models/measurements, durable cargo/runtime reconciliation, | |
| 183 | physical release/recipient validation and the separate operational evidence case. | |
| 184 | The original acceptance scope is not reduced to passing named demonstrations. | |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | # Simulated carried-load characterization |
| 2 | 2 | |
| 3 | This is the first airborne physics prerequisite for [cargo issue #25](https://github.com/stacksjs/drone/issues/25). It characterizes a carried point mass and one prescribed split through our existing multirotor sensor, estimator, controller and authority loop. Delivery missions and physical release are still unimplemented. | |
| 3 | This is the first airborne physics prerequisite for [cargo issue #25](https://github.com/stacksjs/drone/issues/25). It characterizes a carried point mass and one prescribed split through our existing multirotor sensor, estimator, controller and authority loop. This bench does not implement delivery missions; the separate [modeled cargo mission](cargo-delivery.md) provides configured delivery and custody. Physical release remains unimplemented. | |
| 4 | 4 | |
| 5 | 5 | ```sh |
| 6 | 6 | ./buddy cargo:load-bench |
| @@ -35,6 +35,11 @@ The retained source qualification passed 38 tests and 5,553 assertions across co | ||
| 35 | 35 | |
| 36 | 36 | ## Remaining roadmap |
| 37 | 37 | |
| 38 | Issue #25 remains open, with its original acceptance criteria unchecked. Next work must add a delivery mission and site/recipient confirmation, verified release hardware and ACKs, custody, measured energy reserves and alternates. Redundant communications, degraded navigation, containment, diversion and fleet handover also remain open. Operational authorization, detect-and-avoid and physical/HIL evidence require their own documented qualification. | |
| 38 | Issue #25 remains open. The separate [modeled cargo mission](cargo-delivery.md) now implements | |
| 39 | configured delivery, captured mechanism/receiver evidence and custody, declared reserve budgets, | |
| 40 | alternates, two modeled links and phase-bound fault cases. Those capabilities do not change this | |
| 41 | bench's characterization result or flags. Distinct-vehicle fleet handover, measured/electrical | |
| 42 | endurance, release hardware, operational authorization, detect-and-avoid and physical/HIL | |
| 43 | qualification remain open. | |
| 39 | 44 | |
| 40 | 45 | The result keeps `physicalFlightEnabled` and `identityAuthenticated` false, with physical board outputs inhibited. Its six delivery, mechanism verification, custody, recipient, mission and energy-margin capability flags remain false. This characterization does not authorize physical operation or long-range transport. |
| @@ -84,7 +84,7 @@ See the [scenario commands and expected outcomes](vehicles-vtol.md) before compa | ||
| 84 | 84 | | Prescribed application | Owned ground simulation for inert water or granules, with explicit implement authority | [Application](application.md) | |
| 85 | 85 | | Public safety | Synthetic incident collection, authenticated records, review and local dataset management | [Public safety](public-safety.md) | |
| 86 | 86 | | Patrol and docking | Retained modeled vehicle, explicit decisions, finite recurrence and charge opt-in | [Patrol CLI](patrol-cli.md) | |
| 87 | | Cargo and cinema | Bounded carried-load characterization and a simulated POI angular reframe | [Cargo](cargo-load.md), [cinema](cinema.md) | | |
| 87 | | Cargo and cinema | Configured modeled delivery, captured release/custody, alternates and redundant links; fleet handover remains open. Separate carried-load and POI reframe experiments | [Cargo delivery](cargo-delivery.md), [load bench](cargo-load.md), [cinema](cinema.md) | | |
| 88 | 88 | | Environmental sampling and antenna pointing | Synthetic sampling and a selected-vehicle mount model | [Environment](environment.md), [antenna](antenna.md) | |
| 89 | 89 | |
| 90 | 90 | Each guide distinguishes implemented behavior, recorded evidence and remaining |
| @@ -22,6 +22,10 @@ companion application and simulator, not a native board autopilot. | ||
| 22 | 22 | pause/resume, recorded checkpoint recovery, conditional branches, native CLI and stx viewer. |
| 23 | 23 | Whole-runtime checkpoint correlation, payload ACK reconciliation, driver translation and hardware |
| 24 | 24 | evidence remain open in [#10](https://github.com/stacksjs/drone/issues/10). |
| 25 | - Configured modeled cargo delivery uses captured release/receiver custody, finite alternate landing, | |
| 26 | declared reserves, scoped redundant links and post-release failures on the shared runtime. | |
| 27 | Distinct-vehicle fleet handover and physical/electrical/operational qualification remain in | |
| 28 | [#25](https://github.com/stacksjs/drone/issues/25). See [cargo contracts](../cargo-delivery.md). | |
| 25 | 29 | - TypeScript follow/behavior library, kinematic simulator, controller leases and envelope checks. |
| 26 | 30 | - Pi AI Camera acquisition bridge and explicit image-space target selection/loss policy, pending Pi hardware validation. |
| 27 | 31 | - An optional visual guard that holds metric follow logic on lost, unconfirmed, stale or changed visual selection. |
| @@ -29,7 +29,7 @@ means there is no native implementation yet. A protocol-compatible transport is | ||
| 29 | 29 | | Spraying, spreading, prescription maps and implement interlocks | Owned inert-material physics, separate implement leases, quantized meters, exact coverage and shutdown audit | Physical calibration, delivery, field dose/drift, local requirements and HIL remain open | [#22](https://github.com/stacksjs/drone/issues/22) | |
| 30 | 30 | | SAR, fire/disaster support and authorized police observation | Owned collection/runtime, authenticated incident roles, source-bound annotations/review, private datasets, recipient-confirmed handover, authorized workspace with live synthetic execution and deliberate source admission, and separately accepted default-disabled local purge | Sector/team responsibility and full physical/operational qualification remain open | [#23](https://github.com/stacksjs/drone/issues/23) | |
| 31 | 31 | | Perimeter patrol, scheduling, docks and charging | Partial: retained simulation, selected-pad contact, explicit partial charging, observed isolation, finite native recurrence, bounded input/summary replay and owner-only authenticated review/consumption metadata | `patrol:simulate`, `patrol:replay` with fresh consent and [four explicit review endpoints](../patrol-review.md); production scheduling, runtime handoff/recovery, weather and physical docking remain open | [#24](https://github.com/stacksjs/drone/issues/24) | |
| 32 | | Cargo, long-range/BVLOS cases and redundant links | Partial: owned carried-point-mass/CG physics, prescribed split and native link-loss characterization; delivery mission, release ACK, custody, energy margin and redundant links remain open | Native `cargo:load-bench` [simulation evidence and limits](../cargo-load.md); no physical or BVLOS qualification | [#25](https://github.com/stacksjs/drone/issues/25) | | |
| 32 | | Cargo, long-range/BVLOS cases and redundant links | Partial: configured owned delivery with actual mass/CG dynamics, captured one-release/receiver custody, counted alternate landing, declared reserve budgets, scoped primary/backup links and post-release/post-receipt failure cases; distinct-vehicle fleet handover and electrical/measured endurance remain open | Native `cargo:mission` and `cargo:deliver` [simulation contracts and BVLOS evidence gates](../cargo-delivery.md); physical control disabled, no operational authorization | [#25](https://github.com/stacksjs/drone/issues/25) | | |
| 33 | 33 | | Gimbal/camera control, smooth paths and broadcast timecode | Partial: owned hover POI angular reframe, lagged/quantized gimbal, exposure-bound modeled stills and latched target/camera/authority faults; translational paths, physical integration, lens/video and handoff remain open | Native `cinema:simulate` [simulation semantics and limits](../cinema.md); no physical/broadcast timing qualification | [#26](https://github.com/stacksjs/drone/issues/26) | |
| 34 | 34 | | Environment, wildlife, anti-poaching observation and atmospheric samples | Partial: owned two-height synthetic cell sampling, calibration/geotags, conditional uncertainty, collection-policy faults and unsigned five-file provenance/export; physical samples, wildlife/authority and actual geofence qualification remain open | Native `environment:simulate` [semantics and limits](../environment.md); human review required | [#27](https://github.com/stacksjs/drone/issues/27) | |
| 35 | 35 | | Deep feature audit, refresh and conformance gates | Pinned directory accounting and sampled behavior audit: 89 cases, including ten transport and ten camera-feedback/capture gaps; deeper review and implementation remain open | [Audit integrity and source provenance](../parity.md); 26 bounded claims, no general parity claim | [#28](https://github.com/stacksjs/drone/issues/28) | |
| @@ -0,0 +1,18 @@ | ||
| 1 | { | |
| 2 | "version": 1, | |
| 3 | "id": "operator-cargo-route", | |
| 4 | "delivery": { | |
| 5 | "origin": { "latitudeDeg": 0, "longitudeDeg": 0, "ellipsoidHeightM": 100 }, | |
| 6 | "site": { | |
| 7 | "id": "declared-site", | |
| 8 | "recipientId": "declared-recipient", | |
| 9 | "containerId": "parcel-150g", | |
| 10 | "positionM": [2, 1, 0], | |
| 11 | "radiusM": 0.4 | |
| 12 | }, | |
| 13 | "releaseHeightM": 1.2 | |
| 14 | }, | |
| 15 | "load": { "massKg": 0.15, "forwardM": -0.04 }, | |
| 16 | "alternateM": [-1, 1, 0], | |
| 17 | "power": { "availableWh": 50, "maxW": 700, "reservePercent": 35 } | |
| 18 | } | |
| @@ -5,6 +5,7 @@ export interface BoundedProcessOptions { | ||
| 5 | 5 | stdoutBytes: number |
| 6 | 6 | stderrBytes: number |
| 7 | 7 | label: string |
| 8 | strictUtf8?: boolean | |
| 8 | 9 | } |
| 9 | 10 | |
| 10 | 11 | export interface BoundedProcessResult { |
| @@ -46,7 +47,7 @@ export async function runBoundedProcess(command: string[], options: BoundedProce | ||
| 46 | 47 | try { |
| 47 | 48 | while (true) { |
| 48 | 49 | const { done, value } = await reader.read() |
| 49 | if (done) return Buffer.concat(chunks, bytes).toString('utf8') | |
| 50 | if (done) return new TextDecoder('utf-8', { fatal: options.strictUtf8 === true, ignoreBOM: true }).decode(Buffer.concat(chunks, bytes)) | |
| 50 | 51 | if (value.byteLength > limit - bytes) { |
| 51 | 52 | const error = new Error(`${options.label} ${label} exceeds byte limit`) |
| 52 | 53 | stop(error, `${label} exceeds byte limit`) |
| @@ -37,7 +37,7 @@ function record(input: unknown, fields: string[]): Record<string, unknown> { | ||
| 37 | 37 | } |
| 38 | 38 | return result |
| 39 | 39 | } |
| 40 | function captureDeclaration(input: unknown): CargoDeliveryDeclaration { | |
| 40 | export function captureCargoDeliveryDeclaration(input: unknown): CargoDeliveryDeclaration { | |
| 41 | 41 | const top = record(input, ['origin', 'site', 'releaseHeightM']), site = record(top.site, ['id', 'recipientId', 'containerId', 'positionM', 'radiusM']) |
| 42 | 42 | const origin = record(top.origin, ['latitudeDeg', 'longitudeDeg', 'ellipsoidHeightM']) |
| 43 | 43 | if (!['id', 'recipientId', 'containerId'].every(key => typeof site[key] === 'string' && /^[a-z][a-z0-9-]{0,63}$/.test(site[key] as string)) |
| @@ -59,7 +59,7 @@ function finiteTuple(value: unknown, size: number) { | ||
| 59 | 59 | |
| 60 | 60 | /** Captured modeled release/custody policy behind the shared vehicle payload port. No physical IO. */ |
| 61 | 61 | export function createCargoDeliveryCoordinator(input: unknown, mechanism: ModeledCargoMechanism) { |
| 62 | const declaration = captureDeclaration(input), { site } = declaration | |
| 62 | const declaration = captureCargoDeliveryDeclaration(input), { site } = declaration | |
| 63 | 63 | if (!mechanism || mechanism.evidence !== 'modeled-cargo-mechanism' || typeof mechanism.request !== 'function' || typeof mechanism.cancel !== 'function') throw new TypeError('cargo-delivery-modeled-mechanism') |
| 64 | 64 | const delivery: CargoDeliveryCustody = { outcome: 'not-released', containerId: site.containerId, siteId: site.id, recipientId: site.recipientId, requestedAtMs: null, mechanismCapturedAtMs: null, receiptCapturedAtMs: null, acknowledgedAtMs: null, reason: null, mechanismOutcome: 'not-requested' } |
| 65 | 65 | const measurements = createVehicleMeasurementQualifier('quadcopter') |
| @@ -1,20 +1,37 @@ | ||
| 1 | 1 | import type { SensorVector } from '../sensors/types' |
| 2 | 2 | import type { CargoBodySeparation, CargoLoadSnapshot } from './types' |
| 3 | import type { VehicleControllerContext, VehicleMissionPlan, VehiclePayloadCommand, VehiclePlant, VehicleSession } from '../vehicles/types' | |
| 3 | import type { VehicleControllerContext, VehiclePayloadCommand, VehiclePlant, VehicleSession } from '../vehicles/types' | |
| 4 | 4 | import { createRigidBody } from '../controls/physics' |
| 5 | 5 | import { multirotorModel } from '../controls/model' |
| 6 | 6 | import { createMultirotorPlant, createMultirotorProfile } from '../vehicles/multirotor/profile' |
| 7 | 7 | import { runVehicleSimulation } from '../vehicles/runtime' |
| 8 | 8 | import { createRedundantVehicleLinks } from '../vehicles/links' |
| 9 | import type { CargoDeliveryConfiguration } from './plan' | |
| 10 | import { planCargoDelivery } from './plan' | |
| 9 | 11 | import type { CargoDeliveryCustody } from './coordinator' |
| 10 | 12 | import { createCargoDeliveryCoordinator } from './coordinator' |
| 11 | 13 | |
| 12 | 14 | const flightFaults = ['position-loss', 'imu-loss', 'vehicle-sensor-loss', 'rc-override', 'kill', 'host-loss'] as const |
| 13 | export const cargoDeliveryScenarios = ['nominal', 'recipient-absent', 'site-blocked', 'mechanism-jam', 'receipt-loss', 'foreign-receipt', 'primary-link-loss', 'both-links-loss', 'authority-containment', 'diversion-position-loss', 'diversion-kill', ...flightFaults] as const | |
| 15 | const phaseFaultDefinitions = { | |
| 16 | 'after-release-host-loss': { phase: 'after-release', fault: 'hostLost' }, | |
| 17 | 'after-release-position-loss': { phase: 'after-release', fault: 'dropPosition' }, | |
| 18 | 'after-release-imu-loss': { phase: 'after-release', fault: 'dropImu' }, | |
| 19 | 'after-release-vehicle-sensor-loss': { phase: 'after-release', fault: 'dropVehicleSensor' }, | |
| 20 | 'after-release-rc-override': { phase: 'after-release', fault: 'rcOverride' }, | |
| 21 | 'after-release-kill': { phase: 'after-release', fault: 'kill' }, | |
| 22 | 'after-release-both-links-loss': { phase: 'after-release', fault: 'links' }, | |
| 23 | 'after-receipt-host-loss': { phase: 'after-receipt', fault: 'hostLost' }, | |
| 24 | 'after-receipt-kill': { phase: 'after-receipt', fault: 'kill' }, | |
| 25 | 'after-receipt-position-loss': { phase: 'after-receipt', fault: 'dropPosition' }, | |
| 26 | } as const | |
| 27 | const phaseFaults = Object.keys(phaseFaultDefinitions) as (keyof typeof phaseFaultDefinitions)[] | |
| 28 | function phaseFault(scenario: string) { return Object.hasOwn(phaseFaultDefinitions, scenario) ? phaseFaultDefinitions[scenario as keyof typeof phaseFaultDefinitions] : null } | |
| 29 | export const cargoDeliveryScenarios = ['nominal', 'recipient-absent', 'site-blocked', 'mechanism-jam', 'receipt-loss', 'foreign-receipt', 'primary-link-loss', 'both-links-loss', 'authority-containment', 'diversion-position-loss', 'diversion-kill', ...flightFaults, ...phaseFaults] as const | |
| 14 | 30 | type CargoDeliveryScenario = typeof cargoDeliveryScenarios[number] |
| 15 | 31 | interface Parcel { massKg: number, positionM: SensorVector, velocityMps: SensorVector, onSurface: boolean, capturedAtMs: number } |
| 16 | 32 | type DeliveryRelease = CargoBodySeparation & { atMs: number, eventId: string } |
| 17 | 33 | type DeliveryLinkSnapshot = ReturnType<ReturnType<typeof createRedundantVehicleLinks>['snapshot']> |
| 34 | interface DeliveryFaultInjection { phase: 'after-release' | 'after-receipt', anchoredAtMs: number | null, sourceCapturedAtMs: number | null, eventId: string | null, triggeredAtMs: number | null, independentTailMs: number, finalPhysicalAtMs: number } | |
| 18 | 35 | export interface CargoDeliveryResult { |
| 19 | 36 | version: 1 |
| 20 | 37 | evidence: 'owned-cargo-delivery-simulation' |
| @@ -23,6 +40,8 @@ export interface CargoDeliveryResult { | ||
| 23 | 40 | physicalFlightEnabled: false |
| 24 | 41 | boardOutputsInhibited: true |
| 25 | 42 | identityAuthenticated: false |
| 43 | faultInjection?: DeliveryFaultInjection | |
| 44 | configuration?: CargoDeliveryConfiguration | |
| 26 | 45 | delivery: CargoDeliveryCustody |
| 27 | 46 | initialLoad: CargoLoadSnapshot |
| 28 | 47 | finalLoad: CargoLoadSnapshot |
| @@ -32,35 +51,24 @@ export interface CargoDeliveryResult { | ||
| 32 | 51 | linkEvents: DeliveryLinkSnapshot[] |
| 33 | 52 | runtime: ReturnType<typeof runVehicleSimulation> |
| 34 | 53 | } |
| 35 | const site = Object.freeze({ id: 'fixture-receiver-site', recipientId: 'fixture-recipient', containerId: 'parcel-100g', northM: 3, eastM: 0, downM: 0, radiusM: .35 }) | |
| 36 | function inputScenario(input: unknown): CargoDeliveryScenario { | |
| 37 | if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).some(key => key !== 'scenario')) throw new TypeError('cargo-delivery-input') | |
| 38 | const d = Object.getOwnPropertyDescriptor(input, 'scenario') | |
| 39 | if (d === undefined) return 'nominal' | |
| 40 | if (!d.enumerable || !Object.hasOwn(d, 'value') || !cargoDeliveryScenarios.some(value => value === d.value)) throw new TypeError('cargo-delivery-input') | |
| 41 | return d.value | |
| 42 | } | |
| 43 | function deliveryMission(): VehicleMissionPlan { | |
| 44 | const spatial = { acceptanceM: .18, maxArrivalSpeedMps: .15, settleMs: 300, timeoutMs: 20000 } | |
| 45 | return { version: 2, vehicle: 'quadcopter', id: 'cargo-shared-delivery', frame: 'LOCAL_NED', altitude: 'ORIGIN_RELATIVE_DOWN', terrain: 'DECLARED_MODEL_SURFACE', | |
| 46 | origin: { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }, fence: { radiusM: 12, minDownM: -5, maxDownM: 0 }, rallyM: [0, 0, -1], | |
| 47 | power: { availableWh: 40, maxW: 600, reservePercent: 30 }, start: 'start', tasks: [ | |
| 48 | { id: 'start', kind: 'start', next: 'depart' }, | |
| 49 | { id: 'depart', kind: 'takeoff', positionM: [0, 0, -1], ...spatial, next: 'site' }, | |
| 50 | { id: 'site', kind: 'waypoint', positionM: [site.northM, site.eastM, -1], ...spatial, settleMs: 800, next: 'release' }, | |
| 51 | { id: 'release', kind: 'payload', operation: 'material-drop', containerId: site.containerId, timeoutMs: 1500, next: 'home', onFailure: 'alternate-approach' }, | |
| 52 | { id: 'home', kind: 'return', ...spatial, next: 'land' }, | |
| 53 | { id: 'land', kind: 'land', positionM: [0, 0, 0], ...spatial }, | |
| 54 | { id: 'alternate-approach', kind: 'waypoint', positionM: [1.5, -2, -1], ...spatial, next: 'alternate-land' }, | |
| 55 | { id: 'alternate-land', kind: 'land', positionM: [1.5, -2, 0], ...spatial }, | |
| 56 | ] } | |
| 54 | const fixtureConfiguration: CargoDeliveryConfiguration = { version: 1, id: 'cargo-shared-delivery', delivery: { origin: { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }, site: { id: 'fixture-receiver-site', recipientId: 'fixture-recipient', containerId: 'parcel-100g', positionM: [3, 0, 0], radiusM: .35 }, releaseHeightM: 1 }, load: { massKg: .1, forwardM: .08 }, alternateM: [1.5, -2, 0], power: { availableWh: 40, maxW: 600, reservePercent: 30 } } | |
| 55 | function captureInput(input: unknown) { | |
| 56 | if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).some(key => key !== 'scenario' && key !== 'configuration')) throw new TypeError('cargo-delivery-input') | |
| 57 | const d = Object.getOwnPropertyDescriptor(input, 'scenario'), config = Object.getOwnPropertyDescriptor(input, 'configuration') | |
| 58 | if (d && (!d.enumerable || !Object.hasOwn(d, 'value') || !cargoDeliveryScenarios.some(value => value === d.value)) || config && (!config.enumerable || !Object.hasOwn(config, 'value') || config.value === undefined)) throw new TypeError('cargo-delivery-input') | |
| 59 | return { scenario: (d?.value ?? 'nominal') as CargoDeliveryScenario, explicit: !!config, planned: planCargoDelivery(config ? config.value : fixtureConfiguration) } | |
| 57 | 60 | } |
| 58 | 61 | |
| 59 | 62 | /** Fictional local delivery with real modeled mass change and received, delayed evidence. No physical transport. */ |
| 60 | 63 | export function simulateCargoDelivery(input: unknown = {}): CargoDeliveryResult { |
| 61 | const scenario = inputScenario(input), flightScenario = flightFaults.find(value => value === scenario) ?? 'nominal' | |
| 64 | const captured = captureInput(input), { scenario, planned } = captured, configuration = planned.configuration | |
| 65 | const selectedSite = configuration.delivery.site | |
| 66 | const site = { ...selectedSite, northM: selectedSite.positionM[0], eastM: selectedSite.positionM[1], downM: selectedSite.positionM[2] } | |
| 67 | const flightScenario = flightFaults.find(value => value === scenario) ?? 'nominal' | |
| 68 | const selectedFault = phaseFault(scenario) | |
| 69 | const faultInjection: DeliveryFaultInjection | null = selectedFault ? { phase: selectedFault.phase, anchoredAtMs: null, sourceCapturedAtMs: null, eventId: null, triggeredAtMs: null, independentTailMs: 1000, finalPhysicalAtMs: 0 } : null | |
| 62 | 70 | const profile = createMultirotorProfile(flightScenario), baseSession = profile.createSession, baseFaults = profile.faults |
| 63 | profile.mission = deliveryMission() | |
| 71 | profile.mission = planned.mission | |
| 64 | 72 | profile.scenario = scenario |
| 65 | 73 | profile.scenarios = [...cargoDeliveryScenarios] |
| 66 | 74 | profile.name = 'Owned modeled cargo delivery' |
| @@ -69,15 +77,19 @@ export function simulateCargoDelivery(input: unknown = {}): CargoDeliveryResult | ||
| 69 | 77 | if (selected !== scenario) throw new TypeError('cargo-delivery-scenario-changed') |
| 70 | 78 | const base = baseFaults(flightScenario, atMs, phase), failureAtMs = observed?.mission.payloadFailure?.triggeredAtMs |
| 71 | 79 | const diversionFault = failureAtMs !== undefined && atMs >= failureAtMs + 200 |
| 72 | return { ...base, dropPosition: base.dropPosition || scenario === 'diversion-position-loss' && diversionFault, kill: base.kill || scenario === 'diversion-kill' && diversionFault } | |
| 80 | if (phase === 'host' && faultInjection && faultInjection.anchoredAtMs !== null && atMs >= faultInjection.anchoredAtMs + 20) faultInjection.triggeredAtMs ??= atMs | |
| 81 | const phaseActive = faultInjection !== null && faultInjection.triggeredAtMs !== null && atMs >= faultInjection.triggeredAtMs | |
| 82 | const fault = phaseActive ? selectedFault?.fault : null | |
| 83 | return { ...base, dropPosition: base.dropPosition || scenario === 'diversion-position-loss' && diversionFault || fault === 'dropPosition', kill: base.kill || scenario === 'diversion-kill' && diversionFault || fault === 'kill', | |
| 84 | hostLost: base.hostLost || fault === 'hostLost', dropImu: base.dropImu || fault === 'dropImu', dropVehicleSensor: base.dropVehicleSensor || fault === 'dropVehicleSensor', rcOverride: base.rcOverride || fault === 'rcOverride' } | |
| 73 | 85 | } |
| 74 | const body = createRigidBody({ positionM: [0, 0, -.1], groundDownM: -.1, carriedPointLoad: { id: site.containerId, massKg: .1, forwardM: .08 } }) | |
| 86 | const body = createRigidBody({ positionM: [0, 0, -.1], groundDownM: -.1, carriedPointLoad: { id: site.containerId, ...configuration.load } }) | |
| 75 | 87 | const initialLoad = body.loadSnapshot(), adapted = createMultirotorPlant(body) |
| 76 | 88 | let physicalAtMs = 600, parcel: Parcel | null = null, release: DeliveryRelease | null = null |
| 77 | 89 | let armed: VehiclePayloadCommand | null = null, observed: VehicleControllerContext | null = null |
| 78 | 90 | let receiptAtMs: number | null = null |
| 79 | 91 | let mechanismAttempt: { eventId: string, atMs: number, jammed: boolean } | null = null |
| 80 | const coordinator = createCargoDeliveryCoordinator({ origin: profile.mission.origin, site: { id: site.id, recipientId: site.recipientId, containerId: site.containerId, positionM: [site.northM, site.eastM, site.downM], radiusM: site.radiusM }, releaseHeightM: 1 }, { | |
| 92 | const coordinator = createCargoDeliveryCoordinator(configuration.delivery, { | |
| 81 | 93 | evidence: 'modeled-cargo-mechanism', |
| 82 | 94 | request(command) { armed = structuredClone(command) }, |
| 83 | 95 | cancel(eventId) { if (armed?.eventId === eventId) armed = null }, |
| @@ -120,7 +132,8 @@ export function simulateCargoDelivery(input: unknown = {}): CargoDeliveryResult | ||
| 120 | 132 | linkObservation(nowMs) { |
| 121 | 133 | linkSequence++ |
| 122 | 134 | for (const sourceId of ['primary', 'backup'] as const) { |
| 123 | if (nowMs >= 5000 && (scenario === 'both-links-loss' || sourceId === 'primary' && scenario === 'primary-link-loss')) continue | |
| 135 | if (nowMs >= 5000 && (scenario === 'both-links-loss' || sourceId === 'primary' && scenario === 'primary-link-loss') | |
| 136 | || selectedFault?.fault === 'links' && faultInjection && faultInjection.triggeredAtMs !== null && nowMs >= faultInjection.triggeredAtMs) continue | |
| 124 | 137 | linkQueue.push({ sourceId, sequence: linkSequence, capturedAtMs: nowMs, receivedAtMs: nowMs + (sourceId === 'primary' ? 20 : 40) }) |
| 125 | 138 | } |
| 126 | 139 | linkQueue.sort((a, b) => a.receivedAtMs - b.receivedAtMs) |
| @@ -147,23 +160,37 @@ export function simulateCargoDelivery(input: unknown = {}): CargoDeliveryResult | ||
| 147 | 160 | siteClear: scenario !== 'site-blocked', recipientPresent: scenario !== 'recipient-absent' && !scenario.startsWith('diversion-'), mechanismOpen: release?.outcome === 'separated', mechanismJammed: mechanismAttempt?.jammed ?? false, mechanismEventId: mechanismAttempt?.eventId ?? '', |
| 148 | 161 | receiptEventId: receiptCaptured && scenario !== 'receipt-loss' ? release?.eventId ?? '' : '', receiptContainerId: site.containerId } } |
| 149 | 162 | }, |
| 150 | createPayload: () => ({ ...coordinator.port, observe(context) { observed = structuredClone(context); coordinator.port.observe!(context) } }), | |
| 163 | createPayload: () => ({ ...coordinator.port, observe(context) { observed = structuredClone(context); coordinator.port.observe!(context) }, poll(nowMs) { | |
| 164 | const ack = coordinator.port.poll(nowMs) | |
| 165 | if (faultInjection && faultInjection.anchoredAtMs === null) { | |
| 166 | const custody = coordinator.snapshot().delivery | |
| 167 | const capturedAtMs = faultInjection.phase === 'after-receipt' ? custody.outcome === 'received' ? custody.receiptCapturedAtMs : null : custody.mechanismOutcome === 'open' ? custody.mechanismCapturedAtMs : null | |
| 168 | const eventId = observed?.measurement?.values[faultInjection.phase === 'after-receipt' ? 'receiptEventId' : 'mechanismEventId'] | |
| 169 | if (capturedAtMs !== null && typeof eventId === 'string' && eventId.length > 0) { | |
| 170 | faultInjection.anchoredAtMs = nowMs; faultInjection.sourceCapturedAtMs = capturedAtMs; faultInjection.eventId = eventId | |
| 171 | } | |
| 172 | } | |
| 173 | return ack | |
| 174 | } }), | |
| 151 | 175 | } |
| 152 | 176 | } |
| 153 | const runtime = runVehicleSimulation(profile, { seconds: 100 }) | |
| 177 | const runtime = runVehicleSimulation(profile, { seconds: 100, neutralTailMs: faultInjection?.independentTailMs ?? 0 }) | |
| 154 | 178 | const delivery = coordinator.snapshot().delivery |
| 155 | 179 | return structuredClone({ version: 1, evidence: 'owned-cargo-delivery-simulation' as const, scenario, status: runtime.status === 'completed' && runtime.mission.payloadFailure ? 'diverted' as const : runtime.status, physicalFlightEnabled: false as const, boardOutputsInhibited: true as const, identityAuthenticated: false as const, |
| 156 | delivery, initialLoad, finalLoad: body.loadSnapshot(), release, parcel, links, linkEvents, runtime }) | |
| 180 | ...(captured.explicit ? { configuration } : {}), ...(faultInjection ? { faultInjection: { ...faultInjection, finalPhysicalAtMs: physicalAtMs } } : {}), delivery, initialLoad, finalLoad: body.loadSnapshot(), release, parcel, links, linkEvents, runtime }) | |
| 157 | 181 | } |
| 158 | 182 | |
| 159 | 183 | /** A bounded command projection. Matches describe these model cases, never physical delivery. */ |
| 160 | 184 | export function cargoDeliverySummary(run: CargoDeliveryResult) { |
| 185 | const configuration = run.configuration ?? fixtureConfiguration, selectedSite = configuration.delivery.site | |
| 186 | const site = { northM: selectedSite.positionM[0], eastM: selectedSite.positionM[1], radiusM: selectedSite.radiusM } | |
| 161 | 187 | const rt = run.runtime, power = rt.trace.plan.power, measurements = rt.path.flatMap(row => row.measurement ? [row.measurement] : []) |
| 162 | 188 | const landingContact = rt.mission.state === 'completed' && rt.trace.rows.at(-1)?.observation.onGround === true |
| 163 | 189 | const neutral = rt.board.actuators.length === 4 && rt.board.actuators.every(n => n === 0) && rt.board.expiresAtMs === null |
| 164 | 190 | && (rt.authority.state === 'disarmed' || rt.authority.state === 'killed' || rt.authority.state === 'failsafe' || rt.status === 'host-lost' && rt.board.fault === 'output-lease-expired') |
| 165 | 191 | const separationSeen = run.release?.outcome === 'separated' && run.finalLoad.properties.phase === 'separated' && measurements.some(m => m.values.loadAttached === false && m.values.loadMassKg === 1.5) |
| 166 | 192 | const receiptEvent = run.release?.eventId |
| 193 | const selectedFault = phaseFault(run.scenario) | |
| 167 | 194 | let worldMatched = false |
| 168 | 195 | if (run.scenario === 'nominal' || run.scenario === 'primary-link-loss') worldMatched = separationSeen && run.delivery.outcome === 'received' && run.status === 'completed' |
| 169 | 196 | && (run.scenario !== 'primary-link-loss' || run.linkEvents.some(event => event.selectedSource === 'backup') && run.links.selectedSource === 'backup' && !run.links.latchedLoss) |
| @@ -180,12 +207,20 @@ export function cargoDeliverySummary(run: CargoDeliveryResult) { | ||
| 180 | 207 | else if (run.scenario === 'rc-override') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.events.some(event => event.to === 'manual' && event.reason === 'rc-override') |
| 181 | 208 | else if (run.scenario === 'kill') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.state === 'killed' |
| 182 | 209 | else if (run.scenario === 'host-loss') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.lastCheckedAtMs === 4980 && rt.board.fault === 'output-lease-expired' |
| 210 | else if (selectedFault) { | |
| 211 | const injection = run.faultInjection, fault = selectedFault.fault | |
| 212 | const observedFault = fault === 'hostLost' ? rt.board.fault === 'output-lease-expired' && rt.mission.state === 'running' | |
| 213 | : rt.authority.events.some(event => fault === 'kill' ? event.to === 'killed' : fault === 'rcOverride' ? event.to === 'manual' : fault === 'links' ? event.reason === 'link-loss' : fault === 'dropPosition' ? ['estimator-loss', 'stale-estimator'].includes(event.reason) : event.reason === 'stale-interlocks') | |
| 214 | worldMatched = separationSeen && run.parcel?.onSurface === true && observedFault && !!injection && injection.anchoredAtMs !== null && injection.triggeredAtMs !== null && injection.triggeredAtMs >= injection.anchoredAtMs + 20 | |
| 215 | && injection.eventId === receiptEvent && injection.sourceCapturedAtMs !== null && injection.sourceCapturedAtMs >= run.release!.atMs && injection.sourceCapturedAtMs <= injection.anchoredAtMs | |
| 216 | && (selectedFault.phase === 'after-receipt' ? run.delivery.outcome === 'received' && run.delivery.receiptCapturedAtMs !== null : run.delivery.outcome === 'uncertain' && run.delivery.receiptCapturedAtMs === null) | |
| 217 | } | |
| 183 | 218 | else if (run.scenario === 'authority-containment') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.events.some(event => event.reason === 'invalid-target-or-lease') && rt.path.every(row => Math.hypot(row.truthPositionM[0], row.truthPositionM[1]) <= 2) |
| 184 | 219 | else if (run.scenario.startsWith('diversion-')) worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.mission.payloadFailure?.reason === 'rejected' |
| 185 | 220 | && rt.authority.events.some(event => event.atMs !== null && event.atMs >= rt.mission.payloadFailure!.triggeredAtMs + 200 && (run.scenario === 'diversion-kill' ? event.to === 'killed' : ['estimator-loss', 'stale-estimator'].includes(event.reason))) |
| 186 | const expectedStatus = run.scenario === 'nominal' || run.scenario === 'primary-link-loss' ? 'completed' : run.scenario === 'host-loss' ? 'host-lost' : run.scenario === 'both-links-loss' || run.scenario === 'authority-containment' || run.scenario.startsWith('diversion-') || flightFaults.some(value => value === run.scenario) ? 'blocked' : 'diverted' | |
| 221 | const expectedStatus = run.scenario === 'nominal' || run.scenario === 'primary-link-loss' ? 'completed' : run.scenario === 'host-loss' || selectedFault?.fault === 'hostLost' ? 'host-lost' : selectedFault || run.scenario === 'both-links-loss' || run.scenario === 'authority-containment' || run.scenario.startsWith('diversion-') || flightFaults.some(value => value === run.scenario) ? 'blocked' : 'diverted' | |
| 187 | 222 | const summary = structuredClone({ version: 1, evidence: run.evidence, scenario: run.scenario, status: run.status, demonstrationMatched: worldMatched && run.status === expectedStatus && (!['completed', 'diverted'].includes(expectedStatus) || landingContact) && neutral, |
| 188 | physicalFlightEnabled: false, boardOutputsInhibited: true, identityAuthenticated: false, delivery: run.delivery, landingContact, | |
| 223 | physicalFlightEnabled: false, boardOutputsInhibited: true, identityAuthenticated: false, ...(run.configuration ? { configuration } : {}), ...(run.faultInjection ? { faultInjection: run.faultInjection } : {}), delivery: run.delivery, landingContact, | |
| 189 | 224 | load: { initial: run.initialLoad.properties, final: run.finalLoad.properties }, |
| 190 | 225 | release: run.release ? { atMs: run.release.atMs, eventId: run.release.eventId, outcome: run.release.outcome, reason: run.release.reason } : null, |
| 191 | 226 | parcel: run.parcel, links: run.links, linkEvents: run.linkEvents, |
| @@ -194,7 +229,7 @@ export function cargoDeliverySummary(run: CargoDeliveryResult) { | ||
| 194 | 229 | worstCaseMs: rt.budget.worstCaseMs, worstCaseRequiredWh: rt.budget.requiredWh, marginAboveReserveWh: power.availableWh * (1 - power.reservePercent / 100) - rt.budget.requiredWh }, |
| 195 | 230 | board: { actuators: [...rt.board.actuators], expiresAtMs: rt.board.expiresAtMs, fault: rt.board.fault, physicalActuationEnabled: rt.board.physicalActuationEnabled }, |
| 196 | 231 | authority: { hostAvailable: rt.status !== 'host-lost', state: rt.authority.state, reason: rt.authority.reason, lastCheckedAtMs: rt.authority.lastCheckedAtMs, leaseUntilMs: rt.authority.leaseUntilMs }, |
| 197 | model: { loadMassKg: .1, loadForwardM: .08, separation: 'zero-ejection-point-mass', parcel: 'ballistic-point-with-plane-capture', receiver: 'fictional-100ms-receipt', measurementDelayMs: 40, headingAlignment: 'initial-simulation-north' }, | |
| 232 | model: { loadMassKg: configuration.load.massKg, loadForwardM: configuration.load.forwardM, separation: 'zero-ejection-point-mass', parcel: 'ballistic-point-with-plane-capture', receiver: 'fictional-100ms-receipt', measurementDelayMs: 40, headingAlignment: 'initial-simulation-north' }, | |
| 198 | 233 | }) |
| 199 | 234 | if (new TextEncoder().encode(JSON.stringify(summary)).byteLength + 1 > 16384) throw new RangeError('cargo-delivery-summary-unavailable') |
| 200 | 235 | return summary |
| @@ -0,0 +1,58 @@ | ||
| 1 | import type { SensorVector } from '../sensors/types' | |
| 2 | import type { VehicleMissionPlan } from '../vehicles/types' | |
| 3 | import type { CargoDeliveryDeclaration } from './coordinator' | |
| 4 | import { captureCargoDeliveryDeclaration } from './coordinator' | |
| 5 | import { validateVehicleMission } from '../missions/vehicle' | |
| 6 | ||
| 7 | export interface CargoDeliveryConfiguration { | |
| 8 | version: 1 | |
| 9 | id: string | |
| 10 | delivery: CargoDeliveryDeclaration | |
| 11 | load: { massKg: number, forwardM: number } | |
| 12 | alternateM: SensorVector | |
| 13 | power: VehicleMissionPlan['power'] | |
| 14 | } | |
| 15 | function record(value: unknown, fields: string[]) { | |
| 16 | if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype || Reflect.ownKeys(value).length !== fields.length) throw new TypeError('cargo-delivery-configuration') | |
| 17 | const result: Record<string, unknown> = {} | |
| 18 | for (const field of fields) { | |
| 19 | const descriptor = Object.getOwnPropertyDescriptor(value, field) | |
| 20 | if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) throw new TypeError('cargo-delivery-configuration') | |
| 21 | result[field] = descriptor.value | |
| 22 | } | |
| 23 | return result | |
| 24 | } | |
| 25 | ||
| 26 | /** Explicit modeled route admission, before allocating a plant or transport. */ | |
| 27 | export function planCargoDelivery(input: unknown) { | |
| 28 | const top = record(input, ['version', 'id', 'delivery', 'load', 'alternateM', 'power']) | |
| 29 | const delivery = captureCargoDeliveryDeclaration(top.delivery), load = record(top.load, ['massKg', 'forwardM']), power = record(top.power, ['availableWh', 'maxW', 'reservePercent']) | |
| 30 | if (top.version !== 1 || typeof top.id !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(top.id) | |
| 31 | || delivery.origin.latitudeDeg !== 0 || delivery.origin.longitudeDeg !== 0 || delivery.origin.ellipsoidHeightM !== 100 || delivery.site.positionM[2] !== 0 || delivery.site.containerId.length > 40 | |
| 32 | || typeof load.massKg !== 'number' || !Number.isFinite(load.massKg) || load.massKg <= 0 || load.massKg > .25 | |
| 33 | || typeof load.forwardM !== 'number' || !Number.isFinite(load.forwardM) || load.forwardM < -.1 || load.forwardM > .1 | |
| 34 | || !Object.values(power).every(n => typeof n === 'number' && Number.isFinite(n)) || (power.maxW as number) < 600 | |
| 35 | || !Array.isArray(top.alternateM) || top.alternateM.length !== 3 || Reflect.ownKeys(top.alternateM).length !== 4) throw new TypeError('cargo-delivery-configuration') | |
| 36 | const alternateM = [0, 1, 2].map(i => { | |
| 37 | const descriptor = Object.getOwnPropertyDescriptor(top.alternateM, String(i)) | |
| 38 | if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value') || typeof descriptor.value !== 'number' || !Number.isFinite(descriptor.value)) throw new TypeError('cargo-delivery-configuration') | |
| 39 | return descriptor.value | |
| 40 | }) as SensorVector | |
| 41 | if (alternateM[2] !== 0) throw new TypeError('cargo-delivery-configuration') | |
| 42 | const configuration: CargoDeliveryConfiguration = { version: 1, id: top.id, delivery, load: { massKg: load.massKg, forwardM: load.forwardM }, alternateM, power: power as unknown as VehicleMissionPlan['power'] } | |
| 43 | const height = delivery.releaseHeightM, site = delivery.site.positionM | |
| 44 | const spatial = { acceptanceM: .18, maxArrivalSpeedMps: .15, settleMs: 300, timeoutMs: 20000 } | |
| 45 | const proposed: VehicleMissionPlan = { version: 2, vehicle: 'quadcopter', id: configuration.id, frame: 'LOCAL_NED', altitude: 'ORIGIN_RELATIVE_DOWN', terrain: 'DECLARED_MODEL_SURFACE', | |
| 46 | origin: delivery.origin, fence: { radiusM: 12, minDownM: -5, maxDownM: 0 }, rallyM: [0, 0, -height], power: configuration.power, start: 'start', tasks: [ | |
| 47 | { id: 'start', kind: 'start', next: 'depart' }, | |
| 48 | { id: 'depart', kind: 'takeoff', positionM: [0, 0, -height], ...spatial, next: 'site' }, | |
| 49 | { id: 'site', kind: 'waypoint', positionM: [site[0], site[1], -height], ...spatial, settleMs: 800, next: 'release' }, | |
| 50 | { id: 'release', kind: 'payload', operation: 'material-drop', containerId: delivery.site.containerId, timeoutMs: 1500, next: 'home', onFailure: 'alternate-approach' }, | |
| 51 | { id: 'home', kind: 'return', ...spatial, next: 'land' }, | |
| 52 | { id: 'land', kind: 'land', positionM: [0, 0, 0], ...spatial }, | |
| 53 | { id: 'alternate-approach', kind: 'waypoint', positionM: [alternateM[0], alternateM[1], -height], ...spatial, next: 'alternate-land' }, | |
| 54 | { id: 'alternate-land', kind: 'land', positionM: [...alternateM], ...spatial }, | |
| 55 | ] } | |
| 56 | const { plan, worstCaseMs, requiredWh } = validateVehicleMission(proposed) | |
| 57 | return structuredClone({ evidence: 'modeled-cargo-delivery-plan' as const, physicalFlightEnabled: false as const, identityAuthenticated: false as const, configuration, mission: plan, budget: { worstCaseMs, requiredWh } }) | |
| 58 | } | |
| @@ -0,0 +1,51 @@ | ||
| 1 | import { chmod, copyFile, mkdir } from 'node:fs/promises' | |
| 2 | import { join, resolve } from 'node:path' | |
| 3 | import { runBoundedHostProcess } from '../resources/functions/host/process' | |
| 4 | ||
| 5 | const args = process.argv.slice(2) | |
| 6 | if (args.length !== 2) throw new Error('Usage: qualify-cargo-cli <binary> <new-output-directory>') | |
| 7 | const source = resolve(args[0]!), output = resolve(args[1]!) | |
| 8 | await mkdir(output, { mode: 0o700 }) | |
| 9 | const binary = join(output, 'drone') | |
| 10 | await copyFile(source, binary) | |
| 11 | await chmod(binary, 0o755) | |
| 12 | const request = join(output, 'request.json') | |
| 13 | await copyFile(new URL('../examples/cargo-delivery.json', import.meta.url), request) | |
| 14 | const cases: [string, string, string][] = [ | |
| 15 | ['nominal', 'completed', 'received'], ['recipient-absent', 'diverted', 'not-released'], ['site-blocked', 'diverted', 'not-released'], | |
| 16 | ['mechanism-jam', 'diverted', 'not-released'], ['receipt-loss', 'diverted', 'uncertain'], ['foreign-receipt', 'diverted', 'uncertain'], | |
| 17 | ['primary-link-loss', 'completed', 'received'], ['both-links-loss', 'blocked', 'not-released'], ['authority-containment', 'blocked', 'not-released'], | |
| 18 | ['diversion-position-loss', 'blocked', 'not-released'], ['diversion-kill', 'blocked', 'not-released'], | |
| 19 | ['position-loss', 'blocked', 'not-released'], ['imu-loss', 'blocked', 'not-released'], ['vehicle-sensor-loss', 'blocked', 'not-released'], | |
| 20 | ['rc-override', 'blocked', 'not-released'], ['kill', 'blocked', 'not-released'], ['host-loss', 'host-lost', 'not-released'], | |
| 21 | ['after-release-host-loss', 'host-lost', 'uncertain'], ['after-release-position-loss', 'blocked', 'uncertain'], ['after-release-imu-loss', 'blocked', 'uncertain'], | |
| 22 | ['after-release-vehicle-sensor-loss', 'blocked', 'uncertain'], ['after-release-rc-override', 'blocked', 'uncertain'], ['after-release-kill', 'blocked', 'uncertain'], ['after-release-both-links-loss', 'blocked', 'uncertain'], | |
| 23 | ['after-receipt-host-loss', 'host-lost', 'received'], ['after-receipt-kill', 'blocked', 'received'], ['after-receipt-position-loss', 'blocked', 'received'], | |
| 24 | ] | |
| 25 | const rows: Record<string, unknown>[] = [] | |
| 26 | const hash = (bytes: ArrayBuffer | Uint8Array) => new Bun.CryptoHasher('sha256').update(bytes).digest('hex') | |
| 27 | async function run(name: string, command: string[], expectedExit: number, scenario: string, status: string, custody: string, configured: boolean) { | |
| 28 | const result = await runBoundedHostProcess([binary, ...command], { cwd: output, timeoutMs: 30000, stdoutBytes: 16384, stderrBytes: 1024, strictUtf8: true, label: 'Compiled cargo command ' + name }) | |
| 29 | const stdout = Buffer.from(result.stdout, 'utf8'), stderr = Buffer.from(result.stderr, 'utf8'), exit = result.exitCode | |
| 30 | await Bun.write(join(output, name + '.stdout'), stdout) | |
| 31 | await Bun.write(join(output, name + '.stderr'), stderr) | |
| 32 | if (exit !== expectedExit || stderr.byteLength !== 0 || stdout.byteLength > 16384) throw new Error('Unqualified compiled cargo command: ' + name) | |
| 33 | const text = new TextDecoder('utf-8', { fatal: true }).decode(stdout) | |
| 34 | if (!text.endsWith('\n') || text.split('\n').length !== 2) throw new Error('Invalid compiled cargo wire: ' + name) | |
| 35 | const report = JSON.parse(text) | |
| 36 | if (report.evidence !== 'owned-cargo-delivery-simulation' || report.scenario !== scenario || report.status !== status || report.delivery.outcome !== custody || report.demonstrationMatched !== true | |
| 37 | || report.physicalFlightEnabled !== false || report.identityAuthenticated !== false || report.board.physicalActuationEnabled !== false || report.board.expiresAtMs !== null || JSON.stringify(report.board.actuators) !== '[0,0,0,0]' | |
| 38 | || report.landingContact !== ['completed', 'diverted'].includes(status) || report.authority.hostAvailable !== (status !== 'host-lost') || report.energy.measured !== false || report.energy.electricalModel !== false || report.energy.marginAboveReserveWh <= 0) throw new Error('Invalid compiled cargo outcome: ' + name) | |
| 39 | if (custody === 'received' && (![report.delivery.receiptCapturedAtMs, report.delivery.mechanismCapturedAtMs, report.delivery.requestedAtMs, report.delivery.acknowledgedAtMs].every(value => Number.isSafeInteger(value) && value >= 0) || !(report.delivery.receiptCapturedAtMs >= report.delivery.mechanismCapturedAtMs && report.delivery.mechanismCapturedAtMs > report.delivery.requestedAtMs && report.delivery.receiptCapturedAtMs <= report.delivery.acknowledgedAtMs)) | |
| 40 | || custody !== 'received' && report.delivery.receiptCapturedAtMs !== null) throw new Error('Invalid compiled cargo custody: ' + name) | |
| 41 | if (scenario.startsWith('after-') && (!report.faultInjection || ![report.faultInjection.triggeredAtMs, report.faultInjection.anchoredAtMs, report.faultInjection.sourceCapturedAtMs, report.faultInjection.finalPhysicalAtMs].every(value => Number.isSafeInteger(value) && value >= 0) || report.faultInjection.triggeredAtMs !== report.faultInjection.anchoredAtMs + 20 || report.faultInjection.sourceCapturedAtMs > report.faultInjection.anchoredAtMs || report.faultInjection.sourceCapturedAtMs < report.release.atMs || report.faultInjection.eventId !== report.release.eventId || report.faultInjection.finalPhysicalAtMs < report.faultInjection.triggeredAtMs + 1000 || report.faultInjection.independentTailMs !== 1000 || report.parcel.onSurface !== true)) throw new Error('Invalid compiled cargo phase: ' + name) | |
| 42 | if (configured && (report.configuration.id !== 'operator-cargo-route' || report.load.initial.totalMassKg !== 1.65 || report.model.loadMassKg !== .15 || report.model.loadForwardM !== -.04)) throw new Error('Invalid compiled cargo declaration: ' + name) | |
| 43 | if (scenario === 'primary-link-loss' && (report.links.selectedSource !== 'backup' || report.links.latchedLoss) || scenario.endsWith('both-links-loss') && !report.links.latchedLoss) throw new Error('Invalid compiled cargo link evidence: ' + name) | |
| 44 | rows.push({ name, command, exit, scenario, status, custody, stdoutBytes: stdout.byteLength, stdoutSha256: hash(stdout), configured }) | |
| 45 | console.log(name + ' PASS') | |
| 46 | } | |
| 47 | for (const [scenario, status, custody] of cases) await run(scenario, ['cargo:deliver', '--scenario', scenario], 0, scenario, status, custody, false) | |
| 48 | for (const [scenario, status, custody] of cases.filter(([name]) => ['nominal', 'site-blocked', 'after-release-host-loss', 'after-receipt-kill'].includes(name))) { | |
| 49 | await run('configured-' + scenario, ['cargo:mission', request, '--scenario', scenario], scenario === 'nominal' ? 0 : 2, scenario, status, custody, true) | |
| 50 | } | |
| 51 | await Bun.write(join(output, 'receipt.json'), JSON.stringify({ evidence: 'copied-cargo-cli-consistency', binarySha256: hash(await Bun.file(binary).arrayBuffer()), requestSha256: hash(await Bun.file(request).arrayBuffer()), physicalFlightEnabled: false, identityAuthenticated: false, rows }, null, 2)) | |
| @@ -24,8 +24,13 @@ | ||
| 24 | 24 | "../resources/functions/drone/cargo/**/*.ts", |
| 25 | 25 | "./unit/cargo-load.test.ts", |
| 26 | 26 | "./unit/cargo-delivery.test.ts", |
| 27 | "./unit/cargo-delivery-config.test.ts", | |
| 28 | "./unit/cargo-delivery-phase-faults.test.ts", | |
| 27 | 29 | "./unit/cargo-delivery-coordinator.test.ts", |
| 28 | 30 | "./unit/cargo-delivery-cli.test.ts", |
| 31 | "./unit/cargo-mission-cli.test.ts", | |
| 32 | "./unit/cargo-qualification-process.test.ts", | |
| 33 | "../scripts/qualify-cargo-cli.ts", | |
| 29 | 34 | "./unit/cargo-load-cli.test.ts", |
| 30 | 35 | "./fixtures/estimation/**/*.ts", |
| 31 | 36 | "./unit/estimation.test.ts", |
| @@ -0,0 +1,92 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { cargoDeliverySummary, simulateCargoDelivery } from '../../resources/functions/drone/cargo/delivery' | |
| 3 | import { replayVehicleMission } from '../../resources/functions/drone/missions/vehicle' | |
| 4 | import { planCargoDelivery } from '../../resources/functions/drone/cargo/plan' | |
| 5 | ||
| 6 | const configuration = { | |
| 7 | version: 1, | |
| 8 | id: 'operator-cargo-route', | |
| 9 | delivery: { origin: { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }, site: { id: 'declared-site', recipientId: 'declared-recipient', containerId: 'parcel-150g', positionM: [2, 1, 0], radiusM: .4 }, releaseHeightM: 1.2 }, | |
| 10 | load: { massKg: .15, forwardM: -.04 }, | |
| 11 | alternateM: [-1, 1, 0], | |
| 12 | power: { availableWh: 50, maxW: 700, reservePercent: 35 }, | |
| 13 | } | |
| 14 | ||
| 15 | // Existing public simulation seam: catches declared routes, actual loaded-body | |
| 16 | // and sensor/authority/payload integration; misses physical flight qualification. | |
| 17 | test('an explicit cargo configuration drives its own load, destination, release and return on the shared runtime', () => { | |
| 18 | const run = simulateCargoDelivery({ configuration }) | |
| 19 | expect(run.status).toBe('completed') | |
| 20 | expect(run.delivery).toMatchObject({ outcome: 'received', containerId: 'parcel-150g', siteId: 'declared-site', recipientId: 'declared-recipient' }) | |
| 21 | expect(run.initialLoad.properties.totalMassKg).toBe(1.65) | |
| 22 | expect(run.initialLoad.properties.cgBodyM[0]).toBeLessThan(0) | |
| 23 | expect(run.finalLoad.properties.totalMassKg).toBe(1.5) | |
| 24 | expect(run.release?.outcome).toBe('separated') | |
| 25 | expect(run.parcel?.massKg).toBe(.15) | |
| 26 | expect(run.parcel?.onSurface).toBe(true) | |
| 27 | expect(Math.hypot(run.parcel!.positionM[0] - 2, run.parcel!.positionM[1] - 1)).toBeLessThanOrEqual(.4) | |
| 28 | expect(run.runtime.trace.plan.id).toBe('operator-cargo-route') | |
| 29 | expect(run.runtime.trace.plan.power).toEqual(configuration.power) | |
| 30 | expect(run.runtime.trace.plan.tasks.find(task => task.id === 'site')).toMatchObject({ positionM: [2, 1, -1.2] }) | |
| 31 | expect(run.runtime.trace.plan.tasks.find(task => task.id === 'alternate-land')).toMatchObject({ positionM: [-1, 1, 0] }) | |
| 32 | expect(replayVehicleMission(run.runtime.trace).snapshot()).toEqual(run.runtime.mission) | |
| 33 | expect(run.runtime.board.actuators).toEqual([0, 0, 0, 0]) | |
| 34 | const summary = cargoDeliverySummary(run) | |
| 35 | expect(summary.model).toMatchObject({ loadMassKg: .15, loadForwardM: -.04 }) | |
| 36 | expect(summary.energy).toMatchObject({ capacityWh: 50, maximumDeclaredW: 700, reservePercent: 35, measured: false, electricalModel: false }) | |
| 37 | expect(summary.demonstrationMatched).toBe(true) | |
| 38 | expect(run.physicalFlightEnabled).toBe(false) | |
| 39 | expect(run.identityAuthenticated).toBe(false) | |
| 40 | }, 30000) | |
| 41 | ||
| 42 | test('a blocked configured receiver retains its actual mass and lands at the explicitly declared alternate', () => { | |
| 43 | const run = simulateCargoDelivery({ configuration, scenario: 'site-blocked' }) | |
| 44 | expect(run.status).toBe('diverted') | |
| 45 | expect(run.delivery.outcome).toBe('not-released') | |
| 46 | expect(run.release).toBeNull() | |
| 47 | expect(run.finalLoad.properties.totalMassKg).toBe(1.65) | |
| 48 | expect(run.runtime.mission.payloadFailure?.landingTaskId).toBe('alternate-land') | |
| 49 | expect(run.runtime.trace.rows.at(-1)!.observation.onGround).toBe(true) | |
| 50 | const landed = run.runtime.path.at(-1)!.truthPositionM | |
| 51 | expect(Math.hypot(landed[0] + 1, landed[1] - 1)).toBeLessThan(.18) | |
| 52 | expect(run.runtime.board.actuators).toEqual([0, 0, 0, 0]) | |
| 53 | expect(cargoDeliverySummary(run).demonstrationMatched).toBe(true) | |
| 54 | }, 30000) | |
| 55 | ||
| 56 | test('cargo planning captures a complete route and rejects unsupported geometry and unsafe budgets before execution', () => { | |
| 57 | const input = structuredClone(configuration), planned = planCargoDelivery(input) | |
| 58 | input.delivery.site.positionM[0] = 11; input.alternateM[0] = 10; input.load.massKg = .2 | |
| 59 | expect(planned.configuration.delivery.site.positionM).toEqual([2, 1, 0]) | |
| 60 | expect(planned.configuration.load.massKg).toBe(.15) | |
| 61 | expect(planned.mission.tasks.find(task => task.id === 'alternate-land')).toMatchObject({ positionM: [-1, 1, 0] }) | |
| 62 | expect(planned.budget.worstCaseMs).toBe(81520) | |
| 63 | expect(planned.physicalFlightEnabled).toBe(false) | |
| 64 | expect(planned.identityAuthenticated).toBe(false) | |
| 65 | const lowBudget = structuredClone(configuration); lowBudget.power.availableWh = 1 | |
| 66 | expect(() => planCargoDelivery(lowBudget)).toThrow() | |
| 67 | const farSite = structuredClone(configuration); farSite.delivery.site.positionM = [13, 0, 0] | |
| 68 | expect(() => planCargoDelivery(farSite)).toThrow() | |
| 69 | const farAlternate = structuredClone(configuration); farAlternate.alternateM = [13, 0, 0] | |
| 70 | expect(() => planCargoDelivery(farAlternate)).toThrow() | |
| 71 | const unmodeledOrigin = structuredClone(configuration); unmodeledOrigin.delivery.origin.latitudeDeg = 50 | |
| 72 | expect(() => planCargoDelivery(unmodeledOrigin)).toThrow('cargo-delivery-configuration') | |
| 73 | const unmodeledSurface = structuredClone(configuration); unmodeledSurface.delivery.site.positionM[2] = -.2 | |
| 74 | expect(() => planCargoDelivery(unmodeledSurface)).toThrow('cargo-delivery-configuration') | |
| 75 | const understatedPower = structuredClone(configuration); understatedPower.power.maxW = 50 | |
| 76 | expect(() => planCargoDelivery(understatedPower)).toThrow('cargo-delivery-configuration') | |
| 77 | expect(() => simulateCargoDelivery({ configuration: undefined })).toThrow('cargo-delivery-input') | |
| 78 | let reads = 0 | |
| 79 | const getter = { ...configuration, get alternateM() { reads++; return [-1, 1, 0] } } | |
| 80 | expect(() => planCargoDelivery(getter)).toThrow('cargo-delivery-configuration') | |
| 81 | expect(reads).toBe(0) | |
| 82 | }) | |
| 83 | ||
| 84 | test('a configured route that never reaches the selected containment failure retains its actual completed outcome', () => { | |
| 85 | const near = structuredClone(configuration); near.delivery.site.positionM = [1, 0, 0] | |
| 86 | const run = simulateCargoDelivery({ configuration: near, scenario: 'authority-containment' }) | |
| 87 | expect(run.status).toBe('completed') | |
| 88 | expect(run.delivery.outcome).toBe('received') | |
| 89 | expect(run.runtime.authority.events.some(event => event.reason === 'invalid-target-or-lease')).toBe(false) | |
| 90 | expect(cargoDeliverySummary(run).demonstrationMatched).toBe(false) | |
| 91 | expect(cargoDeliverySummary(run).landingContact).toBe(true) | |
| 92 | }, 30000) | |
| @@ -0,0 +1,59 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { cargoDeliverySummary, simulateCargoDelivery } from '../../resources/functions/drone/cargo/delivery' | |
| 3 | ||
| 4 | test('host loss after observed separation leaves uncertain custody while the independent parcel and board keep evolving', () => { | |
| 5 | const run = simulateCargoDelivery({ scenario: 'after-release-host-loss' }) | |
| 6 | expect(run.status).toBe('host-lost') | |
| 7 | expect(run.release?.outcome).toBe('separated') | |
| 8 | expect(run.finalLoad.properties.totalMassKg).toBe(1.5) | |
| 9 | expect(run.delivery.outcome).toBe('uncertain') | |
| 10 | expect(run.delivery.receiptCapturedAtMs).toBeNull() | |
| 11 | expect(run.parcel?.onSurface).toBe(true) | |
| 12 | expect(run.runtime.board.fault).toBe('output-lease-expired') | |
| 13 | expect(run.runtime.board.actuators).toEqual([0, 0, 0, 0]) | |
| 14 | expect(run.runtime.authority.state).toBe('autonomous') | |
| 15 | expect(run.runtime.mission.state).toBe('running') | |
| 16 | expect(run.runtime.trace.rows.some(row => row.result.reason === 'payload-confirmed')).toBe(false) | |
| 17 | const firstOpen = run.runtime.path.find(row => row.measurement?.values.mechanismOpen === true)! | |
| 18 | expect(run.faultInjection).toMatchObject({ phase: 'after-release', anchoredAtMs: firstOpen.atMs, sourceCapturedAtMs: firstOpen.measurement!.capturedAtMs, eventId: run.release!.eventId, triggeredAtMs: firstOpen.atMs + 20, independentTailMs: 1000 }) | |
| 19 | expect(run.faultInjection!.finalPhysicalAtMs).toBeGreaterThan(run.runtime.authority.lastCheckedAtMs! + 1000) | |
| 20 | const summary = cargoDeliverySummary(run) | |
| 21 | expect(summary.authority.hostAvailable).toBe(false) | |
| 22 | expect(summary.demonstrationMatched).toBe(true) | |
| 23 | expect(summary.landingContact).toBe(false) | |
| 24 | }, 30000) | |
| 25 | ||
| 26 | for (const fault of ['position-loss', 'imu-loss', 'vehicle-sensor-loss', 'rc-override', 'kill', 'both-links-loss']) test(`a post-separation ${fault} preserves uncertain custody and independent parcel motion`, () => { | |
| 27 | const run = simulateCargoDelivery({ scenario: 'after-release-' + fault }) | |
| 28 | expect(run.status).toBe('blocked') | |
| 29 | expect(run.release?.outcome).toBe('separated') | |
| 30 | expect(run.delivery.outcome).toBe('uncertain') | |
| 31 | expect(run.delivery.receiptCapturedAtMs).toBeNull() | |
| 32 | expect(run.parcel?.onSurface).toBe(true) | |
| 33 | expect(run.parcel!.capturedAtMs).toBeGreaterThan(run.runtime.trace.rows.at(-1)!.observation.nowMs) | |
| 34 | expect(run.runtime.board.actuators).toEqual([0, 0, 0, 0]) | |
| 35 | expect(run.runtime.mission.state).not.toBe('completed') | |
| 36 | expect(run.runtime.trace.rows.some(row => row.result.reason === 'payload-confirmed')).toBe(false) | |
| 37 | const firstOpen = run.runtime.path.find(row => row.measurement?.values.mechanismOpen === true)! | |
| 38 | expect(run.faultInjection).toMatchObject({ anchoredAtMs: firstOpen.atMs, sourceCapturedAtMs: firstOpen.measurement!.capturedAtMs, eventId: run.release!.eventId, triggeredAtMs: firstOpen.atMs + 20 }) | |
| 39 | const observed = run.runtime.authority.events | |
| 40 | expect(observed.some(event => fault === 'kill' ? event.to === 'killed' : fault === 'rc-override' ? event.to === 'manual' : fault === 'both-links-loss' ? event.reason === 'link-loss' : fault === 'position-loss' ? ['estimator-loss', 'stale-estimator'].includes(event.reason) : event.reason === 'stale-interlocks')).toBe(true) | |
| 41 | expect(cargoDeliverySummary(run).demonstrationMatched).toBe(true) | |
| 42 | }, 30000) | |
| 43 | ||
| 44 | for (const fault of ['host-loss', 'kill', 'position-loss']) test(`a post-receipt ${fault} preserves received custody without inventing carrier completion`, () => { | |
| 45 | const run = simulateCargoDelivery({ scenario: 'after-receipt-' + fault }) | |
| 46 | expect(run.status).toBe(fault === 'host-loss' ? 'host-lost' : 'blocked') | |
| 47 | expect(run.delivery.outcome).toBe('received') | |
| 48 | expect(run.delivery.receiptCapturedAtMs).not.toBeNull() | |
| 49 | expect(run.release?.outcome).toBe('separated') | |
| 50 | expect(run.parcel?.onSurface).toBe(true) | |
| 51 | expect(run.runtime.trace.rows.filter(row => row.observation.payloadAck?.outcome === 'confirmed')).toHaveLength(1) | |
| 52 | const acknowledged = run.runtime.trace.rows.find(row => row.observation.payloadAck?.outcome === 'confirmed')! | |
| 53 | expect(run.faultInjection).toMatchObject({ phase: 'after-receipt', anchoredAtMs: acknowledged.observation.nowMs, sourceCapturedAtMs: acknowledged.observation.payloadAck!.capturedAtMs, eventId: acknowledged.observation.payloadAck!.eventId, triggeredAtMs: acknowledged.observation.nowMs + 20 }) | |
| 54 | expect(run.delivery.receiptCapturedAtMs!).toBeGreaterThan(run.parcel!.capturedAtMs) | |
| 55 | expect(run.runtime.mission.state).not.toBe('completed') | |
| 56 | expect(run.runtime.board.actuators).toEqual([0, 0, 0, 0]) | |
| 57 | expect(cargoDeliverySummary(run).landingContact).toBe(false) | |
| 58 | expect(cargoDeliverySummary(run).demonstrationMatched).toBe(true) | |
| 59 | }, 30000) | |
| @@ -0,0 +1,73 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { command } from '@stacksjs/testing' | |
| 3 | import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { spawn } from 'node:child_process' | |
| 7 | ||
| 8 | test('native Buddy executes an explicit cargo mission file and reports the configured modeled delivery', async () => { | |
| 9 | const result = await command(['cargo:mission', 'examples/cargo-delivery.json']).withTimeout(30000) | |
| 10 | expect(result.timedOut).toBe(false) | |
| 11 | expect(result.exitCode).toBe(0) | |
| 12 | expect(result.stderr).toBe('') | |
| 13 | const report = JSON.parse(result.stdout) | |
| 14 | expect(report.status).toBe('completed') | |
| 15 | expect(report.delivery).toMatchObject({ outcome: 'received', containerId: 'parcel-150g', siteId: 'declared-site', recipientId: 'declared-recipient' }) | |
| 16 | expect(report.configuration.id).toBe('operator-cargo-route') | |
| 17 | expect(report.load.initial.totalMassKg).toBe(1.65) | |
| 18 | expect(report.load.final.totalMassKg).toBe(1.5) | |
| 19 | expect(report.landingContact).toBe(true) | |
| 20 | expect(report.board.actuators).toEqual([0, 0, 0, 0]) | |
| 21 | expect(report.physicalFlightEnabled).toBe(false) | |
| 22 | expect(report.identityAuthenticated).toBe(false) | |
| 23 | expect(result.stdout.trim().split('\n')).toHaveLength(1) | |
| 24 | expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(16384) | |
| 25 | }, 40000) | |
| 26 | ||
| 27 | test('native configured cargo reports alternate and post-release outcomes with exit2', async () => { | |
| 28 | for (const [scenario, status, custody] of [['site-blocked', 'diverted', 'not-released'], ['after-release-host-loss', 'host-lost', 'uncertain'], ['after-receipt-kill', 'blocked', 'received']]) { | |
| 29 | const result = await command(['cargo:mission', 'examples/cargo-delivery.json', '--scenario', scenario!]).withTimeout(30000) | |
| 30 | expect(result.timedOut).toBe(false) | |
| 31 | expect(result.exitCode).toBe(2) | |
| 32 | expect(result.stderr).toBe('') | |
| 33 | const report = JSON.parse(result.stdout) | |
| 34 | expect(report.status).toBe(status) | |
| 35 | expect(report.delivery.outcome).toBe(custody) | |
| 36 | expect(report.demonstrationMatched).toBe(true) | |
| 37 | expect(report.configuration.id).toBe('operator-cargo-route') | |
| 38 | expect(report.board.actuators).toEqual([0, 0, 0, 0]) | |
| 39 | } | |
| 40 | }, 100000) | |
| 41 | ||
| 42 | test('native configured cargo refuses malformed, oversized and symlink requests without caller details', async () => { | |
| 43 | const root = await mkdtemp(join(tmpdir(), 'drone-cargo-mission-')) | |
| 44 | try { | |
| 45 | const invalid = join(root, 'invalid.json'), oversized = join(root, 'oversized.json'), unicode = join(root, 'unicode.json'), link = join(root, 'link.json') | |
| 46 | await writeFile(invalid, '{"private-sentinel":"/operator/private"}') | |
| 47 | await writeFile(oversized, ' '.repeat(16385)) | |
| 48 | await writeFile(unicode, new Uint8Array([255])) | |
| 49 | await symlink(invalid, link) | |
| 50 | for (const path of [invalid, oversized, unicode, link, root, join(root, 'private-missing')]) { | |
| 51 | const result = await command(['cargo:mission', path]).withTimeout(30000) | |
| 52 | expect(result.timedOut).toBe(false) | |
| 53 | expect(result.exitCode).toBe(1) | |
| 54 | expect(result.stdout).toBe('') | |
| 55 | expect(result.stderr).toBe('Cargo mission simulation unavailable: invalid declaration, rejected route or output failure\n') | |
| 56 | } | |
| 57 | } | |
| 58 | finally { await rm(root, { recursive: true, force: true }) } | |
| 59 | }, 190000) | |
| 60 | ||
| 61 | test('a closed configured cargo stdout retains failure and awaits its fixed diagnostic', async () => { | |
| 62 | const child = spawn(process.execPath, ['bin/drone.ts', 'cargo:mission', 'examples/cargo-delivery.json'], { stdio: ['ignore', 'pipe', 'pipe'] }) | |
| 63 | child.stdout.destroy() | |
| 64 | let stderr = '', timedOut = false | |
| 65 | child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); if (Buffer.byteLength(stderr) > 8192) child.kill('SIGKILL') }) | |
| 66 | const guard = setTimeout(() => { timedOut = true; child.kill('SIGKILL') }, 30000) | |
| 67 | let code: number | null | |
| 68 | try { code = await new Promise<number | null>((resolve, reject) => { child.once('error', reject); child.once('close', resolve) }) } | |
| 69 | finally { clearTimeout(guard) } | |
| 70 | expect(timedOut).toBe(false) | |
| 71 | expect(code).toBe(1) | |
| 72 | expect(stderr).toBe('Cargo mission simulation unavailable: invalid declaration, rejected route or output failure\n') | |
| 73 | }, 40000) | |
| @@ -0,0 +1,43 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { chmod, mkdtemp, readFile, rm } from 'node:fs/promises' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join, resolve } from 'node:path' | |
| 5 | import { runBoundedHostProcess } from '../../resources/functions/host/process' | |
| 6 | ||
| 7 | // The real qualifier/child seam catches unbounded capture and leaked processes; | |
| 8 | // it does not qualify the modeled vehicle or CI runner memory consumption. | |
| 9 | for (const stream of ['stdout', 'stderr'] as const) { | |
| 10 | test(`the cargo qualifier rejects ${stream} overflow and reaps its executable`, async () => { | |
| 11 | const root = await mkdtemp(join(tmpdir(), 'drone-cargo-qualifier-')) | |
| 12 | const binary = join(root, 'fixture'), pidFile = join(root, 'child.pid') | |
| 13 | try { | |
| 14 | await Bun.write(binary, `#!/usr/bin/env bun\nawait Bun.write(${JSON.stringify(pidFile)}, String(process.pid));\nprocess.${stream}.write('x'.repeat(32768)); await Bun.sleep(60000)\n`) | |
| 15 | await chmod(binary, 0o755) | |
| 16 | const result = await runBoundedHostProcess([process.execPath, resolve('scripts/qualify-cargo-cli.ts'), binary, join(root, 'output')], { timeoutMs: 5000, stdoutBytes: 4096, stderrBytes: 16384, label: 'Cargo qualifier fixture' }) | |
| 17 | expect(result.exitCode).not.toBe(0) | |
| 18 | expect(result.stderr).toContain(`${stream} exceeds byte limit`) | |
| 19 | const pid = Number(await readFile(pidFile, 'utf8')) | |
| 20 | expect(Number.isSafeInteger(pid)).toBe(true) | |
| 21 | expect(() => process.kill(pid, 0)).toThrow() | |
| 22 | expect(await Bun.file(join(root, 'output/receipt.json')).exists()).toBe(false) | |
| 23 | expect(await Bun.file(join(root, 'output/nominal.stdout')).exists()).toBe(false) | |
| 24 | } | |
| 25 | finally { await rm(root, { recursive: true, force: true }) } | |
| 26 | }, 10000) | |
| 27 | } | |
| 28 | ||
| 29 | test('strict bounded process capture rejects malformed UTF8 after reaping the child and preserves valid bytes', async () => { | |
| 30 | const root = await mkdtemp(join(tmpdir(), 'drone-cargo-encoding-')), pidFile = join(root, 'child.pid') | |
| 31 | const options = { timeoutMs: 5000, stdoutBytes: 16384, stderrBytes: 1024, strictUtf8: true, label: 'Cargo encoding fixture' } | |
| 32 | try { | |
| 33 | await expect(runBoundedHostProcess([process.execPath, '-e', `await Bun.write(${JSON.stringify(pidFile)}, String(process.pid)); await Bun.write(Bun.stdout, new Uint8Array([255]))`], options)).rejects.toThrow() | |
| 34 | const pid = Number(await readFile(pidFile, 'utf8')) | |
| 35 | expect(Number.isSafeInteger(pid)).toBe(true) | |
| 36 | expect(() => process.kill(pid, 0)).toThrow() | |
| 37 | const result = await runBoundedHostProcess([process.execPath, '-e', `await Bun.write(Bun.stdout, new Uint8Array([239,187,191,195,169,10]))`], options) | |
| 38 | expect(result.exitCode).toBe(0) | |
| 39 | expect(Buffer.from(result.stdout, 'utf8')).toEqual(Buffer.from([239, 187, 191, 195, 169, 10])) | |
| 40 | expect(result.stderr).toBe('') | |
| 41 | } | |
| 42 | finally { await rm(root, { recursive: true, force: true }) } | |
| 43 | }, 15000) | |