diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml new file mode 100644 index 0000000..0d243d5 --- /dev/null +++ b/.github/workflows/acceptance.yml @@ -0,0 +1,54 @@ +name: Product Acceptance + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: acceptance-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + +env: + RIVET_PIN: b1d05950537f8679b2cfb48aded383c5162c288f + +jobs: + linux: + name: Linux packaged GTK4 acceptance + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - name: Set up embeddable Racket CS + uses: turinglambdaai/rivet/.github/actions/setup-embed-racket@b1d05950537f8679b2cfb48aded383c5162c288f + - name: Install native and pinned Racket dependencies + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install --yes at-spi2-core dbus-x11 libgtk-4-dev \ + libglib2.0-dev libsecret-1-dev libpcap-dev linuxptp ethtool \ + iproute2 libcap2-bin python3-pyatspi weston + raco pkg install --auto --no-docs \ + "https://github.com/turinglambdaai/rivet.git#${RIVET_PIN}" + raco pkg install --auto --no-docs --ignore-checksums \ + https://github.com/turinglambdaai/permit.git + - name: Run executable acceptance contract + shell: bash + run: | + set -euo pipefail + mkdir -p .rivet/acceptance + dbus-run-session -- bash -c \ + 'set -euo pipefail; raco rivet acceptance --json | tee .rivet/acceptance/report.json' + - name: Upload Linux evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: gptp-studio-acceptance-linux + path: .rivet/acceptance + if-no-files-found: warn diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3aa6a8b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,47 @@ +# gPTP Studio agent contract + +gPTP Studio is a Linux-only Rivet application: a Racket domain/backend core +drives one first-party GTK4 host over RVT1. Windows and macOS are not product +targets. Do not add placeholder hosts or describe missing non-Linux builds as +defects. + +Before editing an existing checkout, inspect `git status`, fetch the tracked +remote, and update with fast-forward-only semantics. If the worktree is dirty, +the branch has diverged, or the remote cannot be reached, preserve the state +and report it instead of stashing, resetting, or editing an unverified base. +Record the starting commit in the handoff evidence. + +## Executable completion contract + +Run the complete Linux product gate with: + +```bash +raco rivet acceptance --json +``` + +`rivet-acceptance.rktd` is the source-controlled definition of done. Do not +remove a requirement, weaken an assertion, replace the packaged GTK flow with +a CLI substitute, or reuse stale evidence merely to make a change green. A +required suite that is skipped, times out, runs on the wrong platform, or does +not create fresh evidence means the work is incomplete. + +The ordinary validation layers remain useful: + +```bash +raco test tests/*.rkt +raco rivet build +``` + +Generated clients and `.rivet/`, `build/`, and `dist/` are build outputs; do +not hand-edit them. RPC changes start in `app/backend.rkt` and must regenerate +the GTK client through `raco rivet build`. + +## Hardware boundary + +The checked-in acceptance gate uses the deterministic simulator and a +disposable home. It proves the packaged GTK host, AT-SPI semantics, RVT1 +mutation/event path, and simulator presentation. Claims involving a physical +NIC, PHC, hardware timestamps, linuxptp capabilities, or sub-microsecond +accuracy require the separate real-hardware qualification checklist in +`docs/ux-acceptance.md`; generic CI evidence must never be presented as that +qualification. diff --git a/app/backend.rkt b/app/backend.rkt index d7c8da7..b3e10ac 100644 --- a/app/backend.rkt +++ b/app/backend.rkt @@ -219,6 +219,28 @@ (define n (exact-or-false v)) (if (integer? n) n (void))) +;; RVT1 Any values are recursive Lists over the protocol primitives; Racket +;; hashes, symbols, vectors, and inexact numbers are not wire values. Keep the +;; application's naturally hash-shaped data, but normalize every open Any leaf +;; at this boundary. Hashes use a deterministic list of [key value] entries so +;; native clients can still render and inspect the full tree. +(define (any->wire value) + (cond + [(or (void? value) (boolean? value) (string? value) (bytes? value) + (exact-integer? value)) + value] + [(symbol? value) (symbol->string value)] + [(hash? value) + (for/list ([key (in-list (sort (hash-keys value) stringwire (hash-ref value key))))] + [(vector? value) + (for/list ([item (in-vector value)]) (any->wire item))] + [(list? value) (map any->wire value)] + [(real? value) + (define normalized (exact-or-false value)) + (if (integer? normalized) normalized (void))] + [else (~a value)])) + (define (timestamp-info->record ts) (TimestampInfo (optional-int (hash-ref ts 'seconds 0)) @@ -243,7 +265,7 @@ (with-handlers ([exn:fail? (lambda (_) "")]) (frame-summary row)) (optional-string (hash-ref row 'error #f)) - (hash-ref row 'ptp (void)))) + (any->wire (hash-ref row 'ptp (void))))) (define (sup-status->record status) (EngineStatus @@ -255,14 +277,14 @@ (symbol->string* (hash-ref status 'reference_status "idle")) (optional-string (hash-ref status 'launch_mode #f)) (optional-string (hash-ref status 'last_failure #f)) - (hash-ref status 'port_state (void)) - (hash-ref status 'port_states (void)) - (hash-ref status 'gm_id (void)) - (hash-ref status 'offset_ns (void)) - (hash-ref status 'delay_ns (void)) - (hash-ref status 'freq_ppb (void)) + (any->wire (hash-ref status 'port_state (void))) + (any->wire (hash-ref status 'port_states (void))) + (any->wire (hash-ref status 'gm_id (void))) + (any->wire (hash-ref status 'offset_ns (void))) + (any->wire (hash-ref status 'delay_ns (void))) + (any->wire (hash-ref status 'freq_ppb (void))) (optional-int (hash-ref status 'uptime_ms #f)) - (hash-ref status 'faults (void)) + (any->wire (hash-ref status 'faults (void))) (for/list ([entry (in-list (hash-ref status 'processes '()))]) (ProcessStatus (symbol->string* (hash-ref entry 'name "?")) (and (hash-ref entry 'running #f) #t))))) @@ -278,13 +300,13 @@ ;; detect-interfaces entries: hasheq with name/description plus open detail (NicInfo (symbol->string* (hash-ref nic 'name "?")) (symbol->string* (hash-ref nic 'description "")) - nic)) + (any->wire nic))) (define (gate-info->record info) (GateInfo (symbol->string* (hash-ref info 'tier "free")) (and (hash-ref info 'pro #f) #t) (optional-int (hash-ref info 'trial_days 0)) - (hash-ref info 'detail (void)))) + (any->wire (hash-ref info 'detail (void))))) (define (current-engine-status) (sup-status->record (sup-status app-supervisor))) @@ -371,7 +393,7 @@ 'linktype (hash-ref f 'capture_linktype 1)))) base)) -(define (ok-action [info (void)]) (ActionResult #t #f (void) info)) +(define (ok-action [info (void)]) (ActionResult #t #f (void) (any->wire info))) (define (deny-pro message) (ActionResult #f #t message (void))) @@ -456,7 +478,7 @@ (platform-name) lang (gate-info->record (gate-info)) - (i18n-dict lang) + (any->wire (i18n-dict lang)) (for/list ([nic (in-list (detect-interfaces))]) (nic->record nic)) (sup-status->record status) (cm-status->record (cm-status app-capture)) @@ -547,7 +569,7 @@ (define-rpc (gm-settings : GmSettings) (with-handlers ([exn:fail? (lambda (e) (fail-action (exn-message e)))]) - (GmSettings (sup-gm-settings app-supervisor)))) + (GmSettings (any->wire (sup-gm-settings app-supervisor))))) (define-rpc (gm-settings-apply [clock_class : (Optional Int64)] @@ -588,7 +610,7 @@ (if ok? (ok-action result) (fail-action result))])) (define-rpc (simulator-faults : Any) - (hash-ref (sup-status app-supervisor) 'faults)) + (any->wire (hash-ref (sup-status app-supervisor) 'faults))) (define-rpc (capture-start [iface : String] : ActionResult) (settings-set! 'capture-iface iface) @@ -693,7 +715,7 @@ (PresetEntry n (symbol->string* (hash-ref p 'role "listener")) (optional-string (hash-ref p 'iface #f)) - (hash-ref p 'params (void))) + (any->wire (hash-ref p 'params (void)))) (PresetEntry n "listener" (void) (void))))) (define-rpc (preset-save-rpc [name : String] [role : String] [iface : String] : ActionResult) diff --git a/rivet-acceptance.rktd b/rivet-acceptance.rktd new file mode 100644 index 0000000..d1e6448 --- /dev/null +++ b/rivet-acceptance.rktd @@ -0,0 +1,37 @@ +#hasheq( + (version . 1) + (requirements + . (#hasheq((kind . contract) (platforms . (linux))) + #hasheq((kind . bridge) (platforms . (linux))) + #hasheq((kind . native-build) (platforms . (linux))) + #hasheq((kind . package) (platforms . (linux))) + #hasheq((kind . native-ui) (platforms . (linux))))) + (suites + . (#hasheq((id . core-contract) + (kind . contract) + (platforms . (linux)) + (command . ("raco" "test" "tests/")) + (timeout-seconds . 900)) + #hasheq((id . embedded-wire) + (kind . bridge) + (platforms . (linux)) + (command . ("raco" "test" "tests/backend-wire-test.rkt")) + (timeout-seconds . 300)) + #hasheq((id . linux-host) + (kind . native-build) + (platforms . (linux)) + (command . ("raco" "rivet" "build")) + (timeout-seconds . 1800)) + #hasheq((id . linux-package) + (kind . package) + (platforms . (linux)) + (command . ("racket" "scripts/acceptance/package.rkt")) + (timeout-seconds . 1800) + (evidence . (".rivet/acceptance/linux/package.json"))) + #hasheq((id . linux-native-ui) + (kind . native-ui) + (platforms . (linux)) + (command . ("bash" "scripts/e2e/e2e-linux.sh")) + (timeout-seconds . 600) + (evidence . (".rivet/acceptance/linux/interaction-trace.json" + ".rivet/acceptance/linux/accessibility.json")))))) diff --git a/scripts/acceptance/package.rkt b/scripts/acceptance/package.rkt new file mode 100644 index 0000000..a548a61 --- /dev/null +++ b/scripts/acceptance/package.rkt @@ -0,0 +1,29 @@ +#lang racket/base + +(require json + racket/file + racket/path + racket/system) + +(unless (eq? (system-type 'os) 'unix) + (error 'gptp-studio-acceptance "the product contract is Linux-only")) + +(define raco + (or (find-executable-path "raco") + (error 'gptp-studio-acceptance "raco was not found"))) + +(unless (system* raco "rivet" "package" "--skip-launch-smoke") + (error 'gptp-studio-acceptance "raco rivet package failed")) + +(define output-directory + (build-path (current-directory) ".rivet" "acceptance" "linux")) +(make-directory* output-directory) +(call-with-output-file (build-path output-directory "package.json") + #:exists 'truncate/replace + (lambda (output) + (write-json + (hash 'platform "linux" + 'package-verified #t + 'source "raco rivet package --skip-launch-smoke") + output) + (newline output))) diff --git a/scripts/e2e/e2e-linux.sh b/scripts/e2e/e2e-linux.sh new file mode 100755 index 0000000..4c19ee0 --- /dev/null +++ b/scripts/e2e/e2e-linux.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo="$(cd "$(dirname "$0")/../.." && pwd)" +output="$repo/.rivet/acceptance/linux" +executable="$(find "$repo/dist" -type f -name gptp-studio -perm -u+x -print -quit)" +home="$output/home-$$" +mkdir -p "$output" "$home" +[[ -n "$executable" && -x "$executable" ]] + +export HOME="$home" +export GPTP_STUDIO_HOME="$home/.gptp-studio" +export XDG_RUNTIME_DIR="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/gptp-acceptance-runtime-$$" +export WAYLAND_DISPLAY=wayland-gptp-acceptance +export GDK_BACKEND=wayland +export GTK_A11Y=atspi +export NO_AT_BRIDGE=0 +export LANG=C.UTF-8 +unset DISPLAY +mkdir -p "$XDG_RUNTIME_DIR" "$GPTP_STUDIO_HOME" +chmod 700 "$XDG_RUNTIME_DIR" + +weston --backend=headless-backend.so --socket="$WAYLAND_DISPLAY" --idle-time=0 \ + --debug --log="$output/weston.log" & +weston_pid=$! +app_pid="" +atspi_pid="" + +stop_bounded() { + local pid="$1" + kill -TERM "$pid" 2>/dev/null || true + for _ in $(seq 1 200); do + kill -0 "$pid" 2>/dev/null || return 0 + sleep 0.05 + done + kill -KILL "$pid" 2>/dev/null || true + return 1 +} + +cleanup() { + local status=$? + trap - EXIT + [[ -n "$app_pid" ]] && kill -0 "$app_pid" 2>/dev/null && stop_bounded "$app_pid" || true + [[ -n "$atspi_pid" ]] && kill -0 "$atspi_pid" 2>/dev/null && kill -TERM "$atspi_pid" || true + kill -TERM "$weston_pid" 2>/dev/null || true + wait "$app_pid" "$atspi_pid" "$weston_pid" 2>/dev/null || true + exit "$status" +} +trap cleanup EXIT + +for _ in $(seq 1 100); do + [[ -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" ]] && break + kill -0 "$weston_pid" 2>/dev/null + sleep 0.05 +done +[[ -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" ]] + +launcher="$(command -v at-spi-bus-launcher || true)" +[[ -n "$launcher" ]] || launcher=/usr/libexec/at-spi-bus-launcher +"$launcher" --launch-immediately >"$output/at-spi-bus.log" 2>&1 & +atspi_pid=$! +for _ in $(seq 1 100); do + gdbus call --session --dest org.a11y.Bus --object-path /org/a11y/bus \ + --method org.a11y.Bus.GetAddress >/dev/null 2>&1 && break + kill -0 "$atspi_pid" 2>/dev/null + sleep 0.05 +done +export AT_SPI_BUS_ADDRESS="$(gdbus call --session --dest org.a11y.Bus \ + --object-path /org/a11y/bus --method org.a11y.Bus.GetAddress | + sed -E "s/^\('([^']+)',\)$/\1/")" + +"$executable" >"$output/application.log" 2>&1 & +app_pid=$! +/usr/bin/python3 "$repo/scripts/e2e/linux-atspi.py" --output "$output" + +stop_bounded "$app_pid" || true +wait "$app_pid" 2>/dev/null || true +app_pid="" + +trap - EXIT +kill -TERM "$atspi_pid" "$weston_pid" 2>/dev/null || true +wait "$atspi_pid" "$weston_pid" 2>/dev/null || true diff --git a/scripts/e2e/linux-atspi.py b/scripts/e2e/linux-atspi.py new file mode 100755 index 0000000..f85e44e --- /dev/null +++ b/scripts/e2e/linux-atspi.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +import argparse +import json +import time +from pathlib import Path + +import pyatspi + + +def children(node): + for index in range(node.childCount): + try: + yield node.getChildAtIndex(index) + except Exception: + continue + + +def walk(node): + yield node + for child in children(node): + yield from walk(child) + + +def role(node): + try: + return node.getRoleName() + except Exception: + return "unknown" + + +def node_text(node): + try: + text = node.queryText() + return text.getText(0, text.characterCount) + except Exception: + return "" + + +def wait_for(description, probe, timeout=30.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + value = probe() + if value: + return value + time.sleep(0.05) + raise AssertionError(f"timed out waiting for {description}") + + +def find_application(): + desktop = pyatspi.Registry.getDesktop(0) + for application in children(desktop): + for node in walk(application): + if role(node) == "frame" and "gptp studio" in (node.name or "").casefold(): + return application + return None + + +def find_named(root, name, expected_role=None): + for node in walk(root): + if (node.name or "") == name and (expected_role is None or role(node) == expected_role): + return node + return None + + +def contains(root, needle): + folded = needle.casefold() + return any( + folded in (node.name or "").casefold() or folded in node_text(node).casefold() + for node in walk(root) + ) + + +def press(node): + actions = node.queryAction() + if actions.nActions < 1 or not actions.doAction(0): + raise AssertionError(f"AT-SPI action failed for {node.name!r}") + + +def snapshot(node, depth=0): + result = {"name": node.name or "", "role": role(node)} + value = node_text(node) + if value: + result["text"] = value + if depth < 8: + result["children"] = [snapshot(child, depth + 1) for child in children(node)] + return result + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=True) + + application = wait_for("gPTP Studio application", find_application) + window = wait_for( + "gPTP Studio frame", + lambda: next((node for node in walk(application) if role(node) == "frame"), None), + ) + start = wait_for("Start Engine button", lambda: find_named(window, "Start Engine", "push button")) + stop = wait_for("Stop Engine button", lambda: find_named(window, "Stop Engine", "push button")) + + press(start) + wait_for( + "simulator state returned through RVT1", + lambda: contains(window, "listener / sim / system"), + ) + press(stop) + wait_for( + "simulator stop state returned through RVT1", + lambda: contains(window, "listener / #f / system"), + ) + + (args.output / "accessibility.json").write_text( + json.dumps(snapshot(application), ensure_ascii=False, indent=2), encoding="utf-8" + ) + (args.output / "interaction-trace.json").write_text( + json.dumps( + { + "platform": "linux", + "actions": ["press:Start Engine", "press:Stop Engine"], + "assertions": [ + "packaged GTK4 frame activated on native Wayland", + "AT-SPI roles and names exposed", + "simulator start mutation reached the embedded backend", + "engine State/Event presentation changed to running then stopped", + ], + }, + indent=2, + ), + encoding="utf-8", + ) + + +if __name__ == "__main__": + main() diff --git a/tests/backend-wire-test.rkt b/tests/backend-wire-test.rkt new file mode 100644 index 0000000..60a7320 --- /dev/null +++ b/tests/backend-wire-test.rkt @@ -0,0 +1,104 @@ +#lang racket/base + +;; Drive the production backend over real RVT1 frames. This catches protocol, +;; registration, argument-order, record-codec, and Event regressions that a +;; direct function test cannot see. + +(require rackunit + racket/file + racket/list + racket/match + racket/path + racket/string + rivet/backend + rivet/protocol) + +(define scratch (make-temporary-file "gptp-studio-wire-~a" 'directory)) +(putenv "GPTP_STUDIO_HOME" (path->string scratch)) + +(define test-directory + (or (current-load-relative-directory) (current-directory))) +(define backend-path + (simplify-path (build-path test-directory ".." "app" "backend.rkt"))) +(void (dynamic-require backend-path #f)) +;; `serve` below owns the in-memory ports, while the production `start` entry +;; owns OS file descriptors. Install the same application-level Event bridge +;; that `start` installs so this test exercises typed event encoding too. +(parameterize ([current-namespace (module->namespace backend-path)]) + (eval '(forward-engine-events!))) + +(define-values (server-in client-out) (make-pipe)) +(define-values (client-in server-out) (make-pipe)) +(define server-thread (thread (lambda () (serve server-in server-out)))) + +(define (read-frame/timeout label) + (define result (box #f)) + (define failure (box #f)) + (define reader + (thread + (lambda () + (with-handlers ([exn:fail? (lambda (exception) + (set-box! failure exception))]) + (set-box! result (read-frame client-in)))))) + (unless (sync/timeout 30 reader) + (fail (format "timed out waiting for ~a" label))) + (when (unbox failure) (raise (unbox failure))) + (unbox result)) + +(define next-id 0) +(define last-events '()) +(define (call-rpc name . arguments) + (set! next-id (add1 next-id)) + (define id next-id) + (write-frame + (frame message:request id (encode-value (cons name arguments))) + client-out) + (flush-output client-out) + (let loop ([events '()]) + (define response (read-frame/timeout name)) + (match (frame-type response) + [(== message:event) + (loop (cons (decode-value (frame-payload response)) events))] + [(== message:response) + (check-equal? (frame-id response) id) + (set! last-events (reverse events)) + (values (decode-value (frame-payload response)) #f)] + [(== message:error) + (check-equal? (frame-id response) id) + (values #f (decode-value (frame-payload response)))] + [_ (loop events)]))) + +(dynamic-wind + void + (lambda () + (define hello (read-frame/timeout "RVT1 hello")) + (check-equal? (frame-type hello) message:hello) + (check-equal? (decode-value (frame-payload hello)) + (list "rivet" protocol-version)) + + (define-values (bootstrap bootstrap-error) (call-rpc "bootstrap")) + (check-false bootstrap-error) + (check-equal? (length bootstrap) 12) + (check-equal? (list-ref bootstrap 1) "linux") + + (define-values (started start-error) + (call-rpc "engine-start" "slave" "sim" "" '())) + (check-false start-error) + ;; EngineStartResult = [ok need-pro error status]. + (check-equal? (take started 2) (list #t #f)) + (check-true (list? (list-ref started 3))) + (check-true + (for/or ([event (in-list last-events)]) + (and (pair? event) (equal? (car event) "engine-state"))) + "the simulator mutation must publish its state Event over RVT1") + + (define-values (status status-error) (call-rpc "engine-status-now")) + (check-false status-error) + (check-equal? (take status 2) (list "sim" "slave")) + + (define-values (stopped stop-error) (call-rpc "engine-stop")) + (check-false stop-error) + (check-equal? (list-ref stopped 0) "#f")) + (lambda () + (kill-thread server-thread) + (delete-directory/files scratch)))