Skip to content

Commit fa8ccd9

Browse files
MajorTalclaude
andcommitted
docs+sdk: slice_kind discriminator + CHANGELOG + mixed-apply README + drift guard
Lands the deferred observability + doc polish items from openspec/changes/sdk-assets-pipeline: - §4.2/4.3: slice_kind on content.upload.* events, slice_kinds on commit.phase + ready events. ByteReader gains a slice field; the per-slice remember factory tags readers at registration time; cross-kind dedup escalates to mixed. - §7.5: tightened CLI/MCP SDK-boundary guard in sync.test.ts by pruning the stale src/tools/assets-put.ts + cli/lib/assets.mjs allowlist entries (legacy /storage/v1/uploads* paths are gone). - §9.1: added a 'Mixed apply' section to sdk/README.md with a typecheck-passing site + assets bytes example. - §9.3: fixed the stale AGENTS.md note about cli/lib/assets.mjs using legacy session primitives. - §9.7: new CHANGELOG.md covering the v2.2.0 lockstep release. - Integration test fixture cleanup: dropped the pre-2.2.0 skip path now that @run402/functions ships assets in npm 2.2.0+. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5a0e705 commit fa8ccd9

8 files changed

Lines changed: 193 additions & 72 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ Core functions return `null` or throw — they never call `process.exit()`. Each
170170
- **`cli/lib/sdk-errors.mjs`**`reportSdkError(err)` writes JSON envelope `{status: "error", http?, message?, body_preview?, ...body}` to stderr and calls `process.exit(1)`. Preserves `ProjectNotFound` plain-text format with the "Hint: project IDs start with prj_" guidance.
171171
- **`cli/lib/config.mjs`** — Imports from `../core-dist/`, adds CLI wrappers (`allowanceAuthHeaders()` with process.exit, `findProject()` with process.exit). Re-exports core keystore functions.
172172
- **`cli/lib/*.mjs`** — Each module exports `async run(sub, args)`. Subcommand bodies: argv parse + SDK call + JSON output + `reportSdkError` on failure.
173-
- **`cli/lib/assets.mjs`** uses SDK asset upload-session primitives for init/status/complete and retains raw `fetch` only for the presigned S3 part URLs returned by the SDK. Resumable state and per-part concurrency stay at the CLI edge.
173+
- **`cli/lib/assets.mjs`** (v2.2.0) delegates `put` to `sdk.assets.put`, which routes through the unified-apply hero (`/apply/v1/plans``/content/v1/plans` → S3 PUT → commit). The pre-v2.x multipart S3 PUT + resumable session machinery is gone; resume semantics now live at the apply-plan level (24h plan TTL). `--concurrency` and `--no-resume` flags are accepted for backward compatibility but ignored.
174174
- **`cli/lib/deploy.mjs`** is the deploy command-group dispatcher. `cli/lib/deploy-v2.mjs` owns `apply`, `resume`, `list`, `events`, diagnostics, and release observability subcommands.
175175
- **`cli/lib/deploy-v2.mjs`**`run402 deploy apply`, `resume`, `list`, `events`, and `release <get|active|diff>` subcommands. Thin wrappers over `r.project(id).apply.*`; `apply` supports `--final-only` as a `--quiet` alias and repeatable `--allow-warning <code>`.
176176
- **`cli/lib/ci.mjs`**`run402 ci link github`, `run402 ci list`, and `run402 ci revoke`. Link signs the canonical delegation locally, verifies/inserts the GitHub repository id, optionally includes normalized `route_scopes`, and writes a workflow using GitHub OIDC (`permissions: id-token: write`) plus the existing `deploy apply` command.

CHANGELOG.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Changelog
2+
3+
All notable changes to `@run402/sdk`, `run402` (CLI), `run402-mcp`, and `@run402/functions`. Versions are kept in lockstep across the four packages.
4+
5+
## 2.2.0 — 2026-05-18
6+
7+
Closes the v1.48 unified-apply asset pipeline end-to-end. v2.0.0/v2.0.1 shipped the deploy hero (`r.project(id).apply(spec)`) but left three structural gaps in the asset slice: the normalizer didn't read `spec.assets`, `NodeAssets.uploadDir/syncDir/prepareDir/putMany` never uploaded bytes, and `Assets.put` still called the removed `/storage/v1/uploads*` substrate (404 in production). This release closes all three.
8+
9+
### Added
10+
11+
- **`@run402/functions` `assets` namespace.** `import { assets } from "@run402/functions"` exposes `assets.put(key, source, opts)` for in-function blob uploads. Routes through the new gateway `POST /apply/v1/service-asset-put` (service-key auth) so per-key visibility flips inside the same activation sub-transaction the wallet-auth apply hero uses. Quota enforcement, per-unique-hash storage billing, and immutable URL retention behave identically to deploy-time `r.project(id).apply({ assets: { put: [...] } })`.
12+
- **Wire-shaped `assets` slice in the unified apply spec.** `ReleaseSpec.assets?: AssetSpec` carries `put?: (AssetPutEntry | AssetPutEntryInput)[]`, `delete?: string[]`, and `sync?: { prefix, prune: true, confirm? }`. The SDK input form (`AssetPutEntryInput` with `source: ContentSource`) and the wire form (`AssetPutEntry` with `sha256` + `size_bytes`) can be mixed in the same array.
13+
- **`r.assets.uploadDir(path, opts)` / `syncDir` / `prepareDir` / `putMany`.** Node-only directory ergonomics that walk filesystem, hash, register byte readers, and submit through the single `apply` hero. `entriesFromLocalDir` now returns `AssetPutEntryInput[]` (with `source` retained) instead of pre-hashed wire entries, so the SDK normalizer registers byte readers and bytes flow through `/content/v1/plans`.
14+
- **`DeployResult.assets`** is populated from the plan response's `asset_entries[]`. Carries `list` / `byKey` with the gateway-authoritative `AssetRef` envelope (resolved URLs + SRI + etag + content_digest) plus `totals.bytes_uploaded` / `bytes_reused` (derived from per-entry `status: "upload_pending" | "present" | "satisfied_by_plan"`).
15+
- **`slice_kind` discriminator on observability events.** `content.upload.skipped` / `content.upload.progress` events carry `slice_kind: "release" | "asset" | "mixed"` per SHA; `commit.phase` and `ready` events carry `slice_kinds: ("release" | "asset")[]` summarizing which slice categories the apply's spec carried. Cross-kind CAS dedup (same SHA in `site` + `assets`) escalates the per-SHA value to `"mixed"`.
16+
- **CLI/MCP unified deploy tool now accepts `assets`.** `deploy.apply` (`run402 deploy apply --manifest run402.json`, MCP `deploy` tool) accepts `assets: { put: [{ key, source: { data, encoding? } | { path } }], delete?, sync? }` via the manifest normalizer.
17+
- **Run402 ReleaseSpec JSON schema** (`schemas/release-spec.v1.json`, hosted at `https://run402.com/schemas/release-spec.v1.json`) now describes the `assets` slice with full `$defs/assetPutEntry`, `$defs/assetSync`.
18+
19+
### Changed
20+
21+
- **`r.assets.put` routes through the apply hero.** Single-key upload calls `r.project(id).apply({ assets: { put: [{ key, source: bytes }] } })` and reads the resolved `AssetRef` from `result.assets.byKey[key]`. Behavior matches v2.0.1 from the caller's perspective; the wire path moved to `/apply/v1/plans` + `/content/v1/plans`.
22+
- **CLI `run402 assets put <file>`** delegates to `sdk.assets.put`. The pre-v2.x multipart S3 PUT + resumable session machinery (`~/.run402/uploads/<upload_id>.json`) is gone; resume semantics live at the apply-plan level (24h TTL). The `--concurrency` and `--no-resume` flags are accepted for backward compatibility but ignored.
23+
- **`@run402/functions` runtime helper bundle.** Added `assets` to the export list alongside `db` / `adminDb` / `getUser` / `email` / `ai` / `routedHttp`. No change to the existing exports.
24+
25+
### Removed / deprecated
26+
27+
- **`Assets.initUploadSession` / `getUploadSession` / `completeUploadSession`** throw `LocalError` with an actionable migration message pointing to `r.project(id).apply({ assets: { put: [...] } })` / `r.assets.uploadDir`. Gateway v1.48 dropped the `/storage/v1/uploads*` substrate. The method shapes (and the `BlobUploadInit*` / `BlobUploadStatus*` / `BlobUploadComplete*` types they reference) are kept in the TypeScript surface for source-compat with downstream code that imports them; surface removal is a v3 candidate.
28+
29+
### Gateway changes (shipped to production alongside this release)
30+
31+
- **`POST /apply/v1/service-asset-put`** (service-key auth). In-function blob upload endpoint. Hashes raw body, PutObject to `_cas/<sha[0:2]>/<sha[2:]>`, upserts `internal.content_objects`, calls the shared `applyOneAssetPut` primitive in a short transaction, returns the resolved `AssetRef`. 25 MB inline cap.
32+
- **`applyOneAssetPut`** extracted from `promoteStagedAssetSlice` as the shared per-put primitive. The wallet apply hero and the service-key route both call it; INSERTs into `internal.blobs` / `internal.asset_versions` (skipped when `operationId === null` for service uploads) / `internal.blob_url_refs` are byte-identical between the two paths.
33+
- **`promoteStagedAssetSlice` now inserts `internal.blob_url_refs`** for every immutable put. Without this row the immutable URL form (`pr-<id>.run402.com/_blob/<key-with-sha-suffix>`) returned 404 for assets uploaded via the unified-apply hero; the legacy `/storage/v1/uploads*` cas-promote path always inserted it.
34+
35+
### Migration notes
36+
37+
If you were using v2.0.x and relied on `r.assets.initUploadSession` for low-level resumable uploads, migrate to `r.project(id).apply({ assets: { put: [...] } })` — the apply engine handles retries and large-file streaming through the unified content plan. For single-key uploads, `r.assets.put(projectId, key, source, opts)` is now the recommended surface and routes through the same hero.
38+
39+
If you were running an older gateway (pre-v1.48), this SDK release won't compile against it because the `/storage/v1/uploads*` routes return 404. Upgrade the gateway first.

fullstack-integration.test.ts

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -682,20 +682,13 @@ describe("Run402 full-stack integration (live API, no mocks)", { timeout: 900_00
682682
headers: apiHeaders("service"),
683683
body: { action: "storage" },
684684
});
685-
if (storage.status === "skipped") {
686-
// The deployed @run402/functions runtime is older than 2.1.1 and
687-
// doesn't export `assets`. The in-function upload path is exercised
688-
// once the runtime ships the helper.
689-
assert.match(String(storage.asset?.reason ?? ""), /assets|2\.1\.1/);
690-
} else {
691-
const functionAsset = (storage.asset ?? {}) as Record<string, string>;
692-
assert.equal(storage.status, "ok");
693-
assert.ok(functionAsset.key);
694-
createdBlobKeys.add(functionAsset.key);
695-
const url = functionAsset.cdnUrl ?? functionAsset.immutableUrl ?? functionAsset.url;
696-
assert.ok(url, "in-function upload must return a retrievable URL");
697-
assert.match(await fetchTextOk(url), /run402 function upload/);
698-
}
685+
const functionAsset = (storage.asset ?? {}) as Record<string, string>;
686+
assert.equal(storage.status, "ok");
687+
assert.ok(functionAsset.key);
688+
createdBlobKeys.add(functionAsset.key);
689+
const url = functionAsset.cdnUrl ?? functionAsset.immutableUrl ?? functionAsset.url;
690+
assert.ok(url, "in-function upload must return a retrievable URL");
691+
assert.match(await fetchTextOk(url), /run402 function upload/);
699692
});
700693

701694
it("observes runtime secrets and exercises email plus AI helper paths", async () => {

integration-fixtures/fullstack-app/functions/fullstack-direct.mjs

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,4 @@
1-
import { adminDb, db, getUser, email, ai } from "@run402/functions";
2-
// `assets` is a 2.1.1+ addition; older deployments of @run402/functions
3-
// don't have it. Detect at module load so the bundler doesn't fail on
4-
// older gateway-bundled images. Once 2.1.1 is the deployed runtime, this
5-
// reduces to a static import.
6-
let assets = null;
7-
try {
8-
const mod = await import("@run402/functions");
9-
if (mod && typeof mod.assets === "object" && typeof mod.assets.put === "function") {
10-
assets = mod.assets;
11-
}
12-
} catch {
13-
assets = null;
14-
}
1+
import { adminDb, db, getUser, email, ai, assets } from "@run402/functions";
152

163
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
174

@@ -57,12 +44,6 @@ function transientMessage(err) {
5744
}
5845

5946
async function uploadFromFunction() {
60-
if (!assets || typeof assets.put !== "function") {
61-
return {
62-
status: "skipped",
63-
reason: "@run402/functions runtime does not yet export `assets` (pre-2.1.1)",
64-
};
65-
}
6647
const stamp = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
6748
const key = `fullstack/fn-${stamp}.txt`;
6849
const body = `run402 function upload ${stamp}`;

sdk/README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,30 @@ const logo = await (await r.project(projectId)).assets.put("logo.png", { bytes }
130130

131131
`immutable: true` is the default since v1.45. The SDK always computes and sends the object SHA-256; pass `false` only when you specifically need mutable URL/cache semantics.
132132

133+
### Mixed apply — site + assets in one atomic activation
134+
135+
Drop a per-key asset put into the same release as your site files. Both promote inside the same activation transaction that flips `live_release_id`, so the asset URLs are live the moment the new release is. Source shorthand: bare strings, `Uint8Array`, or any other `ContentSource` (Blob, FsFileSource from `fileSetFromDir`, `{ data, contentType? }` wrapper). The SDK normalizer hashes once and dedups across slices — same SHA in `site` and `assets` uploads as a single byte stream.
136+
137+
```ts
138+
import { run402, fileSetFromDir } from "@run402/sdk/node";
139+
const r = run402();
140+
const p = await r.project(projectId);
141+
142+
const imageBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
143+
const siteFiles = await fileSetFromDir("./dist");
144+
const result = await p.apply({
145+
site: { replace: siteFiles },
146+
assets: {
147+
put: [
148+
{ key: "static/logo.png", source: imageBytes, content_type: "image/png" },
149+
{ key: "static/styles.css", source: "/* inline css */" },
150+
],
151+
},
152+
});
153+
const logo = result.assets?.byKey["static/logo.png"];
154+
console.log(logo?.cdn_url); // hot the moment the release activates
155+
```
156+
133157
For bulk asset uploads, use the Node-only helpers `uploadDir` (additive), `syncDir` (destructive with explicit `prune: true` + confirmation token), and `prepareDir` (returns `{ manifest, applySlice }` so the agent can render HTML against resolved URLs before committing in one apply transaction):
134158

135159
```ts

0 commit comments

Comments
 (0)