fix: type the platform contract, and remove all five import cycles - #12
Merged
matej21 merged 2 commits intoAug 7, 2026
Merged
Conversation
Both were value-level, not type-shape artifacts, so the ESM module graph really did contain them. Four came from `getServices` (app.ts:49) — a runtime function that files.ts, resources.ts, rpc.ts and upload.ts all import, from the module that imports each of them back in order to mount them. `AppContext`/`AppEnv`/`AppServices` are type-only and were never the problem. All four now live in a new transport/http/context.ts that imports nothing from app.ts; app.ts re-exports them so `from './app.js'` keeps working for consumers. The fifth was workers/plugin.ts <-> workers/context.ts, and it was the worse one: `workerEvents` out of plugin.ts, `WorkerContextImpl` back out of context.ts, both value imports, with `workerEvents` produced by a top-level createEventsFactory call — so it depended on module initialisation order. Moving the events and the EmitEvent type into workers/state.ts also puts the plugin back on the convention mailbox, resources and uploads already follow. Verified with a value-import-only cycle detector over all 419 source files (type-only edges excluded, `~/*` resolved): 5 -> 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0189HdSxxQdTh6umQx7znSiM
… contract
platform-api.ts implemented 13 platform methods as
`Record<string, Handler>` with `input: any`, and referenced @roj-ai/client only
inside comments — the guarantee that "the platform client works unchanged
against the standalone server" rested entirely on prose.
The handler map is now `Partial<{ [M in PlatformMethodName]: Handler<M> }>` with
input and output drawn from the contract's own `MethodInput`/`MethodOutput`.
Partial is deliberate: bundles.*, sessions.publish, sessions.usage,
instances.archive and services.getUrl are unimplemented by design and still fall
through to `method_not_found` — now as an explicit gap rather than a silent one.
Wiring it up immediately produced three compile errors, i.e. three divergences
that had already shipped:
sessions.create returned { sessionId }, contract declares status too
sessions.list returned the raw manager payload, contract declares
{ id, presetId, status, createdAt } with createdAt as ISO
tokens.create returned { token: '' }, contract declares expiresAt too
Verified the link works by renaming a field on CreateSessionOutput: the
standalone build fails, which is the whole point.
Two supporting changes:
sessions.list's manager method declared `output: sessions: z.array(z.unknown())`
while the handler returns SessionMetadata[]. That lie is why the drift was
invisible, so the schema now says what it produces. `callManagerMethod` is still
typed `Result<unknown>` — the manager registry is untyped, unlike the plugin
method registry — so platform-api validates the payload with the now-exported
sessionMetadataSchema instead of asserting it. That also removes a pre-existing
`as` cast.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189HdSxxQdTh6umQx7znSiM
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.
Stacked on #11 — the two items that PR's "Not in scope" listed as worth doing
next. Review #11 first; this targets that branch, so it retargets to
mainautomatically once #11 merges.
Typing the platform contract found a third divergence
standalone-server/platform-api.tsimplemented 13 platform methods asRecord<string, Handler>withinput: any, and referenced@roj-ai/clientonly inside comments. The guarantee this package exists to provide — that the
platform client works unchanged against it — rested entirely on prose.
The map is now
Partial<{ [M in PlatformMethodName]: Handler<M> }>, with inputand output drawn from the contract's own
MethodInput/MethodOutput.Partialis deliberate:bundles.*,sessions.publish,sessions.usage,instances.archiveandservices.getUrlare unimplemented by design and stillfall through to
method_not_found— now an explicit gap rather than a silentone.
Wiring it up produced three compile errors immediately. The audit had found two;
the compiler found the third:
sessions.create{ sessionId }+ statussessions.list{ id, presetId, status, createdAt },createdAtas ISOtokens.create{ token: '' }+ expiresAtI checked the link actually holds rather than assuming it: renaming a field on
CreateSessionOutputbreaks the standalone build. That is the whole point —before this, the same rename produced a runtime
method_not_foundor awrong-shaped JSON body in whichever app happened to run locally.
Why the drift was invisible.
sessions.list's manager method declaredoutput: { sessions: z.array(z.unknown()) }while its handler returnsSessionMetadata[]. The schema now says what it produces.callManagerMethodis still typed
Result<unknown>— the manager method registry is untyped,unlike the plugin method registry — so
platform-apivalidates the payload withthe now-exported
sessionMetadataSchemarather than asserting it, which alsoremoves a pre-existing
ascast.All five import cycles are gone
Both were value-level, so the ESM module graph really did contain them — not a
type-shape artifact.
Four came from
getServices(app.ts:49), a runtime function thatfiles.ts,resources.ts,rpc.tsandupload.tsall import, from the modulethat imports each of them in order to mount them.
AppContext/AppEnv/AppServicesare type-only and were never the problem.All four now live in
transport/http/context.ts, which imports nothing fromapp.ts;app.tsre-exports them sofrom './app.js'keeps working.The fifth was the worse one:
workers/plugin.ts ↔ workers/context.ts, bothdirections value imports, with
workerEventsproduced by a top-levelcreateEventsFactorycall — so it depended on module initialisation order.Moving the events and
EmitEventintoworkers/state.tsalso puts the pluginback on the convention
mailbox,resourcesanduploadsalready follow.Verified with a value-import-only cycle detector over all 419 source files
(type-only edges excluded,
~/*resolved): 5 → 0.Verification
bun run ts:buildclean.bun run lintunchanged (the one warning ispre-existing in
uploads/plugin.ts).bun test packages/*/src packages/*/tests— 943 pass, 5 skip, 0 fail, same as #11.
No new tests here on purpose: the contract typing replaces a category of test
with the compiler, and the cycle work is a pure move with no behaviour change.
Follow-ups this unblocks
smaller — the compiler already covers method names and payload shapes, so what
is left to test is the transport, not the contract.
managermethod registry being typedResult<unknown>while the pluginmethod registry is fully typed is the next asymmetry worth closing. Every
callManagerMethodcaller currently validates or casts.🤖 Generated with Claude Code
https://claude.ai/code/session_0189HdSxxQdTh6umQx7znSiM