Typed LifecycleKit: compile-checked launch plans, engine/UI split, Where migration#116
Typed LifecycleKit: compile-checked launch plans, engine/UI split, Where migration#116kyleve wants to merge 8 commits into
Conversation
Plan step: core-typed-model. Pure groundwork, no behavior change. - New typed model: the LifecycleStep protocol (associated Input/Output), the LifecycleGate protocol (pass-through, foreground-default), the LaunchPlan<Input, Output> combinators (init/then/thenKeeping/gate/ detached) whose generic constraints make out-of-order or hole-leaving plans unrepresentable, plus LifecycleStepContext (reporting half of the old bridge) and LifecycleGateHandle (gate resolution token). - Types are erased in exactly one place (LaunchPlanNode and friends, package-visible for the upcoming runner/UI split); the combinators' constraints guarantee every internal cast. - Value-producing trunk steps must keep modes == .all (precondition): only pass-through positions may gate on the launch reason. - The existing linear API is renamed with a Legacy prefix (types, files, test suites) so the final names are free for the typed engine; it still drives the Where app unchanged and is deleted at the end of this plan. - Tests: LaunchPlanTests (combinators, builder conditionals, erasure, teardown-rooted plans), LifecycleGateHandleTests (resolution machinery), shared FixtureStep/FixtureGate fixtures.
Plan step: runner-rewrite. - LifecycleRunner is now generic over the plan's output. Its nested Phase is the explicit, value-carrying UI signal: launching | running(context) | awaitingGate(handle) | failed(failure) | ready(Launch) — .ready carries the trunk's output so the app surface cannot exist without the value the launch produced. - The walk threads the typed trunk value through steps and gates, memoizes each completed node's output (run-once across promotion + retry; a retry resumes the failed node with the same input), and spawns detached children into a task group owned by the drive: .ready publishes as soon as the trunk finishes, children drain behind it, and their failures land on the off-phase detachedFailures diagnostics surface (recorded, logged by consumers, never fatal). - Preserved invariants from the legacy runner: single cancel-and-drain drive, mode gating per LifecycleReason, idempotent enterForeground() promotion (skipped gates re-evaluate, completed work doesn't repeat), a superseded drive can't clobber the phase (PR #99 regression, extended to gate handles), teardown(plan:input:) runs a typed teardown then relaunches fresh — with teardown's detached children drained before the relaunch, @_spi(Testing) failure injection. - Tests: ported drive/promotion/failure/reset suites and the 200+120 seeded fuzz suites to the typed model; the fuzz model now also predicts detached spawn/failure sets and proves memoized retry never re-runs completed nodes. New gate, detached, and phase-surface coverage. The legacy runner and its suites stay green untouched.
Plan step: ui-split. - New Shared/LifecycleKitUI module (SwiftUI + LifecycleKit only) owning everything rendered: the generic LifecycleContainer switches on the typed runner's phase — splash(context) / registered gate view / failure / content(Launch) — preserving the legacy container's hard-won behavior (buildsNoViewTree renders nothing even at .ready, surface-identity-keyed transitions, launch surfaces layered above content for the reveal). - Gate views register by gate *type* via GateView(for:content:), which statically recovers the gate's Value: the view receives (LifecycleGateHandle, Value) instead of trusting the environment. One registration per gate type (construction precondition); a parked gate with no registration debug-asserts and falls back to the splash. - LifecycleProxy under @Environment(\.lifecycle) forwards retry / enterForeground / typed teardown(_:input:) through the new package-level LifecycleDriving seam in core (an environment value must be non-generic; the plan+input are type-checked at the call site and erased only to cross it). Disconnected default asserts in debug, no-ops in release, like the legacy proxy. - Wired in Package.swift (product + target), Project.swift (LifecycleKitUITests hosted bundle + scheme + Stuff-iOS-Tests build and test entries). Module README.md + AGENTS.md added. - Core still exports the legacy SwiftUI surface (old container, bridge, splash/failure views) until the Where migration lands; the SwiftUI import leaves core when the legacy API is deleted in the cleanup step. - Tests: container rendering per phase (including the gate registry receiving the typed trunk value and the context-fed splash), proxy connected/disconnected + typed teardown forwarding.
Plan step: where-migration. - WhereLaunch.plan(for:) replaces the closure sequence: OpenStoreStep (Void → WhereServices, the one store open), StartSessionStep (WhereServices → WhereSession; fires onServicesReady on every session (re)start, as before), the OnboardingGate (pass-through, foreground- only, re-evaluated on promotion), SyncAuthStep + ReconcileTrackingStep kept sequential on the trunk (reconcile must see the synced authorization), then the detached fan (captureToday [foreground], reminders, summary, issueAlerts, widgetSnapshot) — independent session-configuration steps that no longer serialize or block .ready. New LaunchStepID.startSession names the scope promotion. - Every post-session step takes a NON-OPTIONAL WhereSession as its typed input: all the model.session? optional chaining in launch steps is gone, and WhereModel.startSession(services:) now takes the services and returns the session (no stashed-optional handshake). WhereModel.session remains only as the UI mirror for surfaces the container doesn't feed; eraseAllData() (optional-chained) is deleted in favor of EraseDataStep taking the session directly. - resetPlan(for:) is the typed teardown, rooted at the session being torn down; SettingsView hands its (non-optional) environment session in through the new \.lifecycle proxy. The runner now releases the retained teardown input after a successful teardown so the dead session can't be retained for the process's life (regression-tested by the existing weak-reference reset test). - RootView renders the new LifecycleContainer: content receives the session from .ready (the 'if let session' guard is gone), and OnboardingView is registered for OnboardingGate — it now takes (gate handle, session) instead of a bridge + environment session. Scene activation keeps its single role: enterForeground() promotion. - AppDelegate/WhereApp carry LifecycleRunner<WhereSession>; LaunchPlan gains public nodeIDs so the parity tests can inspect order from outside the package. - Tests updated: parity on plan(for:).nodeIDs (with start-session), onboarding as a parked gate resolved via its handle, teardown calls rooted at the session, and a typed failing-erase plan for the teardown-failure path.
Plan step: cleanup-docs. - Removed the Legacy* linear API and its suites: the closure step struct + LifecycleSteps builder, the non-generic runner, the old phase enum (LifecycleFailure survives in its own file), the LifecycleStepUIBridge (its reporting half lives on in LifecycleStepContext; its resolution half in LifecycleGateHandle), the old container/proxy, and the per-step presentation machinery (minVisible/deferred triggers) — gates render immediately via the registry and slow-launch captions are the splash's own concern. - LifecycleKit is now pure Foundation + Observation: LifecycleSplash, LifecycleFailureView, and the localized strings moved to LifecycleKitUI (whose bundle now owns the string catalog). - Docs: LifecycleKit README/AGENTS rewritten for the typed model (step protocol, LaunchPlan combinators, value-carrying phase, scope convention, memoized retry, cancel-and-drain invariants); Where AGENTS + WhereUI README updated for the plan/gate wiring; root AGENTS + Project.swift double-link notes now name LifecycleKitUI. ./sync-agents run. - Full Stuff-iOS-Tests scheme green (197 suites) and ./swiftformat --lint clean.
Review finding: the run-once memo is keyed by node ID across both the launch and teardown walks, so a teardown node that collides with a completed launch node was silently skipped — an erase that never runs — and, worse than the legacy engine, adopted the launch node's memoized output as the teardown trunk value, which a later teardown node's typed input cast would trap on. teardownErased() now preconditions that teardown node IDs are disjoint from the launch plan's before anything is torn down, matching the programmer-error convention (LaunchPlan already preconditions uniqueness within each plan; this closes the cross-plan gap). Where is unaffected: LaunchStepID keeps both sets disjoint by construction. Untestable as a death test on iOS (no exit tests); documented in the module README + AGENTS instead.
Review finding: the runner surfaces detached-step failures only on its observable detachedFailures array, which nothing in Where renders or logs — so the first *throwing* detached step would fail with no trace in logs, violating the never-silently-swallow rule's 'logs, state, or both'. (Today's detached steps all wrap non-throwing WhereSession methods; this is the seam that keeps a future one honest.) - New DetachedFailureReporter (WhereUI/Launch): observes a runner's detachedFailures via withObservationTracking — UI-independent, so headless background drives are covered — and logs each new entry exactly once at warning level (degraded-but-handled), tolerating the runner's per-attempt resets. Kept alive by its own observation chain; holds the runner weakly. - WhereLaunchLog gains the detachedStepFailed(stepID:description:) event; WhereLaunch.makeLauncher installs the reporter on every runner it builds. - Tests: exactly-once and reset semantics on report(_:), plus an end-to-end observation test driving a real runner whose detached fan throws (no view tree involved).
Review finding 5: a parked gate whose type had no GateView registration degraded to the splash in release — an indefinite spinner that reads as progress, with no retry and no trace. The realistic exposure is a conditionally-needed future gate (e.g. migration consent) that never parks during development and ships unregistered. The container now logs the misconfiguration (os, subsystem com.stuff.lifecyclekitui — the kit deliberately has no app logging facade) and fails the gate's handle with the new MissingGateViewError (localized description naming the gate), so the drive parks in .failed at the gate and the normal failure surface shows: visible, retryable, diagnosable from a screenshot. The debug assertionFailure is dropped so debug matches production — which also makes the path testable. The handle is failed from the fallback view's onAppear, not during body: resolving it resumes the drive's parked continuation, whose phase write must land after the render commits. Tests: new container test drives a parked, unregistered gate through the hosted fallback and asserts the .failed(at: gate) phase carries MissingGateViewError. Full Stuff-iOS-Tests scheme green (one rerun after a simulator 'failed preflight checks' launch flake, unrelated to the change); ./swiftformat --lint clean.
Recommendation, now that the full ladder existsWe explored five points in the design space, each a PR stacked so its diff isolates one decision:
My pick, assuming two more apps land in the repo over the next couple months: ship #121 — this engine with retry removed. Second choice #120. I'd rule out #118 outright. Why not #118 (raw async, no kit)It's the leanest by far and the park-based promotion is genuinely elegant for one app — but it deletes the reusable engine. App #2 re-hand-rolls the phase machine, the scene-activation park, the onboarding handle, and the cancelled-attempt-can't-write-phase guard from scratch; app #3 makes it three copies or a from-scratch re-extraction. Its 3,000-line saving is a Where saving that inverts into duplication across a multi-app repo. Right answer only for a single-app repo, which isn't where we're headed. Why #121 over #116-as-isRetry is no longer carrying its weight. Its one real historical customer was the fresh-install store-open race — and we fixed that structurally, by injection (the whole point of this PR), not by retry. Prototypes C and D both confirmed the same thing empirically: removing retry deletes ~250 lines and roughly a quarter of the runner's bookkeeping ( Why #121 over #120 (the function-style twin)Both remove retry and land within ~10 lines of each other. The real axis is the API surface, and for onboarding new app authors I favor the declarative combinators of #116/#121:
#120's counter-argument is legitimate — a plain async function reads top-to-bottom and is the smaller conceptual surface — so if the team's taste runs to "just write async code," #120 is the close runner-up. But across three apps I'd rather teach one declarative vocabulary with the compiler holding the invariants. Before app #2 adopts it
Net: merge #116 → #121 (this PR with retry stripped), keep #118 and #120 as reference for what each axis costs, and do the small API-hygiene pass before wiring app #2. |
Problem
LifecycleKit modeled launch as a flat list of closure steps with no data flow between them: ordering was declaration-order only, "must succeed vs. best-effort" was encoded by whether a closure happened to throw, cross-step data traveled through
WhereModeloptionals (model.session?.syncAuthorization()silently no-ops if the ordering assumption ever breaks — and the launch still reaches.ready), and the engine imported SwiftUI and carried per-stepAnyViewpresentation machinery. PR #99 made the store impossible to double-open by injection; this PR applies the same make-invalid-states-unrepresentable philosophy to the launch pipeline itself.Changes
LifecycleKit is now a typed engine (Foundation + Observation only — no SwiftUI):
LifecycleStepprotocol (associatedInput/Output) and theLifecycleGateprotocol (pass-through, foreground-default,isNeededre-evaluated on promotion).LaunchPlan<Input, Output>composes a sequential trunk plus concurrent detached fan-outs, with the data flow compiler-checked:thenrequires the next step'sInputto equal the current trunkOutput; only pass-through positions (thenKeeping, gates, detached children) may be mode-gated, so a skipped node can't leave a hole; detached children areVoid-output, so nothing can depend on a fire-and-forget step. Type erasure lives in exactly one internal place, with the combinators' constraints guaranteeing every cast.LifecycleRunner<Launch>walks the plan and publishes a value-carrying phase:launching | running(context) | awaitingGate(handle) | failed(failure) | ready(Launch)..readycarries the trunk's output, so the app surface cannot be built without the value the launch produced. Completed nodes' outputs are memoized per attempt —retry()resumes the failed node with its original input, and promotion never repeats completed work. Detached children never block.readyand surface failures only on the off-phasedetachedFailuresdiagnostics. Preserved from the legacy engine: single cancel-and-drain drive, idempotententerForeground()promotion, the Fix fresh-install launch failure: canonical store, prompt promotion, honest splash #99 superseded-drive-can't-clobber-phase rule (extended to gate handles), typedteardown(plan:input:)+ relaunch with teardown children drained before the relaunch and the retained input released on success.New LifecycleKitUI target owns everything rendered: the generic
LifecycleContainer(phase → splash / gate view / failure /content(Launch), preservingbuildsNoViewTree, surface-identity animations, and launch-surface z-ordering), gate views registered by gate type viaGateView(for:content:)— the registry recovers each gate'sValuestatically, so views receive(handle, typed value)— and the non-genericLifecycleProxyunder@Environment(\.lifecycle).Where migrated onto the typed plan:
OpenStoreStep(Void → WhereServices, still the process's one store open) →StartSessionStep(WhereServices → WhereSession, firesonServicesReadyon every session (re)start) →OnboardingGate→SyncAuthStep/ReconcileTrackingStepon the trunk (reconcile must see the synced authorization) → a detached fan of the five independent session-configuration steps, which no longer serialize. Every post-session step takes a non-optionalWhereSession; themodel.session?chains in steps andRootView'sif let sessionguard are gone (MainTabsis built from the session.readycarries). The reset is a typed teardown plan rooted at the session Settings hands in. The trunk value doubles as the launch's dependency scope, growing by embedding (WhereServices→WhereSession); the scope convention is documented in the module README.Review hardening (self-review findings):
precondition) — the shared run-once memo would otherwise silently skip the teardown node and corrupt the typed trunk value.DetachedFailureReportermirrors detached-step failures intoWhereLogat warning (UI-independent observation, so headless drives are covered) — the runner'sdetachedFailuresstate alone satisfied "observable" but nothing logged it.os) and fails its handle with a localizedMissingGateViewError, landing on the retryable failure surface instead of an indefinite splash; identical in debug and release.Accepted as documented decisions: the (improved) animated splash→onboarding transition,
onServicesReadyfiring once per fresh attempt even for pre-built preview/test sessions, and the construction-timeprecondition(rather than a type-level rule) that value-producing steps keepmodes == .all.Testing
DetachedFailureReporterTests, and the unregistered-gate fallback test.nodeIDs(with the newstart-sessionnode), onboarding as a parked gate resolved through its handle, session-rooted teardown, hook re-fire on reset relaunch, and the weak-reference test that caught (and now pins) the teardown-input release.Stuff-iOS-Testsscheme green locally (iPhone 17, iOS 26.2 simulator);./swiftformat --lintclean.