Skip to content

refactor: services hand out the state a run owns - #93

Merged
LDGerrits merged 15 commits into
refactorfrom
state-ownership
Oct 4, 2026
Merged

LDGerrits merged 15 commits into
refactorfrom
state-ownership

Conversation

@LDGerrits

Copy link
Copy Markdown
Owner

Services that created state for one run held it as a singleton. Now they hand it out as a model the caller owns. Behind that, the domain and platform layers are cleaned up wherever ownership was unclear.

What changes

  • Config: ConfigService.select(args) returns a ConfigSelection that the caller owns.
    • Each entry is valid or broken, and a broken entry keeps its last valid version.
    • reload(files) returns { changed, notices }.
    • initialize and AbstractConfigService are removed, and WatchLog no longer deduplicates config problems itself.
  • Index: IndexService is list and update, and returns an immutable Listing. A build lists the files itself, and a watch holds its own listing and updates it.
  • Build: BuildService is build(selection), rebuild(config, listing) and locate(selection). where uses the same builder as build. The shared types (ConfigBuild, Locations, OutputFile, SyncTool, BuildBlockers) are in build.ts.
  • Toolchain: SyncTool is defined in build. Toolchain supplies the values and main.ts passes them to the build, so build doesn't import toolchain. Each Language has its own init text.
  • Watch: each config has one WatchedConfig record. ChangeBatcher no longer takes a LogService; it emits a ChangeBurst, and WatchLog prints the warning.
  • Init:
    • Setups are Setup<C> objects (ask and then plan), and InitDirectory is pure.
    • Choosing Darklua stores the Darklua object rather than a boolean, and the workspace records which Darklua config file it found.
  • Platform:
    • The file system decides which links nothing may descend into, using one rule that DiskWatcher also uses.
    • The in-memory file system now matches the disk: createDirectory over a file fails, delete under a missing parent does nothing, and a write reports each directory it creates.
    • Shared contract tests cover both file systems.

Behavior changes

  • The watch's "Threshold reached" warning now prints when a burst round starts.
  • The Darklua prompt hint names the config file that was found (.darklua.json5 when that is the file present).
  • A place in a roblox-ts project that uses Darklua syncs from Darklua's output (dist/<place>) when the base config sets no sync dir. Before, it synced from the compiler's output.

Not in this PR

  • A Processor interface: Darklua is the only processor.
  • MemoryFileSystemService implementing Watcher.
  • NullLogService extending AbstractLogService.
  • Coalescing changes in AbstractWatcher.

Open questions

  • Should each config choose its own sync tools? The types allow it, but main.ts passes one table for all configs.
  • where still fails on a config that declares no routes.
  • PlaceSetup decides whether to use Darklua from whether a .darklua.json exists, not from how the project was set up. This was already the case before this PR.

Testing

tsc, eslint and 2077 jest tests (unit and e2e) pass on every commit.

🤖 Generated with Claude Code

LDGerrits and others added 15 commits October 4, 2026 04:42
ConfigService kept one invocation's configs on a process-wide service, and
the watch learned what a reload changed by draining onDidChangeConfig.
select(args, scope) now hands out a ConfigSelection that the command or the
watch session holds, and its reload returns what changed and which errors
are new, so a config that breaks is reported without the log de-duplicating
config problems. ConfigEntry is a valid | broken union, ResolvedConfig
carries its parents and skipped tags, and RebuildReport drops its entry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…build's values

Commands sequenced requireBuildable then run, and the watch asked the service
for blockedConfigs; build-service.ts was a type dump its own internals
imported. build(selection) now checks validity and the set rule itself,
rebuild(config) is the watch's one-config step, and locate(selection)
answers every config with whether every file was asked about. The set rule is
BuildBlockers, a value in build.ts beside the match, left-out, summary and
location types and OutputFile, so the watch asks it directly and watch(selection)
refuses what build would. BuiltProject.placement, which nothing read, is gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
IndexService held one tree for the process with two lifecycles: ensureIndexed
grew it for one-shot commands, and initialize and applyChanges replaced and
edited it for a watch, across awaits a rebuild could read through.
list(dirs) now returns a Listing and update(listing, changes) a new one,
copying only the directories it edits. A build or locate lists the root dirs
it reads once; the watch session holds its listing, replaces it on its intake,
and rebuild(config, listing) reads the one it started with.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nswers it

The build read its sync tools through ToolchainService.getSyncTools, and
Darklua and RobloxTsCompiler each played a build role beside an init one, so
roblox-ts had a compiler constructed without facts just to tell the build what
it emits. SyncTool and MetaReplacement now live in build.ts; Darklua.SYNC_TOOL
and ROBLOX_TS_SYNC_TOOL are the answers, CoreToolchainService lists them, and
main.ts hands them to CoreBuildService. The build no longer imports the
toolchain, and ToolchainService is detect alone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…f its update

The session kept a config's rebuild queue, whether it failed, what its build
read and a global rebuild count in four parallel fields. WatchedConfig holds
them for one config and derives "settled" from its latest build, so the
filter that drops source updates no build read asks each config. The batcher
no longer logs: an overflow carries how many changes it dropped, the burst
cause carries that, and WatchLog prints the warning when it reports the
round, so the watch module takes no LogService.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ames have one home

The validator re-walked every root and reading to find the names that differ
from a declared key only in letter case, which NameReadings had already
found; it now records them as it reads. The name Rojo gives an entry, the
file whose name carries its suffixes and the sources behind an init folder
were each worked out from the entry's kind in a different stage; rojoNameOf,
namingFileOf and membersOf now sit beside ScannedEntry. Placement derives the
files the template displaced from its left-out paths instead of being handed
a second copy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… them

init rendered config files with its own $schema line, the schema and the
RogenConfig shape could drift apart, and the path fields were listed in three
places. configFileContent renders a config the way Rogen writes one, the
schema is built from one entry per RogenConfig field, and PATH_FIELDS is the
list that absolutizing, the command-line tier and the single-config rule read.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ask() wrote private optional state that plan() read back behind "planned only
once it was asked" throws, PlaceSetup had a private constructor with two
factories that set different halves of it, and ProjectSetup carried a
KnownProject and an answers getter only tests used. Setup<C> now has
ask(): C | undefined and plan(choices, builder), so a plan can't be made
without answers; a place's choices hold what it joins, ProjectChoices holds
the copied template, and CoreInitService runs any setup through one planWith.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
InitDirectory carried the file system and config services so any setup could
read the base config, a sync config's sync dir or a template to copy through
it. It now holds the base config CoreInitService read up front with
BaseConfigReader, which also owns the rule that a Darklua repo's sync dir is
on the synced config; ProjectSetup reads the template it copies itself.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…he file system

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
createDirectory over a file fails, delete under a missing parent does
nothing, and a write reports the directories it creates.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The workspace names the Darklua config it found, so the prompt hints at
the right file, and ConfigSet and the setups hold the Darklua they use
instead of asking the workspace for it again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… compiled code

The place's sync dir now follows ConfigSet.syncDir, as the project's does,
instead of preferring the compiler's output when its config sets none.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
rogen Ready Ready Preview Oct 4, 2026 2:18pm UTC

@LDGerrits
LDGerrits merged commit 28615d6 into refactor Oct 4, 2026
3 checks passed
@LDGerrits
LDGerrits deleted the state-ownership branch October 4, 2026 14:24

This branch was successfully deployed

1 active deployment
Preview — b5be2447 Deployed Oct 4, 2026 by vercel[bot]
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