Repository navigation
chore(studio): build the SDK from the checkout permanently - #2288
Conversation
Studio's BFF has used the checked-out sdk/typescript through a file: dependency since #2219, described as temporary until an SDK release carried the execution-template contract. SDK v0.5.0 now does, but returning to npm would let a Studio image pair main's daemon contract with an older released SDK and would require an SDK release before any change spanning proto, SDK, and Studio could ship. Keep the checkout dependency as the design: Studio, its SDK, and the daemon it targets always come from one commit, which continuous builds from main need. - Guard the file: dependency with sdk-source.test.ts. - Stamp About's SDK version with the baked Studio build (0.5.0+<build>), since the package version can predate the source. - Replace the "temporary" wording in the Dockerfile, README, CI, and architecture docs; keep Biome's package-name-only import rule. - Drop the SDK 0.4.0 release-age exclusion and the Renovate rule for adopting SDK releases in apps/ (Renovate skips file: dependencies). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Nashon-Steffen
left a comment
There was a problem hiding this comment.
Multi-Agent Consensus Review
Agents consulted: architecture/CI, code quality/API, docs/comment hygiene, test coverage, codex (gpt-5.5)
Consensus Summary
| # | Finding | Consensus | Severity | Action |
|---|---|---|---|---|
| 1 | Biome relative-import rule misses deep sdk/ imports the docs now rely on |
9/10 | MEDIUM | Fix |
| 2 | Apps lockfile resolves the SDK's deps at older versions than the SDK pins; the guard can't see it | 8/10 | MEDIUM | Fix |
| 3 | "Published SDK" rationale left in CI scripts and BFF wording | 9/10 | LOW | Fix |
| 4 | User docs say every image carries the +build suffix; only release images do |
9/10 | LOW | Fix |
| 5 | "Daemon it targets … one commit" reads as a runtime guarantee | 7/10 | LOW | Fix |
| 6 | Empty injected studioBuildId yields 0.5.0+ |
7/10 | LOW | Fix |
| 7 | sdkVersion now embeds studioBuildId, which is already reported beside it |
7/10 | INFO | Discuss |
| 8 | studio:image task description says context = apps/ |
7/10 | LOW | Fix |
Overall
This makes the file: SDK dependency from #2219 permanent: Studio, its SDK, and the daemon contract build from one commit, so a proto→SDK→Studio change doesn't wait on an SDK release. The PR adds a guard test, adds a +<studio build> stamp on About's SDK version, and updates the docs to match. The reasoning holds up, dependencies still point one way (apps → SDK), and the cleanup (the minimumReleaseAgeExclude entry, the no-op Renovate rule) is correct.
None of the findings blocks the approach. Two need fixing. Making the dependency permanent leaves two jobs that the published tarball used to do implicitly. The first is keeping the BFF on the SDK's public exports. The docs now credit a Biome rule with this, but the rule only matches four exact specifiers: ../../../sdk/typescript/src/index.js passes lint (checked against Biome 2.5.14 with this config). The second is resolving the SDK's own dependencies at the versions the SDK pins. apps/pnpm-lock.yaml still resolves @bufbuild/protobuf 2.15.0 and @types/node 24.13.3 while sdk/typescript/package.json pins 2.16.0 and 24.19.1, and --frozen-lockfile doesn't catch it for a file: directory dependency. That drift is already on main, but this PR is what commits to the design, so it's the natural place to close it. The rest is wording.
For the continuous main → dev builds that motivate this PR: the +build stamp only appears when the build passes STUDIO_BUILD_ID. Today only release.yml does. A dev pipeline that doesn't pass it will show the plain package version, which is the ambiguity the stamp exists to remove.
Outside the diff
- "Published SDK" rationale (9/10, docs + architecture + code quality + codex).
.github/scripts/relevant-changes-workflow_test.sh:147says "Studio consumes the PUBLISHED SDK, so it must NOT be tied to sdk relevance". The assertion is still right, but only becausestudio_relevantalready includessdk/typescript/*(relevant-changes.sh:102), and the stated reason invites someone to drop that path. Suggested wording: "studio_relevant already covers sdk/typescript/*, so the job needs no separate sdk_relevant term.".github/scripts/macos-relevant-changes.sh:44-45gives the same stale reason for excludingapps/*. The exclusion is still correct because nothing in a macOS job importsapps/. Lower priority:apps/server/src/mecatl/inspection.ts:11and thechat.test.tstest names at 103/539/621 say "published SDK" where they mean the public exports. "Public SDK" would be unambiguous. apps/Taskfile.yml:126(7/10, codex + docs): theimagetask description says(context = apps/), butdocker build … ..builds from the repository root. The Dockerfile comment edited here now states the root context, so the two disagree intask --list.
Documentation
user-docs/operating/studio.md:136-140: scope the suffix to published images and rewrap to the site's 80-column prose width (inline suggestion).docs/architecture/api-surface.md:278-282: "the daemon it targets ship from one commit". The same phrasing is inapps/Dockerfile:10-12andapps/README.md:31-33(inline suggestion). Line 271 still callsapps/a "self-contained pnpm workspace", which is no longer quite true with a permanentfile:../../sdkdependency.
Generated with Claude Code
| "paths": { | ||
| "../sdk": "Studio consumes only the published @stacklok-oss/mecatl-sdk.", | ||
| "../../sdk": "Studio consumes only the published @stacklok-oss/mecatl-sdk.", | ||
| "../../../sdk": "Studio consumes only the published @stacklok-oss/mecatl-sdk.", | ||
| "../../../../sdk/typescript": "Studio consumes only the published @stacklok-oss/mecatl-sdk." | ||
| "../sdk": "Import the SDK by package name (@stacklok-oss/mecatl-sdk), never by relative path.", | ||
| "../../sdk": "Import the SDK by package name (@stacklok-oss/mecatl-sdk), never by relative path.", | ||
| "../../../sdk": "Import the SDK by package name (@stacklok-oss/mecatl-sdk), never by relative path.", | ||
| "../../../../sdk/typescript": "Import the SDK by package name (@stacklok-oss/mecatl-sdk), never by relative path." | ||
| } |
There was a problem hiding this comment.
Biome relative-import rule misses deep sdk/ imports (9/10, MEDIUM; architecture, code quality, docs)
paths matches the whole specifier exactly. With this config on Biome 2.5.14, ../../../sdk and ../../../../sdk/typescript are rejected, but ../../../sdk/typescript/src/index.js and ../../../../sdk/typescript/dist/index.js pass. Those are the imports someone would actually write from server/src/. apps/README.md and api-surface.md now cite this rule as what keeps the BFF on the public exports, and with the SDK in the same checkout it's the only thing doing so.
A patterns group flags all four forms. I checked that it still allows @stacklok-oss/mecatl-sdk, @stacklok-oss/mecatl-sdk/node, and ./sdk-version.js, and no current apps/ import matches it:
| "paths": { | |
| "../sdk": "Studio consumes only the published @stacklok-oss/mecatl-sdk.", | |
| "../../sdk": "Studio consumes only the published @stacklok-oss/mecatl-sdk.", | |
| "../../../sdk": "Studio consumes only the published @stacklok-oss/mecatl-sdk.", | |
| "../../../../sdk/typescript": "Studio consumes only the published @stacklok-oss/mecatl-sdk." | |
| "../sdk": "Import the SDK by package name (@stacklok-oss/mecatl-sdk), never by relative path.", | |
| "../../sdk": "Import the SDK by package name (@stacklok-oss/mecatl-sdk), never by relative path.", | |
| "../../../sdk": "Import the SDK by package name (@stacklok-oss/mecatl-sdk), never by relative path.", | |
| "../../../../sdk/typescript": "Import the SDK by package name (@stacklok-oss/mecatl-sdk), never by relative path." | |
| } | |
| "patterns": [ | |
| { | |
| "group": ["**/sdk", "**/sdk/**"], | |
| "message": "Import the SDK by package name (@stacklok-oss/mecatl-sdk), never by relative path." | |
| } | |
| ] |
| readFileSync(new URL("../package.json", import.meta.url), "utf8"), | ||
| ) as { dependencies: Record<string, string> }; | ||
| expect(manifest.dependencies["@stacklok-oss/mecatl-sdk"]).toBe("file:../../sdk/typescript"); | ||
| }); |
There was a problem hiding this comment.
Lockfile resolves the SDK's deps at older versions than the SDK pins (8/10, MEDIUM; architecture, tests)
apps/pnpm-lock.yaml resolves @stacklok-oss/mecatl-sdk@file:../sdk/typescript with @bufbuild/protobuf 2.15.0 and @types/node 24.13.3, but sdk/typescript/package.json pins 2.16.0 and 24.19.1. pnpm install --frozen-lockfile doesn't compare a file: directory dependency's manifest against the lockfile snapshot, so CI stays green and the image runs SDK code tested on protobuf-es 2.16.0 against runtime 2.15.0. Renovate's SDK group bumps sdk/typescript/package.json without touching the apps lockfile, so this will keep drifting. It's already on main, but it undercuts "one commit", and this guard only checks the package.json specifier.
Suggest regenerating apps/pnpm-lock.yaml, then guarding against recurrence. One option: extend this test to compare the lockfile's '@stacklok-oss/mecatl-sdk@file:../sdk/typescript' snapshot dependencies with sdk/typescript/package.json dependencies. Another: a CI step that runs pnpm install --lockfile-only in apps/ and fails on a diff.
| **About** identifies the Studio image, installed SDK, and connected daemon | ||
| builds. Ask users for **Copy support summary** when diagnosing a deployment | ||
| builds. Studio compiles the SDK from the same source as its image, so in an image | ||
| the SDK version ends with the Studio build after a `+`, such as | ||
| `0.5.0+v0.0.48`. Ask users for **Copy support summary** when diagnosing a deployment | ||
| issue. |
There was a problem hiding this comment.
Suffix appears only in release images (9/10, LOW; docs, architecture, code quality)
Only release.yml passes STUDIO_BUILD_ID. CI no-push images, task studio:image, and any dev build that doesn't pass the build arg show the plain package version. Lines 137 and 139 are also past the site's 80-column proseWrap. Suggested scope and rewrap:
| **About** identifies the Studio image, installed SDK, and connected daemon | |
| builds. Ask users for **Copy support summary** when diagnosing a deployment | |
| builds. Studio compiles the SDK from the same source as its image, so in an image | |
| the SDK version ends with the Studio build after a `+`, such as | |
| `0.5.0+v0.0.48`. Ask users for **Copy support summary** when diagnosing a deployment | |
| issue. | |
| **About** identifies the Studio image, installed SDK, and connected daemon | |
| builds. In a published image, the SDK version ends with the Studio release it | |
| was built from, after a `+`, such as `0.5.0+v0.0.48`. Images built without a | |
| release tag show only the SDK package version. Ask users for **Copy support | |
| summary** when diagnosing a deployment issue. |
| and is the only part that talks to Mecatl. It builds the SDK from the same checkout | ||
| through a `file:` dependency, never a registry release, so Studio, the SDK, and the | ||
| daemon it targets ship from one commit and a change spanning all three needs no | ||
| intermediate SDK release. It still imports the SDK only by package name, through the | ||
| same exports as any other consumer. |
There was a problem hiding this comment.
"The daemon it targets … ship from one commit" reads as a runtime guarantee (7/10, LOW; docs, architecture)
In external mode a deployed Studio connects to whatever daemon the operator runs. What is guaranteed is that Studio is built against its own commit's daemon contract, which is how sdk-source.test.ts and the commit message put it. The same phrasing is in apps/Dockerfile:10-12 ("always share one commit") and apps/README.md:31-33.
| and is the only part that talks to Mecatl. It builds the SDK from the same checkout | |
| through a `file:` dependency, never a registry release, so Studio, the SDK, and the | |
| daemon it targets ship from one commit and a change spanning all three needs no | |
| intermediate SDK release. It still imports the SDK only by package name, through the | |
| same exports as any other consumer. | |
| and is the only part that talks to Mecatl. It builds the SDK from the same checkout | |
| through a `file:` dependency, never a registry release, so Studio, the SDK, and the | |
| daemon contract it targets are built from one commit and a change spanning all three | |
| needs no intermediate SDK release. It still imports the SDK only by package name, | |
| through the same exports as any other consumer. |
| return version === undefined || studioBuildId === undefined | ||
| ? version | ||
| : `${version}+${studioBuildId}`; |
There was a problem hiding this comment.
Empty build ID yields 0.5.0+ (7/10, LOW; code quality, tests)
build-info.ts normalizes an empty baked ID to undefined, but createApp({ studioBuildId: "" }) passes "" straight through (?? keeps it), which yields 0.5.0+. Only tests inject it today. Treating empty like absent here is cheap, along with a reportedSdkVersion("0.5.0", "") case:
| return version === undefined || studioBuildId === undefined | |
| ? version | |
| : `${version}+${studioBuildId}`; | |
| return version === undefined || !studioBuildId | |
| ? version | |
| : `${version}+${studioBuildId}`; |
| * Studio compiles the SDK from its own checkout, so the SDK's package version | ||
| * can predate the source in a build. A baked Studio build ID names that source. | ||
| */ | ||
| export function reportedSdkVersion( |
There was a problem hiding this comment.
sdkVersion now embeds studioBuildId (7/10, INFO, discuss; code quality, tests, architecture)
In a release image, About and the support summary show Studio build: v0.0.48 next to SDK version: 0.5.0+v0.0.48. The upside is that a copied SDK line identifies its source on its own. The cost is that sdkVersion is no longer purely the package version, so anything that later compares it against npm versions gets a mixed value. If the suffix stays, a one-line .describe() on sdkVersion in apps/contracts/src/schemas/runtime.ts would document the format for API readers. Otherwise, keep sdkVersion as the bare package version and let About explain that the SDK is built from the Studio source.
Making the file: SDK dependency permanent leaves two jobs the published tarball used to do implicitly. - Biome's relative-import rule matched four exact specifiers, so a deep import such as ../../../sdk/typescript/src/index.js passed lint. Use a **/sdk and **/sdk/** pattern group instead. - A frozen install does not compare a file: dependency's manifest with its lockfile snapshot, so apps/pnpm-lock.yaml still installed the SDK with @bufbuild/protobuf 2.15.0 and @types/node 24.13.3 against the SDK's 2.16.0 and 24.19.1 pins. Refresh the lockfile and have sdk-source.test.ts compare the installed versions with the SDK's pins. Also treat an empty Studio build ID as absent in reportedSdkVersion, scope the About suffix in user docs to published images, describe the one-commit guarantee as the daemon contract rather than the deployed daemon, retire the remaining "published SDK" wording in the CI classifiers and BFF, and correct the studio:image build context. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Summary
Since #2219, Studio's BFF has used the checked-out
sdk/typescriptthrough afile:dependency. That PR called this temporary, to last until an SDK release included the execution-template contract. SDK v0.5.0 (#2284) now includes it. This PR makes the checkout dependency the permanent design instead of switching back to npm.Why not go back to npm:
main's daemon contract with an older released SDK.Why this matters now: continuous builds from
mainto the infradevcluster need Studio, its SDK and the daemon it targets to come from one commit.What changes:
apps/server/src/sdk-source.test.tsfails if the dependency moves to a registry version. I checked it fails when the dependency is set to0.5.0.+, e.g.0.5.0+v0.0.48. The package version alone can predate the source. Local source-mode runs still show the plain version.apps/Dockerfile,apps/README.mdandci.yml.docs/architecture/api-surface.mdis updated.user-docs/operating/studio.mdexplains the About version format.package.test.tsanddeclarations.test.tsalready check the npm tarball on every SDK change.0.4.0minimumReleaseAgeExcludeentry fromapps/pnpm-workspace.yaml.apps/. Renovate skipsfile:dependencies, so the rule did nothing.Issue relationship
Relates to #2219 (follow-up to its temporary checkout-SDK dependency)
Type of change
+<studio build>suffix in built imagesTest plan
Baseline checks
Linting (
task lint): one gosec G703 finding inenvironment/microvm/launch_ownership_darwin.go. That file is macOS-only and this PR has no Go changes.Fast offline test suite (
task test): covered bytask test:racebelow.Pre-review race suite (
task test:race): three macOS-specific failures in code this PR doesn't touch:TestMecatedCLICompositionValidatesMicroVMDefaultAfterReadiness, twice (unix socket under/dev/fd).TestRepositoryGuestAuthenticationRejectsCompetingConnectorBeforeDisclosure(a timing limit).Everything else passed. Linux CI is authoritative.
Offline demo (
go run ./cmd/mecademo): shows the tool call, the permission ask and approval, and the result.Markdown changed:
task docs:checkpassed with 0 broken links or anchors. The fulltask docsstopped atdocs:grpc-refbecausebufisn't installed locally, and that step only regenerates the gRPC reference.User docs/user-facing behavior changed: site build (
task site:build)Guarded engine API affected: compatibility check (
task api:check)Intentional engine API change:
task api:update+engine/CHANGELOG.mdStudio:
task studio:lint studio:typecheck studio:test studio:generated-checkpassed (server 197/197, web 806/806, no contract drift).Changes
apps/server/src/sdk-source.test.tsfile:SDK dependencyapps/server/src/sdk-version.ts,app.ts(+ tests)reportedSdkVersionstamps the SDK version with the Studio buildapps/Dockerfile,apps/README.md,.github/workflows/ci.yml,apps/web/e2e/chat-approval.spec.tsdocs/architecture/api-surface.mduser-docs/operating/studio.mdapps/biome.jsonapps/pnpm-workspace.yaml,renovate.jsonUser-facing change
In a published Studio image, Settings → About shows the SDK version with the Studio build appended, e.g.
0.5.0+v0.0.48. Nothing else changes.Special notes for reviewers
/api/v1/runtimesdkVersionfield keeps its type (string). Only its value format changes in built images. Studio is early access, and the field is display-only in About and the support summary.🤖 Generated with Claude Code