ReviewOS

also looking at this

stacks/drone

test: retain owned browser startup process evidence

#100
Merged chris wants to merge codex/browser-startup-process-evidence into main
21 files +1516 -155
.github/workflows/ci.ymlmodified+16-0
Changes to .github/workflows/ci.yml
@@ -30,6 +30,22 @@ jobs:
3030 - run: bun run parity:check
3131 - run: python3 -m py_compile resources/functions/drone/camera/imx500_worker.py
3232 - 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
3349 - name: Qualify cold build setup across the former hook deadline
3450 run: bun scripts/qualify-runtime-cold-build.ts "$RUNNER_TEMP/runtime-cold-build"
3551 - uses: actions/upload-artifact@v4
config/docs.tsmodified+1-0
Changes to config/docs.ts
@@ -51,6 +51,7 @@ const config: BunPressOptions = {
5151 { text: 'Raspberry Pi setup', link: '/raspberry-pi' },
5252 { text: 'Library API', link: '/api' },
5353 { text: 'Validation evidence', link: '/validation' },
54 { text: 'Browser qualification evidence', link: '/browser-qualification' },
5455 { text: 'Flight-development gates', link: '/flight-development' },
5556 ],
5657 },
docs/adr/0035-shared-clock-vehicle-sessions.mdadded+42-0
Changes to docs/adr/0035-shared-clock-vehicle-sessions.md
@@ -0,0 +1,42 @@
1# ADR 0035: Retained vehicle sessions on one modeled clock
2
3Status: accepted for software simulation.
4
5## Context
6
7Cargo issue #25 needs distinct vehicles with current authority and source evidence at
8the handover epoch. Running one complete fixture and relabeling its result as a fresh
9peer would discard timing, independent watchdog and sensor identity requirements.
10The shared vehicle loop previously ran to completion with fixed boot 55/session 123.
11
12## Decision
13
14Expose `createVehicleSimulationSession` over that same loop. Its caller advances exact
154 ms epochs from zero; the loop retains raw sensing, estimator updates, 20 ms policy,
16mission, controls, authority, payload ownership and independent board state. The ordinary
17`runVehicleSimulation` drains this interface to the same terminal report.
18
19Capture an optional nonzero uint64 boot/session scope before allocating a profile session.
20The actual synthetic raw packets, pipeline clock anchor, estimator reference, supervisor
21and board use that scope. Defaults retain the original identities and report projection.
22Identity labels do not authenticate a peer, prevent duplicate scope selection or establish
23distributed time synchronization.
24
25Ticks expose detached mission/authority/board snapshots, reference, phase and last completed
26host policy epoch. Host loss freezes host work while the finite independent settling tail
27can still advance the board and plant. The caller can advance another live vehicle at the
28same epochs. Payload teardown occurs once. Explicit close or a step exception revokes the
29board and closes ownership; simultaneous failures remain observable.
30
31## Consequences
32
33This is a necessary runtime prerequisite for a captured, two-vehicle custody protocol.
34It does not implement that protocol or claim fleet handover is complete. The cargo
35coordinator must still bind distinct peers, physically modeled parcel transfer, current
36qualified captures, cancellation and conservative uncertain outcomes.
37
38The caller owns stepping and cleanup and must stop stepping a terminal session. Invalid
39epochs do not mutate execution. `result` is unavailable until terminal and detached
40thereafter. Explicit close stops simulation without running recovery or a physical
41settling interval. All physical outputs remain inhibited. This clock is deterministic
42simulation time, not a wall-clock deadline guarantee or real-time hardware scheduler.
docs/adr/0036-point-load-intake.mdadded+53-0
Changes to docs/adr/0036-point-load-intake.md
@@ -0,0 +1,53 @@
1# ADR 0036: One point-load intake for a receiving vehicle
2
3Status: accepted for software simulation.
4
5## Context
6
7Cargo fleet handover needs a physical parcel to leave one carrier, undergo finite modeled
8station handling and enter another carrier's actual dynamics. A custody-label change or
9an initially loaded second vehicle would bypass that mass transfer. The rigid body already
10supports an attached point load and one zero-ejection separation, but had no empty joint.
11
12## Decision
13
14Add `createReceivingRigidBody` over the same integrator, with a captured `expectedPointLoad`.
15Its lifecycle is `awaiting -> attached -> separated`. The initial body has base mass/inertia;
16the planned load is not an actual parcel. A successful intake consumes this one opportunity.
17The old `createRigidBody` constructor still starts attached and exposes no intake method or
18awaiting phase. Its default/no-load numerical paths and result projections remain unchanged.
19
20The receiving joint reports its position and velocity. The incoming parcel record contains
21its own id, mass, position and velocity. Match id/mass exactly and require exact coincident
22binary64 position components at invocation. This is an ideal programmatic contact condition,
23not a sensor tolerance or claim of physical positioning precision. Captured records and
24finite tuple elements reject accessors. Valid refusal or malformed input does not mutate
25the body or consume its intake opportunity.
26
27Let base mass be `m`, point mass `p`, body-forward joint vector `r`, and combined mass `M=m+p`.
28The new body-frame CG is `c=p*r/M`. Combined center velocity is `(m*vBody+p*vParcel)/M`.
29About that center, body-frame angular momentum is base `I*omega` plus
30`m*p/M * cross(r, inverseRotate(vParcel-vBody))`. Dividing by the combined principal inertia
31gives the new angular rate. Reconstruct datum velocity from center velocity and `omega cross c`.
32The joint is on the body x axis, so the existing diagonal parallel-axis inertia is sufficient.
33
34The base and incoming point jointly determine conserved linear/angular momentum. Structural
35position, quaternion and actual motor state remain unchanged. Kinetic energy may decrease;
36retain before/after energy and raw dissipation. Reject non-finite candidates or a gain beyond
37`64 * Number.EPSILON * max(1, kineticBeforeJ, kineticAfterJ)` before committing state. Keep the
38raw represented loss, including tolerated tiny negatives. NED potential is `-mass*g*down`.
39The event reports both potential values separately.
40
41## Consequences
42
43The primitive models an instantaneous inelastic impulse, not contact duration, force peaks,
44parcel spin/deformation, latch actuation or restitution. The retained acceleration is the last
45continuous integration sample, not an invented impact acceleration. A later physics step uses
46the new mass/CG/inertia without resetting the controller, estimator, pose or motor lag.
47
48World/policy code must own and consume the single parcel, admit the physical operation, model
49finite transport to the joint and capture mechanism/vehicle acknowledgement with current
50authority and source epochs. This primitive does not globally prevent a caller from supplying
51the same record to two independent worlds. A joint pose is not a source parcel or proof of custody.
52Station handling and the actual two-vehicle protocol remain unimplemented at this checkpoint.
53No physical output, authenticated identity, battery endurance or fleet completion is claimed.
docs/api.mdmodified+5-0
Changes to docs/api.md
@@ -57,6 +57,11 @@ The [cargo guide](cargo-delivery.md) defines the flat synthetic origin, finite l
5757one-release and receipt semantics, phase-bound faults, native CLI exit codes and remaining
5858fleet/physical/BVLOS work. Budgets are declared power ceilings, not electrical measurements.
5959
60`createReceivingRigidBody({ expectedPointLoad, ...bodyOptions })` in `controls/physics.ts`
61provides the separate [point-load intake prerequisite](cargo-delivery.md#point-load-intake-prerequisite).
62It integrates real modeled mass/CG changes after one coincident inelastic point capture.
63The caller still owns the parcel, finite station handling and captured fleet custody protocol.
64
6065## Target sources
6166
6267`createGpsTargetSource({ targetId, origin, maxSpeedMps?, now? })` converts trusted phone or GNSS fixes
docs/browser-qualification.mdadded+80-0
Changes to docs/browser-qualification.md
@@ -0,0 +1,80 @@
1# Browser qualification observations
2
3The rendered workspace and execution checks launch a fresh native Chrome process in the
4existing fixture-owned process group. They retain the original launch arguments and the
5400-read, 50 ms DevToolsActivePort discovery guard. No retry or browser flag bypass is added.
6The full page/route/Worker scenarios must still pass. A successful native launcher fixture
7does 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
12observer requests samples near launch, 1, 5, 10 and 15 seconds, then at discovery end. Samples
13run asynchronously during discovery, with at most one actual task in flight and a 500 ms
14cooperative cancellation budget. Cancellation stops further process/resource reads after
15outstanding native calls settle. It never releases ownership of an unfinished task. A timeout
16disables later Linux samples and hashing, closes captured descriptors after settlement and
17emits a bounded pending-task notice. Finalization waits for that task; it cannot allow a page
18visit behind it. An uninterruptible filesystem call can hold finalization until the existing
19outer fixture guard fails and terminates its group. This is not a hard OS syscall deadline.
20Actual start/completion epochs are retained; labels are requested milestones, not
21promises that an event occurred at that exact time. A pre-page cleanup sample follows the
22existing root termination/2 second wait. No process or resource sample is requested after a
23page visit. Post-page stderr remains private.
24
25Each sample includes a native root PID existence probe. Linux adds selected `/proc` state:
26parent/group/session, start/user/system ticks, thread count, RSS and kernel wait channel.
27Names, argv, environment, stacks and memory contents are not exported. Main-task child
28traversal and previously observed descendants are capped at 64 processes per sample; root
29thread wait details are capped at 32. Start ticks flag observed PID reuse. Fork/exit/reparenting,
30permissions and truncation can make this incomplete. It is not proof of whole-group cleanup.
31The outer fixture owner separately terminates its isolated group before disposable removal.
32
33Expected discovery-file metadata, observer cgroup-v2 counters and system CPU/memory/IO pressure
34help distinguish missing files, a live process and resource contention. Cgroup counters may
35cover other runner work; system pressure covers other host workloads. Neither proves Chrome
36causality. Unsupported platforms, missing files, permission errors and observation timeouts
37are explicit outcomes. Numeric kernel fields are selected and bounded; raw status, cgroup
38membership, thread names and arbitrary text are not retained.
39
40Startup samples pin at most four distinct actual Linux `/proc/PID/exe` file descriptors
41without reading executable bytes. Only after discovery ends does it hash them under one
422 second cooperative hash budget, with a 512 MiB per-executable limit. Device/inode identity uses exact
43integer strings. Byte count and file timestamps are checked before retaining a digest; all
44pinned descriptors are then closed, with close errors reported. This identifies an observed
45backing executable rather than assuming the configured launcher is Chrome. It neither warms
46the binary before the attempt nor authenticates the code or its entire loaded graph.
47
48The browser version is retained only from a successful CDP version response and a closed
49Chrome version format. If discovery fails, it stays null. Do not infer the failed binary's
50version from another run without matching actual identity evidence. Opening descriptors,
51reading process files and hashing are observation work and can affect scheduling; the
52observed times and unavailable/timeout outcomes must accompany comparisons.
53
54## Native qualification
55
56```sh
57bun 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
60One actual native fixture exits 23 before discovery. Another owns a live child until the
61original cleanup request. The checks preserve all 400 failed probes, inspect actual PIDs,
62verify parent/child shutdown and ensure argv/environment sentinels do not enter the report.
63On Linux the live fixture also checks actual descendant/start/RSS data and the retained
64executable hash against the independently read runtime binary. Optional `BROWSER_LAUNCH_EVIDENCE`
65and `BROWSER_LAUNCH_LIVE_EVIDENCE` retain reports in new directories. CI publishes only the
66allowlisted launch JSON and startup stderr, not scripts, credentials or private fixture files.
67A held OS-adapter promise separately verifies that cancellation cannot overlap another sample,
68finalize early or begin hashing while an operation remains unfinished. It does not qualify
69Linux field acquisition; the actual native Linux fixture provides that evidence.
70A Linux cancellation case also opens real executable descriptors and checks that they are
71closed without a digest once the held native sample settles.
72
73Linux-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
75passing controls. Its root cause and correction remain open until reproduced and verified.
76These observations change evidence, not aircraft control, physical outputs or flight readiness.
77
78Field 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).
docs/cargo-delivery.mdmodified+31-0
Changes to docs/cargo-delivery.md
@@ -149,8 +149,39 @@ temperature limit, voltage-sag prediction or Pi performance result. Changing mas
149149the actual modeled dynamics; it does not identify an electrical load law. A successful
150150budget is conditional on that ceiling and is not measured endurance qualification.
151151
152## Point-load intake prerequisite
153
154`createReceivingRigidBody` in `controls/physics.ts` adds an empty declared joint for a
155future parcel. Supply `expectedPointLoad: { id, massKg, forwardM }`; the mass and offset
156bounds match the carried-load model. Its `awaiting` phase has the base body's actual
157mass/inertia. `intakePoint()` returns only joint position and velocity, not a parcel or
158custody evidence.
159
160`attachParcel({ id, massKg, positionM, velocityMps })` accepts an existing modeled point
161parcel only at the current joint position, equal component by component. Identity and
162mass must match the declaration. The caller must move its one physical-world parcel
163there; this primitive does not transport, copy or globally own it. Malformed own-data
164records throw without changing the body. Valid foreign or noncoincident parcels are
165refused without consuming the joint.
166
167Capture conserves total linear/angular momentum and applies a perfectly inelastic point
168joint. Structural position, orientation and actual motors remain continuous; a non-co-moving
169parcel can impart a genuine velocity/angular-rate impulse. Captured mass changes the actual
170subsequent dynamics, CG and inertia. The event retains detached before/after state, incoming
171parcel and raw kinetic/potential energy accounting. Dissipation is mechanical energy,
172not battery Wh or an identified mechanism efficiency. Tiny binary64 negative loss within
173the reported numerical tolerance is retained rather than clamped.
174
175The joint accepts once, can later separate through the existing zero-ejection model and
176cannot refill after separation. The legacy `createRigidBody` carried-load interface keeps
177its original phase/method contract. [ADR 0036](adr/0036-point-load-intake.md) gives the model
178and limits. This primitive supplies no finite-duration contact forces or impact IMU sample,
179release/intake acknowledgement, station handling, authorization or fleet custody handover.
180Those integrations remain open under issue #25.
181
152182## BVLOS operations evidence
153183
184
154185Country and intended operation must be established separately from library support.
155186Sources below were checked October 24, 2026; inspect their current requirements for the
156187actual operation. This software issues no authorization.
docs/flight-development.mdmodified+3-0
Changes to docs/flight-development.md
@@ -23,3 +23,6 @@ The owned runtime follows the dependency-linked [roadmap](roadmap/README.md). Th
2323support comparison and development while native board controls are implemented and validated.
2424
2525Until those milestones are complete, the code here should be used for simulation and bench development.
26
27Rendered application checks have a separate [browser qualification contract](browser-qualification.md).
28A browser-startup failure does not establish a vehicle, estimator or physical flight failure.
docs/vehicles.mdmodified+61-0
Changes to docs/vehicles.md
@@ -33,6 +33,67 @@ arming/engagement. Independent board leases expire to each declared neutral, inc
3333and air spring/wheel brake channels. Neutral does not imply a vehicle has physically stopped.
3434Host loss leaves a stale host mission snapshot; the independently ticking board still expires output.
3535
36## Shared-clock modeled sessions
37
38`createVehicleSimulationSession(profile, options)` in `vehicles/runtime.ts` retains the same
39owned loop behind `step(atMs)`, `result()` and `close()`. Start at zero and advance exactly
404 ms per call. Two sessions can therefore be advanced at each shared epoch before the
41coordinator evaluates their observations. Rejected duplicate, skipped or invalid epochs
42do not advance the session; a caller can supply the expected epoch afterward.
43
44An optional `scope: { bootId, sessionId }` captures two nonzero unsigned 64-bit bigints
45before session resources open. The boot identity is encoded in the actual synthetic IMU/GNSS
46packets, sensor clock anchor and estimator reference. Both identifiers also scope the
47authority supervisor and independent board. They are correlations, not authentication.
48Omitting scope preserves the original boot 55/session 123 reports.
49
50```ts
51const a = createVehicleSimulationSession(profileA, {
52 seconds: 120, scope: { bootId: 101n, sessionId: 201n },
53})
54const b = createVehicleSimulationSession(profileB, {
55 seconds: 120, scope: { bootId: 102n, sessionId: 202n },
56})
57try {
58 // Advance each live session once at every epoch; stop stepping one after terminal.
59 for (let atMs = 0, doneA = false, doneB = false; !doneA || !doneB; atMs += 4) {
60 if (!doneA) doneA = a.step(atMs).terminal
61 if (!doneB) doneB = b.step(atMs).terminal
62 }
63 console.log(a.result().status, b.result().status)
64}
65finally { a.close(); b.close() }
66```
67
68A tick returns detached mission, authority and board snapshots, the estimator reference,
69its actual epoch, phase, terminal flag and last completed host policy epoch. Policy still
70runs every 20 ms after alignment. During host loss or an opt-in independent settling tail,
71`policyAtMs` does not advance. Tail epochs run only board/plant work. Host authority and
72mission snapshots can remain stale; the board snapshot reflects its actual independent lease.
73
74`result()` refuses an active session and returns a detached terminal report. Explicit
75`close()` is idempotent: it closes owned payload state once, immediately revokes the modeled
76board and reports a blocked `simulation-session-closed` outcome. It does not invent mission
77completion, execute a recovery or settle the physical plant. A step failure also closes
78ownership and revokes output before throwing; a simultaneous cleanup failure is retained
79in an aggregate error. Naturally terminated sessions keep their existing terminal result.
80Closing from an adapter callback stops the in-flight step before another adapter or output
81acceptance runs. Recursive stepping is refused. An interrupted policy epoch is not reported
82as completed, and terminal sessions report `hostAvailable: false`.
83All adapter data results, including faults, plant state, navigation, measurements, payload
84acknowledgements and controller output, are captured inside that ownership check before use.
85A link callback that closes the session and then fails cannot hide the failure as a disconnected
86observation. Void observation/request callbacks keep their ignored-return contract.
87The initial plant snapshot and adapter method references are captured before the session
88is exposed. Replacing a method property afterward does not replace the retained adapter;
89modeled world changes belong in the adapter's own captured state.
90`runVehicleSimulation` now drains this same interface to natural termination.
91
92This enables concurrent modeled vehicle execution. It does not yet implement fleet custody
93transfer, collision avoidance, a network scheduler, distributed clocks or physical multi-vehicle
94control. Callers must avoid reusing identities. [Cargo issue #25](cargo-delivery.md#remaining-issue-scope)
95remains open for the actual handover policy and evidence.
96
3697## Payload and survey evidence
3798
3899Payload requests bind event, task, operation, container, reference, position and an expiry window.
resources/functions/drone/controls/physics.tsmodified+112-42
Changes to resources/functions/drone/controls/physics.ts
@@ -8,53 +8,79 @@ export { rotateBodyToNed, rotateNedToBody } from '../estimation/frames'
88interface RigidBodyInput { positionM?: SensorVector, motorsN?: Motors, groundDownM?: number, groundGradient?: [number, number] }
99interface CarriedPointLoad { id: string, massKg: number, forwardM: number }
1010interface BodySnapshot { positionM: SensorVector, velocityMps: SensorVector, angularRateRadS: SensorVector, accelerationMps2: SensorVector, quaternionWxyz: Quaternion, motorsN: Motors, groundContact: boolean }
11interface LoadProperties { id: string, phase: 'attached' | 'separated', pointMassKg: number, forwardM: number, totalMassKg: number, cgBodyM: SensorVector, inertiaDiagonalKgM2: SensorVector }
12interface LoadSnapshot { properties: LoadProperties, centerPositionM: SensorVector, centerVelocityMps: SensorVector }
13interface SeparationSide { properties: LoadProperties, bodyPositionM: SensorVector, bodyVelocityMps: SensorVector, quaternionWxyz: Quaternion, angularRateRadS: SensorVector, actualMotorsN: Motors, centerPositionM: SensorVector, centerVelocityMps: SensorVector }
11type PointLoadPhase = 'awaiting' | 'attached' | 'separated'
12interface LoadProperties<P extends PointLoadPhase = 'attached' | 'separated'> { id: string, phase: P, pointMassKg: number, forwardM: number, totalMassKg: number, cgBodyM: SensorVector, inertiaDiagonalKgM2: SensorVector }
13interface LoadSnapshot<P extends PointLoadPhase = 'attached' | 'separated'> { properties: LoadProperties<P>, centerPositionM: SensorVector, centerVelocityMps: SensorVector }
14interface SeparationSide<P extends PointLoadPhase = 'attached' | 'separated'> { properties: LoadProperties<P>, bodyPositionM: SensorVector, bodyVelocityMps: SensorVector, quaternionWxyz: Quaternion, angularRateRadS: SensorVector, actualMotorsN: Motors, centerPositionM: SensorVector, centerVelocityMps: SensorVector }
1415type LoadSeparation = { outcome: 'separated', reason: 'prescribed-zero-ejection', before: SeparationSide, after: SeparationSide, parcel: { massKg: number, positionM: SensorVector, velocityMps: SensorVector } } | { outcome: 'refused', reason: 'already-separated' }
1516interface RigidBody { snapshot(): BodySnapshot, step(command: Motors, dtS: number, disturbance?: { windMps?: SensorVector, forceN?: SensorVector, torqueNm?: SensorVector }): BodySnapshot }
1617interface LoadedRigidBody extends RigidBody { loadSnapshot(): LoadSnapshot, separateCarriedLoad(): LoadSeparation }
18export interface IntakeParcel { id: string, massKg: number, positionM: SensorVector, velocityMps: SensorVector }
19export type PointLoadAttachment = {
20 outcome: 'attached'
21 reason: 'coincident-inelastic-point-capture'
22 before: SeparationSide<'awaiting'>
23 after: SeparationSide<'attached'>
24 parcel: IntakeParcel
25 energy: { kineticBeforeJ: number, kineticAfterJ: number, dissipatedJ: number, numericalToleranceJ: number, potentialBeforeJ: number, potentialAfterJ: number }
26} | { outcome: 'refused', reason: 'intake-consumed' | 'unexpected-parcel' | 'mount-not-coincident' }
27export interface ReceivingRigidBody extends RigidBody {
28 loadSnapshot(): LoadSnapshot<PointLoadPhase>
29 intakePoint(): { positionM: SensorVector, velocityMps: SensorVector }
30 attachParcel(input: unknown): PointLoadAttachment
31 separateCarriedLoad(): LoadSeparation | { outcome: 'refused', reason: 'awaiting-load' }
32}
1733function cargoCross(a: SensorVector, b: SensorVector): SensorVector {
1834 return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
1935}
36function captureOwnRecord(value: unknown, allowed: string[], required: string[]) {
37 if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) throw new TypeError()
38 const descriptors = Object.getOwnPropertyDescriptors(value), names = Reflect.ownKeys(value)
39 if (names.some(name => typeof name !== 'string' || !allowed.includes(name)) || required.some(name => !Object.hasOwn(descriptors, name))) throw new TypeError()
40 const captured: Record<string, unknown> = {}
41 for (const name of names as string[]) {
42 const descriptor = descriptors[name]!
43 if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value') || descriptor.value === undefined) throw new TypeError()
44 captured[name] = descriptor.value
45 }
46 return captured
47}
48function captureFiniteTuple(value: unknown, length: number, min: number, max: number) {
49 if (!Array.isArray(value) || value.length !== length || Reflect.ownKeys(value).length !== length + 1) throw new TypeError()
50 const captured: number[] = []
51 for (let i = 0; i < length; i++) {
52 const descriptor = Object.getOwnPropertyDescriptor(value, String(i))
53 if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value') || typeof descriptor.value !== 'number' || !Number.isFinite(descriptor.value) || descriptor.value < min || descriptor.value > max) throw new TypeError()
54 captured.push(descriptor.value)
55 }
56 return captured
57}
58function captureIntakeParcel(input: unknown): IntakeParcel {
59 try {
60 const values = captureOwnRecord(input, ['id', 'massKg', 'positionM', 'velocityMps'], ['id', 'massKg', 'positionM', 'velocityMps'])
61 values.positionM = captureFiniteTuple(values.positionM, 3, -1e6, 1e6)
62 values.velocityMps = captureFiniteTuple(values.velocityMps, 3, -1e6, 1e6)
63 if (typeof values.id !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(values.id) || typeof values.massKg !== 'number' || !Number.isFinite(values.massKg) || values.massKg <= 0 || values.massKg > .25) throw new TypeError()
64 return values as unknown as IntakeParcel
65 }
66 catch { throw new TypeError('cargo-intake-parcel') }
67}
2068/** New opt-in records are captured as exact own data. The absent-own-member path is deliberately legacy. */
21function captureCarriedPointLoad(input: unknown): { input: RigidBodyInput, load: CarriedPointLoad } | null {
69function captureCarriedPointLoad(input: unknown, member: 'carriedPointLoad' | 'expectedPointLoad' = 'carriedPointLoad'): { input: RigidBodyInput, load: CarriedPointLoad } | null {
2270 try {
23 if (!input || (typeof input !== 'object' && typeof input !== 'function') || !Object.getOwnPropertyDescriptor(input, 'carriedPointLoad')) return null
24 function record(value: unknown, allowed: string[], required: string[]) {
25 if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) throw new TypeError()
26 const descriptors = Object.getOwnPropertyDescriptors(value), names = Reflect.ownKeys(value)
27 if (names.some(name => typeof name !== 'string' || !allowed.includes(name)) || required.some(name => !Object.hasOwn(descriptors, name))) throw new TypeError()
28 const captured: Record<string, unknown> = {}
29 for (const name of names as string[]) {
30 const descriptor = descriptors[name]!
31 if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value') || descriptor.value === undefined) throw new TypeError()
32 captured[name] = descriptor.value
33 }
34 return captured
35 }
36 function tuple(value: unknown, length: number, min: number, max: number) {
37 if (!Array.isArray(value) || value.length !== length || Reflect.ownKeys(value).length !== length + 1) throw new TypeError()
38 const captured: number[] = []
39 for (let i = 0; i < length; i++) {
40 const descriptor = Object.getOwnPropertyDescriptor(value, String(i))
41 if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value') || typeof descriptor.value !== 'number' || !Number.isFinite(descriptor.value) || descriptor.value < min || descriptor.value > max) throw new TypeError()
42 captured.push(descriptor.value)
43 }
44 return captured
45 }
46 const captured = record(input, ['positionM', 'motorsN', 'groundDownM', 'groundGradient', 'carriedPointLoad'], ['carriedPointLoad'])
47 const load = record(captured.carriedPointLoad, ['id', 'massKg', 'forwardM'], ['id', 'massKg', 'forwardM'])
71 if (!input || (typeof input !== 'object' && typeof input !== 'function') || !Object.getOwnPropertyDescriptor(input, member)) return null
72 const captured = captureOwnRecord(input, ['positionM', 'motorsN', 'groundDownM', 'groundGradient', member], [member])
73 const load = captureOwnRecord(captured[member], ['id', 'massKg', 'forwardM'], ['id', 'massKg', 'forwardM'])
4874 if (typeof load.id !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(load.id) || typeof load.massKg !== 'number' || !Number.isFinite(load.massKg) || load.massKg <= 0 || load.massKg > 0.25 || typeof load.forwardM !== 'number' || !Number.isFinite(load.forwardM) || load.forwardM < -0.1 || load.forwardM > 0.1) throw new TypeError()
4975 const body: RigidBodyInput = {}
50 if (Object.hasOwn(captured, 'positionM')) body.positionM = tuple(captured.positionM, 3, -1e6, 1e6) as SensorVector
51 if (Object.hasOwn(captured, 'motorsN')) body.motorsN = tuple(captured.motorsN, 4, 0, model.maxMotorThrustN) as Motors
76 if (Object.hasOwn(captured, 'positionM')) body.positionM = captureFiniteTuple(captured.positionM, 3, -1e6, 1e6) as SensorVector
77 if (Object.hasOwn(captured, 'motorsN')) body.motorsN = captureFiniteTuple(captured.motorsN, 4, 0, model.maxMotorThrustN) as Motors
5278 if (Object.hasOwn(captured, 'groundDownM')) {
5379 if (typeof captured.groundDownM !== 'number' || !Number.isFinite(captured.groundDownM) || captured.groundDownM < -1 || captured.groundDownM > 0) throw new TypeError()
5480 body.groundDownM = captured.groundDownM
5581 }
5682 if (Object.hasOwn(captured, 'groundGradient')) {
57 body.groundGradient = tuple(captured.groundGradient, 2, -0.1, 0.1) as [number, number]
83 body.groundGradient = captureFiniteTuple(captured.groundGradient, 2, -0.1, 0.1) as [number, number]
5884 if (Math.hypot(...body.groundGradient) > 0.1) throw new TypeError()
5985 }
6086 return { input: body, load: { id: load.id, massKg: load.massKg, forwardM: load.forwardM } }
@@ -66,6 +92,18 @@ export function createRigidBody(input?: RigidBodyInput): RigidBody
6692export function createRigidBody(input: RigidBodyInput & { carriedPointLoad?: CarriedPointLoad } = {}): RigidBody | LoadedRigidBody {
6793 const configured = captureCarriedPointLoad(input)
6894 if (configured) input = configured.input
95 // This factory never exposes the awaiting phase or the intake operation.
96 return createBody(input, configured, false) as RigidBody | LoadedRigidBody
97}
98
99/** An empty declared point joint. Physical world/policy ownership of the parcel belongs to the caller. */
100export function createReceivingRigidBody(input: RigidBodyInput & { expectedPointLoad: CarriedPointLoad }): ReceivingRigidBody {
101 const configured = captureCarriedPointLoad(input, 'expectedPointLoad')
102 if (!configured) throw new TypeError('cargo-receiver-constructor')
103 return createBody(configured.input, configured, true) as ReceivingRigidBody
104}
105
106function createBody(input: RigidBodyInput, configured: ReturnType<typeof captureCarriedPointLoad>, receiving: boolean): RigidBody | LoadedRigidBody | ReceivingRigidBody {
69107
70108 const groundDownM = input.groundDownM ?? 0
71109 const groundGradient = [...(input.groundGradient ?? [0, 0])]
@@ -78,11 +116,12 @@ export function createRigidBody(input: RigidBodyInput & { carriedPointLoad?: Car
78116 let velocityMps: SensorVector = [0, 0, 0], angularRateRadS: SensorVector = [0, 0, 0], accelerationMps2: SensorVector = [0, 0, 0]
79117 let quaternionWxyz: Quaternion = [1, 0, 0, 0], groundContact = false
80118 function snapshot() { return structuredClone({ positionM, velocityMps, angularRateRadS, accelerationMps2, quaternionWxyz, motorsN, groundContact }) }
81 let attached = true
82 function properties(): LoadProperties {
119 let phase: PointLoadPhase = receiving ? 'awaiting' : 'attached'
120 function properties(selectedPhase: PointLoadPhase = phase): LoadProperties<PointLoadPhase> {
121 const attached = selectedPhase === 'attached'
83122 const load = configured!.load, totalMassKg = attached ? model.massKg + load.massKg : model.massKg
84123 const shift = attached ? model.massKg * load.massKg / totalMassKg * load.forwardM ** 2 : 0
85 return { id: load.id, phase: attached ? 'attached' : 'separated', pointMassKg: load.massKg, forwardM: load.forwardM, totalMassKg, cgBodyM: [attached ? load.massKg * load.forwardM / totalMassKg : 0, 0, 0], inertiaDiagonalKgM2: [model.inertiaKgM2[0]!, model.inertiaKgM2[1]! + shift, model.inertiaKgM2[2]! + shift] }
124 return { id: load.id, phase: selectedPhase, pointMassKg: load.massKg, forwardM: load.forwardM, totalMassKg, cgBodyM: [attached ? load.massKg * load.forwardM / totalMassKg : 0, 0, 0], inertiaDiagonalKgM2: [model.inertiaKgM2[0]!, model.inertiaKgM2[1]! + shift, model.inertiaKgM2[2]! + shift] }
86125 }
87126 let centerPositionM: SensorVector | null = configured ? positionM.map((n, i) => n + properties().cgBodyM[i]!) as SensorVector : null
88127 let centerVelocityMps: SensorVector | null = configured ? [...velocityMps] : null
@@ -140,19 +179,50 @@ export function createRigidBody(input: RigidBodyInput & { carriedPointLoad?: Car
140179 },
141180 }
142181 if (!configured) return body
143 function loadSnapshot(): LoadSnapshot { return { properties: properties(), centerPositionM: [...centerPositionM!], centerVelocityMps: [...centerVelocityMps!] } }
144 function side(): SeparationSide {
182 function loadSnapshot(): LoadSnapshot<PointLoadPhase> { return { properties: properties(), centerPositionM: [...centerPositionM!], centerVelocityMps: [...centerVelocityMps!] } }
183 function side(): SeparationSide<PointLoadPhase> {
145184 return { properties: properties(), bodyPositionM: [...positionM], bodyVelocityMps: [...velocityMps], quaternionWxyz: [...quaternionWxyz], angularRateRadS: [...angularRateRadS], actualMotorsN: [...motorsN], centerPositionM: [...centerPositionM!], centerVelocityMps: [...centerVelocityMps!] }
146185 }
147 function separateCarriedLoad(...args: unknown[]): LoadSeparation {
186 function separateCarriedLoad(...args: unknown[]): LoadSeparation | { outcome: 'refused', reason: 'awaiting-load' } {
148187 if (args.length !== 0) throw new TypeError('cargo-load-separation-arguments')
149 if (!attached) return Object.freeze({ outcome: 'refused', reason: 'already-separated' })
188 if (phase === 'awaiting') return Object.freeze({ outcome: 'refused', reason: 'awaiting-load' })
189 if (phase === 'separated') return Object.freeze({ outcome: 'refused', reason: 'already-separated' })
150190 const before = side(), r: SensorVector = [configured!.load.forwardM, 0, 0]
151191 const positionLever = rotateBodyToNed(quaternionWxyz, r), velocityLever = rotateBodyToNed(quaternionWxyz, cargoCross(angularRateRadS, r))
152192 const parcel = { massKg: configured!.load.massKg, positionM: positionM.map((n, i) => n + positionLever[i]!) as SensorVector, velocityMps: velocityMps.map((n, i) => n + velocityLever[i]!) as SensorVector }
153 attached = false; centerPositionM = [...positionM]; centerVelocityMps = [...velocityMps]
154 return { outcome: 'separated', reason: 'prescribed-zero-ejection', before, after: side(), parcel }
193 phase = 'separated'; centerPositionM = [...positionM]; centerVelocityMps = [...velocityMps]
194 return { outcome: 'separated', reason: 'prescribed-zero-ejection', before: before as SeparationSide, after: side() as SeparationSide, parcel }
195 }
196 if (!receiving) return { ...body, loadSnapshot, separateCarriedLoad } as LoadedRigidBody
197 function intakePoint(): { positionM: SensorVector, velocityMps: SensorVector } {
198 const r: SensorVector = [configured!.load.forwardM, 0, 0], lever = rotateBodyToNed(quaternionWxyz, r), rate = rotateBodyToNed(quaternionWxyz, cargoCross(angularRateRadS, r))
199 return { positionM: positionM.map((n, i) => n + lever[i]!) as SensorVector, velocityMps: velocityMps.map((n, i) => n + rate[i]!) as SensorVector }
200 }
201 function attachParcel(input: unknown, ...extra: unknown[]): PointLoadAttachment {
202 if (extra.length) throw new TypeError('cargo-intake-parcel')
203 const parcel = captureIntakeParcel(input)
204 if (phase !== 'awaiting') return { outcome: 'refused', reason: 'intake-consumed' }
205 const mount = intakePoint()
206 if (parcel.id !== configured!.load.id || parcel.massKg !== configured!.load.massKg) return { outcome: 'refused', reason: 'unexpected-parcel' }
207 if (!parcel.positionM.every((n, i) => n === mount.positionM[i])) return { outcome: 'refused', reason: 'mount-not-coincident' }
208 const before = side() as SeparationSide<'awaiting'>, afterProperties = properties('attached'), total = afterProperties.totalMassKg
209 const c = afterProperties.cgBodyM, inertia = afterProperties.inertiaDiagonalKgM2, reduced = model.massKg * parcel.massKg / total
210 const relative = rotateNedToBody(quaternionWxyz, parcel.velocityMps.map((n, i) => n - velocityMps[i]!) as SensorVector)
211 const transfer = cargoCross([configured!.load.forwardM, 0, 0], relative)
212 const nextRate = angularRateRadS.map((n, i) => (model.inertiaKgM2[i]! * n + reduced * transfer[i]!) / inertia[i]!) as SensorVector
213 const nextCenterVelocity = velocityMps.map((n, i) => (model.massKg * n + parcel.massKg * parcel.velocityMps[i]!) / total) as SensorVector
214 const lever = rotateBodyToNed(quaternionWxyz, c), rateLever = rotateBodyToNed(quaternionWxyz, cargoCross(nextRate, c))
215 const nextCenterPosition = positionM.map((n, i) => n + lever[i]!) as SensorVector
216 const nextVelocity = nextCenterVelocity.map((n, i) => n - rateLever[i]!) as SensorVector
217 const square = (v: SensorVector) => v.reduce((sum, n) => sum + n * n, 0)
218 const kineticBeforeJ = .5 * model.massKg * square(velocityMps) + .5 * angularRateRadS.reduce((sum, n, i) => sum + model.inertiaKgM2[i]! * n * n, 0) + .5 * parcel.massKg * square(parcel.velocityMps)
219 const kineticAfterJ = .5 * total * square(nextCenterVelocity) + .5 * nextRate.reduce((sum, n, i) => sum + inertia[i]! * n * n, 0)
220 const dissipatedJ = kineticBeforeJ - kineticAfterJ, numericalToleranceJ = 64 * Number.EPSILON * Math.max(1, kineticBeforeJ, kineticAfterJ)
221 const potentialBeforeJ = -model.gravityMps2 * (model.massKg * positionM[2] + parcel.massKg * parcel.positionM[2]), potentialAfterJ = -model.gravityMps2 * total * nextCenterPosition[2]
222 if (![...nextRate, ...nextCenterVelocity, ...nextCenterPosition, ...nextVelocity, kineticBeforeJ, kineticAfterJ, dissipatedJ, potentialBeforeJ, potentialAfterJ].every(Number.isFinite) || dissipatedJ < -numericalToleranceJ) throw new RangeError('cargo-intake-numerical-state')
223 phase = 'attached'; angularRateRadS = nextRate; velocityMps = nextVelocity; centerPositionM = nextCenterPosition; centerVelocityMps = nextCenterVelocity
224 return { outcome: 'attached', reason: 'coincident-inelastic-point-capture', before, after: side() as SeparationSide<'attached'>, parcel, energy: { kineticBeforeJ, kineticAfterJ, dissipatedJ, numericalToleranceJ, potentialBeforeJ, potentialAfterJ } }
155225 }
156 return { ...body, loadSnapshot, separateCarriedLoad }
226 return { ...body, loadSnapshot, separateCarriedLoad, intakePoint, attachParcel }
157227}
158228export type RigidBodyState = ReturnType<ReturnType<typeof createRigidBody>['snapshot']>
resources/functions/drone/controls/sensors.tsmodified+3-2
Changes to resources/functions/drone/controls/sensors.ts
@@ -3,9 +3,10 @@ import { rotateNedToBody } from './physics'
33import { createSensorPipeline } from '../sensors/pipeline'
44import { runtimeCrc32 } from '../runtime/protocol'
55import { multirotorModel } from './model'
6const bootId = 55n, boardOrigin = 1000000n
6const boardOrigin = 1000000n
77/** Modeled raw IMU/GNSS only. Measurement epoch is known because this is a synthetic source. */
8export function createPhysicsSensorFeed(additionalDelayMs: number) {
8export function createPhysicsSensorFeed(additionalDelayMs: number, bootId = 55n) {
9 if (typeof bootId !== 'bigint' || bootId < 1n || bootId > 0xffffffffffffffffn) throw new TypeError('Invalid synthetic sensor boot scope')
910 if (!Number.isFinite(additionalDelayMs) || additionalDelayMs < 0 || additionalDelayMs > 200) throw new RangeError('Sensor delay must be 0..200 ms')
1011 const pipeline = createSensorPipeline({ anchor: { bootId, boardUs: boardOrigin, localMs: 0, uncertaintyUs: 0, driftPpm: 0, source: 'fixture' }, calibrations: { imu: {
1112 id: 'physics-frd-synthetic', provenance: 'fixture', axes: [1, 2, 3], referenceC: 25, minC: -20, maxC: 60, accelBias: [0, 0, 0], gyroBias: [0, 0, 0], accelScale: [1, 1, 1], gyroScale: [1, 1, 1], accelTempSlope: [0, 0, 0], gyroTempSlope: [0, 0, 0], accelNoiseStd: [0.005, 0.005, 0.005], gyroNoiseStd: [0.001, 0.001, 0.001],
resources/functions/drone/vehicles/runtime.tsmodified+183-105
Changes to resources/functions/drone/vehicles/runtime.ts
@@ -31,8 +31,24 @@ function captureLinkObservation(input: unknown, nowMs: number): { capturedAtMs:
3131 if (!time?.enumerable || !Object.hasOwn(time, 'value') || !Number.isSafeInteger(time.value) || time.value < 0 || time.value > nowMs || !connected?.enumerable || !Object.hasOwn(connected, 'value') || typeof connected.value !== 'boolean') throw new TypeError('Unqualified vehicle link observation')
3232 return { capturedAtMs: time.value, connected: connected.value }
3333}
34/** Owned raw sensor -> estimator -> mission -> vehicle policy -> authority -> independent board loop. */
35export function runVehicleSimulation(profileInput: VehicleProfile, options: { scenario?: string, seconds?: number, neutralTailMs?: number, missionActions?: readonly ScheduledMissionAction[] } = {}) {
34export interface VehicleSimulationOptions {
35 scenario?: string
36 seconds?: number
37 neutralTailMs?: number
38 missionActions?: readonly ScheduledMissionAction[]
39 scope?: { bootId: bigint, sessionId: bigint }
40}
41function captureSimulationScope(input: unknown) {
42 if (input === undefined) return { bootId: 55n, sessionId: 123n }
43 if (!input || typeof input !== 'object' || ![Object.prototype, null].includes(Object.getPrototypeOf(input)) || Reflect.ownKeys(input).length !== 2) throw new TypeError('Invalid vehicle simulation scope')
44 const boot = Object.getOwnPropertyDescriptor(input, 'bootId'), session = Object.getOwnPropertyDescriptor(input, 'sessionId')
45 if (!boot?.enumerable || !Object.hasOwn(boot, 'value') || !session?.enumerable || !Object.hasOwn(session, 'value') || [boot.value, session.value].some(value => typeof value !== 'bigint' || value < 1n || value > 0xffffffffffffffffn)) throw new TypeError('Invalid vehicle simulation scope')
46 return { bootId: boot.value as bigint, sessionId: session.value as bigint }
47}
48/** Retained owned loop, advanced at exactly 4 ms epochs from zero by its caller. */
49export function createVehicleSimulationSession(profileInput: VehicleProfile, options: VehicleSimulationOptions = {}) {
50 const scope = captureSimulationScope(options.scope), vehicle = profileInput.vehicle
51 const runtimeFaults = profileInput.faults.bind(profileInput)
3652 const scenario = options.scenario ?? profileInput.scenario, seconds = options.seconds ?? 120
3753 const neutralTailMs = options.neutralTailMs ?? 0
3854 const positionPrediction = profileInput.positionPrediction
@@ -43,21 +59,23 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
4359 const missionActions = captureMissionActions(options.missionActions, seconds * 1000)
4460 const { plan, worstCaseMs, requiredWh } = validateVehicleMission(profileInput.mission)
4561 const batteryModel = captureVehicleBatteryModel(profileInput.batteryModel, plan.power)
46 if (profileInput.vehicle !== plan.vehicle || plan.origin.latitudeDeg !== 0 || plan.origin.longitudeDeg !== 0 || plan.origin.ellipsoidHeightM !== 100) throw new TypeError('Vehicle fixture requires its declared 0,0,100 WGS84 origin')
62 if (vehicle !== plan.vehicle || plan.origin.latitudeDeg !== 0 || plan.origin.longitudeDeg !== 0 || plan.origin.ellipsoidHeightM !== 100) throw new TypeError('Vehicle fixture requires its declared 0,0,100 WGS84 origin')
4763 const hasTransitions = plan.tasks.some(task => task.kind === 'vtol-mode')
4864 const captureMotion = plan.tasks.some(task => task.kind === 'vtol-mode' && task.approach === 'bounded-approach-v1')
4965 let transitionObservation: VehicleVtolTransitionObservation | null = null
5066 const actuators = structuredClone(profileInput.actuators), fence = structuredClone(profileInput.authorityFence)
5167 const session = profileInput.createSession(), navigation = session.createNavigation?.()
52 const linkProvider = session.linkObservation
53 if (profileInput.vehicle === 'subsea' && !navigation) throw new TypeError('Subsea profile requires owned pressure/DVL navigation')
68 const linkProvider = session.linkObservation, renderMeasurement = session.renderMeasurement.bind(session)
69 const observeIndependent = session.observeIndependent?.bind(session), updateNavigation = navigation?.update.bind(navigation)
70 if (vehicle === 'subsea' && !navigation) throw new TypeError('Subsea profile requires owned pressure/DVL navigation')
5471 const engine = createVehicleMissionEngine(plan), plant = session.createPlant(), controller = session.createController()
55 const supervisor = createVehicleAuthoritySupervisor({ bootId: 55n, sessionId: 123n, vehicle: profileInput.vehicle, actuators, fence })
56 const board = createVehicleOutputWatchdog({ bootId: 55n, sessionId: 123n, vehicle: profileInput.vehicle, actuators })
57 const feed = createPhysicsSensorFeed(0), estimator = createEstimator({ origin: plan.origin, gravityCorrection: 'initialization-only', positionPrediction })
58 const qualifier = createVehicleMeasurementQualifier(profileInput.vehicle, batteryModel ?? undefined)
72 const plantSnapshot = plant.snapshot.bind(plant), stepPlant = plant.step.bind(plant), stepController = controller.step.bind(controller)
73 const supervisor = createVehicleAuthoritySupervisor({ ...scope, vehicle: vehicle, actuators, fence })
74 const board = createVehicleOutputWatchdog({ ...scope, vehicle: vehicle, actuators })
75 const feed = createPhysicsSensorFeed(0, scope.bootId), estimator = createEstimator({ origin: plan.origin, gravityCorrection: 'initialization-only', positionPrediction })
76 const qualifier = createVehicleMeasurementQualifier(vehicle, batteryModel ?? undefined)
5977 const observedSensors = { imu: 0, gnss: 0 }, sensorSequences = { imu: 0, gnss: 0 }
60 let truth = plant.snapshot(), mission = engine.snapshot()
78 let truth = structuredClone(plantSnapshot()), mission = engine.snapshot()
6179 let estimate: EstimatorSnapshot = estimator.snapshot(0)
6280 let measurement: VehicleMeasurement | null = null, measurementPose: VehicleMeasurementPose | null = null
6381 let batteryObservation: VehicleBatteryObservation | null = null
@@ -68,105 +86,165 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc
6886 const transitions: { atMs: number, state: string, taskId: string, reason: string }[] = []
6987 let previousState = '', previousTask = ''
7088 const payload = session.createPayload?.()
71 let finalAtMs = 0
72 try {
73 for (let nowMs = 0; nowMs <= seconds * 1000; nowMs += 4) {
89 const observePayload = payload?.observe?.bind(payload), pollPayload = payload?.poll.bind(payload), requestPayload = payload?.request.bind(payload), closePayload = payload?.close.bind(payload)
90 let finalAtMs = 0, nextAtMs = 0, policyAtMs: number | null = null, hostEndAtMs: number | null = null
91 let terminal = false, stepping = false
92 const interrupted = Symbol('vehicle-session-closed')
93 function owned<T>(work: () => T): T {
94 if (terminal) throw interrupted
95 const value = work()
96 if (terminal) throw interrupted
97 return value
98 }
99 function captured<T>(work: () => T): T { return owned(() => structuredClone(work())) }
100 function stepHost(nowMs: number) {
101 const faults = captured(() => runtimeFaults(scenario, nowMs, 'host')), boardState = board.tick(nowMs)
102 if (nowMs > 600) truth = captured(() => stepPlant(boardState.actuators, 0.004, { windMps: faults.windMps ?? [0, 0, 0], currentMps: faults.currentMps ?? [0, 0, 0] }))
103 if (faults.hostLost) hostLostAt ??= nowMs
104 if (nowMs >= 600) owned(() => observeIndependent?.(structuredClone(truth), nowMs, [...boardState.actuators], hostLostAt === null))
105 if (hostLostAt !== null) {
106 if (nowMs - hostLostAt >= 100) { status = 'host-lost'; fault = board.snapshot().fault ?? 'host-lost'; return false }
107 return true
108 }
109 feed.capture(truth, nowMs, faults.dropImu, vehicle === 'subsea' || faults.dropPosition)
110 const sensors = feed.advanceTo(nowMs)
111 for (const kind of ['imu', 'gnss'] as const) {
112 const sequence = sensors.sensors.find(sensor => sensor.kind === kind)?.observation?.sequence ?? 0
113 if (sequence > sensorSequences[kind]) { observedSensors[kind]++; sensorSequences[kind] = sequence }
114 }
115 const baseEstimate = estimator.update(sensors)
116 estimate = updateNavigation ? captured(() => updateNavigation(baseEstimate, measurement, nowMs)) : baseEstimate
117 if (nowMs % 20 !== 0) return true
118 const reference = vehicleReference(estimate)
119 const imuObservation = sensors.sensors.find(s => s.kind === 'imu')?.observation
120 const capturePose = predictVehicleMeasurementPose(estimate, imuObservation, nowMs, { includeMotion: captureMotion })
121 if ((capturePose || vehicle === 'subsea') && !faults.dropVehicleSensor) {
122 if (queue.length >= 8) throw new RangeError('Vehicle sensor queue exhausted')
123 const packet = captured(() => renderMeasurement(truth, nowMs, reference))
124 if (packet.capturedAtMs !== nowMs || !Number.isSafeInteger(packet.receivedAtMs) || packet.receivedAtMs < nowMs + 20 || packet.receivedAtMs > nowMs + 100) throw new TypeError('Vehicle renderer must declare capture and bounded receipt epochs')
125 queue.push({ measurement: packet, pose: capturePose })
126 queue.sort((a, b) => a.measurement.receivedAtMs - b.measurement.receivedAtMs || a.measurement.sequence - b.measurement.sequence)
127 }
128 while (queue[0] && queue[0].measurement.receivedAtMs <= nowMs) { const entry = queue.shift()!; measurement = entry.measurement; measurementPose = entry.pose }
129 let qualified: VehicleMeasurement | null = null
130 try { qualified = qualifier.qualify(measurement, nowMs, reference) }
131 catch (error) { fault ??= error instanceof Error ? error.message : 'vehicle-sensor-envelope'; measurementPose = null }
132 measurement = qualified
133 if (batteryModel && qualified) {
134 batteryObservation = observeVehicleBattery(qualified, batteryModel, nowMs)
135 if (batteryObservation.cutoff) fault ??= 'modeled-battery-supply-cutoff'
136 }
137 if (updateNavigation) estimate = captured(() => updateNavigation(baseEstimate, qualified, nowMs))
138 let link = linkProvider === undefined ? { capturedAtMs: nowMs, connected: !faults.linkLost } : { capturedAtMs: 0, connected: false }
139 if (typeof linkProvider === 'function') {
140 try { link = captured(() => captureLinkObservation(linkProvider.call(session, nowMs), nowMs)) }
141 catch (error) { if (error === interrupted || terminal) throw error; /* An installed adapter failure is disconnected, never a replacement fixture. */ }
142 }
143 if (faults.linkLost) link.connected = false
144 if (nowMs < 600) return true
145 const b = board.snapshot()
146 const batteryPercent = batteryModel ? qualified && batteryObservation && !batteryObservation.cutoff ? batteryObservation.remainingPercentLowerBound : 0 : 100 - plan.power.maxW * Math.max(0, nowMs - 600) / 3600000 / plan.power.availableWh * 100
147 const observation = { kind: 'simulation' as const, nowMs, estimate,
148 runtime: { bootId: b.bootId, capturedAtMs: nowMs, watchdogHealthy: b.fault === null },
149 rc: { capturedAtMs: nowMs, connected: true, armSwitch: true, override: faults.rcOverride ?? false },
150 interlocks: { capturedAtMs: qualified?.capturedAtMs ?? nowMs - 1000, killActive: faults.kill ?? false, onGround: qualified?.values.onSurface === true, motorsStopped: qualified?.values.stopped === true },
151 battery: { capturedAtMs: batteryModel ? qualified?.capturedAtMs ?? nowMs - 1000 : nowMs, remainingPercent: batteryPercent }, link }
152 const send = (action: 'arm' | 'engage' | 'renew' | 'disarm', targetM = mission.targetM) => {
153 const s = supervisor.snapshot()
154 return supervisor.update(observation, { version: 1, sessionId: s.sessionId, bootId: s.bootId, epoch: s.epoch, sequence: s.sequence + 1, issuedAtMs: nowMs, action, mode: 'position', leaseMs: 100, target: { positionM: targetM ?? [0, 0, 0], yawRad: 0 } })
155 }
156 supervisor.update(observation)
157 if (nowMs === 600) { send('arm'); send('engage', [0, 0, 0]) }
158 const context: VehicleControllerContext = { nowMs, enabled: supervisor.snapshot().state === 'autonomous', estimate, measurement, measurementPose, mission,
159 ...(vehicle === 'vtol' || vehicle === 'quadcopter' ? { controlRate: capturePose ? { evidence: 'modeled-qualified-imu' as const, reference: capturePose.reference, capturedAtMs: capturePose.gyroCapturedAtMs, angularRateRadS: [...(imuObservation!.value as ImuMeasurement).angularRateRadS] as SensorVector } : null } : {}),
160 }
161 owned(() => observePayload?.(structuredClone(context)))
162 const ack = captured(() => pollPayload?.(nowMs)) ?? null
163 const missionObservation = { ...observeMission({ estimate, authority: supervisor.snapshot().state, batteryPercent, onGround: qualified?.values.onSurface === true }, vehicle === 'subsea' ? 'LOCAL_WATER_SURFACE' : 'WGS84_ELLIPSOID'), ...(!estimate.localPosition ? { origin: structuredClone(plan.origin), positionM: null, velocityMps: null } : {}), payloadAck: ack, ...(hasTransitions ? { transition: transitionObservation } : {}) }
164 transitionObservation = null
165 let action: VehicleMissionAction | undefined = nowMs === 600 ? 'start' : undefined
166 const scheduled = missionActions[missionActionIndex]
167 if (scheduled?.atMs === nowMs) { action = scheduled.action; missionActionIndex++ }
168 mission = engine.step(missionObservation, action)
169 if (mission.payloadFailure?.triggeredAtMs === nowMs) owned(() => observePayload?.(structuredClone({ ...context, mission })))
170 if (mission.payload && !requested.has(mission.payload.eventId)) {
171 if (!payload) { fault ??= 'missing-vehicle-payload-port'; send('disarm') }
172 else { owned(() => observePayload?.(structuredClone({ ...context, mission }))); owned(() => requestPayload!(structuredClone(mission.payload!))); requested.add(mission.payload.eventId) }
173 }
174 const active = mission.state === 'running' || mission.state === 'paused'
175 if (active && mission.targetM && !fault) send('renew')
176 else { send('disarm'); board.revoke() }
177 const output: VehicleControlOutput = captured(() => stepController({ ...context, enabled: supervisor.snapshot().state === 'autonomous', mission }))
178 if (hasTransitions && active && !output.inhibited && supervisor.snapshot().state === 'autonomous') transitionObservation = output.transition ? structuredClone(output.transition) : null
179 if (active && output.inhibited && supervisor.snapshot().state === 'autonomous') { fault ??= output.reason; send('disarm'); board.revoke() }
180 const frame = supervisor.gate(output, nowMs)
181 if (frame && !terminal) board.accept(frame, nowMs)
182 else board.revoke()
183 policyAtMs = nowMs
184 path.push({ atMs: nowMs, positionM: estimate.localPosition ? [...estimate.localPosition.positionM] as SensorVector : null, truthPositionM: [...truth.positionM], mission: structuredClone(mission), measurement: measurement ? structuredClone(measurement) : null, measurementPose: measurementPose ? structuredClone(measurementPose) : null, actuators: [...board.snapshot().actuators], ...(hasTransitions ? { transitionObservation: transitionObservation ? structuredClone(transitionObservation) : null } : {}), ...(output.backCapture ? { backCapture: structuredClone(output.backCapture) } : {}) })
185 if (mission.state !== previousState || mission.taskId !== previousTask) { transitions.push({ atMs: nowMs, state: mission.state, taskId: mission.taskId, reason: mission.reason }); previousState = mission.state; previousTask = mission.taskId }
186 if (!['ready', 'running', 'paused'].includes(mission.state)) { status = mission.state === 'completed' ? 'completed' : 'blocked'; fault ??= mission.state === 'completed' ? null : mission.reason; return false }
187 return true
188 }
189 function finishHost() {
190 if (hostEndAtMs !== null) return
191 hostEndAtMs = finalAtMs
192 try { closePayload?.() }
193 finally { if (status !== 'host-lost') board.revoke() }
194 }
195 function close() {
196 if (terminal) return
197 terminal = true
198 status = 'blocked'; fault ??= 'simulation-session-closed'
199 try { finishHost() }
200 finally { board.revoke() }
201 }
202 function progress(nowMs: number, phase: 'host' | 'independent-tail') {
203 return { atMs: nowMs, phase, hostAvailable: phase === 'host' && hostLostAt === null && !terminal, terminal, policyAtMs, reference: vehicleReference(estimate), mission: structuredClone(mission), authority: supervisor.snapshot(), board: board.snapshot() }
204 }
205 function step(nowMs: number) {
206 if (terminal) throw new Error('Vehicle simulation session is closed')
207 if (stepping) throw new Error('Vehicle simulation session is already stepping')
208 if (!Number.isSafeInteger(nowMs) || nowMs !== nextAtMs) throw new RangeError('Vehicle simulation requires its next 4 ms epoch')
209 const phase = hostEndAtMs === null ? 'host' as const : 'independent-tail' as const
210 stepping = true
211 try {
74212 finalAtMs = nowMs
75 const faults = profileInput.faults(scenario, nowMs, 'host'), boardState = board.tick(nowMs)
76 if (nowMs > 600) truth = plant.step(boardState.actuators, 0.004, { windMps: faults.windMps ?? [0, 0, 0], currentMps: faults.currentMps ?? [0, 0, 0] })
77 if (faults.hostLost) hostLostAt ??= nowMs
78 if (nowMs >= 600) session.observeIndependent?.(structuredClone(truth), nowMs, [...boardState.actuators], hostLostAt === null)
79 if (hostLostAt !== null) {
80 if (nowMs - hostLostAt >= 100) { status = 'host-lost'; fault = board.snapshot().fault ?? 'host-lost'; break }
81 continue
82 }
83 feed.capture(truth, nowMs, faults.dropImu, profileInput.vehicle === 'subsea' || faults.dropPosition)
84 const sensors = feed.advanceTo(nowMs)
85 for (const kind of ['imu', 'gnss'] as const) {
86 const sequence = sensors.sensors.find(sensor => sensor.kind === kind)?.observation?.sequence ?? 0
87 if (sequence > sensorSequences[kind]) { observedSensors[kind]++; sensorSequences[kind] = sequence }
88 }
89 const baseEstimate = estimator.update(sensors)
90 estimate = navigation ? navigation.update(baseEstimate, measurement, nowMs) : baseEstimate
91 if (nowMs % 20 !== 0) continue
92 const reference = vehicleReference(estimate)
93 const imuObservation = sensors.sensors.find(s => s.kind === 'imu')?.observation
94 const capturePose = predictVehicleMeasurementPose(estimate, imuObservation, nowMs, { includeMotion: captureMotion })
95 if ((capturePose || profileInput.vehicle === 'subsea') && !faults.dropVehicleSensor) {
96 if (queue.length >= 8) throw new RangeError('Vehicle sensor queue exhausted')
97 const packet = structuredClone(session.renderMeasurement(truth, nowMs, reference))
98 if (packet.capturedAtMs !== nowMs || !Number.isSafeInteger(packet.receivedAtMs) || packet.receivedAtMs < nowMs + 20 || packet.receivedAtMs > nowMs + 100) throw new TypeError('Vehicle renderer must declare capture and bounded receipt epochs')
99 queue.push({ measurement: packet, pose: capturePose })
100 queue.sort((a, b) => a.measurement.receivedAtMs - b.measurement.receivedAtMs || a.measurement.sequence - b.measurement.sequence)
101 }
102 while (queue[0] && queue[0].measurement.receivedAtMs <= nowMs) { const entry = queue.shift()!; measurement = entry.measurement; measurementPose = entry.pose }
103 let qualified: VehicleMeasurement | null = null
104 try { qualified = qualifier.qualify(measurement, nowMs, reference) }
105 catch (error) { fault ??= error instanceof Error ? error.message : 'vehicle-sensor-envelope'; measurementPose = null }
106 measurement = qualified
107 if (batteryModel && qualified) {
108 batteryObservation = observeVehicleBattery(qualified, batteryModel, nowMs)
109 if (batteryObservation.cutoff) fault ??= 'modeled-battery-supply-cutoff'
110 }
111 if (navigation) estimate = navigation.update(baseEstimate, qualified, nowMs)
112 let link = linkProvider === undefined ? { capturedAtMs: nowMs, connected: !faults.linkLost } : { capturedAtMs: 0, connected: false }
113 if (typeof linkProvider === 'function') {
114 try { link = captureLinkObservation(linkProvider.call(session, nowMs), nowMs) }
115 catch { /* An installed adapter failure is disconnected, never a replacement fixture. */ }
213 if (phase === 'host') {
214 if (!stepHost(nowMs) || nowMs === seconds * 1000) finishHost()
215 if (hostEndAtMs !== null && neutralTailMs === 0) terminal = true
116216 }
117 if (faults.linkLost) link.connected = false
118 if (nowMs < 600) continue
119 const b = board.snapshot()
120 const batteryPercent = batteryModel ? qualified && batteryObservation && !batteryObservation.cutoff ? batteryObservation.remainingPercentLowerBound : 0 : 100 - plan.power.maxW * Math.max(0, nowMs - 600) / 3600000 / plan.power.availableWh * 100
121 const observation = { kind: 'simulation' as const, nowMs, estimate,
122 runtime: { bootId: b.bootId, capturedAtMs: nowMs, watchdogHealthy: b.fault === null },
123 rc: { capturedAtMs: nowMs, connected: true, armSwitch: true, override: faults.rcOverride ?? false },
124 interlocks: { capturedAtMs: qualified?.capturedAtMs ?? nowMs - 1000, killActive: faults.kill ?? false, onGround: qualified?.values.onSurface === true, motorsStopped: qualified?.values.stopped === true },
125 battery: { capturedAtMs: batteryModel ? qualified?.capturedAtMs ?? nowMs - 1000 : nowMs, remainingPercent: batteryPercent }, link }
126 const send = (action: 'arm' | 'engage' | 'renew' | 'disarm', targetM = mission.targetM) => {
127 const s = supervisor.snapshot()
128 return supervisor.update(observation, { version: 1, sessionId: s.sessionId, bootId: s.bootId, epoch: s.epoch, sequence: s.sequence + 1, issuedAtMs: nowMs, action, mode: 'position', leaseMs: 100, target: { positionM: targetM ?? [0, 0, 0], yawRad: 0 } })
217 else {
218 // No host sensor, estimation, mission or control work runs in this phase.
219 const faults = captured(() => runtimeFaults(scenario, nowMs, 'independent-tail')), boardState = board.tick(nowMs)
220 truth = captured(() => stepPlant(boardState.actuators, .004, { windMps: faults.windMps ?? [0, 0, 0], currentMps: faults.currentMps ?? [0, 0, 0] }))
221 owned(() => observeIndependent?.(structuredClone(truth), nowMs, [...boardState.actuators], false))
222 if (nowMs === hostEndAtMs! + neutralTailMs) terminal = true
129223 }
130 supervisor.update(observation)
131 if (nowMs === 600) { send('arm'); send('engage', [0, 0, 0]) }
132 const context: VehicleControllerContext = { nowMs, enabled: supervisor.snapshot().state === 'autonomous', estimate, measurement, measurementPose, mission,
133 ...(profileInput.vehicle === 'vtol' || profileInput.vehicle === 'quadcopter' ? { controlRate: capturePose ? { evidence: 'modeled-qualified-imu' as const, reference: capturePose.reference, capturedAtMs: capturePose.gyroCapturedAtMs, angularRateRadS: [...(imuObservation!.value as ImuMeasurement).angularRateRadS] as SensorVector } : null } : {}),
134 }
135 payload?.observe?.(structuredClone(context))
136 const ack = payload?.poll(nowMs) ?? null
137 const missionObservation = { ...observeMission({ estimate, authority: supervisor.snapshot().state, batteryPercent, onGround: qualified?.values.onSurface === true }, profileInput.vehicle === 'subsea' ? 'LOCAL_WATER_SURFACE' : 'WGS84_ELLIPSOID'), ...(!estimate.localPosition ? { origin: structuredClone(plan.origin), positionM: null, velocityMps: null } : {}), payloadAck: ack, ...(hasTransitions ? { transition: transitionObservation } : {}) }
138 transitionObservation = null
139 let action: VehicleMissionAction | undefined = nowMs === 600 ? 'start' : undefined
140 const scheduled = missionActions[missionActionIndex]
141 if (scheduled?.atMs === nowMs) { action = scheduled.action; missionActionIndex++ }
142 mission = engine.step(missionObservation, action)
143 if (mission.payloadFailure?.triggeredAtMs === nowMs) payload?.observe?.(structuredClone({ ...context, mission }))
144 if (mission.payload && !requested.has(mission.payload.eventId)) {
145 if (!payload) { fault ??= 'missing-vehicle-payload-port'; send('disarm') }
146 else { payload.observe?.(structuredClone({ ...context, mission })); payload.request(structuredClone(mission.payload)); requested.add(mission.payload.eventId) }
147 }
148 const active = mission.state === 'running' || mission.state === 'paused'
149 if (active && mission.targetM && !fault) send('renew')
150 else { send('disarm'); board.revoke() }
151 const output: VehicleControlOutput = controller.step({ ...context, enabled: supervisor.snapshot().state === 'autonomous', mission })
152 if (hasTransitions && active && !output.inhibited && supervisor.snapshot().state === 'autonomous') transitionObservation = output.transition ? structuredClone(output.transition) : null
153 if (active && output.inhibited && supervisor.snapshot().state === 'autonomous') { fault ??= output.reason; send('disarm'); board.revoke() }
154 const frame = supervisor.gate(output, nowMs)
155 if (frame) board.accept(frame, nowMs)
156 else board.revoke()
157 path.push({ atMs: nowMs, positionM: estimate.localPosition ? [...estimate.localPosition.positionM] as SensorVector : null, truthPositionM: [...truth.positionM], mission: structuredClone(mission), measurement: measurement ? structuredClone(measurement) : null, measurementPose: measurementPose ? structuredClone(measurementPose) : null, actuators: [...board.snapshot().actuators], ...(hasTransitions ? { transitionObservation: transitionObservation ? structuredClone(transitionObservation) : null } : {}), ...(output.backCapture ? { backCapture: structuredClone(output.backCapture) } : {}) })
158 if (mission.state !== previousState || mission.taskId !== previousTask) { transitions.push({ atMs: nowMs, state: mission.state, taskId: mission.taskId, reason: mission.reason }); previousState = mission.state; previousTask = mission.taskId }
159 if (!['ready', 'running', 'paused'].includes(mission.state)) { status = mission.state === 'completed' ? 'completed' : 'blocked'; fault ??= mission.state === 'completed' ? null : mission.reason; break }
224 nextAtMs += 4
225 return progress(nowMs, phase)
226 }
227 catch (error) {
228 if (error === interrupted) return progress(nowMs, phase)
229 terminal = true; status = 'blocked'; fault ??= 'simulation-step-failed'
230 try { finishHost() }
231 catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Vehicle simulation step and cleanup failed') }
232 finally { board.revoke() }
233 throw error
160234 }
235 finally { stepping = false }
161236 }
162 finally { payload?.close(); if (status !== 'host-lost') board.revoke() }
163 // Opt-in material/actuator settling has no host sensor, estimation, mission or control work.
164 const hostEndAtMs = finalAtMs
165 for (let elapsed = 4; elapsed <= neutralTailMs; elapsed += 4) {
166 const nowMs = hostEndAtMs + elapsed, faults = profileInput.faults(scenario, nowMs, 'independent-tail'), boardState = board.tick(nowMs)
167 truth = plant.step(boardState.actuators, .004, { windMps: faults.windMps ?? [0, 0, 0], currentMps: faults.currentMps ?? [0, 0, 0] })
168 session.observeIndependent?.(structuredClone(truth), nowMs, [...boardState.actuators], false)
169 finalAtMs = nowMs
237 function result() {
238 if (!terminal) throw new Error('Vehicle simulation session is still active')
239 return structuredClone({ version: 1, evidence: 'owned-vehicle-physics-simulation' as const, physicalFlightEnabled: false as const, boardOutputsInhibited: true as const, vehicle: vehicle, scenario, status, fault, navigationEvidence: { ...(positionPrediction ? { positionPrediction: { ...fixtureInertialModel } } : {}), observedSensors, datum: estimate.localPosition?.altitudeDatum ?? null, quality: [...estimate.quality], localPositionHealthy: estimate.health.localPosition }, mission, trace: engine.trace(), path, transitions, budget: { worstCaseMs, requiredWh }, authority: supervisor.snapshot(), board: board.snapshot(), finalTruthPositionM: [...truth.positionM] as SensorVector, ...(batteryModel ? { energy: { evidence: 'modeled-vehicle-battery-v1' as const, model: batteryModel, lastObservation: batteryObservation } } : {}) })
240 }
241 return { step, result, close }
242}
243/** Run the same retained session synchronously to its natural terminal result. */
244export function runVehicleSimulation(profileInput: VehicleProfile, options: VehicleSimulationOptions = {}) {
245 const session = createVehicleSimulationSession(profileInput, options)
246 try {
247 for (let nowMs = 0; ; nowMs += 4) { if (session.step(nowMs).terminal) return session.result() }
170248 }
171 return { version: 1, evidence: 'owned-vehicle-physics-simulation' as const, physicalFlightEnabled: false as const, boardOutputsInhibited: true as const, vehicle: profileInput.vehicle, scenario, status, fault, navigationEvidence: { ...(positionPrediction ? { positionPrediction: { ...fixtureInertialModel } } : {}), observedSensors, datum: estimate.localPosition?.altitudeDatum ?? null, quality: [...estimate.quality], localPositionHealthy: estimate.health.localPosition }, mission, trace: engine.trace(), path, transitions, budget: { worstCaseMs, requiredWh }, authority: supervisor.snapshot(), board: board.snapshot(), finalTruthPositionM: [...truth.positionM] as SensorVector, ...(batteryModel ? { energy: { evidence: 'modeled-vehicle-battery-v1' as const, model: batteryModel, lastObservation: batteryObservation } } : {}) }
249 finally { session.close() }
172250}
tests/helpers/browser-launch-evidence.tsmodified+2-0
Changes to tests/helpers/browser-launch-evidence.ts
@@ -2,12 +2,14 @@ import { Buffer } from 'node:buffer'
22import { writeSync } from 'node:fs'
33import { mkdir, open, writeFile } from 'node:fs/promises'
44import { join } from 'node:path'
5import type { BrowserStartupEvidence } from './browser-startup-process'
56
67export interface BrowserLaunchObservation {
78 phase: 'startup-unavailable' | 'discovery-failed' | 'closed'
89 elapsedMs: number
910 launch: { pid: number, exitObserved: boolean, settledExitCode: number | null, signalCode: string | number | null, exitObservedBeforeCleanup: boolean, cleanupRequested: boolean, exitElapsedMs: number | null }
1011 portProbe: { attempts: number, successfulReads: number, failedReads: number, lastReadError: string | null, discovered: boolean }
12 startup?: BrowserStartupEvidence
1113}
1214export function browserFileErrorCode(error: unknown): string {
1315 const code = (error as NodeJS.ErrnoException | null)?.code
tests/helpers/browser-startup-linux.tsadded+167-0
Changes to tests/helpers/browser-startup-linux.ts
@@ -0,0 +1,167 @@
1import { createHash } from 'node:crypto'
2import { lstat, open, readFile, readlink, readdir } from 'node:fs/promises'
3import type { FileHandle } from 'node:fs/promises'
4import { join } from 'node:path'
5import { browserFileErrorCode } from './browser-launch-evidence'
6
7const processLimit = 64, threadLimit = 32, executableLimit = 4, executableByteLimit = 512 * 1024 * 1024
8async 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}
16const decimal = (value: string | undefined) => value && /^\d{1,20}$/.test(value) ? value : null
17const integer = (value: string | undefined) => { const n = decimal(value); return n !== null && Number.isSafeInteger(Number(n)) ? Number(n) : null }
18async 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}
36async 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}
45async 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}
50async 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}
58async 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
75type 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. */
77export 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}
tests/helpers/browser-startup-process.tsadded+61-0
Changes to tests/helpers/browser-startup-process.ts
@@ -0,0 +1,61 @@
1import { browserFileErrorCode } from './browser-launch-evidence'
2import type { createLinuxStartupObservation } from './browser-startup-linux'
3
4type NativeSnapshot = Awaited<ReturnType<ReturnType<typeof createLinuxStartupObservation>['snapshot']>>
5export 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
11type Probe = 'present' | 'missing' | 'denied' | 'unavailable'
12function 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. */
21export 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}
61export type BrowserStartupEvidence = ReturnType<ReturnType<typeof createBrowserStartupObserver>['evidence']>
tests/helpers/workspace-browser.tsmodified+17-5
Changes to tests/helpers/workspace-browser.ts
@@ -4,6 +4,8 @@ import { join } from 'node:path'
44import { observeBrowserOperation } from './browser-operation'
55import { browserFileErrorCode, retainBrowserLaunchEvidence } from './browser-launch-evidence'
66import type { BrowserLaunchObservation } from './browser-launch-evidence'
7import { createBrowserStartupObserver } from './browser-startup-process'
8import { createLinuxStartupObservation } from './browser-startup-linux'
79
810/** Dependency-free CDP, with a fresh profile. Credentials never enter command arguments. */
911async function openSocket(url: string): Promise<WebSocket> {
@@ -22,31 +24,41 @@ export async function startWorkspaceBrowser(root: string) {
2224 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')) })
2325 const started = Date.now()
2426 const began = performance.now()
27 const startup = createBrowserStartupObserver(child.pid, began, process.platform === 'linux' ? createLinuxStartupObservation(child.pid, profile, began) : null)
2528 let closing = false
2629 const launch: BrowserLaunchObservation['launch'] = { pid: child.pid, exitObserved: false, settledExitCode: null, signalCode: null, exitObservedBeforeCleanup: false, cleanupRequested: false, exitElapsedMs: null }
2730 const portProbe: BrowserLaunchObservation['portProbe'] = { attempts: 0, successfulReads: 0, failedReads: 0, lastReadError: null, discovered: false }
2831 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 }
3039 void child.exited.then(code => {
3140 launch.exitObserved = true; launch.settledExitCode = code; launch.signalCode = child.signalCode; launch.exitObservedBeforeCleanup = !closing; launch.exitElapsedMs = performance.now() - began
3241 if (!closing) console.log(`QA isolated Chrome exited unexpectedly: ${code}, elapsedMs:${Date.now() - started}`)
3342 })
3443 let port = ''
3544 for (let attempt = 0; attempt < 400; attempt++) {
45 startup.tick()
3646 portProbe.attempts++
3747 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 '' })
3848 if (port) { portProbe.discovered = true; break }
3949 await Bun.sleep(50)
4050 }
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') }
4253 // Keep a browser-level session, as well as page sessions, for the owned lifecycle.
4354 let browserSocket: WebSocket
4455 try {
4556 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)
4759 browserSocket = await observeBrowserOperation('browser-socket', () => openSocket(version.webSocketDebuggerUrl))
4860 }
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 }
5062 let browserSequence = 0
5163 browserSocket.onmessage = () => {}
5264 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) {
96108 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')
97109 return page
98110 },
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') },
100112 }
101113}
tests/tsconfig.runtime.jsonmodified+2-0
Changes to tests/tsconfig.runtime.json
@@ -23,6 +23,7 @@
2323 "./unit/controls.test.ts",
2424 "../resources/functions/drone/cargo/**/*.ts",
2525 "./unit/cargo-load.test.ts",
26 "./unit/cargo-intake.test.ts",
2627 "./unit/cargo-delivery.test.ts",
2728 "./unit/cargo-delivery-config.test.ts",
2829 "./unit/cargo-delivery-phase-faults.test.ts",
@@ -80,6 +81,7 @@
8081 "./unit/vehicle-mission-checkpoints.test.ts",
8182 "./unit/vehicle-authority.test.ts",
8283 "./unit/vehicle-runtime.test.ts",
84 "./unit/vehicle-sessions.test.ts",
8385 "./unit/multirotor-vehicle.test.ts",
8486 "./unit/vehicle-links.test.ts",
8587 "./unit/air.test.ts",
tests/unit/cargo-intake.test.tsadded+167-0
Changes to tests/unit/cargo-intake.test.ts
@@ -0,0 +1,167 @@
1import { expect, test } from 'bun:test'
2import { createReceivingRigidBody, createRigidBody } from '../../resources/functions/drone/controls/physics'
3import type { SensorVector } from '../../resources/functions/drone/sensors/types'
4import type { Quaternion } from '../../resources/functions/drone/estimation/frames'
5
6const plus = (a: SensorVector, b: SensorVector) => a.map((n, i) => n + b[i]!) as SensorVector
7const minus = (a: SensorVector, b: SensorVector) => a.map((n, i) => n - b[i]!) as SensorVector
8const scale = (a: SensorVector, n: number) => a.map(v => v * n) as SensorVector
9const cross = (a: SensorVector, b: SensorVector): SensorVector => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
10const dot = (a: SensorVector, b: SensorVector) => a.reduce((sum, n, i) => sum + n * b[i]!, 0)
11function rotate([w, x, y, z]: Quaternion, v: SensorVector): SensorVector {
12 return [[1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y)], [2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x)], [2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)]].map(row => row.reduce((sum, n, i) => sum + n * v[i]!, 0)) as SensorVector
13}
14function closeVector(actual: SensorVector, expected: SensorVector) { expect(Math.max(...minus(actual, expected).map(Math.abs))).toBeLessThanOrEqual(2e-12) }
15function mechanical(state: ReturnType<ReturnType<typeof createReceivingRigidBody>['snapshot']>, parcel: { massKg: number, positionM: SensorVector, velocityMps: SensorVector }) {
16 const origin: SensorVector = [7, -3, 5], inertia: SensorVector = [.03, .03, .05]
17 const baseP = scale(state.velocityMps, 1.5), parcelP = scale(parcel.velocityMps, parcel.massKg)
18 const spin = rotate(state.quaternionWxyz, state.angularRateRadS.map((n, i) => n * inertia[i]!) as SensorVector)
19 return { momentum: plus(baseP, parcelP), angular: plus(spin, plus(cross(minus(state.positionM, origin), baseP), cross(minus(parcel.positionM, origin), parcelP))),
20 kinetic: .75 * dot(state.velocityMps, state.velocityMps) + .5 * state.angularRateRadS.reduce((sum, n, i) => sum + inertia[i]! * n * n, 0) + .5 * parcel.massKg * dot(parcel.velocityMps, parcel.velocityMps),
21 potential: -9.80665 * (1.5 * state.positionM[2] + parcel.massKg * parcel.positionM[2]) }
22}
23
24// Actual physics seam: proves point-load capture and state continuity, not a
25// mechanism acknowledgement, vehicle authority or fleet custody protocol.
26test('a receiving body captures one coincident point mass with the worked inelastic momentum result', () => {
27 const body = createReceivingRigidBody({ positionM: [0, 0, -1], expectedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: .08 } })
28 expect(body.loadSnapshot().properties.phase).toBe('awaiting')
29 expect(body.loadSnapshot().properties.totalMassKg).toBe(1.5)
30 const before = body.snapshot(), point = body.intakePoint()
31 expect(point.positionM).toEqual([.08, 0, -1])
32 const event = body.attachParcel({ id: 'parcel-a', massKg: .1, positionM: point.positionM, velocityMps: [0, 1, 0] })
33 expect(event.outcome).toBe('attached')
34 if (event.outcome !== 'attached') throw new Error('Expected actual modeled capture')
35 expect(event.before.properties.phase).toBe('awaiting')
36 expect(event.after.properties.phase).toBe('attached')
37 expect(event.after.properties.totalMassKg).toBe(1.6)
38 expect(event.after.properties.cgBodyM).toEqual([.005, 0, 0])
39 // Rational expected values allow the binary64 accumulation error, not a model change.
40 for (const [i, expected] of [.03, .0306, .0506].entries()) expect(event.after.properties.inertiaDiagonalKgM2[i]!).toBeCloseTo(expected, 14)
41 expect(body.snapshot().angularRateRadS[2]).toBeCloseTo(.1482213438735178, 13)
42 expect(body.snapshot().velocityMps[1]).toBeCloseTo(.06175889328063241, 13)
43 expect(event.energy.kineticBeforeJ).toBeCloseTo(.05, 14)
44 expect(event.energy.kineticAfterJ).toBeCloseTo(.003680830039525692, 14)
45 expect(event.energy.dissipatedJ).toBeCloseTo(.04631916996047431, 14)
46 expect(body.snapshot().positionM).toEqual(before.positionM)
47 expect(body.snapshot().quaternionWxyz).toEqual(before.quaternionWxyz)
48 expect(body.snapshot().motorsN).toEqual(before.motorsN)
49 expect(body.attachParcel({ id: 'parcel-a', massKg: .1, positionM: point.positionM, velocityMps: [0, 1, 0] })).toEqual({ outcome: 'refused', reason: 'intake-consumed' })
50 const separated = body.separateCarriedLoad()
51 expect(separated.outcome).toBe('separated')
52 if (separated.outcome !== 'separated') throw new Error('Expected actual subsequent release')
53 expect(mechanical(body.snapshot(), separated.parcel).kinetic).toBeCloseTo(.003680830039525692, 14)
54})
55
56test('rotated captures conserve world momentum about an independent origin and dissipate relative motion', () => {
57 for (const massKg of [.01, .1, .25]) for (const forwardM of [-.1, 0, .1]) {
58 const body = createReceivingRigidBody({ positionM: [1, -2, -1], motorsN: [2, 2, 2, 2], expectedPointLoad: { id: 'parcel-a', massKg, forwardM } })
59 for (let i = 0; i < 40; i++) body.step([3, 2.9, 3.1, 3], .004, { windMps: [.2, -.1, 0], torqueNm: [.02, -.01, .03] })
60 const before = body.snapshot(), point = body.intakePoint()
61 const parcel = { id: 'parcel-a', massKg, ...point, velocityMps: plus(point.velocityMps, [.2, -.3, .1]) }
62 closeVector(point.positionM, plus(before.positionM, rotate(before.quaternionWxyz, [forwardM, 0, 0])))
63 const initial = mechanical(before, parcel), event = body.attachParcel(parcel)
64 expect(event.outcome).toBe('attached')
65 if (event.outcome !== 'attached') throw new Error('Expected rotated capture')
66 const after = body.snapshot(), afterPoint = { massKg, positionM: plus(after.positionM, rotate(after.quaternionWxyz, [forwardM, 0, 0])), velocityMps: plus(after.velocityMps, rotate(after.quaternionWxyz, cross(after.angularRateRadS, [forwardM, 0, 0]))) }
67 const final = mechanical(after, afterPoint)
68 closeVector(final.momentum, initial.momentum)
69 closeVector(final.angular, initial.angular)
70 expect(Math.abs(final.potential - initial.potential)).toBeLessThanOrEqual(2e-12)
71 expect(initial.kinetic - final.kinetic).toBeGreaterThan(0)
72 expect(event.energy.kineticBeforeJ).toBeCloseTo(initial.kinetic, 12)
73 expect(event.energy.kineticAfterJ).toBeCloseTo(final.kinetic, 12)
74 expect(event.energy.dissipatedJ).toBeCloseTo(initial.kinetic - final.kinetic, 12)
75 expect(event.energy.potentialBeforeJ).toBeCloseTo(initial.potential, 12)
76 expect(event.energy.potentialAfterJ).toBeCloseTo(final.potential, 12)
77 expect(after.positionM).toEqual(before.positionM)
78 expect(after.quaternionWxyz).toEqual(before.quaternionWxyz)
79 expect(after.motorsN).toEqual(before.motorsN)
80 event.after.bodyVelocityMps[0] = 999; event.parcel.positionM[0] = 999
81 expect(body.snapshot()).toEqual(after)
82 }
83})
84
85test('co-moving intake preserves body motion through a later separation', () => {
86 const body = createReceivingRigidBody({ positionM: [0, 0, -1], expectedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: -.08 } })
87 for (let i = 0; i < 30; i++) body.step([4, 3.8, 4.1, 4], .004, { torqueNm: [.01, -.02, .03] })
88 const before = body.snapshot(), parcel = { id: 'parcel-a', massKg: .1, ...body.intakePoint() }, event = body.attachParcel(parcel)
89 expect(event.outcome).toBe('attached')
90 if (event.outcome !== 'attached') throw new Error('Expected co-moving capture')
91 closeVector(body.snapshot().velocityMps, before.velocityMps)
92 closeVector(body.snapshot().angularRateRadS, before.angularRateRadS)
93 expect(Math.abs(event.energy.dissipatedJ)).toBeLessThanOrEqual(2e-12)
94 const separated = body.separateCarriedLoad()
95 expect(separated.outcome).toBe('separated')
96 if (separated.outcome !== 'separated') throw new Error('Expected subsequent separation')
97 closeVector(separated.parcel.positionM, parcel.positionM)
98 closeVector(separated.parcel.velocityMps, parcel.velocityMps)
99 closeVector(body.snapshot().velocityMps, before.velocityMps)
100 closeVector(body.snapshot().angularRateRadS, before.angularRateRadS)
101 expect(body.loadSnapshot().properties.phase).toBe('separated')
102 expect(body.loadSnapshot().properties.totalMassKg).toBe(1.5)
103 expect(body.attachParcel(parcel)).toEqual({ outcome: 'refused', reason: 'intake-consumed' })
104 expect(body.separateCarriedLoad()).toEqual({ outcome: 'refused', reason: 'already-separated' })
105})
106
107test('the actual subsequent acceleration uses the received mass and the empty receiver has no phantom load', () => {
108 for (const attached of [false, true]) {
109 const body = createReceivingRigidBody({ positionM: [0, 0, -1], expectedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: 0 } })
110 if (attached) expect(body.attachParcel({ id: 'parcel-a', massKg: .1, ...body.intakePoint() }).outcome).toBe('attached')
111 const state = body.step([4, 4, 4, 4], .004), mass = attached ? 1.6 : 1.5
112 const thrust = 16 * (1 - Math.exp(-.004 / .04))
113 expect(state.accelerationMps2[2]).toBeCloseTo(9.80665 - thrust / mass, 12)
114 expect(body.loadSnapshot().properties.totalMassKg).toBe(mass)
115 }
116})
117
118test('intake refuses foreign or noncoincident parcels without consuming or moving the body', () => {
119 const body = createReceivingRigidBody({ positionM: [0, 0, -1], expectedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: .08 } })
120 const before = body.snapshot(), point = { id: 'parcel-a', massKg: .1, ...body.intakePoint() }
121 expect(body.separateCarriedLoad()).toEqual({ outcome: 'refused', reason: 'awaiting-load' })
122 for (const candidate of [{ ...point, id: 'parcel-b' }, { ...point, massKg: .2 }]) expect(body.attachParcel(candidate)).toEqual({ outcome: 'refused', reason: 'unexpected-parcel' })
123 expect(body.attachParcel({ ...point, positionM: [.081, 0, -1] })).toEqual({ outcome: 'refused', reason: 'mount-not-coincident' })
124 expect(body.snapshot()).toEqual(before)
125 expect(body.loadSnapshot().properties.phase).toBe('awaiting')
126 expect(body.attachParcel(point).outcome).toBe('attached')
127})
128
129test('intake captures exact own data and malformed input cannot evaluate getters or mutate state', () => {
130 const body = createReceivingRigidBody({ positionM: [0, 0, -1], expectedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: 0 } })
131 const before = body.snapshot(), point = { id: 'parcel-a', massKg: .1, ...body.intakePoint() }
132 let called = false
133 const accessor = { ...point, get velocityMps() { called = true; return [0, 0, 0] } }
134 const tuple = [0, 0, 0]; Object.defineProperty(tuple, '0', { enumerable: true, get() { called = true; return 0 } })
135 for (const value of [null, { ...point, extra: true }, { ...point, massKg: NaN }, { ...point, velocityMps: [0, Infinity, 0] }, accessor, { ...point, velocityMps: tuple }, { ...point, positionM: [, 0, -1] }]) expect(() => body.attachParcel(value)).toThrow('cargo-intake-parcel')
136 const proxy = new Proxy(point, { ownKeys() { throw new Error('private-caller-sentinel') } })
137 expect(() => body.attachParcel(proxy)).toThrow(/^cargo-intake-parcel$/)
138 expect(() => Reflect.apply(body.attachParcel, body, [point, true])).toThrow('cargo-intake-parcel')
139 expect(called).toBe(false)
140 expect(body.snapshot()).toEqual(before)
141 expect(body.loadSnapshot().properties.phase).toBe('awaiting')
142 expect(() => createReceivingRigidBody({ expectedPointLoad: { get id() { called = true; return 'parcel-a' }, massKg: .1, forwardM: 0 } })).toThrow()
143 expect(called).toBe(false)
144 expect(body.attachParcel(point).outcome).toBe('attached')
145})
146
147test('joint contact is current physical state and the planned identity is captured once', () => {
148 const expected = { id: 'parcel-a', massKg: .1, forwardM: .08 }
149 const body = createReceivingRigidBody({ positionM: [0, 0, -1], expectedPointLoad: expected })
150 const stale = { id: 'parcel-a', massKg: .1, ...body.intakePoint() }
151 expected.id = 'parcel-b'; expected.massKg = .2; expected.forwardM = -.08
152 body.step([0, 0, 0, 0], .004)
153 expect(body.attachParcel(stale)).toEqual({ outcome: 'refused', reason: 'mount-not-coincident' })
154 expect(body.loadSnapshot().properties.phase).toBe('awaiting')
155 const current = { id: 'parcel-a', massKg: .1, ...body.intakePoint() }
156 expect(body.attachParcel(current).outcome).toBe('attached')
157 expect(body.loadSnapshot().properties.forwardM).toBe(.08)
158})
159
160test('legacy body factories keep their original methods and attached/separated phases', () => {
161 expect(Object.keys(createRigidBody())).toEqual(['snapshot', 'step'])
162 const legacy = createRigidBody({ carriedPointLoad: { id: 'parcel-a', massKg: .1, forwardM: .08 } })
163 expect(Object.keys(legacy)).toEqual(['snapshot', 'step', 'loadSnapshot', 'separateCarriedLoad'])
164 expect(legacy.loadSnapshot().properties.phase).toBe('attached')
165 expect(legacy.separateCarriedLoad().outcome).toBe('separated')
166 expect(legacy.loadSnapshot().properties.phase).toBe('separated')
167})
tests/unit/public-safety-browser-launch.test.tsmodified+60-1
Changes to tests/unit/public-safety-browser-launch.test.ts
@@ -1,5 +1,5 @@
11import { expect, test } from 'bun:test'
2import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
2import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
33import { tmpdir } from 'node:os'
44import { join } from 'node:path'
55import process from 'node:process'
@@ -25,6 +25,11 @@ test.skipIf(process.platform === 'win32')('failed native launch retains startup
2525 expect(report.launch.cleanupRequested).toBe(true)
2626 expect(report.portProbe.attempts).toBe(400)
2727 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')
2833 expect(report.stderr.status).toBe('retained')
2934 expect(report.stderr.retainedBytes).toBeLessThanOrEqual(65536)
3035 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
4651 else console.log(JSON.stringify({ evidence: 'controlled-native-launch', retainedRoot: root, passed }))
4752 }
4853}, 30000)
54
55// The existing native launch seam checks observed processes, not the cause of a Chrome failure.
56test.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)
tests/unit/public-safety-browser-startup-process.test.tsadded+80-0
Changes to tests/unit/public-safety-browser-startup-process.test.ts
@@ -0,0 +1,80 @@
1import { expect, test } from 'bun:test'
2import { mkdtemp, readlink, readdir, rm } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import { join } from 'node:path'
5import { createBrowserStartupObserver } from '../helpers/browser-startup-process'
6import type { StartupProcessAdapter } from '../helpers/browser-startup-process'
7import { createLinuxStartupObservation } from '../helpers/browser-startup-linux'
8
9// OS adapter seam: a held syscall promise checks ownership/cancellation, not Linux field acquisition.
10test('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
48test.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})
tests/unit/vehicle-sessions.test.tsadded+370-0
Changes to tests/unit/vehicle-sessions.test.ts
@@ -0,0 +1,370 @@
1import { expect, test } from 'bun:test'
2import { createVehicleSimulationSession } from '../../resources/functions/drone/vehicles/runtime'
3import { createMultirotorProfile } from '../../resources/functions/drone/vehicles/multirotor/profile'
4import type { VehiclePayloadCommand } from '../../resources/functions/drone/vehicles/types'
5import type { SensorVector } from '../../resources/functions/drone/sensors/types'
6
7test('two vehicle sessions share policy epochs while their raw sensor and authority scopes remain distinct', () => {
8 const a = createVehicleSimulationSession(createMultirotorProfile(), { seconds: 2, scope: { bootId: 101n, sessionId: 201n } })
9 const b = createVehicleSimulationSession(createMultirotorProfile(), { seconds: 2, scope: { bootId: 102n, sessionId: 202n } })
10 try {
11 expect(() => a.result()).toThrow('still active')
12 for (let nowMs = 0; nowMs <= 1000; nowMs += 4) {
13 const left = a.step(nowMs), right = b.step(nowMs)
14 expect(left.atMs).toBe(right.atMs)
15 expect(left.board.bootId).toBe(101n)
16 expect(right.board.bootId).toBe(102n)
17 if (nowMs === 1000) {
18 expect(left.reference.startsWith('101:')).toBe(true)
19 expect(right.reference.startsWith('102:')).toBe(true)
20 expect(left.authority.sessionId).toBe(201n)
21 expect(right.authority.sessionId).toBe(202n)
22 expect(left.authority.state).toBe('autonomous')
23 expect(right.authority.state).toBe('autonomous')
24 expect(left.board.actuators.some(n => n > 0)).toBe(true)
25 expect(right.board.actuators.some(n => n > 0)).toBe(true)
26 expect(left.policyAtMs).toBe(1000)
27 expect(right.policyAtMs).toBe(1000)
28 }
29 }
30 }
31 finally { a.close(); b.close() }
32 expect(a.result().board.actuators).toEqual([0, 0, 0, 0])
33 expect(b.result().board.actuators).toEqual([0, 0, 0, 0])
34})
35
36test('one vehicle can lose its host and settle independently while the peer continues fresh policy', () => {
37 const profile = createMultirotorProfile(), original = profile.faults
38 profile.faults = (scenario, atMs, phase) => ({ ...original(scenario, atMs, phase), hostLost: atMs >= 740 })
39 const a = createVehicleSimulationSession(profile, { seconds: 2, neutralTailMs: 1000, scope: { bootId: 101n, sessionId: 201n } })
40 const b = createVehicleSimulationSession(createMultirotorProfile(), { seconds: 2, scope: { bootId: 102n, sessionId: 202n } })
41 try {
42 for (let nowMs = 0; nowMs <= 1840; nowMs += 4) {
43 const left = a.step(nowMs), right = b.step(nowMs)
44 if (nowMs >= 844) {
45 expect(left.phase).toBe('independent-tail')
46 expect(left.hostAvailable).toBe(false)
47 expect(left.policyAtMs).toBe(720)
48 expect(left.board.actuators).toEqual([0, 0, 0, 0])
49 }
50 if (nowMs === 1840) {
51 expect(left.terminal).toBe(true)
52 expect(right.terminal).toBe(false)
53 expect(right.hostAvailable).toBe(true)
54 expect(right.policyAtMs).toBe(1840)
55 expect(right.authority.state).toBe('autonomous')
56 expect(right.board.actuators.some(n => n > 0)).toBe(true)
57 }
58 }
59 const run = a.result()
60 expect(run.status).toBe('host-lost')
61 expect(run.board.fault).toBe('output-lease-expired')
62 expect(run.path.at(-1)!.atMs).toBe(720)
63 expect(run.path.at(-1)!.measurement!.reference.startsWith('101:')).toBe(true)
64 expect(() => a.step(1844)).toThrow('closed')
65 }
66 finally { a.close(); b.close() }
67})
68
69test('vehicle sessions reject invalid scope before allocation and invalid epochs without advancing state', () => {
70 const profile = createMultirotorProfile()
71 let opened = false, getterCalled = false
72 profile.createSession = () => { opened = true; throw new Error('Unexpected allocation') }
73 for (const scope of [null, { bootId: 0n, sessionId: 1n }, { bootId: 1n, sessionId: 0x10000000000000000n }, { bootId: 1, sessionId: 2n }, { get bootId() { getterCalled = true; return 1n }, sessionId: 2n }]) {
74 expect(() => createVehicleSimulationSession(profile, { scope: scope as never })).toThrow('scope')
75 }
76 expect(opened).toBe(false)
77 expect(getterCalled).toBe(false)
78 const scope = { bootId: 101n, sessionId: 201n }, session = createVehicleSimulationSession(createMultirotorProfile(), { seconds: 2, scope })
79 scope.bootId = 999n; scope.sessionId = 999n
80 try {
81 expect(() => session.step(4)).toThrow('next 4 ms epoch')
82 expect(session.step(0).board.bootId).toBe(101n)
83 expect(() => session.step(0)).toThrow('next 4 ms epoch')
84 expect(() => session.step(8)).toThrow('next 4 ms epoch')
85 expect(session.step(4).atMs).toBe(4)
86 }
87 finally { session.close() }
88 const result = session.result()
89 expect(result.status).toBe('blocked')
90 expect(result.fault).toBe('simulation-session-closed')
91 result.board.actuators[0] = 99
92 expect(session.result().board.actuators).toEqual([0, 0, 0, 0])
93})
94
95test('a step failure and explicit close both release payload ownership once and revoke the board', () => {
96 for (const fail of [false, true]) {
97 const profile = createMultirotorProfile(), create = profile.createSession
98 let closes = 0
99 profile.createSession = () => {
100 const base = create()
101 return { ...base, renderMeasurement(...args) { if (fail && args[1] === 1000) throw new Error('Captured renderer failure'); return base.renderMeasurement(...args) },
102 createPayload: () => ({ request() {}, poll() { return null }, close() { closes++ } }),
103 }
104 }
105 const session = createVehicleSimulationSession(profile, { seconds: 2 })
106 try {
107 for (let nowMs = 0; nowMs < 1000; nowMs += 4) session.step(nowMs)
108 if (fail) expect(() => session.step(1000)).toThrow('Captured renderer failure')
109 }
110 finally { session.close(); session.close() }
111 expect(closes).toBe(1)
112 expect(session.result().board.actuators).toEqual([0, 0, 0, 0])
113 expect(session.result().board.expiresAtMs).toBeNull()
114 expect(session.result().fault).toBe(fail ? 'simulation-step-failed' : 'simulation-session-closed')
115 expect(() => session.step(1004)).toThrow('closed')
116 }
117})
118
119test('a simultaneous step and teardown failure is retained after the independent board is revoked', () => {
120 const profile = createMultirotorProfile(), create = profile.createSession
121 let closes = 0
122 profile.createSession = () => {
123 const base = create()
124 return { ...base, renderMeasurement(...args) { if (args[1] === 1000) throw new Error('Renderer failed'); return base.renderMeasurement(...args) },
125 createPayload: () => ({ request() {}, poll() { return null }, close() { closes++; throw new Error('Payload teardown failed') } }),
126 }
127 }
128 const session = createVehicleSimulationSession(profile, { seconds: 2 })
129 for (let atMs = 0; atMs < 1000; atMs += 4) session.step(atMs)
130 let failure: unknown
131 try { session.step(1000) }
132 catch (error) { failure = error }
133 expect(failure).toBeInstanceOf(AggregateError)
134 expect((failure as AggregateError).errors.map(error => error.message)).toEqual(['Renderer failed', 'Payload teardown failed'])
135 session.close()
136 expect(closes).toBe(1)
137 expect(session.result().board.actuators).toEqual([0, 0, 0, 0])
138 expect(session.result().board.expiresAtMs).toBeNull()
139})
140
141test('a controller callback cannot reopen the board lease after closing its own session', () => {
142 const profile = createMultirotorProfile(), create = profile.createSession
143 let session: ReturnType<typeof createVehicleSimulationSession>, returnedNonzero = false
144 profile.createSession = () => {
145 const base = create()
146 return { ...base, createController() {
147 const controller = base.createController()
148 return { step(context) {
149 const output = controller.step(context)
150 if (context.nowMs === 1000) { returnedNonzero = output.actuators.some(n => n > 0); session.close() }
151 return output
152 } }
153 } }
154 }
155 session = createVehicleSimulationSession(profile, { seconds: 2 })
156 try {
157 for (let atMs = 0; atMs < 1000; atMs += 4) session.step(atMs)
158 const tick = session.step(1000)
159 expect(returnedNonzero).toBe(true)
160 expect(tick.terminal).toBe(true)
161 expect(tick.hostAvailable).toBe(false)
162 expect(tick.policyAtMs).toBe(980)
163 expect(tick.board.actuators).toEqual([0, 0, 0, 0])
164 expect(tick.board.expiresAtMs).toBeNull()
165 expect(session.result().board.actuators).toEqual([0, 0, 0, 0])
166 expect(session.result().fault).toBe('simulation-session-closed')
167 }
168 finally { session.close() }
169})
170
171test('a controller cannot recursively advance the same in-flight session epoch', () => {
172 const profile = createMultirotorProfile(), create = profile.createSession
173 let session: ReturnType<typeof createVehicleSimulationSession>, attempted = false
174 profile.createSession = () => {
175 const base = create()
176 return { ...base, createController() {
177 const controller = base.createController()
178 return { step(context) {
179 if (context.nowMs === 1000 && !attempted) {
180 attempted = true
181 expect(() => session.step(1000)).toThrow('already stepping')
182 }
183 return controller.step(context)
184 } }
185 } }
186 }
187 session = createVehicleSimulationSession(profile, { seconds: 2 })
188 try {
189 for (let atMs = 0; atMs <= 1000; atMs += 4) session.step(atMs)
190 expect(attempted).toBe(true)
191 expect(session.step(1004).atMs).toBe(1004)
192 }
193 finally { session.close() }
194})
195
196test('closing from a payload observation prevents later polling and controller work in the same epoch', () => {
197 const profile = createMultirotorProfile(), create = profile.createSession
198 let session: ReturnType<typeof createVehicleSimulationSession>, closes = 0, workAfterClose = 0
199 profile.createSession = () => {
200 const base = create()
201 return { ...base, createController() {
202 const controller = base.createController()
203 return { step(context) { if (closes) workAfterClose++; return controller.step(context) } }
204 }, createPayload: () => ({
205 observe(context) { if (context.nowMs === 1000) session.close() },
206 request() { if (closes) workAfterClose++ },
207 poll() { if (closes) workAfterClose++; return null },
208 close() { closes++ },
209 }) }
210 }
211 session = createVehicleSimulationSession(profile, { seconds: 2 })
212 try {
213 for (let atMs = 0; atMs < 1000; atMs += 4) session.step(atMs)
214 const tick = session.step(1000)
215 expect(tick.terminal).toBe(true)
216 expect(tick.policyAtMs).toBe(980)
217 expect(tick.hostAvailable).toBe(false)
218 expect(workAfterClose).toBe(0)
219 expect(closes).toBe(1)
220 expect(tick.board.actuators).toEqual([0, 0, 0, 0])
221 expect(tick.board.expiresAtMs).toBeNull()
222 }
223 finally { session.close() }
224})
225
226test('a closing link callback failure is surfaced without calling the closed payload', () => {
227 const profile = createMultirotorProfile(), create = profile.createSession
228 let session: ReturnType<typeof createVehicleSimulationSession>, closes = 0, observationsAfterClose = 0
229 profile.createSession = () => ({ ...create(),
230 linkObservation(nowMs) {
231 if (nowMs === 1000) { session.close(); throw new Error('Link callback failed after close') }
232 return { capturedAtMs: nowMs, connected: true }
233 },
234 createPayload: () => ({ observe() { if (closes) observationsAfterClose++ }, request() {}, poll() { return null }, close() { closes++ } }),
235 })
236 session = createVehicleSimulationSession(profile, { seconds: 2 })
237 try {
238 for (let atMs = 0; atMs < 1000; atMs += 4) session.step(atMs)
239 expect(() => session.step(1000)).toThrow('Link callback failed after close')
240 expect(observationsAfterClose).toBe(0)
241 expect(closes).toBe(1)
242 expect(session.result().board.actuators).toEqual([0, 0, 0, 0])
243 }
244 finally { session.close() }
245})
246
247test('a returned actuator getter cannot reopen output when capture closes its session', () => {
248 const profile = createMultirotorProfile(), create = profile.createSession
249 let session: ReturnType<typeof createVehicleSimulationSession>, getterCalled = false
250 profile.createSession = () => {
251 const base = create()
252 return { ...base, createController() {
253 const controller = base.createController()
254 return { step(context) {
255 const output = controller.step(context)
256 if (context.nowMs === 1000) {
257 const actuators = [...output.actuators]
258 Object.defineProperty(output, 'actuators', { enumerable: true, get() { getterCalled = true; session.close(); return actuators } })
259 }
260 return output
261 } }
262 } }
263 }
264 session = createVehicleSimulationSession(profile, { seconds: 2 })
265 try {
266 for (let atMs = 0; atMs < 1000; atMs += 4) session.step(atMs)
267 const tick = session.step(1000)
268 expect(getterCalled).toBe(true)
269 expect(tick.terminal).toBe(true)
270 expect(tick.board.actuators).toEqual([0, 0, 0, 0])
271 expect(tick.board.expiresAtMs).toBeNull()
272 expect(tick.policyAtMs).toBe(980)
273 }
274 finally { session.close() }
275})
276
277test('a returned ACK getter cannot confirm payload work after closing its session', () => {
278 const profile = createMultirotorProfile(), create = profile.createSession
279 profile.mission.tasks = [{ id: 'start', kind: 'start', next: 'capture' }, { id: 'capture', kind: 'payload', operation: 'camera-still', containerId: null, timeoutMs: 1500, next: 'depart' }, ...profile.mission.tasks.filter(task => task.kind !== 'start')]
280 let session: ReturnType<typeof createVehicleSimulationSession>, pending: VehiclePayloadCommand | null = null, getterCalled = false
281 profile.createSession = () => ({ ...create(), createPayload: () => ({
282 request(command) { pending = structuredClone(command) },
283 poll(nowMs) {
284 if (!pending || nowMs <= pending.issuedAtMs) return null
285 const command = pending
286 return { eventId: command.eventId, taskId: command.taskId, operation: command.operation, containerId: command.containerId, reference: command.reference,
287 capturedAtMs: nowMs, outcome: 'confirmed', metadata: {}, get positionM(): SensorVector { getterCalled = true; session.close(); return [...command.positionM] } }
288 },
289 close() { pending = null },
290 }) })
291 session = createVehicleSimulationSession(profile, { seconds: 12 })
292 try {
293 for (let atMs = 0; ; atMs += 4) { if (session.step(atMs).terminal) break }
294 expect(getterCalled).toBe(true)
295 expect(session.result().trace.rows.some(row => row.result.reason === 'payload-confirmed')).toBe(false)
296 expect(session.result().fault).toBe('simulation-session-closed')
297 expect(session.result().board.actuators).toEqual([0, 0, 0, 0])
298 }
299 finally { session.close() }
300})
301
302test('a returned fault getter cannot advance the physical plant after closing its session', () => {
303 const profile = createMultirotorProfile(), create = profile.createSession, faults = profile.faults
304 let session: ReturnType<typeof createVehicleSimulationSession>, closed = false, stepsAfterClose = 0, getterCalled = false
305 profile.faults = (scenario, atMs, phase) => atMs !== 1000 ? faults(scenario, atMs, phase) : {
306 get windMps(): SensorVector { getterCalled = true; closed = true; session.close(); return [0, 0, 0] },
307 }
308 profile.createSession = () => {
309 const base = create()
310 return { ...base, createPlant() {
311 const plant = base.createPlant()
312 return { snapshot: plant.snapshot, step(...args) { if (closed) stepsAfterClose++; return plant.step(...args) } }
313 } }
314 }
315 session = createVehicleSimulationSession(profile, { seconds: 2 })
316 try {
317 for (let atMs = 0; atMs <= 1000; atMs += 4) session.step(atMs)
318 expect(getterCalled).toBe(true)
319 expect(stepsAfterClose).toBe(0)
320 expect(session.result().fault).toBe('simulation-session-closed')
321 expect(session.result().board.actuators).toEqual([0, 0, 0, 0])
322 }
323 finally { session.close() }
324})
325
326test('the initial plant snapshot is captured before a live session can close through its getters', () => {
327 const profile = createMultirotorProfile(), create = profile.createSession
328 let session: ReturnType<typeof createVehicleSimulationSession> | undefined, readsAfterExposure = 0, callbacksAfterClose = 0, closed = false
329 profile.createSession = () => {
330 const base = create()
331 return { ...base, createPlant() {
332 const plant = base.createPlant()
333 return { step: plant.step, snapshot() {
334 const state = plant.snapshot()
335 return { ...state, get details() { if (session) { readsAfterExposure++; closed = true; session.close() } return state.details } }
336 } }
337 }, observeIndependent() { if (closed) callbacksAfterClose++ } }
338 }
339 session = createVehicleSimulationSession(profile, { seconds: 2 })
340 try {
341 for (let atMs = 0; atMs <= 1000; atMs += 4) { if (session.step(atMs).terminal) break }
342 expect(readsAfterExposure).toBe(0)
343 expect(callbacksAfterClose).toBe(0)
344 expect(() => session!.result()).toThrow('still active')
345 }
346 finally { session.close() }
347})
348
349test('adapter methods are bound before exposure so method getters cannot close then invoke work', () => {
350 const profile = createMultirotorProfile(), create = profile.createSession
351 let session: ReturnType<typeof createVehicleSimulationSession> | undefined, readsAfterExposure = 0, callbacksAfterClose = 0, closed = false
352 profile.createSession = () => {
353 const base = create()
354 return { ...base, createController() {
355 const controller = base.createController()
356 return { get step() {
357 if (session) { readsAfterExposure++; closed = true; session.close() }
358 return (context: Parameters<typeof controller.step>[0]) => { if (closed) callbacksAfterClose++; return controller.step(context) }
359 } }
360 } }
361 }
362 session = createVehicleSimulationSession(profile, { seconds: 2 })
363 try {
364 for (let atMs = 0; atMs <= 1000; atMs += 4) { if (session.step(atMs).terminal) break }
365 expect(readsAfterExposure).toBe(0)
366 expect(callbacksAfterClose).toBe(0)
367 expect(() => session!.result()).toThrow('still active')
368 }
369 finally { session.close() }
370})