Repository navigation
feat(engine): @Resource — find resource types at launch (ENG-3734) - #29
Merged
Ryan Zhu (underthestars-zhy) merged 14 commits intoNov 1, 2026
Conversation
Apps listed every resource type in Astrolabe.resourceTypes so records could be decoded after a restart. A type left off the list failed silently: once body stopped declaring it, its record stayed undecodable and the resource was never removed. @resource replaces the list, the way Swift Testing finds @test: @resource struct StagingFile { … } The macro adds the Resource conformance and a record, a @section / @used constant in __DATA_CONST,__astrolabe_res. At launch the daemon reads that section in every loaded image and seeds the registry with every linked type. - Resource requires the record. An unavailable default turns a conformance written without the macro into an error that says to add @resource. - ResourceMacro (member + extension) rejects generic types and types nested in a generic type or function: a record names one concrete type. - Engine takes resourceTypes: RunEngine passes the linked types, tests pass their own. The test binary links ClashA/ClashB, which share a kind on purpose. - Astrolabe.resourceTypes is removed. It never shipped on main. - swift-tools-version 6.3: @section/@used are SE-0492, Swift 6.3. - Tests: the generic FakeResource<P> becomes four concrete @resource fakes over a FakeResource protocol, with the same kinds and JSON. ResourceMacroTests covers expansions and diagnostics, and ResourceRecordsTests covers discovery. The macro test helper moves to AssertExpansion.swift. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The v2 engine couldn't install the Xcode Command Line Tools, so it
couldn't bootstrap Homebrew. CommandLineTools is the first built-in
resource, installed headlessly through softwareupdate:
CommandLineTools() // install if missing
CommandLineTools(version: .latest) // and keep it current
.installed reads no fact. .latest reads \.commandLineTools
.latestAvailable, the max of the installed version and the newest
non-beta CLT the catalog lists: softwareupdate stops listing CLT once
it's current, so the max is the same before and after an install and
is safe to hash. Its first read after launch never contacts Apple, and
it checks the catalog at most every 6 hours. \.commandLineTools
.installed is public for resources that must redo work after an
update.
- Both facts read under the lock the rollout installs under, so
neither sees a half-finished install. ensure checks the Mac first
without the lock, unless an install is in progress.
- Versions are read from the listing's Version: field, not the label
(macOS 26 changed the format), and normalize to major.minor.patch.
- The rollout forgets a stale receipt, places the trigger file
(removed on every path), installs the newest CLT at or above the
minimum, verifies it, and selects it when xcode-select points
nowhere. remove never uninstalls.
- All I/O goes through CommandLineToolsSystem, bound per task through
CommandLineToolsContext, so tests never run softwareupdate.
- Re-adds the Semaphore dependency for the cancellable lock.
- Tests: listing fixtures for macOS 15 and 26, versions, the catalog
schedule, both reads, the rollout, the lock, and engine runs over a
FakeCLTSystem that writes its receipt before the install finishes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 tasks
The v2 engine only rolled an id out again when its declared hash changed, a retry came due, or its operation was cancelled. A deleted CLT stayed deleted, and Homebrew (ENG-3749) has the same gap. @monitor works like @fact, but for resources: @resource struct CommandLineToolsInstall { let minimum: CLTVersion? @monitor(\.commandLineTools.isInstalled) var isInstalled … } It reads the fact once, when body builds the resource, and stores the value in the snapshot. The engine hashes every monitor into a monitor hash, kept apart from compute's: a monitor-only change reruns rollout toward the same hash, and never cancels a running one. CLT monitors the new derived fact \.commandLineTools.isInstalled, so a deletion is repaired, and an update made outside Astrolabe (16.4 → 16.5) starts nothing. - Monitors are found by reflection, the way SwiftUI finds DynamicProperty fields: no macro changes and no public requirement, and a monitor built outside body is detected and logged (astrolabe.declaration.unobserved_monitor). - Target stays declared-only. The monitor hash rides beside it in Declaration.Item and a computed Mark.monitor, so failures, attempts, backoff and .wait are unchanged, and a flapping fact can't defeat backoff. records.json gains no field: the value lives in the snapshot. - diff cancels a running operation only for a new target; a monitor change holds .waitingForOperation and reruns once it ends. - wrappedValue is Value?: nil outside a pass, or in a record written before the monitor existed, which still decodes (the key is left out when unread, and a missing key decodes as unread). - The dry run marks a monitor-only rollout with "(monitor)". - CommandLineTools() now reads installed through the monitor; its first pass waits for that read, which never contacts Apple. Docs for installed, Resource.compute and the README follow. - Tests: @monitor reads and coding; engine runs for an idle change, a change mid-rollout, a declared change superseding, a flip-back, records across restarts and from before the monitor, one follow-up after a repair, backoff and .rollback, the dry run, and an unobserved monitor; CLT deleted while running, under .latest, and while the daemon was down, an outside update, and an old record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 tasks
The engine never re-reads a fact when an operation ends, so whatever waits on \.commandLineTools.installed (Homebrew, ENG-3749) sat gated for up to 15 minutes after CLT landed. At 60 s a read nearly always comes due during the install and queues on the CLT lock, so the value lands the moment the install ends. A deleted CLT is also repaired within a minute. The cost is a stat and one pkgutil --pkg-info a minute. latestAvailable stays at 15 minutes, and its catalog check at 6 hours. - Tests: the drift tests' "next read" advances are 60 s, so they prove the new interval. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every Brew (ENG-3738) needs brew, and the v2 engine could install CLT
but not Homebrew. Homebrew(owner:) installs it headlessly, as the root
daemon, even at the login window, and keeps it owned by one account:
CommandLineTools() // required: Homebrew never installs CLT
Homebrew(owner: "ops")
owner is an account's short name, never root, one per Mac. Every brew
runs as it; people run brew by hand with `brew as-brew-user <cmd>`.
- Installs with Homebrew's own install.sh, run as the owner with
NONINTERACTIVE=1. It refuses root, and as the owner needs sudo only to
create the prefix in root's /opt. So the daemon creates /opt/homebrew
and chowns it (and anything a broken install left) to the owner first:
no sudo, no password. The daemon writes /etc/paths.d/homebrew, which
install.sh skips without sudo.
- Homebrew is a Component that declares HomebrewInstall only once
\.commandLineTools.isInstalled reads true. Safe to gate: remove does
nothing. Without CommandLineTools() it waits silently.
- @monitor(\.homebrew.owners) repairs drift. owners lstats the prefix,
Cellar, Caskroom, opt and bin, and each entry directly inside them,
every 60 s, under the lock the rollout installs under. Deleted ([]) →
install again. Wrong or mixed owners (another admin's brew install) →
chown -R -P <owner> /opt/homebrew, nothing downloaded. brew update
changes nothing.
- ensure mirrors CLTInstaller: a lock-free healthy fast path (owners ==
[owner] and brew --version runs as owner), then a locked CLT read that
waits out a CLT install, then the Homebrew lock. Locks never nest.
Unknown owner, root, Intel and a missing CLT throw before touching the
Mac; a re-own or install that didn't take throws, so retries apply.
- UserContext.setting(_:) sets extra environment variables for a child
run as a user.
- CLT docs no longer say Homebrew calls CLTInstaller.ensure.
- Examples/HomebrewEngine reads the owner from
/etc/astrolabe-homebrew-owner: a LaunchDaemon never sees the sudo
environment that installed it.
- Tests: the owners read (sorted, waits out an install, throws on
failure), ensure on a fresh Mac, leftovers, failures, cancellation, bad
inputs, a CLT install in progress, mixed owners, an owner change, a
re-own that didn't take, a brew that won't run, the lock, and the
engine: CLT then Homebrew, no CommandLineTools(), restart, deletion,
another admin's formula, an owner change and drop, CLT deleted, and the
dry run. withCLT moves to Support beside withHomebrew.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 tasks
…3738) v2 could install CLT and Homebrew itself, but not a single formula or cask, and most farm setup is Brew(...). Brew ports v1's Brew onto @resource, @monitor, and the Homebrew facts and lock: CommandLineTools() Homebrew(owner: "ops") Brew("wget") Brew("firefox", type: .cask) Brew("tailscale", version: .atLeast("1.102.0")) - Brew is a Component that declares BrewPackage (astrolabe.brew) once \.homebrew.isInstalled reads true. Gated on isInstalled, never on one owner: remove really uninstalls, and mixed owners (another admin's brew install) would otherwise hide, and uninstall, every package. - The id is brew:<type>:<short name>, so tap-qualifying a name keeps the id and never uninstalls. The hash covers name, type and minimum: a new .atLeast floor upgrades in place. - rollout: check the Mac without the lock (satisfied → no lock, no brew); else under HomebrewContext.lock, check again, resolve the one owner from system.owners(), brew install|upgrade --formula|--cask <name> as the owner, and verify. An uncomparable version throws and never upgrades. [] owners → homebrewMissing; mixed, root or #uid → homebrewNotReady, retried once Homebrew re-owns. - remove: brew uninstall --formula|--cask <short name>, only if it's there. Uses only what the id is built from. - \.homebrew.packages scans Cellar, Caskroom and opt every 60 s with readdir and readlink, no brew and no lock. Throws only on real I/O errors. Each BrewPackage monitors \.homebrew[satisfies:], its own Bool, so a hand-run brew uninstall or a downgrade is undone within a minute. This replaces v1's 15 s loop. - .latest(checkEvery:) is dropped: time passing never changes a hash. A follow-up adds an available fact that body pins into .atLeast. - HomebrewSystem gains packages() and brew(_:as:). BrewVersion is v1's, verbatim. - Examples/BrewEngine reads packages from /etc/astrolabe-brew-packages. - Tests: ids, hashes and the snapshot; the scan on a temp prefix; v1's version cases; check and the subscript; ensure and uninstall over the fake (call order, no lock when satisfied, upgrade, floors, owners, failures, casks, taps, cancellation, one brew at a time); and the engine: CLT then Homebrew then Brew with no failures, no Homebrew(), drift, dropped from body and across a restart, a new floor, tap qualification, mixed owners, a deleted Homebrew, a superseded rollout waiting on the lock, and the dry run. FakeHomebrewSystem keeps one package map and runs brew. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y in dry runs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rift' into ryan/eng-3749-astrolabe-homebrew-itself-as-a-v2-resource
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e' into ryan/eng-3738-astrolabe-brew-as-a-v2-resource
…-a-v2-resource feat(brew): Brew — Homebrew formulae and casks as a v2 resource (ENG-3738)
…w-itself-as-a-v2-resource feat(homebrew): Homebrew — Homebrew itself as a v2 resource (ENG-3749)
…-repair-resources-that-drift feat(engine): @monitor — repair resources that drift (ENG-3751)
…-line-tools-as-a-v2-resource feat(clt): CommandLineTools — Xcode CLT as a v2 resource (ENG-3739)
Ryan Zhu (underthestars-zhy)
merged commit Nov 1, 2026
a1857e8
into
ryan/eng-3705-astrolabe-entry-style-macro-for-facts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements ENG-3734. Stacked on #28 (ENG-3705), which is stacked on #26 (ENG-3699) and #25 (ENG-3688,
next).What
Apps had to list every resource type so the engine could decode
records.jsonafter a restart:A missing entry failed silently, and only after a restart. Example:
state.jsondropsa.txtand the daemon restarts. TheStagingFilerecord can't be decoded, soa.txtis never removed.@Resourcereplaces the list, the way Swift Testing finds@Test:The macro adds the conformance and a record that the linker gathers into one section of the binary:
At launch the daemon reads that section in every loaded image (
_dyld_image_count+getsectiondata) and gives every linked type to the registry. So a record decodes whether or notbodydeclares its type.Mistakes the compiler catches
Resourcerequires__resourceRecord, and an unavailable default turns a hand-written conformance into an error:@section/@useddon't work in generic contexts anyway.kindclaimed by two types anywhere in the binary now stops the daemon at launch, even ifbodydeclares neither.Changes
ResourceMacro(member + extension) inAstrolabeMacros. It skips the conformance when the type already declares it, for example through a refining protocol.ResourceRecords.linkedTypes()scans the section. Records carry a magic number, so stray bytes are never called.EnginetakesresourceTypes:.RunEnginepasses the linked types, and tests pass their own: the test binary linksClashA/ClashB, which share a kind on purpose.Astrolabe.resourceTypesis removed. It never shipped onmain, so there's no deprecation.swift-tools-version: 6.3:@section/@usedare SE-0492, implemented in Swift 6.3. README, docs andAGENTS.mdupdated.StagingFileis@Resource, andStagingAppdrops its list.Tests
swift teston this Mac (Swift 6.4): 21/21 macro tests pass, and 165/166 engine tests pass. The one failure isstorageClientConcurrentWritesPreserveAllKeys(.helperNotFound, Xcode 27 beta), which also fails onmain.ResourceMacroTests: expansions for a struct and a nested type, and the diagnostics for a protocol, a generic type, and a type inside a generic type or function.ResourceRecordsTests: every@Resourcetype in the test binary is found exactly once, including a nested private type nothing names. A registry over the linked types decodes them, and the twoclashtypes fail it.FakeResource<P>becomes four concrete@Resourcetypes over aFakeResourceprotocol, with the same kinds and JSON. The harness takesresourceTypes:and defaults to all four.@Resourceplus an explicit: Resourcecompiles, and a generic type gets the macro's error.swift build -c release --product StagingEnginecontains exactly one 16-byte record in__DATA_CONST,__astrolabe_res, starting with the magic. A separate spike found every type in debug, release, release with-dead_strip, and full LTO.Not yet verified
a.txt, remove it fromstate.json, and restart the daemon.a.txtis removed.swiftly.🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need.Summary by CodeRabbit
@Resourceand discovered automatically when the engine launches, including types not explicitly listed by an app.