feat: the application kernel - #1
Merged
Merged
Conversation
Also add @unthrown/vitest to tsconfig's `types` so tsc picks up its Matchers augmentation (toBeOkWith/toBeErrWith/toBeDefect) — needed because this is the first spec file to use those matchers, and the setupFiles registration alone only wires them at vitest runtime, not for `tsc --noEmit`.
Task 5 review, finding 1: tsconfig.json's `types` array governs the
tsdown --dts build too, so adding "@unthrown/vitest" there tied the
published .d.ts to a devDependency and applied it repo-wide instead of
just to specs. Revert `types` to ["node"] and add an ambient
src/vitest.d.ts (`import type {} from "@unthrown/vitest";`), picked up
by `include: ["src/**/*"]` for `tsc --noEmit` but unreachable from
src/index.ts, so it never enters the bundled build.
Also, per review additions 2 and 3:
- strengthen the abortAll and awaitIdle tests to cover two concurrent
units instead of one, so a first-or-last-only bug would be caught
- add a one-line comment on abortAll recording that the open set is
iterated live, so a unit started from within an abort listener is
aborted by the same pass
…compliant runtime - Track a monotonic closed-unit count in the registry and derive `completed` from it instead of `inFlightAtStart - abandoned`, which could go negative when a unit started after the drain's opening sample. - Sample `inFlightAtStart`/`closedAtStart` at the very start of the drain, so they line up with the `"draining"` event emitted from the same turn. - Stop awaiting `Serving.drain` before the timeout race begins, and always release its signal once the race settles, so a runtime that treats that signal as its own cue to return can no longer deadlock the drain. - `Serving.drain` now returns `void`: only the kernel can see the unit registry, so it alone owns the completed/abandoned accounting.
There was a problem hiding this comment.
Pull request overview
Introduces @btravstack/start, a TypeScript “application kernel” that boots a @btravstack/di module into a single-runtime process, provides deterministic lifecycle/drain behavior (including SIGTERM handling), and exposes transport-agnostic probes/events while preserving the application’s modeled error type via unthrown Results.
Changes:
- Adds the kernel implementation (
start, lifecycle phases, draining, probes, signals/uncaught handling, events) plus the published public surface (index.ts) andrunMainexit-code mapping. - Adds a full testing toolkit and invariant suite (
testRuntime,createFakeClock,withApp, type-level docs compilation tests) with 100% coverage enforcement. - Scaffolds repo toolchain (pnpm workspace + turbo), CI workflow, changeset, and documentation/spec records.
Reviewed changes
Copilot reviewed 60 out of 63 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| turbo.json | Turbo task graph for build/lint/test workflows. |
| README.md | Root README documenting the kernel contract, lifecycle, probes, and exit codes. |
| pnpm-workspace.yaml | Workspace + dependency catalog configuration. |
| packages/start/vitest.config.ts | Vitest config with v8 coverage thresholds. |
| packages/start/tsconfig.test-d.json | Dedicated tsconfig for *.test-d.ts type tests. |
| packages/start/tsconfig.json | Package TS config (NodeNext / outDir / excludes). |
| packages/start/src/with-app.ts | Test helper to run an app and guarantee teardown. |
| packages/start/src/vitest.d.ts | Declares @unthrown/vitest matchers for tests. |
| packages/start/src/units.ts | Unit registry + ambient record wiring around unit execution. |
| packages/start/src/units.spec.ts | Unit registry behavior tests (tracking, nesting, aborting, idle). |
| packages/start/src/uncaught.ts | Installs uncaught exception/rejection handlers (first-cause only). |
| packages/start/src/uncaught.spec.ts | Tests uncaught handler reporting + disposal. |
| packages/start/src/testing.ts | @btravstack/start/testing barrel export. |
| packages/start/src/test-runtime.ts | In-memory runtime fixture for kernel/drain testing. |
| packages/start/src/test-runtime.spec.ts | Tests for the test runtime behavior. |
| packages/start/src/start.ts | Core kernel: boot, lifecycle state, draining integration, probes, signals. |
| packages/start/src/start.test-d.ts | Type-level tests for the runtime-needs phantom gate. |
| packages/start/src/start.spec.ts | Behavioral tests for start/stop/drain/signals/uncaught and teardown errors. |
| packages/start/src/signals.ts | SIGTERM/SIGINT handler installation + disposal. |
| packages/start/src/signals.spec.ts | Tests for signal handler behavior and cleanup. |
| packages/start/src/runtime.ts | Runtime contract types + RuntimeStartFailed error. |
| packages/start/src/run-main.ts | runMain exit-code mapping without process.exit(). |
| packages/start/src/run-main.spec.ts | Tests exit-code mapping and “never calls process.exit”. |
| packages/start/src/probes.ts | Probe server implementation for /livez and /readyz. |
| packages/start/src/probes.spec.ts | Probe server tests (health routes, 404, bind failure). |
| packages/start/src/phase.ts | Monotonic phase tracker implementation. |
| packages/start/src/phase.spec.ts | Phase tracker tests. |
| packages/start/src/invariants.spec.ts | End-to-end invariants suite (ordering, readiness latch, disposal, bind failure). |
| packages/start/src/index.ts | Public API surface for @btravstack/start. |
| packages/start/src/index.test-d.ts | Type test for exported VERSION. |
| packages/start/src/index.spec.ts | Runtime test for exported VERSION. |
| packages/start/src/fake-clock.ts | Deterministic clock for lifecycle/drain timing tests. |
| packages/start/src/fake-clock.spec.ts | Fake clock tests (advance, abortable sleep). |
| packages/start/src/events.ts | Kernel event model + safe sink + stderr sink. |
| packages/start/src/events.spec.ts | Tests for safe sink swallowing and stderr JSON output. |
| packages/start/src/drain.ts | Three-beat drain implementation + accounting. |
| packages/start/src/drain.spec.ts | Drain ordering, accounting, deadline/skip behavior tests. |
| packages/start/src/drain-report.ts | DrainReport type and semantics documentation. |
| packages/start/src/docs-examples.test-d.ts | Compiles README code samples + asserts type equality to shipped API. |
| packages/start/src/deferred.ts | Idempotent deferred primitive for shutdown signaling. |
| packages/start/src/clock.ts | Injectable Clock + systemClock with abortable sleep. |
| packages/start/src/clock.spec.ts | Tests for system clock sleeping and abort behavior. |
| packages/start/src/ambient.ts | AsyncLocalStorage ambient unit record. |
| packages/start/src/ambient.spec.ts | Ambient record tests (scope, awaits, concurrency). |
| packages/start/README.md | Package README (published-facing) aligned with root README. |
| packages/start/package.json | Package manifest: dual CJS/ESM exports, peers, scripts. |
| packages/start/LICENSE | Package-level MIT license. |
| package.json | Root workspace manifest + scripts + engines + tooling deps. |
| LICENSE | Root MIT license. |
| lefthook.yml | Git hooks wiring and exclusions. |
| knip.json | Dead-code / unused dep configuration. |
| docs/superpowers/specs/2026-08-09-btravstack-start-design.md | Design doc updated with “shipped as” callouts and delta table. |
| docs/superpowers/plans/2026-08-10-btravstack-start.md | Plan annotated as executed and linked to final deltas. |
| commitlint.config.js | Conventional commits enforcement via shared config. |
| CLAUDE.md | Authoritative internal spec and invariants record for the repo. |
| .oxlintrc.json | oxlint configuration (incl. unthrown plugin rules). |
| .oxfmtrc.json | oxfmt configuration and overrides. |
| .node-version | Dev Node pin. |
| .gitignore | Repo ignore patterns (including agent scratch). |
| .github/workflows/ci.yml | CI workflow using reusable workflow + node version matrix. |
| .changeset/initial-kernel.md | Initial changeset describing the published kernel. |
| .changeset/config.json | Changesets configuration. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
btravers
commented
Aug 10, 2026
…ajor
startProbeServer left its bind-failure `once("error", ...)` listener
attached after a successful listen, so a later probe-server error would
resolve an already-settled promise and vanish silently. Detach it once
the bind succeeds, so a post-bind error surfaces as an uncaught exception
and is handled by this kernel's own uncaughtException path instead.
Also point .changeset/config.json's $schema at the installed
@changesets/config major (3) instead of a stale patch pin (3.1.1 vs the
installed 3.1.4), matching knip.json's major-pin convention.
btravers
commented
Aug 10, 2026
Two workspace packages, one per clean-architecture layer, because the package boundary is what makes the dependency direction a build error rather than a convention. order-domain depends on unthrown and nothing else: an Order, the rule that a quantity is positive, and the three tagged errors every outer layer names failures with. src/layering.test-d.ts asserts the wrong-direction import does not resolve, so adding the application layer to this package's dependencies fails test:types on the now-unused @ts-expect-error. order-application declares the ports the use cases need — OrderRepository in the domain's own error vocabulary — and ApplicationModule deliberately does not provide OrderRepository, leaving it an unmet need that di propagates into a call-site arity error until infrastructure supplies one. Its specs run the use cases against a stub repository from a test-only module, with no database, no HTTP and no kernel booted. currentUnit() is the single kernel touchpoint, read fresh per call so trace ids differ per unit.
btravers
commented
Aug 11, 2026
…int rule `unthrown` ships `OkAsync` / `ErrAsync` precisely so `Ok(v).toAsync()` need never be written, and `OkAsync()` mirrors `Ok()`'s no-arg `void` overload so `Ok(undefined)` need never be written either. 50 occurrences across the kernel, its specs, the examples and both READMEs are rewritten; the one surviving `.toAsync()` (`use-cases.ts`) lifts a `Result` that already exists, which is what the method is for. `start.ts`'s probe branch was the one non-mechanical rewrite: a bare `OkAsync()` is `AsyncResult<void, …>` and `void` is not assignable to `undefined`, so the sibling branch's `map`-used-for-effect-then-`return undefined` becomes the `tap` + `discard` it always was, and the internal channel type follows. Bump `unthrown`, `@unthrown/oxlint` and `@unthrown/vitest` to 5.2.0, and enable all eight plugin rules rather than the five in `recommended` — `no-throw`, `prefer-ensure` and 5.2.0's new `no-get-or-throw`. `prefer-ensure` reports nothing. The six surviving `throw`s each carry a targeted disable naming why: three are loud test fixtures whose failure means the test is buggy, and three are throws that ARE the subject under test (a `Defect` has no public constructor, so `run-main.spec.ts` has no other way to mint one). `no-get-or-throw` is switched off for the spec glob — the exemption the rule's own diagnostic prescribes — and stays on everywhere else. No public behaviour change: 91 tests, coverage unmoved.
The domain layer hand-rolled its entity: a structural `type Order` plus a `placeOrder` that checked `quantity > 0` inline. @btravstack/entity is the btravstack library for exactly this layer, and these examples exist to show the stack composing, so the domain should use it. `Order` is now an entity with branded `OrderId`/`Quantity` fields, an immutable `id`, and the quantity rule declared as an `Entity.invariant` — so it is re-checked on every path that produces an `Order` (`make`, `update`), not only the one the old check was written on. `placeOrder` keeps its exact signature and names the entity's `InvalidEntity` as `InvalidQuantity`, which is what the outer layers already speak. No export was renamed: order-application compiles and its five specs pass untouched. The domain's specs go 4 -> 9, asserting what the entity promises — runtime immutability, update re-running the invariant, an immutable field refused when smuggled past the type, and `toJSON` never carrying `_tag`. `OrderId` deliberately carries no length rule: it keeps the `InvalidEntity` -> `InvalidQuantity` translation total, so the error never stands in for a failure it does not name.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 84 out of 89 changed files in this pull request and generated no new comments.
Suppressed comments (1)
packages/start/src/drain.ts:56
drainAppracesPromise.all([drainStopped, registry.awaitIdle()])against the timeout, but it never inspects theResultproduced byserving.drain(...). IfServing.drainsettles as aDefect,Promise.allstill resolves and the drain is reported as successful, silently ignoring the defect. This makes runtime drain failures invisible and can produce a misleadingDrainReport.
…splits Three pairs/trios of tightly-coupled files existed only because the 13-task plan that built the kernel produced one file per task rather than one file per concern: - drain-report.ts (13-line DrainReport type) merges into drain.ts, its only non-trivial consumer's neighbour - signals.ts + uncaught.ts merge into process-handlers.ts: both install and dispose process-level handlers, both are consumed only by start.ts at the same two dispose sites - ambient.ts merges into units.ts, its only importer No behaviour, signatures, or exported names changed — index.ts and testing.ts export the identical 24 public names, just from consolidated source files. Specs merged verbatim (same names, same assertions). 19 source files (1079 lines) become 16 (1076 lines); 96 tests still pass with 100% line/function coverage.
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.
Builds
@btravstack/start, the application kernel: it boots a@btravstack/dimodule into a running process with one runtime, drains in-flight work on SIGTERM, and closes the application scope on every path.Implemented from the design and the plan, 13 tasks, each reviewed before the next began.
What it does
startis a thin wrapper overModule.scoped. It owns three things and nothing else: the application object, the lifecycle state machine, and theRuntimecontract. It knows nothing about HTTP, AMQP or Temporal — those are future runtime packages.Contextcarries capabilities. TheAsyncLocalStoragestore holdsunitId/traceId/tenantId/deadlineand nothing else — no services, so it cannot become a service locator.startnever throws and never callsprocess.exit.runMainis the single sanctioned place a process decides its fate.AsyncResult<ExitReport, E | RuntimeStartFailed>.81 tests; 100% statements/functions/lines. Full gate green:
format --check,lint,typecheck,knip,test,build. CI added.Design changes made during implementation
The design document is kept as the historical record, annotated with
> **Shipped as:**callouts and a summary table. The substantive changes:Serving.drainreturnsAsyncResult<void, never>, not aDrainReport. Two competing drain reports was a design error — only the kernel sees the unit registry, so the kernel owns the accounting.Needsto the module's exports, while a comment claimed otherwise. A conditional type on an inference-bearing parameter was rejected because it collapsesX/Etounknown. The gate is bypassable by a caller who deliberately hand-writes the phantom arguments — the same escape hatch di's own gate leaves, asserted rather than assumed.RuntimeHost.ctxisContext<InstanceType<Needs>>— a runtime declares needs as port classes, di parameterisesContextby port instance types.uncaughtrow, so a crashed process exited 0 — the kernel was turning a crash into a successful exit.DrainReportfields have precise meanings, andcompletedis now a monotonic count. The original formula could go negative, tripping the abandoned-work exit code on a clean shutdown.Known gap, not fixed here
The probe server binds
127.0.0.1only. A KuberneteshttpGetprobe dials the pod IP, so as shipped/livezand/readyzare unreachable by the orchestrator the drain design targets. This needs ahostoption before the package is useful in the deployment it was designed for. Documented factually rather than papered over.Deliberately out of scope
@btravstack/start-http,-amqp,-temporal; observability; config loading; auth; CLI. Per-unit scope forking is deferred —RunUnitis typed for it and the fork site is marked.