also looking at this
feat: add scoped cargo receiving mission tasks
#102
21 files
+1186
-15
| @@ -30,6 +30,22 @@ jobs: | ||
| 30 | 30 | - run: bun run parity:check |
| 31 | 31 | - run: python3 -m py_compile resources/functions/drone/camera/imx500_worker.py |
| 32 | 32 | - run: python3 -m unittest discover -s tests/hardware |
| 33 | - name: Qualify startup observations with actual exited and live native processes | |
| 34 | env: | |
| 35 | BROWSER_LAUNCH_EVIDENCE: ${{ runner.temp }}/browser-launch-exited | |
| 36 | BROWSER_LAUNCH_LIVE_EVIDENCE: ${{ runner.temp }}/browser-launch-live | |
| 37 | run: bun test tests/unit/public-safety-browser-launch.test.ts tests/unit/public-safety-browser-launch-evidence.test.ts tests/unit/public-safety-browser-startup-process.test.ts | |
| 38 | - uses: actions/upload-artifact@v4 | |
| 39 | if: always() | |
| 40 | with: | |
| 41 | name: browser-launch-native-evidence | |
| 42 | if-no-files-found: ignore | |
| 43 | retention-days: 7 | |
| 44 | path: | | |
| 45 | ${{ runner.temp }}/browser-launch-exited/browser-launch.json | |
| 46 | ${{ runner.temp }}/browser-launch-exited/chrome-startup-stderr.log | |
| 47 | ${{ runner.temp }}/browser-launch-live/browser-launch.json | |
| 48 | ${{ runner.temp }}/browser-launch-live/chrome-startup-stderr.log | |
| 33 | 49 | - name: Qualify cold build setup across the former hook deadline |
| 34 | 50 | run: bun scripts/qualify-runtime-cold-build.ts "$RUNNER_TEMP/runtime-cold-build" |
| 35 | 51 | - uses: actions/upload-artifact@v4 |
| @@ -51,6 +51,7 @@ const config: BunPressOptions = { | ||
| 51 | 51 | { text: 'Raspberry Pi setup', link: '/raspberry-pi' }, |
| 52 | 52 | { text: 'Library API', link: '/api' }, |
| 53 | 53 | { text: 'Validation evidence', link: '/validation' }, |
| 54 | { text: 'Browser qualification evidence', link: '/browser-qualification' }, | |
| 54 | 55 | { text: 'Flight-development gates', link: '/flight-development' }, |
| 55 | 56 | ], |
| 56 | 57 | }, |
| @@ -108,6 +109,7 @@ const config: BunPressOptions = { | ||
| 108 | 109 | { text: 'Synthetic airborne environmental sampling', link: '/environment' }, |
| 109 | 110 | { text: 'Simulated carried-load characterization', link: '/cargo-load' }, |
| 110 | 111 | { text: 'Modeled cargo delivery and custody', link: '/cargo-delivery' }, |
| 112 | { text: 'Modeled cargo station handling', link: '/cargo-station' }, | |
| 111 | 113 | { text: 'Simulated POI camera reframe', link: '/cinema' }, |
| 112 | 114 | ], |
| 113 | 115 | }, |
| @@ -62,6 +62,11 @@ provides the separate [point-load intake prerequisite](cargo-delivery.md#point-l | ||
| 62 | 62 | It integrates real modeled mass/CG changes after one coincident inelastic point capture. |
| 63 | 63 | The caller still owns the parcel, finite station handling and captured fleet custody protocol. |
| 64 | 64 | |
| 65 | `createCargoStationHandler(declaration)` in `cargo/station.ts` owns one existing point | |
| 66 | through ballistic capture, finite rail transport, cancellation braking and a single | |
| 67 | departure. See the [station model and integration limits](cargo-station.md). It does not | |
| 68 | grant vehicle authority or confirm custody. | |
| 69 | ||
| 65 | 70 | ## Target sources |
| 66 | 71 | |
| 67 | 72 | `createGpsTargetSource({ targetId, origin, maxSpeedMps?, now? })` converts trusted phone or GNSS fixes |
| @@ -0,0 +1,80 @@ | ||
| 1 | # Browser qualification observations | |
| 2 | ||
| 3 | The rendered workspace and execution checks launch a fresh native Chrome process in the | |
| 4 | existing fixture-owned process group. They retain the original launch arguments and the | |
| 5 | 400-read, 50 ms DevToolsActivePort discovery guard. No retry or browser flag bypass is added. | |
| 6 | The full page/route/Worker scenarios must still pass. A successful native launcher fixture | |
| 7 | does not qualify those scenarios or explain an intermittent Chrome startup failure. | |
| 8 | ||
| 9 | ## Startup evidence | |
| 10 | ||
| 11 | `browser-launch.json` retains its exit/port counters and adds a `startup` observation. The | |
| 12 | observer requests samples near launch, 1, 5, 10 and 15 seconds, then at discovery end. Samples | |
| 13 | run asynchronously during discovery, with at most one actual task in flight and a 500 ms | |
| 14 | cooperative cancellation budget. Cancellation stops further process/resource reads after | |
| 15 | outstanding native calls settle. It never releases ownership of an unfinished task. A timeout | |
| 16 | disables later Linux samples and hashing, closes captured descriptors after settlement and | |
| 17 | emits a bounded pending-task notice. Finalization waits for that task; it cannot allow a page | |
| 18 | visit behind it. An uninterruptible filesystem call can hold finalization until the existing | |
| 19 | outer fixture guard fails and terminates its group. This is not a hard OS syscall deadline. | |
| 20 | Actual start/completion epochs are retained; labels are requested milestones, not | |
| 21 | promises that an event occurred at that exact time. A pre-page cleanup sample follows the | |
| 22 | existing root termination/2 second wait. No process or resource sample is requested after a | |
| 23 | page visit. Post-page stderr remains private. | |
| 24 | ||
| 25 | Each sample includes a native root PID existence probe. Linux adds selected `/proc` state: | |
| 26 | parent/group/session, start/user/system ticks, thread count, RSS and kernel wait channel. | |
| 27 | Names, argv, environment, stacks and memory contents are not exported. Main-task child | |
| 28 | traversal and previously observed descendants are capped at 64 processes per sample; root | |
| 29 | thread wait details are capped at 32. Start ticks flag observed PID reuse. Fork/exit/reparenting, | |
| 30 | permissions and truncation can make this incomplete. It is not proof of whole-group cleanup. | |
| 31 | The outer fixture owner separately terminates its isolated group before disposable removal. | |
| 32 | ||
| 33 | Expected discovery-file metadata, observer cgroup-v2 counters and system CPU/memory/IO pressure | |
| 34 | help distinguish missing files, a live process and resource contention. Cgroup counters may | |
| 35 | cover other runner work; system pressure covers other host workloads. Neither proves Chrome | |
| 36 | causality. Unsupported platforms, missing files, permission errors and observation timeouts | |
| 37 | are explicit outcomes. Numeric kernel fields are selected and bounded; raw status, cgroup | |
| 38 | membership, thread names and arbitrary text are not retained. | |
| 39 | ||
| 40 | Startup samples pin at most four distinct actual Linux `/proc/PID/exe` file descriptors | |
| 41 | without reading executable bytes. Only after discovery ends does it hash them under one | |
| 42 | 2 second cooperative hash budget, with a 512 MiB per-executable limit. Device/inode identity uses exact | |
| 43 | integer strings. Byte count and file timestamps are checked before retaining a digest; all | |
| 44 | pinned descriptors are then closed, with close errors reported. This identifies an observed | |
| 45 | backing executable rather than assuming the configured launcher is Chrome. It neither warms | |
| 46 | the binary before the attempt nor authenticates the code or its entire loaded graph. | |
| 47 | ||
| 48 | The browser version is retained only from a successful CDP version response and a closed | |
| 49 | Chrome version format. If discovery fails, it stays null. Do not infer the failed binary's | |
| 50 | version from another run without matching actual identity evidence. Opening descriptors, | |
| 51 | reading process files and hashing are observation work and can affect scheduling; the | |
| 52 | observed times and unavailable/timeout outcomes must accompany comparisons. | |
| 53 | ||
| 54 | ## Native qualification | |
| 55 | ||
| 56 | ```sh | |
| 57 | bun test tests/unit/public-safety-browser-launch.test.ts tests/unit/public-safety-browser-launch-evidence.test.ts tests/unit/public-safety-browser-startup-process.test.ts | |
| 58 | ``` | |
| 59 | ||
| 60 | One actual native fixture exits 23 before discovery. Another owns a live child until the | |
| 61 | original cleanup request. The checks preserve all 400 failed probes, inspect actual PIDs, | |
| 62 | verify parent/child shutdown and ensure argv/environment sentinels do not enter the report. | |
| 63 | On Linux the live fixture also checks actual descendant/start/RSS data and the retained | |
| 64 | executable hash against the independently read runtime binary. Optional `BROWSER_LAUNCH_EVIDENCE` | |
| 65 | and `BROWSER_LAUNCH_LIVE_EVIDENCE` retain reports in new directories. CI publishes only the | |
| 66 | allowlisted launch JSON and startup stderr, not scripts, credentials or private fixture files. | |
| 67 | A held OS-adapter promise separately verifies that cancellation cannot overlap another sample, | |
| 68 | finalize early or begin hashing while an operation remains unfinished. It does not qualify | |
| 69 | Linux field acquisition; the actual native Linux fixture provides that evidence. | |
| 70 | A Linux cancellation case also opens real executable descriptors and checks that they are | |
| 71 | closed without a digest once the held native sample settles. | |
| 72 | ||
| 73 | Linux-only observations require Linux evidence; a macOS pass does not establish them. | |
| 74 | [Issue #72](https://github.com/stacksjs/drone/issues/72) retains the original failed attempts and | |
| 75 | passing controls. Its root cause and correction remain open until reproduced and verified. | |
| 76 | These observations change evidence, not aircraft control, physical outputs or flight readiness. | |
| 77 | ||
| 78 | Field semantics follow the kernel's [proc reference](https://docs.kernel.org/filesystems/proc.html), | |
| 79 | [cgroup-v2 reference](https://docs.kernel.org/admin-guide/cgroup-v2.html) and | |
| 80 | [pressure reference](https://docs.kernel.org/accounting/psi.html). | |
| @@ -179,6 +179,46 @@ and limits. This primitive supplies no finite-duration contact forces or impact | ||
| 179 | 179 | release/intake acknowledgement, station handling, authorization or fleet custody handover. |
| 180 | 180 | Those integrations remain open under issue #25. |
| 181 | 181 | |
| 182 | ## Finite station handling prerequisite | |
| 183 | ||
| 184 | The separate [station actor](cargo-station.md) now retains one released point through | |
| 185 | ballistic surface capture, finite powered motion, cancellation braking and one departure | |
| 186 | to a declared receiving joint. Its public integration test uses actual source separation | |
| 187 | and actual receiving-body intake. The world coordinator must still consume the one parcel, | |
| 188 | check the receiver's current authority and produce correlated custody acknowledgement. | |
| 189 | This model alone does not implement a fleet mission or qualify electrical endurance. | |
| 190 | ||
| 191 | ## Receiving mission task prerequisite | |
| 192 | ||
| 193 | The shared owned mission engine accepts `material-receive` only for quadcopters, with a | |
| 194 | non-null `containerId`, a required fixed-origin `positionM` pad anchor and an integer | |
| 195 | 100 to 60,000 ms timeout. The anchor must be inside the mission fence and on or up to | |
| 196 | 1 m above its declared zero surface. Normal and optional one-way alternate landing | |
| 197 | routes contribute to the longest-path time and declared reserve calculation. | |
| 198 | ||
| 199 | Entering intake immediately creates its scoped pending command, including the first | |
| 200 | control frame when the plan starts there. The quad controller uses qualified surface | |
| 201 | contact to keep modeled motor commands neutral during intake. This avoids a one-frame | |
| 202 | spin-up while waiting for the next mission evaluation. It does not erase existing motor | |
| 203 | lag, change mass or generate an acknowledgement. | |
| 204 | ||
| 205 | Mission observations must retain contact, speed at most 0.1 m/s and position within | |
| 206 | 0.2 m of the pad. Losing these interlocks blocks receipt even if an ACK is present. | |
| 207 | A confirmed ACK still needs exact event/task/operation/container/reference correlation, | |
| 208 | a capture inside its deadline, freshness and a finite spatial check. ACK position requires | |
| 209 | three finite own coordinates; sparse arrays are rejected before acknowledgement handling. Timeout, | |
| 210 | foreign scope, rejection or uncertainty can follow a declared counted alternate route; | |
| 211 | they never advance the normal takeoff path or retry intake. A late unsolicited ACK | |
| 212 | blocks rather than reviving the original command. | |
| 213 | ||
| 214 | `tests/unit/cargo-receive-mission.test.ts` covers the native task and an actually empty | |
| 215 | receiving body in its own raw-sensor, estimator and authority runtime. With ACK withheld, | |
| 216 | it stays at the declared pad with zero actual motor thrust and no phantom parcel mass. | |
| 217 | The port that verifies an actual point intake and emits captured custody evidence is | |
| 218 | still unimplemented. A caller-invented ACK is not proof of mechanical transfer. The | |
| 219 | two-vehicle coordinator, fresh mechanism authority, cancellation/uncertainty matrix and | |
| 220 | bounded fleet CLI remain open under #25. | |
| 221 | ||
| 182 | 222 | ## BVLOS operations evidence |
| 183 | 223 | |
| 184 | 224 | |
| @@ -0,0 +1,108 @@ | ||
| 1 | # Modeled cargo station handling | |
| 2 | ||
| 3 | `createCargoStationHandler` in `resources/functions/drone/cargo/station.ts` owns one | |
| 4 | existing point-parcel record through free fall, surface capture, a finite powered rail | |
| 5 | and one departure. This is a software simulation prerequisite for | |
| 6 | [distinct-vehicle cargo handover](cargo-delivery.md#remaining-issue-scope). | |
| 7 | Physical outputs remain disabled. | |
| 8 | ||
| 9 | ## Use an actual parcel | |
| 10 | ||
| 11 | Pass the parcel produced by a carrier's actual `separateCarriedLoad()` event, adding | |
| 12 | its declared id. An empty receiving body's `intakePoint()` supplies a possible rail | |
| 13 | endpoint; it does not supply a parcel. The world coordinator must remove the previous | |
| 14 | owner when it creates the station and consume the departure exactly once. Independent | |
| 15 | station instances cannot detect a caller cloning a parcel into multiple worlds. | |
| 16 | ||
| 17 | ```ts | |
| 18 | import { createCargoStationHandler } from '../resources/functions/drone/cargo/station' | |
| 19 | ||
| 20 | const station = createCargoStationHandler({ | |
| 21 | atMs: 4, | |
| 22 | parcel: { id: 'parcel-a', massKg: 0.1, positionM: [0.08, 0, -1], velocityMps: [0, 0, 0] }, | |
| 23 | pickup: { centerM: [0.08, 0, 0], radiusM: 0.1 }, | |
| 24 | destinationM: [1.08, 0, 0], | |
| 25 | maxSpeedMps: 0.5, | |
| 26 | maxAccelerationMps2: 1, | |
| 27 | }) | |
| 28 | ``` | |
| 29 | ||
| 30 | The literal parcel above is an isolated numerical example. The integration test uses | |
| 31 | real source separation and real receiving dynamics rather than manufacturing a parcel | |
| 32 | from the receiving joint. All positions, velocities and forces use fixed-origin LOCAL_NED: | |
| 33 | metres, metres per second and newtons, with down positive. Mass is kilograms and time | |
| 34 | is one modeled local clock in milliseconds. | |
| 35 | ||
| 36 | ## Lifecycle and clock | |
| 37 | ||
| 38 | The constructor captures strict own-data records and dense finite three-element arrays. | |
| 39 | Unknown fields, accessors and invalid declarations throw `cargo-station-constructor`. | |
| 40 | Later caller mutation cannot alter retained state. Bounds are: | |
| 41 | ||
| 42 | - Safe nonnegative initial epoch aligned to 4 ms, leaving 180,000 ms of safe clock range. | |
| 43 | - Parcel id matching `[a-z][a-z0-9-]{0,39}`, mass greater than zero and at most 0.25 kg. | |
| 44 | - Declared coordinates within +/-1,000 m; initial velocity components within +/-5 m/s. | |
| 45 | - Horizontal pickup radius 0.05 to 2 m. The infinite rough capture plane has down in [-1, 0] m; | |
| 46 | the parcel starts on that plane or up to 3 m above it. | |
| 47 | - Destination on or up to 1 m above the plane. Center-to-destination distance plus pickup | |
| 48 | radius is at most 20 m, with worst declared rail duration at most 120,000 ms. | |
| 49 | - Spatial speed 0.05 to 1 m/s and acceleration 0.05 to 2 m/s². | |
| 50 | ||
| 51 | Call `step(previousAtMs + 4)` to advance the actor. Repeated, skipped, backward, fractional | |
| 52 | or exhausted epochs throw `cargo-station-clock` before mutation. The lifetime is at most | |
| 53 | 180,000 ms from construction; the owning world must bound its run accordingly. | |
| 54 | ||
| 55 | Free fall uses exact constant-gravity kinematics and a quadratic within-step contact time. | |
| 56 | The ideal rough plane stops all velocity components. The retained impact contains its | |
| 57 | fractional contact epoch, actual point, incoming velocity, impulse and kinetic loss. | |
| 58 | The snapshot epoch is the enclosing 4 ms step endpoint. No force peak or impact duration | |
| 59 | is invented. A point outside the declared horizontal disc becomes `missed` at its actual | |
| 60 | contact location and cannot start transport. | |
| 61 | ||
| 62 | `start()` accepts only `held`, with enough lifetime for the full rail path. It never | |
| 63 | retries, moves a missed point onto the rail or starts during free fall. Rail motion uses | |
| 64 | a rest-to-rest quintic position law with derived duration respecting both declared spatial | |
| 65 | limits. The parcel follows the whole path; its final analytic endpoint is the declared | |
| 66 | position with zero velocity. A zero-length route still has a finite 504 ms duration. | |
| 67 | ||
| 68 | `cancel()` is one-way. A moving parcel enters `braking`, with constant deceleration at | |
| 69 | the declared acceleration limit until it stops at the physically integrated location. | |
| 70 | A falling parcel continues under gravity until plane contact. These requests return | |
| 71 | `cancelling`; `cancelled` means it was already at rest. Snapshots distinguish the request | |
| 72 | epoch, current phase and fractional stop epoch. Cancellation retains the parcel and | |
| 73 | prevents transfer; it does not restore an aircraft's authority or land a vehicle. | |
| 74 | ||
| 75 | `takeReadyParcel()` accepts only `ready`, returns a detached parcel and clears the | |
| 76 | station's owned mass. Further departure attempts are refused. The receiving body's | |
| 77 | `attachParcel()` still independently checks its actual current joint. A stale destination | |
| 78 | can therefore fail intake; the world must retain the departed parcel and an uncertain | |
| 79 | outcome rather than moving or duplicating it to make receipt succeed. | |
| 80 | ||
| 81 | ## Mechanical accounting | |
| 82 | ||
| 83 | Snapshots retain current and departed mass/energy, rail force, impact loss and work. | |
| 84 | Mechanical energy is `0.5 * mass * speed² - mass * gravity * down`. Free fall conserves | |
| 85 | that quantity to binary64 accumulation tolerance. Surface capture dissipates the incoming | |
| 86 | kinetic energy. Rail force includes gravity support; lifting increases potential energy. | |
| 87 | `stationNetWorkJ` accumulates the represented mechanical-energy change during rail motion | |
| 88 | and braking. Positive and negative work fields sum positive and signed negative **sampled | |
| 89 | energy increments**, not a continuous actuator-efficiency or power integration. | |
| 90 | ||
| 91 | The residual is `current + departed + planeDissipation - initial - stationNetWork`. | |
| 92 | It exposes numerical accumulation instead of substituting a declared battery budget. | |
| 93 | This model has no motor, gearbox, battery, measured efficiency, station recoil, parcel | |
| 94 | spin, deformation or physical mechanism qualification. | |
| 95 | ||
| 96 | ## Evidence and remaining integration | |
| 97 | ||
| 98 | `tests/unit/cargo-station.test.ts` exercises actual source/station/receiver mass transfer, | |
| 99 | finite displacement, a worked midpoint, ballistic contact time/impulse/energy, pickup miss, | |
| 100 | cancellation during rising flight and powered motion, spatial speed/acceleration bounds, | |
| 101 | vertical work, strict admission, detached state and lifetime rejection. | |
| 102 | ||
| 103 | This actor does not grant a mechanism lease, authenticate a recipient, produce a custody | |
| 104 | ACK or coordinate two live vehicle mission engines. Those operations must use each | |
| 105 | vehicle's own sensor, estimator and current authority scope on the common clock. The | |
| 106 | [receiving mission task](cargo-delivery.md#receiving-mission-task-prerequisite) now holds | |
| 107 | a declared pad under the shared engine. Its physical-intake acknowledgement port, the | |
| 108 | two-vehicle custody protocol, cancellation/uncertainty matrix and bounded fleet CLI remain open under [#25](https://github.com/stacksjs/drone/issues/25). | |
| @@ -23,3 +23,6 @@ The owned runtime follows the dependency-linked [roadmap](roadmap/README.md). Th | ||
| 23 | 23 | support comparison and development while native board controls are implemented and validated. |
| 24 | 24 | |
| 25 | 25 | Until those milestones are complete, the code here should be used for simulation and bench development. |
| 26 | ||
| 27 | Rendered application checks have a separate [browser qualification contract](browser-qualification.md). | |
| 28 | A browser-startup failure does not establish a vehicle, estimator or physical flight failure. | |
| @@ -101,6 +101,11 @@ Modeled acknowledgements preserve capture time separately from delivery. An unce | ||
| 101 | 101 | result blocks progression and is never automatically retried as a new action. Recovery during a |
| 102 | 102 | pending action also blocks. These identities do not establish exactly-once delivery on hardware. |
| 103 | 103 | |
| 104 | The opt-in quadcopter `material-receive` task adds a declared pad anchor and a bounded | |
| 105 | wait for one correlated receipt. It enters with inhibited motor commands under qualified | |
| 106 | surface contact. Its [interlocks and counted failure routes](cargo-delivery.md#receiving-mission-task-prerequisite) | |
| 107 | are distinct from the still-pending mechanical-intake and fleet custody coordinator. | |
| 108 | ||
| 104 | 109 | Surface sounding reduction retains raw acoustic corrections, local datum, capture-pose estimator |
| 105 | 110 | and gyro epochs and declared position/attitude/clock uncertainty. It projects the beam footprint and |
| 106 | 111 | rejects unqualified capture timing or uncertainty below the sensor declaration. This is a modeled |
| @@ -0,0 +1,166 @@ | ||
| 1 | import type { IntakeParcel } from '../controls/physics' | |
| 2 | import type { SensorVector } from '../sensors/types' | |
| 3 | import { multirotorModel } from '../controls/model' | |
| 4 | ||
| 5 | interface StationDeclaration { | |
| 6 | atMs: number | |
| 7 | parcel: IntakeParcel | |
| 8 | pickup: { centerM: SensorVector, radiusM: number } | |
| 9 | destinationM: SensorVector | |
| 10 | maxSpeedMps: number | |
| 11 | maxAccelerationMps2: number | |
| 12 | } | |
| 13 | export interface CargoStationSnapshot { | |
| 14 | evidence: 'modeled-cargo-station-v1' | |
| 15 | physicalActuationEnabled: false | |
| 16 | phase: 'falling' | 'held' | 'missed' | 'moving' | 'braking' | 'cancelled' | 'ready' | 'departed' | |
| 17 | atMs: number | |
| 18 | startedAtMs: number | null | |
| 19 | durationMs: number | null | |
| 20 | cancelRequestedAtMs: number | null | |
| 21 | stoppedAtMs: number | null | |
| 22 | ownedMassKg: number | |
| 23 | parcel: IntakeParcel | null | |
| 24 | forceN: SensorVector | |
| 25 | impact: { atMs: number, positionM: SensorVector, velocityBeforeMps: SensorVector, impulseNs: SensorVector, dissipationJ: number } | null | |
| 26 | energy: { initialJ: number, currentJ: number, departedJ: number, stationNetWorkJ: number, positiveWorkJ: number, negativeWorkJ: number, planeDissipationJ: number, residualJ: number } | |
| 27 | } | |
| 28 | function record(input: unknown, names: string[]) { | |
| 29 | if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).length !== names.length) throw new TypeError() | |
| 30 | const captured: Record<string, unknown> = {} | |
| 31 | for (const name of names) { | |
| 32 | const d = Object.getOwnPropertyDescriptor(input, name) | |
| 33 | if (!d?.enumerable || !Object.hasOwn(d, 'value')) throw new TypeError() | |
| 34 | captured[name] = d.value | |
| 35 | } | |
| 36 | return captured | |
| 37 | } | |
| 38 | function vector(input: unknown, min: number, max: number): SensorVector { | |
| 39 | if (!Array.isArray(input) || input.length !== 3 || Reflect.ownKeys(input).length !== 4) throw new TypeError() | |
| 40 | const captured: number[] = [] | |
| 41 | for (let i = 0; i < 3; i++) { | |
| 42 | const d = Object.getOwnPropertyDescriptor(input, String(i)) | |
| 43 | if (!d?.enumerable || !Object.hasOwn(d, 'value') || typeof d.value !== 'number' || !Number.isFinite(d.value) || d.value < min || d.value > max) throw new TypeError() | |
| 44 | captured.push(d.value) | |
| 45 | } | |
| 46 | return captured as SensorVector | |
| 47 | } | |
| 48 | const within = (v: unknown, min: number, max: number): v is number => typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max | |
| 49 | const distance = (a: SensorVector, b: SensorVector) => Math.hypot(...a.map((n, i) => n - b[i]!)) | |
| 50 | function durationMs(lengthM: number, speed: number, acceleration: number) { | |
| 51 | // Peak derivatives of 10u^3 - 15u^4 + 6u^5 are 15/8 and 10/sqrt(3). | |
| 52 | return Math.ceil(1000 * Math.max(.5, 1.875 * lengthM / speed, Math.sqrt(10 / Math.sqrt(3) * lengthM / acceleration)) / 8) * 8 | |
| 53 | } | |
| 54 | function capture(input: unknown): StationDeclaration { | |
| 55 | try { | |
| 56 | const v = record(input, ['atMs', 'parcel', 'pickup', 'destinationM', 'maxSpeedMps', 'maxAccelerationMps2']) | |
| 57 | const p = record(v.parcel, ['id', 'massKg', 'positionM', 'velocityMps']), pickup = record(v.pickup, ['centerM', 'radiusM']) | |
| 58 | if (!Number.isSafeInteger(v.atMs) || !within(v.atMs, 0, Number.MAX_SAFE_INTEGER - 180000) || v.atMs % 4 !== 0 | |
| 59 | || typeof p.id !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(p.id) || !within(p.massKg, Number.MIN_VALUE, .25) | |
| 60 | || !within(pickup.radiusM, .05, 2) || !within(v.maxSpeedMps, .05, 1) || !within(v.maxAccelerationMps2, .05, 2)) throw new TypeError() | |
| 61 | const positionM = vector(p.positionM, -1000, 1000), velocityMps = vector(p.velocityMps, -5, 5), centerM = vector(pickup.centerM, -1000, 1000), destinationM = vector(v.destinationM, -1000, 1000) | |
| 62 | if (!within(centerM[2], -1, 0) || !within(positionM[2], centerM[2] - 3, centerM[2]) || destinationM[2] < centerM[2] - 1 || destinationM[2] > centerM[2] | |
| 63 | || distance(centerM, destinationM) + pickup.radiusM > 20 | |
| 64 | || durationMs(distance(centerM, destinationM) + pickup.radiusM, v.maxSpeedMps, v.maxAccelerationMps2) > 120000) throw new TypeError() | |
| 65 | return { atMs: v.atMs, parcel: { id: p.id, massKg: p.massKg, positionM, velocityMps }, pickup: { centerM, radiusM: pickup.radiusM }, destinationM, maxSpeedMps: v.maxSpeedMps, maxAccelerationMps2: v.maxAccelerationMps2 } | |
| 66 | } | |
| 67 | catch { throw new TypeError('cargo-station-constructor') } | |
| 68 | } | |
| 69 | ||
| 70 | /** One point parcel on a prescribed, finite powered station rail. No hardware IO or custody ACK. */ | |
| 71 | export function createCargoStationHandler(input: unknown) { | |
| 72 | const config = capture(input), gravity = multirotorModel.gravityMps2 | |
| 73 | let parcel: IntakeParcel | null = config.parcel, phase: CargoStationSnapshot['phase'] = 'falling', nowMs = config.atMs | |
| 74 | let startedAtMs: number | null = null, motionMs: number | null = null, fromM: SensorVector | null = null | |
| 75 | let cancelRequestedAtMs: number | null = null, stoppedAtMs: number | null = null | |
| 76 | let brake: { atMs: number, positionM: SensorVector, velocityMps: SensorVector, speedMps: number } | null = null | |
| 77 | let forceN: SensorVector = [0, 0, 0], impact: CargoStationSnapshot['impact'] = null | |
| 78 | const mechanical = (p: IntakeParcel) => .5 * p.massKg * p.velocityMps.reduce((sum, n) => sum + n * n, 0) - p.massKg * gravity * p.positionM[2] | |
| 79 | const initialJ = mechanical(parcel) | |
| 80 | let departedJ = 0, stationNetWorkJ = 0, positiveWorkJ = 0, negativeWorkJ = 0, planeDissipationJ = 0 | |
| 81 | function capturePlane(atMs: number) { | |
| 82 | const p = parcel!, before = [...p.velocityMps] as SensorVector | |
| 83 | const dissipationJ = .5 * p.massKg * before.reduce((sum, n) => sum + n * n, 0) | |
| 84 | p.positionM[2] = config.pickup.centerM[2] | |
| 85 | impact = { atMs, positionM: [...p.positionM], velocityBeforeMps: before, impulseNs: before.map(n => -p.massKg * n) as SensorVector, dissipationJ } | |
| 86 | planeDissipationJ += dissipationJ | |
| 87 | p.velocityMps = [0, 0, 0]; forceN = [0, 0, -p.massKg * gravity] | |
| 88 | phase = cancelRequestedAtMs !== null ? 'cancelled' : Math.hypot(p.positionM[0] - config.pickup.centerM[0], p.positionM[1] - config.pickup.centerM[1]) <= config.pickup.radiusM ? 'held' : 'missed' | |
| 89 | if (cancelRequestedAtMs !== null) stoppedAtMs = atMs | |
| 90 | } | |
| 91 | if (parcel.positionM[2] === config.pickup.centerM[2] && parcel.velocityMps[2] >= 0) capturePlane(nowMs) | |
| 92 | function retainWork(beforeJ: number) { | |
| 93 | const work = mechanical(parcel!) - beforeJ | |
| 94 | stationNetWorkJ += work; positiveWorkJ += Math.max(0, work); negativeWorkJ += Math.min(0, work) | |
| 95 | } | |
| 96 | function snapshot(): CargoStationSnapshot { | |
| 97 | const currentJ = parcel ? mechanical(parcel) : 0 | |
| 98 | return structuredClone({ evidence: 'modeled-cargo-station-v1', physicalActuationEnabled: false, phase, atMs: nowMs, startedAtMs, durationMs: motionMs, cancelRequestedAtMs, stoppedAtMs, parcel, | |
| 99 | ownedMassKg: parcel?.massKg ?? 0, forceN, impact, energy: { initialJ, currentJ, departedJ, stationNetWorkJ, positiveWorkJ, negativeWorkJ, planeDissipationJ, residualJ: currentJ + departedJ + planeDissipationJ - initialJ - stationNetWorkJ } }) | |
| 100 | } | |
| 101 | return { | |
| 102 | snapshot, | |
| 103 | start(...args: unknown[]): { outcome: 'started', atMs: number, durationMs: number } | { outcome: 'refused', reason: 'not-held' | 'deadline' } { | |
| 104 | if (args.length) throw new TypeError('cargo-station-arguments') | |
| 105 | if (phase !== 'held') return { outcome: 'refused', reason: 'not-held' } | |
| 106 | const duration = durationMs(distance(parcel!.positionM, config.destinationM), config.maxSpeedMps, config.maxAccelerationMps2) | |
| 107 | if (nowMs - config.atMs + duration > 180000) return { outcome: 'refused', reason: 'deadline' } | |
| 108 | fromM = [...parcel!.positionM]; motionMs = duration; startedAtMs = nowMs; phase = 'moving' | |
| 109 | return { outcome: 'started', atMs: nowMs, durationMs: duration } | |
| 110 | }, | |
| 111 | cancel(...args: unknown[]): { outcome: 'cancelling' | 'cancelled', atMs: number } | { outcome: 'refused', reason: 'already-cancelled' | 'departed' } { | |
| 112 | if (args.length) throw new TypeError('cargo-station-arguments') | |
| 113 | if (phase === 'departed') return { outcome: 'refused', reason: 'departed' } | |
| 114 | if (cancelRequestedAtMs !== null) return { outcome: 'refused', reason: 'already-cancelled' } | |
| 115 | cancelRequestedAtMs = nowMs | |
| 116 | const speedMps = Math.hypot(...parcel!.velocityMps) | |
| 117 | if (phase === 'moving' && speedMps > 0) { | |
| 118 | brake = { atMs: nowMs, positionM: [...parcel!.positionM], velocityMps: [...parcel!.velocityMps], speedMps } | |
| 119 | phase = 'braking' | |
| 120 | } | |
| 121 | else if (phase !== 'falling') { phase = 'cancelled'; stoppedAtMs = nowMs } | |
| 122 | return { outcome: phase === 'cancelled' ? 'cancelled' : 'cancelling', atMs: nowMs } | |
| 123 | }, | |
| 124 | step(atMs: number, ...extra: unknown[]) { | |
| 125 | if (extra.length || !Number.isSafeInteger(atMs) || atMs !== nowMs + 4 || atMs - config.atMs > 180000) throw new TypeError('cargo-station-clock') | |
| 126 | if (phase === 'falling') { | |
| 127 | const p = parcel!, down = p.positionM[2] - config.pickup.centerM[2], vz = p.velocityMps[2] | |
| 128 | const speedAtPlane = Math.sqrt(vz * vz - 2 * gravity * down) | |
| 129 | // Stable positive root for downward motion, including a point already at the plane. | |
| 130 | const hitSeconds = vz >= 0 ? (down === 0 ? 0 : -2 * down / (vz + speedAtPlane)) : (-vz + speedAtPlane) / gravity | |
| 131 | const seconds = Math.min(.004, hitSeconds) | |
| 132 | p.positionM = p.positionM.map((n, i) => n + p.velocityMps[i]! * seconds + (i === 2 ? .5 * gravity * seconds ** 2 : 0)) as SensorVector | |
| 133 | p.velocityMps[2] += gravity * seconds | |
| 134 | if (hitSeconds <= .004) capturePlane(nowMs + hitSeconds * 1000) | |
| 135 | } | |
| 136 | else if (phase === 'braking') { | |
| 137 | const b = brake!, p = parcel!, beforeJ = mechanical(p), stopSeconds = b.speedMps / config.maxAccelerationMps2 | |
| 138 | const seconds = Math.min((atMs - b.atMs) / 1000, stopSeconds), fraction = Math.max(0, 1 - seconds / stopSeconds) | |
| 139 | p.positionM = b.positionM.map((n, i) => n + b.velocityMps[i]! * (seconds - .5 * seconds ** 2 / stopSeconds)) as SensorVector | |
| 140 | p.velocityMps = b.velocityMps.map(n => n * fraction) as SensorVector | |
| 141 | forceN = b.velocityMps.map((n, i) => p.massKg * (-n / stopSeconds - (i === 2 ? gravity : 0))) as SensorVector | |
| 142 | retainWork(beforeJ) | |
| 143 | if (seconds === stopSeconds) { phase = 'cancelled'; stoppedAtMs = b.atMs + stopSeconds * 1000; forceN = [0, 0, -p.massKg * gravity] } | |
| 144 | } | |
| 145 | else if (phase === 'moving') { | |
| 146 | const beforeJ = mechanical(parcel!), seconds = motionMs! / 1000, u = Math.min(1, (atMs - startedAtMs!) / motionMs!) | |
| 147 | const s = 10 * u ** 3 - 15 * u ** 4 + 6 * u ** 5, ds = 30 * u ** 2 * (1 - u) ** 2, dds = 60 * u * (1 - u) * (1 - 2 * u) | |
| 148 | const delta = config.destinationM.map((n, i) => n - fromM![i]!) as SensorVector | |
| 149 | parcel!.positionM = u === 1 ? [...config.destinationM] : fromM!.map((n, i) => n + delta[i]! * s) as SensorVector | |
| 150 | parcel!.velocityMps = delta.map(n => n * ds / seconds) as SensorVector | |
| 151 | forceN = delta.map((n, i) => parcel!.massKg * (n * dds / seconds ** 2 - (i === 2 ? gravity : 0))) as SensorVector | |
| 152 | retainWork(beforeJ) | |
| 153 | if (u === 1) phase = 'ready' | |
| 154 | } | |
| 155 | nowMs = atMs | |
| 156 | return snapshot() | |
| 157 | }, | |
| 158 | takeReadyParcel(...args: unknown[]): { outcome: 'departed', atMs: number, parcel: IntakeParcel } | { outcome: 'refused', reason: 'not-ready' } { | |
| 159 | if (args.length) throw new TypeError('cargo-station-arguments') | |
| 160 | if (phase !== 'ready') return { outcome: 'refused', reason: 'not-ready' } | |
| 161 | const outgoing = structuredClone(parcel!) | |
| 162 | departedJ = mechanical(parcel!); parcel = null; phase = 'departed'; forceN = [0, 0, 0] | |
| 163 | return { outcome: 'departed', atMs: nowMs, parcel: outgoing } | |
| 164 | }, | |
| 165 | } | |
| 166 | } | |
| @@ -1,3 +1,4 @@ | ||
| 1 | import type { SensorVector } from '../sensors/types' | |
| 1 | 2 | import type { VehicleMissionAction, VehicleMissionObservation, VehicleMissionPlan, VehicleMissionSnapshot, VehicleMissionTask, VehiclePayloadAck, VehiclePayloadCommand, VehicleVtolTransitionCommand, VehicleVtolTransitionObservation } from '../vehicles/types' |
| 2 | 3 | import type { MissionState } from './engine' |
| 3 | 4 | import { vehicleKinds } from '../vehicles/types' |
| @@ -41,10 +42,13 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl | ||
| 41 | 42 | } |
| 42 | 43 | else if (t.kind === 'payload') { |
| 43 | 44 | fields.push('operation', 'containerId', 'timeoutMs', 'next', 'onFailure') |
| 44 | const supported = p.vehicle === 'quadcopter' ? ['camera-still', 'material-drop'] : p.vehicle === 'fixed-wing' || p.vehicle === 'vtol' ? ['camera-still'] : p.vehicle === 'ground' ? ['camera-still', 'material-drop', 'mower-enable', 'mower-disable'] : p.vehicle === 'surface' ? ['camera-still', 'water-sample'] : ['camera-still', 'inspection-capture'] | |
| 45 | if (!supported.includes(t.operation) || (t.containerId !== null && !id(t.containerId)) || (t.operation === 'water-sample' && !id(t.containerId)) || !number(t.timeoutMs, 100, 10000) || !id(t.next)) invalid() | |
| 46 | if (p.vehicle === 'quadcopter' && t.operation === 'material-drop' && !id(t.containerId)) invalid() | |
| 47 | if (t.onFailure !== undefined && (p.vehicle !== 'quadcopter' || t.operation !== 'material-drop' || !id(t.onFailure))) invalid() | |
| 45 | const receiving = t.operation === 'material-receive' | |
| 46 | if (receiving) fields.push('positionM') | |
| 47 | const supported = p.vehicle === 'quadcopter' ? ['camera-still', 'material-drop', 'material-receive'] : p.vehicle === 'fixed-wing' || p.vehicle === 'vtol' ? ['camera-still'] : p.vehicle === 'ground' ? ['camera-still', 'material-drop', 'mower-enable', 'mower-disable'] : p.vehicle === 'surface' ? ['camera-still', 'water-sample'] : ['camera-still', 'inspection-capture'] | |
| 48 | if (!supported.includes(t.operation) || (t.containerId !== null && !id(t.containerId)) || (t.operation === 'water-sample' && !id(t.containerId)) || !number(t.timeoutMs, 100, receiving ? 60000 : 10000) || !id(t.next)) invalid() | |
| 49 | if (p.vehicle === 'quadcopter' && ['material-drop', 'material-receive'].includes(t.operation) && !id(t.containerId)) invalid() | |
| 50 | if (receiving && (!Number.isSafeInteger(t.timeoutMs) || !vehicleMissionContains(p, t.positionM) || t.positionM[2] < -1 || t.positionM[2] > 0)) invalid() | |
| 51 | if (t.onFailure !== undefined && (p.vehicle !== 'quadcopter' || !['material-drop', 'material-receive'].includes(t.operation) || !id(t.onFailure))) invalid() | |
| 48 | 52 | durationMs = t.timeoutMs; edges = t.onFailure === undefined ? [t.next] : [t.next, t.onFailure] |
| 49 | 53 | } |
| 50 | 54 | else if (['takeoff', 'waypoint', 'land', 'stop', 'loiter', 'return'].includes(t.kind)) { |
| @@ -86,11 +90,18 @@ export function validateVehicleMission(input: unknown): { plan: VehicleMissionPl | ||
| 86 | 90 | return { plan: structuredClone(p), worstCaseMs, requiredWh } |
| 87 | 91 | } |
| 88 | 92 | export interface VehicleMissionTrace { version: 2 | 3, evidence: 'owned-vehicle-mission-simulation', plan: VehicleMissionPlan, rows: { observation: VehicleMissionObservation, action: VehicleMissionAction | null, result: VehicleMissionSnapshot }[] } |
| 93 | function validAckPosition(value: unknown): value is SensorVector { | |
| 94 | return validVector(value) && [0, 1, 2].every(i => Object.hasOwn(value, i)) | |
| 95 | } | |
| 89 | 96 | function validAck(value: unknown): value is VehiclePayloadAck { |
| 90 | if (!object(value) || !keys(value, ['eventId', 'taskId', 'operation', 'containerId', 'capturedAtMs', 'reference', 'positionM', 'outcome', 'metadata']) || typeof value.eventId !== 'string' || value.eventId.length > 200 || !id(value.taskId) || !['camera-still', 'water-sample', 'material-drop', 'mower-enable', 'mower-disable', 'inspection-capture'].includes(value.operation as string) || (value.containerId !== null && !id(value.containerId)) || !Number.isSafeInteger(value.capturedAtMs) || (value.capturedAtMs as number) < 0 || typeof value.reference !== 'string' || value.reference.length > 120 || !validVector(value.positionM) || !['confirmed', 'rejected', 'uncertain'].includes(value.outcome as string)) return false | |
| 97 | if (!object(value) || !keys(value, ['eventId', 'taskId', 'operation', 'containerId', 'capturedAtMs', 'reference', 'positionM', 'outcome', 'metadata']) || typeof value.eventId !== 'string' || value.eventId.length > 200 || !id(value.taskId) || !['camera-still', 'water-sample', 'material-drop', 'material-receive', 'mower-enable', 'mower-disable', 'inspection-capture'].includes(value.operation as string) || (value.containerId !== null && !id(value.containerId)) || !Number.isSafeInteger(value.capturedAtMs) || (value.capturedAtMs as number) < 0 || typeof value.reference !== 'string' || value.reference.length > 120 || !validAckPosition(value.positionM) || !['confirmed', 'rejected', 'uncertain'].includes(value.outcome as string)) return false | |
| 91 | 98 | if (value.metadata !== undefined && (!object(value.metadata) || Object.keys(value.metadata).length > 32 || Object.entries(value.metadata).some(([key, item]) => !/^[a-zA-Z][a-zA-Z0-9_-]{0,39}$/.test(key) || !(typeof item === 'boolean' || (typeof item === 'number' && Number.isFinite(item)) || (typeof item === 'string' && item.length <= 160))))) return false |
| 92 | 99 | return true |
| 93 | 100 | } |
| 101 | function receivingAtPad(task: Extract<VehicleMissionTask, { operation: 'material-receive' }>, o: VehicleMissionObservation) { | |
| 102 | return o.onGround && o.positionM !== null && o.velocityMps !== null && Math.hypot(...o.velocityMps) <= .1 | |
| 103 | && Math.hypot(...o.positionM.map((n, i) => n - task.positionM[i]!)) <= .2 | |
| 104 | } | |
| 94 | 105 | function validTransition(value: unknown): value is VehicleVtolTransitionObservation { |
| 95 | 106 | return object(value) && keys(value, ['evidence', 'eventId', 'taskId', 'mode', 'reference', 'capturedAtMs', 'measurementCapturedAtMs', 'phase', 'outcome', 'reason']) |
| 96 | 107 | && value.evidence === 'modeled-vtol-controller-observation' && typeof value.eventId === 'string' && value.eventId.length <= 256 && id(value.taskId) && ['hover', 'cruise'].includes(value.mode as string) |
| @@ -253,11 +264,13 @@ export function createVehicleMissionEngine(input: unknown) { | ||
| 253 | 264 | else reason = 'vtol-transition-progress' |
| 254 | 265 | } |
| 255 | 266 | } |
| 267 | else if (t.kind === 'payload' && t.operation === 'material-receive' && !receivingAtPad(t, o)) block('material-receive-interlocks') | |
| 256 | 268 | else if (t.kind === 'payload') { |
| 257 | 269 | pending ??= { eventId: `${plan.id}:${reference}:${t.id}`, taskId: t.id, operation: t.operation, containerId: t.containerId, issuedAtMs: o.nowMs, expiresAtMs: o.nowMs + t.timeoutMs - taskElapsedMs, positionM: [...o.positionM], reference: reference! } |
| 258 | 270 | const ack = o.payloadAck |
| 259 | 271 | if (ack) { |
| 260 | const scopeMismatch = ack.eventId !== pending.eventId || ack.taskId !== pending.taskId || ack.operation !== pending.operation || ack.containerId !== pending.containerId || ack.reference !== pending.reference || ack.capturedAtMs < pending.issuedAtMs || ack.capturedAtMs >= pending.expiresAtMs || ack.capturedAtMs > o.nowMs || o.nowMs - ack.capturedAtMs > 200 || Math.hypot(...ack.positionM.map((n, i) => n - pending!.positionM[i]!)) > 1 | |
| 272 | const separationM = Math.hypot(...ack.positionM.map((n, i) => n - pending!.positionM[i]!)) | |
| 273 | const scopeMismatch = ack.eventId !== pending.eventId || ack.taskId !== pending.taskId || ack.operation !== pending.operation || ack.containerId !== pending.containerId || ack.reference !== pending.reference || ack.capturedAtMs < pending.issuedAtMs || ack.capturedAtMs >= pending.expiresAtMs || ack.capturedAtMs > o.nowMs || o.nowMs - ack.capturedAtMs > 200 || !Number.isFinite(separationM) || separationM > 1 | |
| 261 | 274 | if (scopeMismatch || ack.outcome !== 'confirmed') { if (!divertPayload(t, o, scopeMismatch ? 'scope-mismatch' : ack.outcome === 'rejected' ? 'rejected' : 'uncertain')) block('payload-outcome-uncertain') } |
| 262 | 275 | else { advance(t.next); reason = 'payload-confirmed' } |
| 263 | 276 | } |
| @@ -277,6 +290,12 @@ export function createVehicleMissionEngine(input: unknown) { | ||
| 277 | 290 | } |
| 278 | 291 | } |
| 279 | 292 | } |
| 293 | // Intake must inhibit the first control frame, including entry directly from start. | |
| 294 | const entered = tasks.get(taskId)! | |
| 295 | if (state === 'running' && entered.kind === 'payload' && entered.operation === 'material-receive') { | |
| 296 | if (!receivingAtPad(entered, o)) block('material-receive-interlocks') | |
| 297 | else pending ??= { eventId: `${plan.id}:${reference}:${entered.id}`, taskId: entered.id, operation: entered.operation, containerId: entered.containerId, issuedAtMs: o.nowMs, expiresAtMs: o.nowMs + entered.timeoutMs - taskElapsedMs, positionM: [...o.positionM], reference: reference! } | |
| 298 | } | |
| 280 | 299 | const result = snapshot() |
| 281 | 300 | rows.push({ observation: o, action: action ?? null, result: structuredClone(result) }) |
| 282 | 301 | return result |
| @@ -48,7 +48,7 @@ function createSession(): VehicleSession { | ||
| 48 | 48 | angularRateRadS: rate?.angularRateRadS ?? [0, 0, 0], rateCapturedAtMs: rate?.capturedAtMs ?? -Infinity, |
| 49 | 49 | target: { positionM: context.mission.targetM ?? [0, 0, 0], yawRad: 0, issuedAtMs: context.nowMs } }) |
| 50 | 50 | const measurement = context.measurement |
| 51 | const contact = !output.inhibited && context.mission.taskKind === 'land' && measurement?.vehicle === 'quadcopter' && measurement.evidence === 'modeled-vehicle-sensors' | |
| 51 | const contact = !output.inhibited && (context.mission.taskKind === 'land' || context.mission.payload?.operation === 'material-receive') && measurement?.vehicle === 'quadcopter' && measurement.evidence === 'modeled-vehicle-sensors' | |
| 52 | 52 | && measurement.reference === rate?.reference && measurement.capturedAtMs <= context.nowMs && context.nowMs - measurement.capturedAtMs <= 120 && measurement.receivedAtMs <= context.nowMs && measurement.values.onSurface === true |
| 53 | 53 | return { kind: 'simulated-vehicle-control', vehicle: 'quadcopter', atMs: context.nowMs, actuators: contact ? [0, 0, 0, 0] : [...output.motorsN], inhibited: fault !== null || output.inhibited, reason: fault ?? (contact ? 'quad-qualified-contact' : output.reason), physicalFlightEnabled: false, boardOutputsInhibited: true } |
| 54 | 54 | } } |
| @@ -44,7 +44,7 @@ export interface VehicleBatteryObservation { | ||
| 44 | 44 | terminalV: number |
| 45 | 45 | cutoff: boolean |
| 46 | 46 | } |
| 47 | export type VehiclePayloadOperation = 'camera-still' | 'material-drop' | 'water-sample' | 'mower-enable' | 'mower-disable' | 'inspection-capture' | |
| 47 | export type VehiclePayloadOperation = 'camera-still' | 'material-drop' | 'material-receive' | 'water-sample' | 'mower-enable' | 'mower-disable' | 'inspection-capture' | |
| 48 | 48 | export interface VehiclePayloadCommand { eventId: string, taskId: string, operation: VehiclePayloadOperation, containerId: string | null, issuedAtMs: number, expiresAtMs: number, positionM: SensorVector, reference: string } |
| 49 | 49 | export interface VehiclePayloadAck { eventId: string, taskId: string, operation: VehiclePayloadOperation, containerId: string | null, capturedAtMs: number, reference: string, positionM: SensorVector, outcome: 'confirmed' | 'rejected' | 'uncertain', metadata?: Record<string, number | string | boolean> } |
| 50 | 50 | export interface VehiclePayloadPort { observe?(context: VehicleControllerContext): void, request(command: VehiclePayloadCommand): void, poll(nowMs: number): VehiclePayloadAck | null, close(): void } |
| @@ -89,7 +89,8 @@ export type VehicleMissionTask = | ||
| 89 | 89 | | { id: string, kind: 'takeoff' | 'waypoint' | 'land' | 'stop', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next?: string } |
| 90 | 90 | | { id: string, kind: 'return', acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string } |
| 91 | 91 | | { id: string, kind: 'loiter', positionM: SensorVector, acceptanceM: number, maxArrivalSpeedMps: number, settleMs: number, timeoutMs: number, next: string } |
| 92 | | { id: string, kind: 'payload', operation: VehiclePayloadOperation, containerId: string | null, timeoutMs: number, next: string, onFailure?: string } | |
| 92 | | { id: string, kind: 'payload', operation: Exclude<VehiclePayloadOperation, 'material-receive'>, containerId: string | null, timeoutMs: number, next: string, onFailure?: string } | |
| 93 | | { id: string, kind: 'payload', operation: 'material-receive', containerId: string, positionM: SensorVector, timeoutMs: number, next: string, onFailure?: string } | |
| 93 | 94 | | { id: string, kind: 'branch', batteryAtLeast: number, then: string, otherwise: string } |
| 94 | 95 | | { id: string, kind: 'abort' } |
| 95 | 96 | /** Explicit v2 profile; v1 quadcopter plans and traces retain their original contract. */ |
| @@ -2,12 +2,14 @@ import { Buffer } from 'node:buffer' | ||
| 2 | 2 | import { writeSync } from 'node:fs' |
| 3 | 3 | import { mkdir, open, writeFile } from 'node:fs/promises' |
| 4 | 4 | import { join } from 'node:path' |
| 5 | import type { BrowserStartupEvidence } from './browser-startup-process' | |
| 5 | 6 | |
| 6 | 7 | export interface BrowserLaunchObservation { |
| 7 | 8 | phase: 'startup-unavailable' | 'discovery-failed' | 'closed' |
| 8 | 9 | elapsedMs: number |
| 9 | 10 | launch: { pid: number, exitObserved: boolean, settledExitCode: number | null, signalCode: string | number | null, exitObservedBeforeCleanup: boolean, cleanupRequested: boolean, exitElapsedMs: number | null } |
| 10 | 11 | portProbe: { attempts: number, successfulReads: number, failedReads: number, lastReadError: string | null, discovered: boolean } |
| 12 | startup?: BrowserStartupEvidence | |
| 11 | 13 | } |
| 12 | 14 | export function browserFileErrorCode(error: unknown): string { |
| 13 | 15 | const code = (error as NodeJS.ErrnoException | null)?.code |
| @@ -0,0 +1,167 @@ | ||
| 1 | import { createHash } from 'node:crypto' | |
| 2 | import { lstat, open, readFile, readlink, readdir } from 'node:fs/promises' | |
| 3 | import type { FileHandle } from 'node:fs/promises' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { browserFileErrorCode } from './browser-launch-evidence' | |
| 6 | ||
| 7 | const processLimit = 64, threadLimit = 32, executableLimit = 4, executableByteLimit = 512 * 1024 * 1024 | |
| 8 | async function text(path: string, signal: AbortSignal) { | |
| 9 | if (signal.aborted) return { value: null, code: 'ABORT_ERR' } | |
| 10 | try { | |
| 11 | const value = await readFile(path, { encoding: 'utf8', signal }) | |
| 12 | return value.length <= 32768 ? { value, code: null } : { value: null, code: 'BYTE_LIMIT' } | |
| 13 | } | |
| 14 | catch (error) { return { value: null, code: browserFileErrorCode(error) } } | |
| 15 | } | |
| 16 | const decimal = (value: string | undefined) => value && /^\d{1,20}$/.test(value) ? value : null | |
| 17 | const integer = (value: string | undefined) => { const n = decimal(value); return n !== null && Number.isSafeInteger(Number(n)) ? Number(n) : null } | |
| 18 | async function processState(pid: number, signal: AbortSignal) { | |
| 19 | const [raw, status, wait, children] = await Promise.all([text(`/proc/${pid}/stat`, signal), text(`/proc/${pid}/status`, signal), text(`/proc/${pid}/wchan`, signal), text(`/proc/${pid}/task/${pid}/children`, signal)]) | |
| 20 | signal.throwIfAborted() | |
| 21 | const end = raw.value?.lastIndexOf(') ') ?? -1 | |
| 22 | // tcomm may contain spaces/parentheses and is deliberately discarded. | |
| 23 | const fields = end >= 0 ? raw.value!.slice(end + 2).trim().split(/\s+/) : [] | |
| 24 | const valid = fields.length >= 22 && /^[RSDZTtXxKWPI]$/.test(fields[0]!) && decimal(fields[19]) !== null | |
| 25 | const rss = status.value?.match(/^VmRSS:\s+(\d+)\s+kB$/m)?.[1] | |
| 26 | const ids = children.value?.trim().split(/\s+/).map(integer).filter((n): n is number => n !== null && n > 0) ?? [] | |
| 27 | return { | |
| 28 | record: { pid, code: valid ? null : raw.code ?? 'FORMAT', state: valid ? fields[0]! : null, | |
| 29 | parentPid: valid ? integer(fields[1]) : null, group: valid ? integer(fields[2]) : null, session: valid ? integer(fields[3]) : null, | |
| 30 | startTicks: valid ? decimal(fields[19]) : null, userTicks: valid ? decimal(fields[11]) : null, systemTicks: valid ? decimal(fields[12]) : null, | |
| 31 | threads: valid ? integer(fields[17]) : null, rssKiB: integer(rss), statusCode: status.code, | |
| 32 | wait: wait.value !== null && /^[A-Za-z0-9_.:]{1,80}$/.test(wait.value.trim()) ? wait.value.trim() : null, waitCode: wait.code, childrenCode: children.code }, | |
| 33 | children: ids.slice(0, processLimit), childrenCapped: ids.length > processLimit, | |
| 34 | } | |
| 35 | } | |
| 36 | async function counters(path: string, allowed: string[], signal: AbortSignal) { | |
| 37 | const result = await text(path, signal), values: Record<string, string> = {} | |
| 38 | signal.throwIfAborted() | |
| 39 | for (const line of result.value?.split('\n') ?? []) { | |
| 40 | const [key, value] = line.trim().split(/\s+/) | |
| 41 | if (key && allowed.includes(key) && decimal(value) !== null) values[key] = value! | |
| 42 | } | |
| 43 | return { values, code: result.code } | |
| 44 | } | |
| 45 | async function scalar(path: string, signal: AbortSignal) { | |
| 46 | const result = await text(path, signal), value = result.value?.trim() | |
| 47 | signal.throwIfAborted() | |
| 48 | return { value: value === 'max' || decimal(value) !== null ? value! : null, code: result.code ?? (value === 'max' || decimal(value) !== null ? null : 'FORMAT') } | |
| 49 | } | |
| 50 | async function pressure(resource: string, signal: AbortSignal) { | |
| 51 | const result = await text(`/proc/pressure/${resource}`, signal), values: Record<string, { avg10: number, avg60: number, avg300: number, totalMicroseconds: string }> = {} | |
| 52 | for (const line of result.value?.split('\n') ?? []) { | |
| 53 | const match = /^(some|full) avg10=(\d{1,3}(?:\.\d{1,6})?) avg60=(\d{1,3}(?:\.\d{1,6})?) avg300=(\d{1,3}(?:\.\d{1,6})?) total=(\d{1,20})$/.exec(line) | |
| 54 | if (match) values[match[1]!] = { avg10: Number(match[2]), avg60: Number(match[3]), avg300: Number(match[4]), totalMicroseconds: match[5]! } | |
| 55 | } | |
| 56 | return { values, code: result.code } | |
| 57 | } | |
| 58 | async function resources(signal: AbortSignal) { | |
| 59 | signal.throwIfAborted() | |
| 60 | const membership = await text('/proc/self/cgroup', signal) | |
| 61 | signal.throwIfAborted() | |
| 62 | const path = membership.value?.split('\n').find(line => line.startsWith('0::'))?.slice(3) | |
| 63 | const base = path?.startsWith('/') && !path.split('/').includes('..') ? join('/sys/fs/cgroup', path) : null | |
| 64 | const [cpuPressure, memoryPressure, ioPressure] = await Promise.all(['cpu', 'memory', 'io'].map(name => pressure(name, signal))) | |
| 65 | signal.throwIfAborted() | |
| 66 | const cgroup = base ? { | |
| 67 | memoryCurrent: await scalar(join(base, 'memory.current'), signal), memoryMax: await scalar(join(base, 'memory.max'), signal), | |
| 68 | memoryEvents: await counters(join(base, 'memory.events'), ['low', 'high', 'max', 'oom', 'oom_kill', 'oom_group_kill', 'sock_throttled'], signal), | |
| 69 | cpu: await counters(join(base, 'cpu.stat'), ['usage_usec', 'user_usec', 'system_usec', 'nr_periods', 'nr_throttled', 'throttled_usec', 'nr_bursts', 'burst_usec'], signal), | |
| 70 | pidsCurrent: await scalar(join(base, 'pids.current'), signal), pidsMax: await scalar(join(base, 'pids.max'), signal), | |
| 71 | } : null | |
| 72 | return { scope: 'observer cgroup, potentially shared with other runner processes', pressureScope: 'system pressure, shared with other host workloads', membershipCode: membership.code ?? (base ? null : 'NOT_V2'), cgroup, cpuPressure, memoryPressure, ioPressure } | |
| 73 | } | |
| 74 | ||
| 75 | type Executable = { path: string, device: string, inode: string, byteLength: number, observedPids: number[], openedAtMs: number, sha256: string | null, hashedAfterDiscovery: boolean, code: string | null, closeCode: string | null } | |
| 76 | /** No argv, environment, comm, stack or memory dump is exported. */ | |
| 77 | export function createLinuxStartupObservation(pid: number, profile: string, began: number) { | |
| 78 | const tracked = new Map<number, string>(), pinned = new Map<string, { handle: FileHandle, mtimeNs: bigint, ctimeNs: bigint, evidence: Executable }>() | |
| 79 | const identityErrors: Array<{ pid: number, code: string }> = [] | |
| 80 | let handlesSealed = false, executableLimitReached = false | |
| 81 | async function executable(id: number, signal: AbortSignal) { | |
| 82 | if (handlesSealed || signal.aborted) return | |
| 83 | let handle: FileHandle | undefined | |
| 84 | try { | |
| 85 | handle = await open(`/proc/${id}/exe`, 'r') | |
| 86 | if (handlesSealed || signal.aborted) return | |
| 87 | const stat = await handle.stat({ bigint: true }), key = `${stat.dev}:${stat.ino}` | |
| 88 | if (handlesSealed || signal.aborted) return | |
| 89 | const prior = pinned.get(key) | |
| 90 | if (prior) { if (!prior.evidence.observedPids.includes(id)) prior.evidence.observedPids.push(id); return } | |
| 91 | if (pinned.size >= executableLimit) { executableLimitReached = true; return } | |
| 92 | const path = await readlink(`/proc/self/fd/${handle.fd}`) | |
| 93 | if (handlesSealed || signal.aborted) return | |
| 94 | if (!stat.isFile() || stat.size <= 0n || stat.size > BigInt(executableByteLimit) || path.length > 4096) { if (identityErrors.length < 8) identityErrors.push({ pid: id, code: 'IDENTITY_LIMIT' }); return } | |
| 95 | pinned.set(key, { handle, mtimeNs: stat.mtimeNs, ctimeNs: stat.ctimeNs, evidence: { path, device: String(stat.dev), inode: String(stat.ino), byteLength: Number(stat.size), observedPids: [id], openedAtMs: performance.now() - began, sha256: null, hashedAfterDiscovery: false, code: null, closeCode: null } }) | |
| 96 | handle = undefined | |
| 97 | } | |
| 98 | catch (error) { if (identityErrors.length < 8) identityErrors.push({ pid: id, code: browserFileErrorCode(error) }) } | |
| 99 | finally { | |
| 100 | if (handle) { | |
| 101 | try { await handle.close() } | |
| 102 | catch (closeError) { if (identityErrors.length < 8) identityErrors.push({ pid: id, code: browserFileErrorCode(closeError) }) } | |
| 103 | } | |
| 104 | } | |
| 105 | } | |
| 106 | return { | |
| 107 | async snapshot(signal: AbortSignal) { | |
| 108 | signal.throwIfAborted() | |
| 109 | const root = await processState(pid, signal), descendants: Array<typeof root.record & { reused: boolean }> = [] | |
| 110 | const check = (id: number, start: string | null) => { | |
| 111 | if (!start) return false | |
| 112 | const prior = tracked.get(id) | |
| 113 | if (prior && prior !== start) return false | |
| 114 | if (tracked.size < processLimit) tracked.set(id, start) | |
| 115 | return true | |
| 116 | } | |
| 117 | const rootMatches = check(pid, root.record.startTicks) | |
| 118 | if (rootMatches) await executable(pid, signal) | |
| 119 | const queue = [...new Set([...(rootMatches ? root.children : []), ...tracked.keys()].filter(id => id !== pid))] | |
| 120 | const seen = new Set<number>([pid]); let processLimitReached = root.childrenCapped | |
| 121 | while (queue.length && seen.size < processLimit && !signal.aborted) { | |
| 122 | const id = queue.shift()! | |
| 123 | if (seen.has(id)) continue | |
| 124 | seen.add(id) | |
| 125 | const state = await processState(id, signal), matches = check(id, state.record.startTicks) | |
| 126 | descendants.push({ ...state.record, reused: state.record.startTicks !== null && !matches }) | |
| 127 | if (matches) { await executable(id, signal); queue.push(...state.children.filter(n => !seen.has(n))); processLimitReached ||= state.childrenCapped } | |
| 128 | } | |
| 129 | processLimitReached ||= queue.length > 0 || tracked.size === processLimit | |
| 130 | signal.throwIfAborted() | |
| 131 | let threadCode: string | null = null | |
| 132 | const allThreads = await readdir(`/proc/${pid}/task`).catch(error => { threadCode = browserFileErrorCode(error); return [] }) | |
| 133 | signal.throwIfAborted() | |
| 134 | const threads = await Promise.all(allThreads.filter(id => /^\d+$/.test(id)).slice(0, threadLimit).map(async id => { | |
| 135 | const wait = await text(`/proc/${pid}/task/${id}/wchan`, signal) | |
| 136 | return { tid: Number(id), wait: wait.value !== null && /^[A-Za-z0-9_.:]{1,80}$/.test(wait.value.trim()) ? wait.value.trim() : null, code: wait.code } | |
| 137 | })) | |
| 138 | signal.throwIfAborted() | |
| 139 | const discoveryFile = await lstat(join(profile, 'DevToolsActivePort')).then(value => ({ present: true, regular: value.isFile(), bytes: value.size, code: null })).catch(error => ({ present: false, regular: null, bytes: null, code: browserFileErrorCode(error) })) | |
| 140 | signal.throwIfAborted() | |
| 141 | return { status: 'observed' as const, root: { ...root.record, reused: root.record.startTicks !== null && !rootMatches }, descendants, processLimitReached, | |
| 142 | childScope: 'main-task children and previously observed descendants; incomplete during fork/exit/reparenting', threads, threadCode, threadDetailsCapped: allThreads.length > threadLimit, discoveryFile, resources: await resources(signal) } | |
| 143 | }, | |
| 144 | async settleExecutables(shouldHash: boolean) { | |
| 145 | handlesSealed = true | |
| 146 | const abort = new AbortController(), timer = setTimeout(() => abort.abort(), 2000) | |
| 147 | try { | |
| 148 | for (const { handle, evidence, mtimeNs, ctimeNs } of pinned.values()) { | |
| 149 | try { | |
| 150 | if (!shouldHash) { evidence.code = 'OBSERVATION_CANCELED'; continue } | |
| 151 | if (abort.signal.aborted) { evidence.code = 'HASH_BUDGET'; continue } | |
| 152 | const hash = createHash('sha256'); let bytes = 0 | |
| 153 | for await (const chunk of handle.createReadStream({ autoClose: false, start: 0, end: evidence.byteLength - 1, signal: abort.signal })) { hash.update(chunk); bytes += chunk.length } | |
| 154 | const after = await handle.stat({ bigint: true }) | |
| 155 | if (abort.signal.aborted) { evidence.code = 'HASH_BUDGET'; continue } | |
| 156 | if (bytes !== evidence.byteLength || after.size !== BigInt(bytes) || after.mtimeNs !== mtimeNs || after.ctimeNs !== ctimeNs) { evidence.code = 'EXECUTABLE_CHANGED'; continue } | |
| 157 | evidence.sha256 = hash.digest('hex'); evidence.hashedAfterDiscovery = true | |
| 158 | } | |
| 159 | catch (error) { evidence.code = browserFileErrorCode(error) } | |
| 160 | finally { await handle.close().catch(error => { evidence.closeCode = browserFileErrorCode(error) }) } | |
| 161 | } | |
| 162 | } | |
| 163 | finally { clearTimeout(timer) } | |
| 164 | }, | |
| 165 | evidence() { return { executables: [...pinned.values()].map(value => structuredClone(value.evidence)), identityErrors: structuredClone(identityErrors), executableLimitReached } }, | |
| 166 | } | |
| 167 | } | |
| @@ -0,0 +1,61 @@ | ||
| 1 | import { browserFileErrorCode } from './browser-launch-evidence' | |
| 2 | import type { createLinuxStartupObservation } from './browser-startup-linux' | |
| 3 | ||
| 4 | type NativeSnapshot = Awaited<ReturnType<ReturnType<typeof createLinuxStartupObservation>['snapshot']>> | |
| 5 | export interface StartupProcessAdapter { | |
| 6 | snapshot: (signal: AbortSignal) => Promise<NativeSnapshot | { status: 'unavailable', code: string | null }> | |
| 7 | settleExecutables: (hash: boolean) => Promise<void> | |
| 8 | evidence: ReturnType<typeof createLinuxStartupObservation>['evidence'] | |
| 9 | } | |
| 10 | ||
| 11 | type Probe = 'present' | 'missing' | 'denied' | 'unavailable' | |
| 12 | function rootProbe(pid: number): Probe { | |
| 13 | try { process.kill(pid, 0); return 'present' } | |
| 14 | catch (error) { | |
| 15 | const code = browserFileErrorCode(error) | |
| 16 | return code === 'ESRCH' ? 'missing' : code === 'EPERM' ? 'denied' : 'unavailable' | |
| 17 | } | |
| 18 | } | |
| 19 | ||
| 20 | /** Startup-only facts. A PID probe does not establish complete process-group ownership. */ | |
| 21 | export function createBrowserStartupObserver(pid: number, began: number, linux: StartupProcessAdapter | null) { | |
| 22 | type Linux = NativeSnapshot | { status: 'unsupported' | 'timeout' | 'unavailable', code: string | null } | |
| 23 | const snapshots: Array<{ label: string, elapsedMs: number, completedAtMs: number, root: { pid: number, probe: Probe }, linux: Linux }> = [] | |
| 24 | const milestones = [0, 1000, 5000, 10000, 15000] | |
| 25 | let next = 0, sealed = false, inFlight = false, canceled = false, browserVersion: string | null = null | |
| 26 | const sample = async (label: string) => { | |
| 27 | const elapsedMs = performance.now() - began, root = { pid, probe: rootProbe(pid) } | |
| 28 | const abort = new AbortController() | |
| 29 | let timer: ReturnType<typeof setTimeout> | undefined, expired = false | |
| 30 | try { | |
| 31 | let observation: Linux = { status: 'unsupported', code: null } | |
| 32 | if (linux && canceled) observation = { status: 'unavailable', code: 'OBSERVATION_CANCELED' } | |
| 33 | else if (linux) { | |
| 34 | timer = setTimeout(() => { | |
| 35 | expired = true; canceled = true; abort.abort() | |
| 36 | console.error(JSON.stringify({ oracle: 'browser-startup-observation-pending', pid, elapsedMs: performance.now() - began })) | |
| 37 | }, 500) | |
| 38 | // Cancellation requests are not settlement. Own this actual task until it returns. | |
| 39 | observation = await linux.snapshot(abort.signal).catch(error => ({ status: 'unavailable' as const, code: browserFileErrorCode(error) })) | |
| 40 | if (expired) observation = { status: 'timeout', code: 'OBSERVATION_BUDGET' } | |
| 41 | } | |
| 42 | snapshots.push({ label, elapsedMs, completedAtMs: performance.now() - began, root, linux: observation }) | |
| 43 | } | |
| 44 | finally { if (timer) clearTimeout(timer) } | |
| 45 | } | |
| 46 | let pending: Promise<void> = Promise.resolve() | |
| 47 | return { | |
| 48 | tick() { | |
| 49 | if (!sealed && !canceled && !inFlight && next < milestones.length && performance.now() - began >= milestones[next]!) { | |
| 50 | const at = milestones[next++]! | |
| 51 | inFlight = true | |
| 52 | pending = sample(at === 0 ? 'launch' : `elapsed-${at}ms`).finally(() => { inFlight = false }) | |
| 53 | } | |
| 54 | }, | |
| 55 | async finishDiscovery() { sealed = true; await pending; await sample('discovery-ended'); await linux?.settleExecutables(!canceled) }, | |
| 56 | async cleanupEnded() { await sample('cleanup-ended') }, | |
| 57 | recordVersion(value: unknown) { if (typeof value === 'string' && /^(?:HeadlessChrome|Chrome)\/\d{1,6}(?:\.\d{1,8}){3}$/.test(value)) browserVersion = value }, | |
| 58 | evidence() { return { method: 'startup-only-process-observation-v1' as const, platform: process.platform, browserVersion, snapshots: structuredClone(snapshots), ...linux?.evidence() ?? { executables: [], identityErrors: [], executableLimitReached: false } } }, | |
| 59 | } | |
| 60 | } | |
| 61 | export type BrowserStartupEvidence = ReturnType<ReturnType<typeof createBrowserStartupObserver>['evidence']> | |
| @@ -4,6 +4,8 @@ import { join } from 'node:path' | ||
| 4 | 4 | import { observeBrowserOperation } from './browser-operation' |
| 5 | 5 | import { browserFileErrorCode, retainBrowserLaunchEvidence } from './browser-launch-evidence' |
| 6 | 6 | import type { BrowserLaunchObservation } from './browser-launch-evidence' |
| 7 | import { createBrowserStartupObserver } from './browser-startup-process' | |
| 8 | import { createLinuxStartupObservation } from './browser-startup-linux' | |
| 7 | 9 | |
| 8 | 10 | /** Dependency-free CDP, with a fresh profile. Credentials never enter command arguments. */ |
| 9 | 11 | async function openSocket(url: string): Promise<WebSocket> { |
| @@ -22,31 +24,41 @@ export async function startWorkspaceBrowser(root: string) { | ||
| 22 | 24 | const child = Bun.spawn([process.env.BROWSE_BROWSER ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '--headless=new', '--disable-gpu', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', '--disable-features=CalculateNativeWinOcclusion', '--disable-dev-shm-usage', '--disable-extensions', '--disable-component-update', '--no-first-run', '--no-default-browser-check', '--disable-background-networking', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdout: 'ignore', stderr: Bun.file(join(root, 'chrome.log')) }) |
| 23 | 25 | const started = Date.now() |
| 24 | 26 | const began = performance.now() |
| 27 | const startup = createBrowserStartupObserver(child.pid, began, process.platform === 'linux' ? createLinuxStartupObservation(child.pid, profile, began) : null) | |
| 25 | 28 | let closing = false |
| 26 | 29 | const launch: BrowserLaunchObservation['launch'] = { pid: child.pid, exitObserved: false, settledExitCode: null, signalCode: null, exitObservedBeforeCleanup: false, cleanupRequested: false, exitElapsedMs: null } |
| 27 | 30 | const portProbe: BrowserLaunchObservation['portProbe'] = { attempts: 0, successfulReads: 0, failedReads: 0, lastReadError: null, discovered: false } |
| 28 | 31 | let pageVisited = false |
| 29 | const retain = (phase: BrowserLaunchObservation['phase']) => retainBrowserLaunchEvidence(root, { phase, elapsedMs: performance.now() - began, launch: { ...launch }, portProbe: { ...portProbe } }, { startupStderr: !pageVisited, exportDirectory: process.env.WORKSPACE_BROWSER_EVIDENCE ?? process.env.EXECUTION_BROWSER_EVIDENCE }) | |
| 32 | const retain = (phase: BrowserLaunchObservation['phase']) => retainBrowserLaunchEvidence(root, { phase, elapsedMs: performance.now() - began, launch: { ...launch }, portProbe: { ...portProbe }, startup: startup.evidence() }, { startupStderr: !pageVisited, exportDirectory: process.env.WORKSPACE_BROWSER_EVIDENCE ?? process.env.EXECUTION_BROWSER_EVIDENCE }) | |
| 33 | const cleanup = async () => { | |
| 34 | closing = true; launch.cleanupRequested = true | |
| 35 | if (child.exitCode === null) child.kill() | |
| 36 | await Promise.race([child.exited, Bun.sleep(2000)]) | |
| 37 | if (!pageVisited) await startup.cleanupEnded() | |
| 38 | } | |
| 30 | 39 | void child.exited.then(code => { |
| 31 | 40 | launch.exitObserved = true; launch.settledExitCode = code; launch.signalCode = child.signalCode; launch.exitObservedBeforeCleanup = !closing; launch.exitElapsedMs = performance.now() - began |
| 32 | 41 | if (!closing) console.log(`QA isolated Chrome exited unexpectedly: ${code}, elapsedMs:${Date.now() - started}`) |
| 33 | 42 | }) |
| 34 | 43 | let port = '' |
| 35 | 44 | for (let attempt = 0; attempt < 400; attempt++) { |
| 45 | startup.tick() | |
| 36 | 46 | portProbe.attempts++ |
| 37 | 47 | port = await readFile(join(profile, 'DevToolsActivePort'), 'utf8').then(value => { portProbe.successfulReads++; portProbe.lastReadError = null; return value.split('\n')[0]! }).catch(error => { portProbe.failedReads++; portProbe.lastReadError = browserFileErrorCode(error); return '' }) |
| 38 | 48 | if (port) { portProbe.discovered = true; break } |
| 39 | 49 | await Bun.sleep(50) |
| 40 | 50 | } |
| 41 | if (!port) { closing = true; launch.cleanupRequested = true; if (child.exitCode === null) child.kill(); await Promise.race([child.exited, Bun.sleep(2000)]); await retain('startup-unavailable'); throw new Error('Fresh browser must start') } | |
| 51 | await startup.finishDiscovery() | |
| 52 | if (!port) { await cleanup(); await retain('startup-unavailable'); throw new Error('Fresh browser must start') } | |
| 42 | 53 | // Keep a browser-level session, as well as page sessions, for the owned lifecycle. |
| 43 | 54 | let browserSocket: WebSocket |
| 44 | 55 | try { |
| 45 | 56 | const response = await observeBrowserOperation('version-fetch', () => fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(5000) })) |
| 46 | const version = await observeBrowserOperation('version-json', () => response.json()) as { webSocketDebuggerUrl: string } | |
| 57 | const version = await observeBrowserOperation('version-json', () => response.json()) as { webSocketDebuggerUrl: string, Browser?: unknown } | |
| 58 | startup.recordVersion(version.Browser) | |
| 47 | 59 | browserSocket = await observeBrowserOperation('browser-socket', () => openSocket(version.webSocketDebuggerUrl)) |
| 48 | 60 | } |
| 49 | catch (error) { closing = true; launch.cleanupRequested = true; if (child.exitCode === null) child.kill(); await Promise.race([child.exited, Bun.sleep(2000)]); await retain('discovery-failed'); throw error } | |
| 61 | catch (error) { await cleanup(); await retain('discovery-failed'); throw error } | |
| 50 | 62 | let browserSequence = 0 |
| 51 | 63 | browserSocket.onmessage = () => {} |
| 52 | 64 | const keepAlive = setInterval(() => { if (browserSocket.readyState === WebSocket.OPEN) browserSocket.send(JSON.stringify({ id: ++browserSequence, method: 'Browser.getVersion' })) }, 5000) |
| @@ -96,6 +108,6 @@ export async function startWorkspaceBrowser(root: string) { | ||
| 96 | 108 | await page.wait(javascript ? "document.querySelector('#incident-key') !== null && document.querySelector('[data-workspace-status]')?.textContent.trim() === 'disconnected'" : "document.querySelector('#incident-key') !== null", 'Compiled workspace must initialize') |
| 97 | 109 | return page |
| 98 | 110 | }, |
| 99 | async close() { console.log(`QA browser.close requested, elapsedMs:${Date.now() - started}`); closing = true; launch.cleanupRequested = true; clearInterval(keepAlive); browserSocket.close(); for (const page of pages) page.close(); if (child.exitCode === null) child.kill(); await Promise.race([child.exited, Bun.sleep(2000)]); await retain('closed') }, | |
| 111 | async close() { console.log(`QA browser.close requested, elapsedMs:${Date.now() - started}`); clearInterval(keepAlive); browserSocket.close(); for (const page of pages) page.close(); await cleanup(); await retain('closed') }, | |
| 100 | 112 | } |
| 101 | 113 | } |
| @@ -24,6 +24,8 @@ | ||
| 24 | 24 | "../resources/functions/drone/cargo/**/*.ts", |
| 25 | 25 | "./unit/cargo-load.test.ts", |
| 26 | 26 | "./unit/cargo-intake.test.ts", |
| 27 | "./unit/cargo-station.test.ts", | |
| 28 | "./unit/cargo-receive-mission.test.ts", | |
| 27 | 29 | "./unit/cargo-delivery.test.ts", |
| 28 | 30 | "./unit/cargo-delivery-config.test.ts", |
| 29 | 31 | "./unit/cargo-delivery-phase-faults.test.ts", |
| @@ -0,0 +1,149 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import type { VehicleMissionObservation, VehiclePayloadCommand } from '../../resources/functions/drone/vehicles/types' | |
| 3 | import { createReceivingRigidBody } from '../../resources/functions/drone/controls/physics' | |
| 4 | import { createVehicleSimulationSession } from '../../resources/functions/drone/vehicles/runtime' | |
| 5 | import { createMultirotorPlant, createMultirotorProfile, multirotorMission } from '../../resources/functions/drone/vehicles/multirotor/profile' | |
| 6 | import { createVehicleMissionEngine, parseVehicleMissionTrace, replayVehicleMission, validateVehicleMission } from '../../resources/functions/drone/missions/vehicle' | |
| 7 | ||
| 8 | function receivePlan() { | |
| 9 | const spatial = { acceptanceM: .2, maxArrivalSpeedMps: .18, settleMs: 200, timeoutMs: 10000 } | |
| 10 | return { ...multirotorMission(), id: 'cargo-receiver', tasks: [ | |
| 11 | { id: 'start', kind: 'start', next: 'intake' }, | |
| 12 | { id: 'intake', kind: 'payload', operation: 'material-receive', containerId: 'parcel-a', positionM: [3, 2, -.1], timeoutMs: 60000, next: 'depart', onFailure: 'divert' }, | |
| 13 | { id: 'depart', kind: 'takeoff', positionM: [3, 2, -1], ...spatial, next: 'land' }, | |
| 14 | { id: 'land', kind: 'land', positionM: [3, 2, 0], ...spatial }, | |
| 15 | { id: 'divert', kind: 'waypoint', positionM: [4, 2, -1], ...spatial, timeoutMs: 8000, next: 'alternate' }, | |
| 16 | { id: 'alternate', kind: 'land', positionM: [4, 2, 0], ...spatial, timeoutMs: 8000 }, | |
| 17 | ] } | |
| 18 | } | |
| 19 | function observation(nowMs: number): VehicleMissionObservation { | |
| 20 | return { kind: 'simulation', nowMs, reference: '102:0,0,0,0,0', origin: { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }, healthy: true, positionM: [3, 2, -.1], velocityMps: [0, 0, 0], authority: 'autonomous', batteryPercent: 98, onGround: true, payloadAck: null } | |
| 21 | } | |
| 22 | ||
| 23 | // Native mission function seam: proves receipt command, anchor, scope and counted | |
| 24 | // plan behavior. It does not produce mechanical intake or current payload authority. | |
| 25 | test('a receiving mission holds its declared pad and advances only after its own correlated receipt ACK', () => { | |
| 26 | const plan = receivePlan(), validated = validateVehicleMission(plan) | |
| 27 | expect(validated.worstCaseMs).toBe(80020) | |
| 28 | const engine = createVehicleMissionEngine(plan) | |
| 29 | expect(engine.step(observation(600), 'start')).toMatchObject({ taskId: 'intake', state: 'running', targetM: [3, 2, -.1] }) | |
| 30 | const pending = engine.step(observation(620)).payload! | |
| 31 | expect(pending).toMatchObject({ taskId: 'intake', operation: 'material-receive', containerId: 'parcel-a', reference: '102:0,0,0,0,0', issuedAtMs: 600, expiresAtMs: 60600 }) | |
| 32 | const result = engine.step({ ...observation(640), payloadAck: { eventId: pending.eventId, taskId: pending.taskId, operation: pending.operation, containerId: pending.containerId, reference: pending.reference, positionM: pending.positionM, capturedAtMs: 640, outcome: 'confirmed' } }) | |
| 33 | expect(result.taskId).toBe('depart') | |
| 34 | expect(result.reason).toBe('payload-confirmed') | |
| 35 | expect(result.payload).toBeNull() | |
| 36 | expect(replayVehicleMission(engine.trace()).snapshot()).toEqual(result) | |
| 37 | expect(parseVehicleMissionTrace(JSON.stringify(engine.trace()))).toEqual(engine.trace()) | |
| 38 | }) | |
| 39 | ||
| 40 | ||
| 41 | // Actual receiving body plus raw-sensor/estimator/authority runtime. The only | |
| 42 | // external fixture is a payload wire that withholds its ACK, not an intake mock. | |
| 43 | test('an unacknowledged receiving mission keeps its actual parked body neutral under its own live authority', () => { | |
| 44 | const body = createReceivingRigidBody({ positionM: [3, 2, -.1], groundDownM: -.1, expectedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: .08 } }) | |
| 45 | const profile = createMultirotorProfile(), create = profile.createSession | |
| 46 | profile.mission = validateVehicleMission(receivePlan()).plan | |
| 47 | const commands: VehiclePayloadCommand[] = [] | |
| 48 | profile.createSession = () => ({ ...create(), createPlant: () => createMultirotorPlant(body), createPayload: () => ({ request(command) { commands.push(structuredClone(command)) }, poll() { return null }, close() {} }) }) | |
| 49 | const session = createVehicleSimulationSession(profile, { seconds: 3, scope: { bootId: 102n, sessionId: 202n } }) | |
| 50 | try { | |
| 51 | for (let nowMs = 0; nowMs <= 2000; nowMs += 4) { | |
| 52 | const row = session.step(nowMs) | |
| 53 | if (nowMs >= 600) { | |
| 54 | expect(row.authority.state).toBe('autonomous') | |
| 55 | expect(row.authority.sessionId).toBe(202n) | |
| 56 | expect(row.reference.startsWith('102:')).toBe(true) | |
| 57 | expect(row.board.actuators).toEqual([0, 0, 0, 0]) | |
| 58 | expect(body.snapshot().positionM).toEqual([3, 2, -.1]) | |
| 59 | expect(body.snapshot().motorsN).toEqual([0, 0, 0, 0]) | |
| 60 | expect(body.loadSnapshot().properties.totalMassKg).toBe(1.5) | |
| 61 | } | |
| 62 | } | |
| 63 | expect(commands).toHaveLength(1) | |
| 64 | expect(commands[0]!.operation).toBe('material-receive') | |
| 65 | expect(commands[0]!.issuedAtMs).toBe(600) | |
| 66 | expect(body.loadSnapshot().properties.phase).toBe('awaiting') | |
| 67 | } | |
| 68 | finally { session.close() } | |
| 69 | }) | |
| 70 | ||
| 71 | test('a foreign, stale or uncertain receipt takes only the declared counted failure route', () => { | |
| 72 | for (const changed of [ | |
| 73 | { reference: '101:0,0,0,0,0' }, { eventId: 'source-vehicle-event' }, { containerId: 'parcel-b' }, | |
| 74 | { capturedAtMs: 599 }, { capturedAtMs: 641 }, { outcome: 'uncertain' as const }, { outcome: 'rejected' as const }, | |
| 75 | ]) { | |
| 76 | const engine = createVehicleMissionEngine(receivePlan()) | |
| 77 | const request = engine.step(observation(600), 'start').payload! | |
| 78 | const ack = { eventId: request.eventId, taskId: request.taskId, operation: request.operation, containerId: request.containerId, reference: request.reference, positionM: request.positionM, capturedAtMs: 620, outcome: 'confirmed' as 'confirmed' | 'uncertain' | 'rejected', ...changed } | |
| 79 | const row = engine.step({ ...observation(640), payloadAck: ack }) | |
| 80 | expect(row.taskId).toBe('divert') | |
| 81 | expect(row.payloadFailure).toMatchObject({ taskId: 'intake', operation: 'material-receive', approachTaskId: 'divert', landingTaskId: 'alternate' }) | |
| 82 | expect(row.payload).toBeNull() | |
| 83 | expect(row.reason).toBe('payload-failure-diversion') | |
| 84 | } | |
| 85 | }) | |
| 86 | ||
| 87 | test('receiving cannot start or accept an ACK away from its pad, moving or without contact', () => { | |
| 88 | for (const change of [{ onGround: false }, { positionM: [3, 2, -.31] as [number, number, number] }, { velocityMps: [.101, 0, 0] as [number, number, number] }]) { | |
| 89 | for (const atEntry of [true, false]) { | |
| 90 | const engine = createVehicleMissionEngine(receivePlan()) | |
| 91 | let result | |
| 92 | if (atEntry) result = engine.step({ ...observation(600), ...change }, 'start') | |
| 93 | else { | |
| 94 | const pending = engine.step(observation(600), 'start').payload! | |
| 95 | result = engine.step({ ...observation(620), ...change, payloadAck: { eventId: pending.eventId, taskId: pending.taskId, operation: pending.operation, containerId: pending.containerId, reference: pending.reference, positionM: pending.positionM, capturedAtMs: 620, outcome: 'confirmed' } }) | |
| 96 | } | |
| 97 | expect(result.state).toBe('blocked') | |
| 98 | expect(result.payload).toBeNull() | |
| 99 | expect(result.taskId).not.toBe('depart') | |
| 100 | expect(result.targetM).toBeNull() | |
| 101 | } | |
| 102 | } | |
| 103 | }) | |
| 104 | ||
| 105 | test('receipt expiry diverts once and cannot be cleared by a late ACK', () => { | |
| 106 | const engine = createVehicleMissionEngine(receivePlan()) | |
| 107 | const pending = engine.step(observation(600), 'start').payload! | |
| 108 | for (let atMs = 620; atMs <= 60600; atMs += 20) engine.step(observation(atMs)) | |
| 109 | const timedOut = engine.snapshot() | |
| 110 | expect(timedOut.taskId).toBe('divert') | |
| 111 | expect(timedOut.payloadFailure).toMatchObject({ reason: 'timeout', triggeredAtMs: 60600 }) | |
| 112 | const late = engine.step({ ...observation(60620), payloadAck: { eventId: pending.eventId, taskId: pending.taskId, operation: pending.operation, containerId: pending.containerId, reference: pending.reference, positionM: pending.positionM, capturedAtMs: 60600, outcome: 'confirmed' } }) | |
| 113 | expect(late.state).toBe('blocked') | |
| 114 | expect(late.taskId).toBe('divert') | |
| 115 | expect(late.reason).toBe('unsolicited-payload-ack') | |
| 116 | }) | |
| 117 | ||
| 118 | test('receipt validation preserves finite graph reserve and rejects unsupported profiles, missing anchors and retry paths', () => { | |
| 119 | for (const change of [ | |
| 120 | (p: ReturnType<typeof receivePlan>) => { p.vehicle = 'ground' }, | |
| 121 | (p: ReturnType<typeof receivePlan>) => { Reflect.set(p.tasks[1]!, 'containerId', null) }, | |
| 122 | (p: ReturnType<typeof receivePlan>) => { delete p.tasks[1]!.positionM }, | |
| 123 | (p: ReturnType<typeof receivePlan>) => { p.tasks[1]!.positionM = [3, 2, -1.001] }, | |
| 124 | (p: ReturnType<typeof receivePlan>) => { p.tasks[1]!.timeoutMs = 60001 }, | |
| 125 | (p: ReturnType<typeof receivePlan>) => { p.tasks[1]!.timeoutMs = 100.5 }, | |
| 126 | (p: ReturnType<typeof receivePlan>) => { p.tasks[5]!.next = 'intake' }, | |
| 127 | (p: ReturnType<typeof receivePlan>) => { p.tasks[1]!.onFailure = 'depart' }, | |
| 128 | (p: ReturnType<typeof receivePlan>) => { p.tasks[3]!.timeoutMs = 60000; p.tasks[5]!.timeoutMs = 60000; p.power.availableWh = 20 }, | |
| 129 | ]) { | |
| 130 | const input = receivePlan(); change(input) | |
| 131 | expect(() => validateVehicleMission(input)).toThrow() | |
| 132 | } | |
| 133 | const alternateBudget = receivePlan() | |
| 134 | alternateBudget.tasks[3]!.timeoutMs = 1000; alternateBudget.tasks[4]!.timeoutMs = 30000; alternateBudget.tasks[5]!.timeoutMs = 30000 | |
| 135 | expect(validateVehicleMission(alternateBudget).worstCaseMs).toBe(120020) | |
| 136 | }) | |
| 137 | ||
| 138 | test('missing or nonfinite ACK coordinates cannot confirm receipt or create an unreplayable accepted trace', () => { | |
| 139 | for (const position of [Array<number>(3), [3, , -.1], [NaN, 2, -.1], [3, 2, Infinity]]) { | |
| 140 | const engine = createVehicleMissionEngine(receivePlan()), pending = engine.step(observation(600), 'start').payload! | |
| 141 | const ack = { eventId: pending.eventId, taskId: pending.taskId, operation: pending.operation, containerId: pending.containerId, reference: pending.reference, positionM: position as [number, number, number], capturedAtMs: 620, outcome: 'confirmed' as const } | |
| 142 | expect(() => engine.step({ ...observation(620), payloadAck: ack })).toThrow('Invalid vehicle mission observation') | |
| 143 | expect(engine.snapshot().state).toBe('blocked') | |
| 144 | expect(engine.snapshot().taskId).toBe('intake') | |
| 145 | expect(engine.snapshot().payload).toBeNull() | |
| 146 | expect(engine.snapshot().reason).not.toBe('payload-confirmed') | |
| 147 | expect(() => parseVehicleMissionTrace(JSON.stringify(engine.trace()))).not.toThrow() | |
| 148 | } | |
| 149 | }) | |
| @@ -0,0 +1,194 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { createCargoStationHandler } from '../../resources/functions/drone/cargo/station' | |
| 3 | import { createReceivingRigidBody, createRigidBody } from '../../resources/functions/drone/controls/physics' | |
| 4 | ||
| 5 | // Physical function seam: one real separated parcel crosses a finite station path | |
| 6 | // and enters actual receiving dynamics. This is not vehicle authority or a custody ACK. | |
| 7 | test('one separated point load moves continuously to the receiving joint and leaves the station once', () => { | |
| 8 | const load = { id: 'parcel-a', massKg: .1, forwardM: .08 } | |
| 9 | const source = createRigidBody({ positionM: [0, 0, 0], carriedPointLoad: load }) | |
| 10 | const receiver = createReceivingRigidBody({ positionM: [1, 0, 0], expectedPointLoad: load }) | |
| 11 | source.step([0, 0, 0, 0], .004); receiver.step([0, 0, 0, 0], .004) | |
| 12 | const release = source.separateCarriedLoad() | |
| 13 | if (release.outcome !== 'separated') throw new Error('Expected actual source separation') | |
| 14 | const station = createCargoStationHandler({ atMs: 4, parcel: { id: load.id, ...release.parcel }, pickup: { centerM: [.08, 0, 0], radiusM: .1 }, destinationM: receiver.intakePoint().positionM, maxSpeedMps: .5, maxAccelerationMps2: 1 }) | |
| 15 | expect(station.snapshot().ownedMassKg).toBe(.1) | |
| 16 | expect(source.loadSnapshot().properties.totalMassKg + receiver.loadSnapshot().properties.totalMassKg + station.snapshot().ownedMassKg).toBe(3.1) | |
| 17 | expect(station.start()).toEqual({ outcome: 'started', atMs: 4, durationMs: 3752 }) | |
| 18 | let previous = station.snapshot().parcel!.positionM | |
| 19 | for (let nowMs = 8; nowMs <= 3756; nowMs += 4) { | |
| 20 | receiver.step([0, 0, 0, 0], .004) | |
| 21 | const state = station.step(nowMs), parcel = state.parcel! | |
| 22 | expect(Math.hypot(...parcel.positionM.map((n, i) => n - previous[i]!))).toBeLessThanOrEqual(.002 + 1e-12) | |
| 23 | if (nowMs === 1880) { expect(parcel.positionM[0]).toBeCloseTo(.58, 14); expect(parcel.velocityMps[0]).toBeCloseTo(.4997334754797441, 13) } | |
| 24 | previous = parcel.positionM | |
| 25 | } | |
| 26 | expect(station.snapshot().phase).toBe('ready') | |
| 27 | const exit = station.takeReadyParcel() | |
| 28 | if (exit.outcome !== 'departed') throw new Error('Expected actual station exit') | |
| 29 | expect(receiver.attachParcel(exit.parcel).outcome).toBe('attached') | |
| 30 | expect(station.snapshot().parcel).toBeNull() | |
| 31 | expect(station.snapshot().ownedMassKg).toBe(0) | |
| 32 | expect(source.loadSnapshot().properties.totalMassKg + receiver.loadSnapshot().properties.totalMassKg).toBe(3.1) | |
| 33 | expect(station.takeReadyParcel()).toEqual({ outcome: 'refused', reason: 'not-ready' }) | |
| 34 | expect(Math.abs(station.snapshot().energy.residualJ)).toBeLessThan(1e-12) | |
| 35 | }) | |
| 36 | ||
| 37 | test('the actual released point falls before surface capture and retains its impact energy and epoch', () => { | |
| 38 | const load = { id: 'parcel-a', massKg: .1, forwardM: .08 }, gravity = 9.80665 | |
| 39 | const source = createRigidBody({ positionM: [0, 0, -1], carriedPointLoad: load }) | |
| 40 | source.step([0, 0, 0, 0], .004) | |
| 41 | const release = source.separateCarriedLoad() | |
| 42 | if (release.outcome !== 'separated') throw new Error('Expected airborne source separation') | |
| 43 | const initial = release.parcel, v0 = initial.velocityMps[2], height = -initial.positionM[2] | |
| 44 | const hitAtMs = 4 + 1000 * (Math.sqrt(v0 ** 2 + 2 * gravity * height) - v0) / gravity | |
| 45 | const station = createCargoStationHandler({ atMs: 4, parcel: { id: load.id, ...initial }, pickup: { centerM: [.08, 0, 0], radiusM: .1 }, destinationM: [1.08, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 }) | |
| 46 | expect(station.snapshot().phase).toBe('falling') | |
| 47 | expect(station.start()).toEqual({ outcome: 'refused', reason: 'not-held' }) | |
| 48 | let nowMs = 4 | |
| 49 | while (station.snapshot().phase === 'falling') { | |
| 50 | nowMs += 4 | |
| 51 | const state = station.step(nowMs), seconds = (nowMs - 4) / 1000 | |
| 52 | expect(Math.abs(state.energy.residualJ)).toBeLessThan(1e-12) | |
| 53 | if (state.phase === 'falling') expect(state.parcel!.positionM[2]).toBeCloseTo(initial.positionM[2] + v0 * seconds + .5 * gravity * seconds ** 2, 12) | |
| 54 | expect(nowMs).toBeLessThan(1000) | |
| 55 | } | |
| 56 | const landed = station.snapshot() | |
| 57 | expect(landed.phase).toBe('held') | |
| 58 | expect(landed.parcel!.positionM).toEqual([.08, 0, 0]) | |
| 59 | expect(landed.parcel!.velocityMps).toEqual([0, 0, 0]) | |
| 60 | expect(landed.impact!.atMs).toBeCloseTo(hitAtMs, 9) | |
| 61 | expect(landed.impact!.impulseNs[2]).toBeCloseTo(-.1 * Math.sqrt(v0 ** 2 + 2 * gravity * height), 12) | |
| 62 | expect(landed.energy.planeDissipationJ).toBeCloseTo(.05 * v0 ** 2 + .1 * gravity * height, 12) | |
| 63 | expect(landed.energy.stationNetWorkJ).toBe(0) | |
| 64 | expect(station.start().outcome).toBe('started') | |
| 65 | }) | |
| 66 | ||
| 67 | test('cancellation brakes the real moving parcel finitely and retains it instead of transferring it', () => { | |
| 68 | const station = createCargoStationHandler({ atMs: 4, parcel: { id: 'parcel-a', massKg: .1, positionM: [.08, 0, 0], velocityMps: [0, 0, 0] }, pickup: { centerM: [.08, 0, 0], radiusM: .1 }, destinationM: [1.08, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 }) | |
| 69 | station.start() | |
| 70 | for (let atMs = 8; atMs <= 1880; atMs += 4) station.step(atMs) | |
| 71 | const before = station.snapshot(), v0 = before.parcel!.velocityMps[0] | |
| 72 | expect(station.cancel()).toEqual({ outcome: 'cancelling', atMs: 1880 }) | |
| 73 | expect(station.snapshot().phase).toBe('braking') | |
| 74 | expect(station.snapshot().parcel).toEqual(before.parcel) | |
| 75 | expect(station.takeReadyParcel()).toEqual({ outcome: 'refused', reason: 'not-ready' }) | |
| 76 | let atMs = 1880, previous = before.parcel! | |
| 77 | while (station.snapshot().phase === 'braking') { | |
| 78 | atMs += 4 | |
| 79 | const row = station.step(atMs), p = row.parcel! | |
| 80 | expect(p.velocityMps[0]).toBeGreaterThanOrEqual(0) | |
| 81 | expect(p.velocityMps[0]).toBeLessThan(previous.velocityMps[0]) | |
| 82 | expect(Math.abs(p.velocityMps[0] - previous.velocityMps[0])).toBeLessThanOrEqual(.004 + 1e-12) | |
| 83 | expect(Math.hypot(...p.positionM.map((n, i) => n - previous.positionM[i]!))).toBeLessThanOrEqual(.002 + 1e-12) | |
| 84 | expect(Math.abs(row.energy.residualJ)).toBeLessThan(1e-12) | |
| 85 | expect(atMs).toBeLessThan(2500) | |
| 86 | previous = p | |
| 87 | } | |
| 88 | const stopped = station.snapshot() | |
| 89 | expect(stopped.phase).toBe('cancelled') | |
| 90 | expect(stopped.parcel!.velocityMps).toEqual([0, 0, 0]) | |
| 91 | expect(stopped.parcel!.positionM[0]).toBeCloseTo(.58 + v0 ** 2 / 2, 12) | |
| 92 | expect(stopped.stoppedAtMs).toBeCloseTo(1880 + v0 * 1000, 9) | |
| 93 | expect(stopped.ownedMassKg).toBe(.1) | |
| 94 | expect(station.start()).toEqual({ outcome: 'refused', reason: 'not-held' }) | |
| 95 | expect(station.cancel()).toEqual({ outcome: 'refused', reason: 'already-cancelled' }) | |
| 96 | expect(station.step(atMs + 4).parcel).toEqual(stopped.parcel) | |
| 97 | }) | |
| 98 | ||
| 99 | test('a drifting parcel misses the declared pickup instead of being pulled onto the rail', () => { | |
| 100 | const station = createCargoStationHandler({ atMs: 0, parcel: { id: 'parcel-a', massKg: .1, positionM: [0, 0, -1], velocityMps: [1, 0, 0] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 }) | |
| 101 | let atMs = 0 | |
| 102 | while (station.snapshot().phase === 'falling') station.step(atMs += 4) | |
| 103 | const missed = station.snapshot() | |
| 104 | expect(missed.phase).toBe('missed') | |
| 105 | // Independent one-metre drop: flight time sqrt(2/g), with constant 1 m/s north velocity. | |
| 106 | expect(missed.parcel!.positionM[0]).toBeCloseTo(Math.sqrt(2 / 9.80665), 12) | |
| 107 | expect(missed.energy.planeDissipationJ).toBeCloseTo(1.030665, 12) | |
| 108 | expect(missed.impact!.impulseNs[0]).toBe(-.1) | |
| 109 | expect(missed.ownedMassKg).toBe(.1) | |
| 110 | expect(Math.abs(missed.energy.residualJ)).toBeLessThan(1e-12) | |
| 111 | expect(station.start()).toEqual({ outcome: 'refused', reason: 'not-held' }) | |
| 112 | expect(station.takeReadyParcel()).toEqual({ outcome: 'refused', reason: 'not-ready' }) | |
| 113 | expect(station.step(atMs + 4).parcel).toEqual(missed.parcel) | |
| 114 | }) | |
| 115 | ||
| 116 | test('cancelling a rising parcel retains gravity and impact before it can settle', () => { | |
| 117 | const station = createCargoStationHandler({ atMs: 0, parcel: { id: 'parcel-a', massKg: .1, positionM: [0, 0, 0], velocityMps: [0, 0, -1] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 }) | |
| 118 | expect(station.cancel()).toEqual({ outcome: 'cancelling', atMs: 0 }) | |
| 119 | expect(station.snapshot().phase).toBe('falling') | |
| 120 | expect(station.snapshot().forceN).toEqual([0, 0, 0]) | |
| 121 | expect(station.step(4).parcel!.positionM[2]).toBeCloseTo(-.0039215468, 12) | |
| 122 | let atMs = 4 | |
| 123 | while (station.snapshot().phase === 'falling') station.step(atMs += 4) | |
| 124 | const stopped = station.snapshot() | |
| 125 | expect(stopped.phase).toBe('cancelled') | |
| 126 | expect(stopped.impact!.atMs).toBeCloseTo(2000 / 9.80665, 9) | |
| 127 | expect(stopped.stoppedAtMs).toBe(stopped.impact!.atMs) | |
| 128 | expect(stopped.energy.planeDissipationJ).toBeCloseTo(.05, 12) | |
| 129 | expect(stopped.energy.stationNetWorkJ).toBe(0) | |
| 130 | expect(Math.abs(stopped.energy.residualJ)).toBeLessThan(1e-12) | |
| 131 | expect(station.start().outcome).toBe('refused') | |
| 132 | expect(station.takeReadyParcel().outcome).toBe('refused') | |
| 133 | }) | |
| 134 | ||
| 135 | test('lifting retains mechanical work and speed/acceleration limits on the full spatial path', () => { | |
| 136 | const station = createCargoStationHandler({ atMs: 0, parcel: { id: 'parcel-a', massKg: .2, positionM: [0, 0, 0], velocityMps: [0, 0, 0] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 2, -1], maxSpeedMps: .5, maxAccelerationMps2: 1 }) | |
| 137 | station.start() | |
| 138 | let atMs = 0, previous = station.snapshot() | |
| 139 | while (station.snapshot().phase === 'moving') { | |
| 140 | const row = station.step(atMs += 4) | |
| 141 | expect(Math.hypot(...row.parcel!.velocityMps)).toBeLessThanOrEqual(.5 + 1e-12) | |
| 142 | expect(Math.hypot(...row.parcel!.velocityMps.map((n, i) => (n - previous.parcel!.velocityMps[i]!) / .004))).toBeLessThanOrEqual(1 + 1e-12) | |
| 143 | expect(Math.abs(row.energy.residualJ)).toBeLessThan(1e-12) | |
| 144 | previous = row | |
| 145 | } | |
| 146 | expect(previous.parcel!.positionM).toEqual([1, 2, -1]) | |
| 147 | expect(Math.hypot(...previous.parcel!.velocityMps)).toBe(0) | |
| 148 | expect(previous.energy.stationNetWorkJ).toBeCloseTo(1.96133, 12) | |
| 149 | expect(previous.energy.positiveWorkJ).toBeGreaterThanOrEqual(previous.energy.stationNetWorkJ) | |
| 150 | expect(previous.energy.positiveWorkJ + previous.energy.negativeWorkJ).toBeCloseTo(previous.energy.stationNetWorkJ, 12) | |
| 151 | expect(station.takeReadyParcel().outcome).toBe('departed') | |
| 152 | expect(station.snapshot().energy.departedJ).toBeCloseTo(1.96133, 12) | |
| 153 | expect(Math.abs(station.snapshot().energy.residualJ)).toBeLessThan(1e-12) | |
| 154 | }) | |
| 155 | ||
| 156 | test('station admission captures data without invoking caller accessors and rejects invalid declarations privately', () => { | |
| 157 | const input = { atMs: 0, parcel: { id: 'parcel-a', massKg: .1, positionM: [0, 0, 0], velocityMps: [0, 0, 0] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 } | |
| 158 | const station = createCargoStationHandler(input), original = station.snapshot() | |
| 159 | input.parcel.positionM[0] = 20; input.parcel.massKg = .2; input.pickup.centerM[0] = 10; input.destinationM[0] = 40 | |
| 160 | const detached = station.snapshot() | |
| 161 | detached.parcel!.positionM[0] = 88; detached.impact!.impulseNs[0] = 19; detached.energy.initialJ = 500 | |
| 162 | expect(station.snapshot()).toEqual(original) | |
| 163 | expect(station.start()).toEqual({ outcome: 'started', atMs: 0, durationMs: 3752 }) | |
| 164 | expect(station.start()).toEqual({ outcome: 'refused', reason: 'not-held' }) | |
| 165 | const fresh = { atMs: 0, parcel: { id: 'parcel-a', massKg: .1, positionM: [0, 0, 0], velocityMps: [0, 0, 0] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 } | |
| 166 | const getter = structuredClone(fresh) | |
| 167 | let invoked = 0 | |
| 168 | Object.defineProperty(getter.parcel, 'positionM', { enumerable: true, get() { invoked++; throw new Error('private-source') } }) | |
| 169 | const revoked = Proxy.revocable({}, {}); revoked.revoke() | |
| 170 | const bad = [null, {}, { ...fresh, extra: true }, { ...fresh, atMs: 3 }, { ...fresh, atMs: -4 }, { ...fresh, atMs: Number.MAX_SAFE_INTEGER }, { ...fresh, maxSpeedMps: 0 }, { ...fresh, maxAccelerationMps2: Infinity }, { ...fresh, parcel: { ...fresh.parcel, massKg: .251 } }, { ...fresh, parcel: { ...fresh.parcel, positionM: [0, 0, .01] } }, { ...fresh, parcel: { ...fresh.parcel, positionM: [0, 0, -3.01] } }, { ...fresh, parcel: { ...fresh.parcel, velocityMps: [0, 0, NaN] } }, { ...fresh, destinationM: [50, 0, 0] }, getter, revoked.proxy] | |
| 171 | for (const v of bad) expect(() => createCargoStationHandler(v)).toThrow('cargo-station-constructor') | |
| 172 | expect(invoked).toBe(0) | |
| 173 | }) | |
| 174 | ||
| 175 | test('invalid and exhausted clocks cannot change the parcel, and delayed starts preserve the lifetime bound', () => { | |
| 176 | const config = { atMs: 0, parcel: { id: 'parcel-a', massKg: .1, positionM: [0, 0, 0], velocityMps: [0, 0, 0] }, pickup: { centerM: [0, 0, 0], radiusM: .1 }, destinationM: [1, 0, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 } | |
| 177 | const station = createCargoStationHandler(config), initial = station.snapshot() | |
| 178 | for (const invalid of [0, -4, 1, 8, NaN, Infinity]) expect(() => station.step(invalid)).toThrow('cargo-station-clock') | |
| 179 | expect(() => station.step(4, 'extra')).toThrow('cargo-station-clock') | |
| 180 | expect(() => station.start('extra')).toThrow('cargo-station-arguments') | |
| 181 | expect(() => station.cancel('extra')).toThrow('cargo-station-arguments') | |
| 182 | expect(() => station.takeReadyParcel('extra')).toThrow('cargo-station-arguments') | |
| 183 | expect(station.snapshot()).toEqual(initial) | |
| 184 | for (let atMs = 4; atMs <= 179996; atMs += 4) station.step(atMs) | |
| 185 | const held = station.snapshot() | |
| 186 | expect(station.start()).toEqual({ outcome: 'refused', reason: 'deadline' }) | |
| 187 | expect(station.snapshot()).toEqual(held) | |
| 188 | station.step(180000) | |
| 189 | const end = station.snapshot() | |
| 190 | expect(() => station.step(180004)).toThrow('cargo-station-clock') | |
| 191 | expect(station.snapshot()).toEqual(end) | |
| 192 | expect(station.cancel()).toEqual({ outcome: 'cancelled', atMs: 180000 }) | |
| 193 | expect(station.takeReadyParcel().outcome).toBe('refused') | |
| 194 | }) | |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { expect, test } from 'bun:test' |
| 2 | import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' | |
| 2 | import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' | |
| 3 | 3 | import { tmpdir } from 'node:os' |
| 4 | 4 | import { join } from 'node:path' |
| 5 | 5 | import process from 'node:process' |
| @@ -25,6 +25,11 @@ test.skipIf(process.platform === 'win32')('failed native launch retains startup | ||
| 25 | 25 | expect(report.launch.cleanupRequested).toBe(true) |
| 26 | 26 | expect(report.portProbe.attempts).toBe(400) |
| 27 | 27 | expect(report.portProbe.lastReadError).toBe('ENOENT') |
| 28 | expect(report.startup.platform).toBe(process.platform) | |
| 29 | expect(report.startup.method).toBe('startup-only-process-observation-v1') | |
| 30 | expect(report.startup.snapshots.at(-1).label).toBe('cleanup-ended') | |
| 31 | expect(report.startup.snapshots.at(-1).root.pid).toBe(actualPid) | |
| 32 | expect(report.startup.snapshots.at(-1).root.probe).toBe('missing') | |
| 28 | 33 | expect(report.stderr.status).toBe('retained') |
| 29 | 34 | expect(report.stderr.retainedBytes).toBeLessThanOrEqual(65536) |
| 30 | 35 | expect(await readFile(join(root, 'chrome-startup-stderr.log'), 'utf8')).toContain('controlled native browser startup refusal') |
| @@ -46,3 +51,57 @@ test.skipIf(process.platform === 'win32')('failed native launch retains startup | ||
| 46 | 51 | else console.log(JSON.stringify({ evidence: 'controlled-native-launch', retainedRoot: root, passed })) |
| 47 | 52 | } |
| 48 | 53 | }, 30000) |
| 54 | ||
| 55 | // The existing native launch seam checks observed processes, not the cause of a Chrome failure. | |
| 56 | test.skipIf(process.platform === 'win32')('a live native launcher and child remain observable until cleanup without exporting argv or environment', async () => { | |
| 57 | const supplied = process.env.BROWSER_LAUNCH_LIVE_EVIDENCE | |
| 58 | const root = supplied ? (await mkdir(supplied), supplied) : await mkdtemp(join(tmpdir(), 'drone-browser-live-')) | |
| 59 | const executable = join(root, 'launch.ts'), pidPath = join(root, 'pids.json') | |
| 60 | const original = process.env.BROWSE_BROWSER | |
| 61 | let passed = false | |
| 62 | try { | |
| 63 | await writeFile(executable, `#!${process.execPath}\nimport { writeFileSync } from 'node:fs'\nconst child=Bun.spawn([process.execPath,'-e','setInterval(()=>{},1000)','PRIVATE_CHILD_ARG_SENTINEL'],{stdout:'ignore',stderr:'ignore',env:{...process.env,PRIVATE_FIXTURE_TOKEN:'PRIVATE_CHILD_ENV_SENTINEL'}})\nwriteFileSync(${JSON.stringify(pidPath)},JSON.stringify([process.pid,child.pid]))\nprocess.on('SIGTERM',async()=>{child.kill();await child.exited;process.exit(0)})\nconsole.error('controlled live browser startup fixture')\nsetInterval(()=>{},1000)\n`, { mode: 0o700 }) | |
| 64 | process.env.BROWSE_BROWSER = executable | |
| 65 | await expect(startWorkspaceBrowser(root)).rejects.toThrow('Fresh browser must start') | |
| 66 | const [pid, childPid] = JSON.parse(await readFile(pidPath, 'utf8')) as [number, number] | |
| 67 | const text = await readFile(join(root, 'browser-launch.json'), 'utf8'), report = JSON.parse(text) | |
| 68 | expect(text).not.toContain('PRIVATE_CHILD_ARG_SENTINEL') | |
| 69 | expect(text).not.toContain('PRIVATE_CHILD_ENV_SENTINEL') | |
| 70 | expect(report.portProbe.attempts).toBe(400) | |
| 71 | expect(report.launch.exitObservedBeforeCleanup).toBe(false) | |
| 72 | expect(report.launch.exitObserved).toBe(true) | |
| 73 | const before = report.startup.snapshots.find((s: any) => s.label === 'discovery-ended') | |
| 74 | expect(before.root.pid).toBe(pid) | |
| 75 | expect(before.root.probe).toBe('present') | |
| 76 | expect(report.startup.snapshots.at(-1).root.probe).toBe('missing') | |
| 77 | expect(report.startup.executables).toBeArray() | |
| 78 | if (process.platform === 'linux') { | |
| 79 | expect(before.linux.status).toBe('observed') | |
| 80 | expect(before.linux.root.startTicks).toMatch(/^\d+$/) | |
| 81 | expect(before.linux.root.rssKiB).toBeGreaterThan(0) | |
| 82 | expect(before.linux.descendants.some((p: any) => p.pid === childPid && p.parentPid === pid)).toBe(true) | |
| 83 | const identity = report.startup.executables.find((e: any) => e.observedPids.includes(pid) && e.sha256 !== null) | |
| 84 | expect(identity).toBeDefined() | |
| 85 | expect(identity.byteLength).toBe((await stat(process.execPath)).size) | |
| 86 | expect(identity.sha256).toBe(new Bun.CryptoHasher('sha256').update(await Bun.file(process.execPath).arrayBuffer()).digest('hex')) | |
| 87 | expect(identity.hashedAfterDiscovery).toBe(true) | |
| 88 | } | |
| 89 | else expect(before.linux.status).toBe('unsupported') | |
| 90 | expect(() => process.kill(pid, 0)).toThrow() | |
| 91 | expect(() => process.kill(childPid, 0)).toThrow() | |
| 92 | passed = true | |
| 93 | } | |
| 94 | finally { | |
| 95 | if (original === undefined) delete process.env.BROWSE_BROWSER | |
| 96 | else process.env.BROWSE_BROWSER = original | |
| 97 | const pids: number[] = JSON.parse(await readFile(pidPath, 'utf8').catch(() => '[]')) | |
| 98 | for (const pid of pids) { | |
| 99 | let gone = false | |
| 100 | try { process.kill(pid, 0) } | |
| 101 | catch (error) { gone = (error as NodeJS.ErrnoException).code === 'ESRCH' } | |
| 102 | expect(gone).toBe(true) | |
| 103 | } | |
| 104 | if (passed && !supplied) await rm(root, { recursive: true, force: true }) | |
| 105 | else console.log(JSON.stringify({ evidence: 'controlled-live-native-launch', retainedRoot: root, passed })) | |
| 106 | } | |
| 107 | }, 35000) | |
| @@ -0,0 +1,80 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { mkdtemp, readlink, readdir, rm } from 'node:fs/promises' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { createBrowserStartupObserver } from '../helpers/browser-startup-process' | |
| 6 | import type { StartupProcessAdapter } from '../helpers/browser-startup-process' | |
| 7 | import { createLinuxStartupObservation } from '../helpers/browser-startup-linux' | |
| 8 | ||
| 9 | // OS adapter seam: a held syscall promise checks ownership/cancellation, not Linux field acquisition. | |
| 10 | test('an expired observation remains owned through actual settlement and prevents later provider work', async () => { | |
| 11 | let release!: () => void | |
| 12 | const held = new Promise<void>(resolve => { release = resolve }) | |
| 13 | let calls = 0, active = 0, maximumActive = 0, finalized = false | |
| 14 | const settlements: boolean[] = [] | |
| 15 | const adapter: StartupProcessAdapter = { | |
| 16 | async snapshot() { | |
| 17 | calls++; active++; maximumActive = Math.max(maximumActive, active) | |
| 18 | try { if (calls === 1) await held; return { status: 'unavailable', code: 'CONTROLLED_OS' } } | |
| 19 | finally { active-- } | |
| 20 | }, | |
| 21 | async settleExecutables(hash) { expect(active).toBe(0); settlements.push(hash) }, | |
| 22 | evidence: () => ({ executables: [], identityErrors: [], executableLimitReached: false }), | |
| 23 | } | |
| 24 | const observer = createBrowserStartupObserver(process.pid, performance.now() - 1001, adapter) | |
| 25 | let finishing: Promise<void> | undefined | |
| 26 | try { | |
| 27 | observer.tick() | |
| 28 | await Bun.sleep(600) | |
| 29 | observer.tick() | |
| 30 | expect(calls).toBe(1) | |
| 31 | finishing = observer.finishDiscovery().then(() => { finalized = true }) | |
| 32 | await Bun.sleep(10) | |
| 33 | expect(finalized).toBe(false) | |
| 34 | expect(settlements).toEqual([]) | |
| 35 | release(); await finishing | |
| 36 | expect(maximumActive).toBe(1) | |
| 37 | expect(calls).toBe(1) | |
| 38 | expect(settlements).toEqual([false]) | |
| 39 | const final = observer.evidence() | |
| 40 | observer.tick(); await Bun.sleep(10) | |
| 41 | expect(observer.evidence()).toEqual(final) | |
| 42 | expect(final.snapshots[0]!.linux.status).toBe('timeout') | |
| 43 | expect(final.snapshots[0]!.completedAtMs).toBeGreaterThan(final.snapshots[0]!.elapsedMs + 500) | |
| 44 | } | |
| 45 | finally { release(); await finishing; await Bun.sleep(0) } | |
| 46 | }) | |
| 47 | ||
| 48 | test.skipIf(process.platform !== 'linux')('cancellation closes actual pinned executable descriptors without hashing after the held native sample settles', async () => { | |
| 49 | const root = await mkdtemp(join(tmpdir(), 'drone-observer-descriptors-')) | |
| 50 | const executable = await readlink('/proc/self/exe') | |
| 51 | const descriptors = async () => { | |
| 52 | const ids = await readdir('/proc/self/fd') | |
| 53 | const targets = await Promise.all(ids.map(id => readlink(`/proc/self/fd/${id}`).catch(() => null))) | |
| 54 | return targets.filter(path => path === executable).length | |
| 55 | } | |
| 56 | const before = await descriptors(), began = performance.now() | |
| 57 | const native = createLinuxStartupObservation(process.pid, root, began) | |
| 58 | let release!: () => void, ready!: () => void | |
| 59 | const held = new Promise<void>(resolve => { release = resolve }), captured = new Promise<void>(resolve => { ready = resolve }) | |
| 60 | const observer = createBrowserStartupObserver(process.pid, began, { | |
| 61 | async snapshot(signal) { | |
| 62 | const result = await native.snapshot(signal).catch(() => ({ status: 'unavailable' as const, code: 'NATIVE_UNAVAILABLE' })) | |
| 63 | ready(); await held; return result | |
| 64 | }, | |
| 65 | settleExecutables: hash => native.settleExecutables(hash), evidence: native.evidence, | |
| 66 | }) | |
| 67 | let finishing: Promise<void> | undefined | |
| 68 | try { | |
| 69 | observer.tick(); await captured | |
| 70 | expect(await descriptors()).toBeGreaterThan(before) | |
| 71 | await Bun.sleep(600) | |
| 72 | finishing = observer.finishDiscovery() | |
| 73 | release(); await finishing | |
| 74 | const report = observer.evidence() | |
| 75 | expect(report.executables.length).toBeGreaterThan(0) | |
| 76 | expect(report.executables.every(e => e.sha256 === null && e.code === 'OBSERVATION_CANCELED' && e.closeCode === null)).toBe(true) | |
| 77 | expect(await descriptors()).toBe(before) | |
| 78 | } | |
| 79 | finally { release(); await (finishing ?? observer.finishDiscovery()); await rm(root, { recursive: true, force: true }) } | |
| 80 | }) | |