Skip to content

feat(harness): add the application registry, the main-actor driver, and tessera launch - #65

Merged
robfeldmann merged 31 commits into
mainfrom
phase-4/04-driver
Oct 24, 2026
Merged

robfeldmann merged 31 commits into
mainfrom
phase-4/04-driver

Conversation

@robfeldmann

@robfeldmann robfeldmann commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

Plan 036 Slice 4, stacked on #64 (phase-4/03-harness-runtime).

What this adds

  • ApplicationRegistry and Viewport (public) in TesseraHarness, with HarnessCLI.main(registry:) (public, async -> Never): apps, launch NAME, help [COMMAND].
  • A package Step.run(_:graph:event:isolation:) in TesseraRuntime, written as the spec's canonical step, and the package @MainActor Driver over it. A headless host (a session over a headless device into one Ghostty virtual terminal), a live host, and a per-test actor host share the step; the first two share TerminalSession.draw.
  • The mobile rule: 40 columns at natural height, from a package nil-height proposal on ViewGraph (measuredRootSize(proposing:)) plus measurements at two tall heights. A height-greedy app fails size mobile naming mobileScreen.
  • Examples/Sources/DemoSupport (Demo, DemoFixture, isQuitKey, Demos.registry(), the counter demo) and Examples/Sources/TesseraDev, the tessera executable, with mise run dev:apps and dev:launch. ApplicationConsumer is deleted.
  • Two compile-fail fixtures with positive controls, run by scripts/check-isolation-fixtures.sh from quality:architecture.
  • The tessera-devtools skill and its AGENTS.md pointer; the Developer tooling page, the layout spec, README, CONTRIBUTING, and CHANGELOG say what is built.

Verification

All on the baseline toolchain (Xcode 26.6) unless noted; none of the diff touches a Package.resolved.

  • mise run quality:format, mise run core:test (750 tests; 700 on the base), swift test --package-path Examples (23; 13 on the base), mise run quality:lint (its quality:architecture runs the boundary checker's unit tests and both isolation probes), mise run docs:lint: green.
  • Xcode 27.1 (--scratch-path, deleted after): root build and all 750 tests, Examples tests (23), and both isolation probes pass.
  • Registry tests cover lookup, the unknown-name error, and duplicates: a duplicate fails checkedEntries, names, entry(named:) (for a unique name too), tessera apps, tessera launch (exit 3), and a scenario run (a failing report with verb app). Registration itself never throws.
  • A greedy app fails size mobile with a message naming mobileScreen; a natural-height app runs at 40 columns by its measured rows; size mobileScreen runs the greedy app at 40x16.
  • DriverTests renders a ViewGraph through TerminalSession.draw into an InMemoryTerminalDevice and asserts the exact bytes the device received (alternate screen, first frame, the changed cell, teardown), which restores the coverage the retired Showcase's ShowcaseTerminalSessionTests gave.
  • A nonisolated app steps on a per-test actor and a main-actor app on the main actor through the same Step.run; an @Observable app steps like any other.
  • The fixtures: an actor host given a main-actor Browser fails with "main actor-isolated conformance of 'Browser' to 'Application' cannot be used in actor-isolated context" (the script matches the shared prefix); extension Journal: Application {} from a plain Swift 6 module fails with "crosses into main actor-isolated code and can cause data races"; both controls compile. A wrong expectation fails the script.
  • The demo's snapshot tests (Examples/Tests/DemoSupportTests) run the counter headless at desktop and mobile.

tessera launch live, under a pty

expect spawned stty rows 24 cols 80; exec tessera launch counter (and, separately, mise run dev:launch counter), sent + + + - q, and logged the output; the screens below are that log replayed (frames 0 to 4 of 6, top rows of each):

terminal modes: ?1049h ?2004h ?1004h ?2026h … ?25h ?1004l ?2004l ?1049l   (alt screen entered, then restored)
frame 0   Counter                         frame 1   Counter
          ────────────────────────…                 ────────────────────────…
          Count                      0              Count                      1
frame 2   Count                      2    frame 3   Count                      3
frame 4   Count                      2    (after `-`); `q` draws one more frame, then exit status 0

Decisions and findings for the owner

Recorded in the plan under Decisions and Discoveries; the ones marked "(owner to weigh)" are reversible:

  • The harness's headless host uses its own 30-line HeadlessTerminalDevice, not InMemoryTerminalDevice: TesseraTerminalTestSupport links swift-snapshot-testing, and an executable that links it fails at launch (dyld: Library not loaded: @rpath/Testing.framework/Versions/A/Testing, observed on the first tessera apps). The proper fix moves the SnapshotTesting-free device into its own target, which changes where a public type lives, so it is yours.
  • The driver delivers the starting size to the app as a .resize through the same step; the spec's runtime loop does not, but the moved scenario corpus and any size-aware demo need it.
  • A scenario now requires its app NAME header (no global default app exists once the registry is a value).
  • Demo snapshot tests reach package API with @testable import TesseraHarness; the public scenario runner replaces that route later.
  • HarnessCLI lives in TesseraHarness (the stability list names it there), and TesseraDev is a one-line main.swift.

Review follow-ups

The critic review's six nits are addressed on top: apps --json writes minimum: null (and a failure command: null) instead of omitting the key; the docs say the driver and its hosts are internal, as the code has them; the step's doc and the spec say a synchronous nonisolated caller cannot compile the call; CI (Test Examples in ci.yml, and ci:build-test) runs swift test --package-path Examples; launch checks that stdin is a terminal before the lifecycle starts, so it exits 3 with "standard input is not one" instead of failing inside raw mode; and the registry's headless path is tested for every adoption shape, including the main-actor app. The spec's runtime loop, and Application.apply's docs, now deliver the starting size as a .resize too.

CI

Green on e707aa0 (run 40067583854, 14 jobs) before the follow-ups: test (macos-26), test (ubuntu-latest), test (ubuntu-latest / swift-6.4.1), test (windows-latest), test (xcode-27), lint, and the version consumers. The follow-ups' run is the one to read.

…ort slice, and record the catalog capability audit

Record the owner's 2026-10-23 decisions in plan 036: Flow and ViewThatFits get their own slice before styling, the terminal colour-report probe becomes Slice 1 and runs in parallel, catalog requirements gain Focus, Pointer, Scroll, and Clock subsections that the capability slices work through, and the coverage walk reports levels, key-and-count routes, and stop reasons. Renumber the slices, add unattended-execution rules, update the developer tooling page, and add research record 043 with the audit's placements and drafted gap requirements for owner review.
Add the package-level seams Slice 2 of plan 036 lists, adapted from the seams
spike patch to today's sources, with no new public API:

- InMemoryTerminalDevice: package init(...keepsInputOpen:), deliverResize(to:),
  and closeInput(). An open device registers its byte and size-change streams
  when terminalDevice is read, so a resize delivered afterwards is never lost
  and late readers see no replayed history.
- InMemoryTerminalSession: package init(device:) to wrap an open device.
- TerminalSession: lastDrawn (buffer, cursor, sequence; set after a successful
  flush), size, and outputTap (teed before the draw's io.write);
  withApplicationTerminal(configuration:io:...) forwards probeImageID and
  probeSleep.
- ViewGraph: a package init taking the clock the passes read.

The seam tests live in TerminalSessionTests and ViewGraphClockTests.
Make TerminalSession.draw a nonisolated(nonsending) entry point, per the
runtime isolation design in the Phase 5 spec proposal: the frame body runs in
the caller's isolation, so a @mainactor host renders a non-Sendable graph
where it lives, and the session serializes draws and commits the finished
buffer on itself. sending is dropped from the body and the result.

The draw splits into beginFrame (size and rendering policy, fixed once),
the body in the caller's isolation, and commitFrame (encode, tee outputTap,
write, flush, then set lastDrawn after a successful flush). The seam
semantics are unchanged and nothing new is public.

A @mainactor test host draws through an InMemoryTerminalDevice session; the
same code did not compile against the sending signature.
The draw body now runs in the caller's isolation, so the scope that owns the
session can close between beginFrame and the commit. commitFrame checks that
the session is open before it encodes, taps, or writes, so a draw caught in
that window throws TerminalSessionError.closed instead of painting a frame onto
the restored terminal.

Document that deliverResize(to:) also emits where the public resize(to:) only
changes the reported size, amend the Phase 5 spec's draw transaction and plan
036's Slice 2 text to match, and narrow the Delivers line to the main-actor
host the test uses.
TesseraRuntime holds the app contract: Application refines only AnyObject, with body, apply(_:), and a defaulted isFinished, and its documentation carries the four adoption shapes. Tessera re-exports it, and TesseraRuntimeTests compiles each shape against import Tessera.

TesseraHarness is a library product beside TesseraTestSupport. It carries the HeadlessRunner spike's driver, scenario parser, runner, report, and registry with its scenario corpus and tests, every public declaration now package. The spike stays under spikes/ as frozen evidence.

Both targets get boundary allowlist rows and the shared swiftSettings, docs:targets and docs:merge know TesseraRuntime, and .gitattributes pins scenario files to LF.
Each view component ships its own demo instead of one monolithic app. Delete Examples/Sources/TesseraShowcase, its tests and Xcode schemes, the core:showcase task, and the manifest entries only the Showcase used, and point README.md, CONTRIBUTING.md, and docs/project-status.md at core:example.
…ement

Check the Slice 3 box with its evidence, move Next: to Slice 4, record the copy-not-move, access, dependency, shim, and .gitattributes decisions the owner may want to weigh, add the discoveries, and update the changelog.
# Conflicts:
#	docs/plans/phase-4-view-layer-and-developer-tooling.md
…the unused CLI

Qualify the Application docs and the spec: leaving @mainactor off an extension conformance is a compile error from another module and in plain Swift 6 mode, but a module with default main-actor isolation or InferIsolatedConformances infers the isolated conformance for its own types.

Add Examples/Sources/ApplicationConsumer, a one-file target that imports only Tessera and declares an Application, so the Examples build fails if Tessera stops re-exporting TesseraRuntime. Remove the harness's CLI, which had no caller and no test; Slice 4 writes HarnessCLI fresh and the spike keeps the original.

Record in plan Slice 4 that its driver snapshot test restores the graph-to-session-to-bytes coverage the Showcase test gave, and note the three findings under Discoveries.
A scratch two-module SwiftPM package on Swift 6.3.3 and 6.4 settled when a bare extension conformance on a main-actor type compiles: it is an error in plain Swift 6 mode, builds with default main-actor isolation in the extension's module, and builds under InferIsolatedConformances (on 6.3.3 only for a type declared @mainactor, not one isolated through its module's default isolation). Rewrite the Application docs, the spec paragraph, and the plan's Discoveries and Slice 4 Verify to say so, run Slice 4's second fixture in plain Swift 6 mode, and describe ShotArtifact without the removed CLI.
…nd tessera launch

ApplicationRegistry and Viewport are the first public names in TesseraHarness. The registry
selects an app by name through a main-actor factory opened once per run, and a duplicate name
makes every read of it fail. `mobile` is 40 columns at the app's natural height, measured with a
package nil-height proposal on ViewGraph and two tall probe heights; an app whose root grows
with the proposal fails `size mobile` naming `mobileScreen`.

TesseraRuntime gains the package Step.run(_:graph:event:isolation:), and the harness gains the
@mainactor Driver over it, shared by a headless host (a session over a headless device into a
Ghostty virtual terminal), a live host, and a per-test actor host. HarnessCLI.main(registry:)
runs apps, launch NAME, and help. DemoSupport adds the Demo protocol, isQuitKey, and a counter
demo; Examples/Sources/TesseraDev builds it as the tessera executable, with mise run dev:apps
and dev:launch.

Two compile-fail fixtures with positive controls pin the Application isolation rules, run by
quality:architecture. ApplicationConsumer is deleted, since DemoSupport imports Tessera from
outside the root package. The tessera-devtools skill documents what is usable.
apps --json writes minimum: null (and a failure writes command: null) instead of omitting the
key. launch checks that stdin is a terminal before the lifecycle starts, so it exits 3 with a
clear message instead of failing inside raw mode. The registry's headless path is tested for
every adoption shape, including the main-actor app.

CI and ci:build-test now run swift test --package-path Examples. The step's doc and the spec
say a synchronous nonisolated caller cannot compile the call, and the spec's runtime loop
delivers the starting size as a resize, as the driver does. The docs call the driver and its
hosts internal, as the code has them.
…ntime

# Conflicts:
#	CHANGELOG.md
#	docs/plans/phase-4-view-layer-and-developer-tooling.md
Sort the root and Examples manifests alphabetically, state enduring facts in comments and DocC instead of plan numbers, conform Locator and FrameExpectation to CustomStringConvertible, say what Application's class-only contract means, express the scenario tests as snapshots, and record the owner's approval of the Slice 3 decisions.
… phase-4/04-driver

# Conflicts:
#	CHANGELOG.md
#	Package.swift
#	Sources/TesseraHarness/Driver.swift
#	Tests/TesseraHarnessTests/Fixtures.swift
#	Tests/TesseraHarnessTests/ScenarioTests.swift
#	docs/plans/phase-4-view-layer-and-developer-tooling.md
Sort the conformance lists of RegistryError and StillLabel, express the driver, CLI, registry, viewport, and demo tests as inline snapshots and expectNoDifference where they check structured state, and give the DemoSupport tests the snapshot libraries they need.
…arser

TesseraHarness alone depends on ArgumentParser, which parses the arguments, writes the help,
and completes app names for fish, zsh, and bash. HarnessCLI keeps the 1.0 promises it would
not: a usage error exits 2 where ArgumentParser exits 64, the JSON object and the text form
are chosen from the raw --json and --pretty so a failed parse is reported in the form asked
for, and a usage error lists what is accepted.
With no name, dev:launch prints the command's help and the registered apps and exits 0
instead of mise's missing-argument error, and dev:launch completes the app names from the
tessera binary a previous run built, printing nothing rather than building when there is none.
A dev task ran `swift run` for each tessera call, so one that calls it twice printed two
SwiftPM build blocks. It now builds once with `swift build --quiet`, which prints nothing
when it succeeds and still prints the compiler's errors when it fails, and runs the built
binary for every call.
…pletion as the scripts ask

help takes one command: `help launch extra` is a usage error that lists the commands again,
and `help help` is pinned. The completion tests send the request the shipped scripts send
(positional@0 and the option's flag) instead of the legacy argument name, for every command
that takes an app name.
… terminal

A task run without its argument, or with an empty one, printed help and exited 0 even in a
script, where an unset variable then passed for success. It exits 0 at a terminal and 2 in a
pipe or a script, as tessera does.
@robfeldmann
robfeldmann marked this pull request as ready for review October 24, 2026 20:37
Base automatically changed from phase-4/03-harness-runtime to main October 24, 2026 20:40
# Conflicts:
#	CHANGELOG.md
#	CONTRIBUTING.md
#	Examples/Package.swift
#	Package.swift
#	README.md
#	Sources/TesseraHarness/Driver.swift
#	Sources/TesseraHarness/Scenarios/Runner.swift
#	Sources/TesseraHarness/Scenarios/Scenario.swift
#	Sources/TesseraRuntime/Application.swift
#	Tests/TesseraHarnessTests/ScenarioTests.swift
#	docs/architecture/developer-tooling.md
#	docs/plans/phase-4-view-layer-and-developer-tooling.md
#	scripts/check-package-boundaries.py
#	scripts/test_check_package_boundaries.py
Slice 4 deletes ApplicationConsumer and moves the harness's demos and registry; merging the squashed #64 re-added them.
@robfeldmann
robfeldmann merged commit 55723de into main Oct 24, 2026
29 of 30 checks passed
@robfeldmann
robfeldmann deleted the phase-4/04-driver branch October 24, 2026 21:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant