From f5e7503503bfcfe46a6d9788be2c32b4477c61fb Mon Sep 17 00:00:00 2001 From: Akira Shimosako Date: Sat, 3 Oct 2026 23:21:31 +0900 Subject: [PATCH 01/16] feat(m6): verify authenticated fresh setup through runtime probes --- design/m6_fresh_initialization.md | 22 +- design/m6_implementation_log.md | 8 + design/m6_review_queue.md | 2 +- packages/cli/src/cloudflare-api.test.ts | 5 + packages/cli/src/cloudflare-api.ts | 18 +- packages/cli/src/m6-admin-json.ts | 27 +- packages/cli/src/m6-initialization-rest.ts | 7 +- packages/cli/src/m6-setup-client.test.ts | 76 ++++++ packages/cli/src/m6-setup-client.ts | 63 +++++ packages/cli/src/m6-worker-deployment.test.ts | 7 +- packages/cli/src/m6-worker-deployment.ts | 186 +------------- packages/shared/src/index.ts | 7 + packages/shared/src/m6-setup.ts | 26 ++ packages/shared/src/m6-worker-deployment.ts | 236 ++++++++++++++++++ src/admin-body.ts | 2 +- src/index.ts | 2 +- src/m6-management.ts | 1 + src/m6-routes.ts | 22 +- src/m6-service-initialization.ts | 12 +- src/m6-setup-admin.test.ts | 106 ++++++++ src/m6-setup-admin.ts | 75 ++++++ src/m6-setup-queue.ts | 28 +++ src/m6-setup-record.ts | 32 +++ src/m6-setup-runtime.ts | 67 +++++ src/m6-setup-worker.ts | 19 ++ src/test-support/m6-setup.ts | 42 ++++ 26 files changed, 879 insertions(+), 219 deletions(-) create mode 100644 packages/cli/src/m6-setup-client.test.ts create mode 100644 packages/cli/src/m6-setup-client.ts create mode 100644 packages/shared/src/m6-setup.ts create mode 100644 packages/shared/src/m6-worker-deployment.ts create mode 100644 src/m6-setup-admin.test.ts create mode 100644 src/m6-setup-admin.ts create mode 100644 src/m6-setup-queue.ts create mode 100644 src/m6-setup-record.ts create mode 100644 src/m6-setup-runtime.ts create mode 100644 src/m6-setup-worker.ts create mode 100644 src/test-support/m6-setup.ts diff --git a/design/m6_fresh_initialization.md b/design/m6_fresh_initialization.md index 91c9962..f593e79 100644 --- a/design/m6_fresh_initialization.md +++ b/design/m6_fresh_initialization.md @@ -14,17 +14,35 @@ - `src/m6-service-initialization.ts`: 空bucketの確認と初期化ownerのCAS、実行version/config hash、検証前後のREST deployment/settings証拠、最終CASを担当する。完了は`paused`であり、明示再開を別操作とする。 - `packages/cli/src/m6-service-initialization.ts`: 資源作成・配備・初期化の要求前journal保存と一度限りの実行を担当する。保存と非期限lockは既存の共通storageを利用する。 - `packages/cli/src/m6-initialization-rest.ts` / `cloudflare-api.ts`: 新規資源のREST作成、衝突を拒否するWorker名のclaim、凍結metadataの単一PUT、Queue/notification設定と配備検査を担当する。runtime検証をREST設定から推測しない。 +- `packages/shared/src/m6-worker-deployment.ts`: CLIとWorkerが同じ配備検査を使用する。Cloudflare応答から、secret/author/未管理設定を除いた最小snapshotを作る。 +- `src/m6-setup-*` / `packages/cli/src/m6-setup-client.ts`: 認証付きprepare/status/probe/completeと、実HTTP・loopback・Queue往復の検証を担当する。別の汎用状態機械や独立したchallenge IDを設けず、凍結operation IDを再利用する。 ## 手順と失敗 CLIは`prepared → resources_requested → resources_created → deploy_requested → deployed → initialization_requested → initialized`を記録する。source/metadataはhashだけを保存し、administrator secretやコード本文を保存しない。実際にPUTするmetadataも凍結hashと照合する。資源・PUT・初期化の応答喪失ではrequestedを保持し、自動再送・資源の採用・cleanupを行わない。部分作成を巻き戻したと説明しない。 -serverはservice TOMLとadmission以外のobjectがあるbucketを拒否する。初期化targetはoperation/deployment/Worker version/config hashで固定する。同一targetの準備・完了は冪等だが、別target・既存legacy admissionを上書きしない。 +serverはservice TOMLとadmission以外のobjectがあるbucketを拒否する。検証開始後の完了時に限り、同operationの小さいruntime-check記録を許す。初期化targetはoperation/deployment/Worker version/config hashで固定する。同一targetの準備・完了は冪等だが、別target・既存legacy admissionを上書きしない。 稼働検証adapterは信頼された内部の読み取り専用callbackとして必須である。HTTP入力のtrue/falseをそのまま証拠にするAPIは設けていない。検証中の設定変更、別deployment、CAS競合は稼働へ昇格せず、未完了ownerを残す。 +## 認証付きの起動時検査 + +試験専用entry `src/m6-setup-worker.ts`にだけ初期化経路を接続する。通常candidate・既存binaryのready=falseと書込禁止は変えない。 + +1. CLIがCloudflare RESTからdeployment/version/settings/previewの前後snapshotを採取する。検査の実装はWorkerと共有し、account API tokenはCLIに留める。 +2. prepareがexact初期化ownerを確認し、named cache entrypointでtag purgeを実行した後、小さい固定requestを条件付き保存し、一回Queueへ送信する。 +3. main Queue consumerがQueue名・実行version・request・初期化ownerを照合し、同記録へCAS receiptを保存する。重複配信は記録を書き換えない。 +4. CLIが公開originの認証付きprobeを同URLで二回GETし、request/version/cache ownerと異なるinvocation IDを確認する。Workerも実default loopback fetchを二回呼び出す。 +5. Workerが実管理handlerのstrict入力検証、初期化中の配信503、private pathの404/no-storeを確認する。検証前後のconfig・owner・record ETagと二つのREST snapshotを照合してから、共通CASでpausedへ進める。 + +REST snapshotは認証済み管理者から受け取るCloudflare観測値であり、Cloudflareの署名付き証明ではない。設定検査と実行記録を照合するが、競合するaccount deployerや侵害された管理者からの虚偽snapshotを防ぐ仕組みとは説明しない。初期化中は管理者による他の配備・設定変更を止める。 + +`publication_routes_verified`はここでは実管理routeの入力検証へ到達した意味であり、Show/Episodeの公開成功やpublication consumerの処理完了を証明しない。`cutover_verified`も新規空サービスの起動境界の意味であり、legacy IO/cache収束の代用ではない。named RPC/purgeの成功はcache HITの実測ではない。コンテンツ公開、cache HIT/purge後の本文、GET/HEAD/Range、300MBは後続の受け入れで確認する。 + +Queue statusの有限回待機はCLIの観測終了だけである。時間だけでowner/consumerを失効させない。prepare/complete/Queue送信の応答喪失は自動再送せず、既存記録とrequested journalを保持する。Queue receiptが未着なら完了しない。runtime-check記録にはtargetと固定receiptのみを永久保持し、secret・metadata本文・任意の例外messageを保存しない。 + ## 検証と残件 初期化・CAS競合・誤証拠・設定変更・応答喪失・journal/lock・REST資源衝突を自動テストした。既存の一つのcontent-flow結合テストも、新規初期化からShow登録・公開・改訂・停止/再開/削除へ通す経路に置き換えた。別の巨大なE2Eテストは追加していない。 -これらはsimulated REST/cache/Queueである。公開CLI/初期化HTTP入口、実際の配信・管理・consumer検証adapterと専用Cloudflare環境での受け入れは残る。通常candidateのready=falseとmanagement書込禁止は維持する。300MB受け入れ・unknown IO回復等のM6共通gateも省略しない。 +認証付き初期化HTTP入口と実HTTP/loopback/Queue検証adapterは試験entryへ接続済み。自動テストはsimulated REST/cache/Queueであり、公開CLI/単一binary接続と専用Cloudflare環境での起動・公開受け入れは残る。通常candidateのready=falseとmanagement書込禁止は維持する。300MB受け入れ・unknown IO回復等のM6共通gateも省略しない。 diff --git a/design/m6_implementation_log.md b/design/m6_implementation_log.md index 58d9dbc..059b652 100644 --- a/design/m6_implementation_log.md +++ b/design/m6_implementation_log.md @@ -1,5 +1,13 @@ # M6: 公開停止・削除 実装ログ +## 2026-10-03: 新規M6の認証付き初期化と起動時runtime検証を接続 + +- 配備検査をsharedへ移し、CLIのCloudflare REST応答をsecret/author/未管理設定なしのsnapshotへ絞る。Workerも同じ検査を使用し、account API tokenはCLIに留める。 +- 試験専用`m6-setup-worker.ts`へprepare/status/probe/completeを接続した。実default loopback・外部HTTP・cache owner/purge・main Queue往復のCAS receiptを照合してから既存初期化CASでpausedへ進める。HTTPのready申告は許可せず、正常な管理mutationやpublication consumerの成功を模擬した証拠は作らない。 +- 一つのoperation IDと小さい永久記録を利用し、Queue重複をreadonlyにする。未知応答・有限回の観測待ちでは再送/owner解放/通常ゲート解除を行わない。管理共通境界でversion/readinessをtoken取得前に検査する。 +- 全863テスト/13156 assertions、TypeScript/M6実証tsconfig、試験Worker browser bundle、Linux binary buildが合格。ここまでCloudflare資源の変更はない。正常コンテンツ公開/cache HIT/GET-HEAD-Range/300MBの実機受け入れとは区別する。 +- 次は試験CLI/単一binaryへ新規初期化と明示再開を接続し、専用Cloudflare環境でShow/Episode公開を確認する。通常更新、unknown outcome復旧、M6全体の受け入れは後続。詳細は`m6_fresh_initialization.md`。 + ## 2026-10-02: 三種類の移行journalもbounded/no-follow読取・owned lock返却へ - 既存flat layoutを維持したままbridge deploy/migration setup/candidate deployの親directory検査、private一段create/fsync、dangling lock保持とmissing record再生成拒否、他所有lock非削除を接続した。record読取はstat+readFileからbounded/no-follow/regular-file/前後stat検査へ変更した。 diff --git a/design/m6_review_queue.md b/design/m6_review_queue.md index 81ace26..5c8050d 100644 --- a/design/m6_review_queue.md +++ b/design/m6_review_queue.md @@ -48,7 +48,7 @@ 2026-10-03に管理者が「共通journalリファクタリングをmainへmerge → 別branchで新規M6初期化/稼働判定の分離 → 別branchで形式を変えない通常更新 → 旧形式変換は必要時だけ」を承認した。旧形式変換を新規M6サービスの必須経路にしない。既存環境の即時削除や公開gate解除の承認とは扱わない。 -新規初期化のserver CAS、runtime readinessと移行監査証拠の分離、CLI durable controller、新規資源/WorkerのREST配備adapterを内部実装した。完了はpausedであり、公開CLI・HTTP入口・実runtime検証adapter/Cloudflare受け入れは残る。[新規初期化](./m6_fresh_initialization.md)参照。 +新規初期化のserver CAS、runtime readinessと移行監査証拠の分離、CLI durable controller、新規資源/WorkerのREST配備adapterを内部実装した。認証付き初期化HTTP入口と実HTTP/loopback/Queue起動検証adapterも試験専用entryへ接続した。配備検査はCLI/Worker共通とし、account tokenをWorkerへ渡さない。完了はpausedであり、公開CLI/単一binary接続・実コンテンツ公開・Cloudflare受け入れは残る。[新規初期化](./m6_fresh_initialization.md)参照。 保存形式を変えないM6更新の内部経路も追加した。既存のpause/drainに続いてShow controlの未完了ownerだけを確認し、service CASで閉じてから配備・現在version検証・readiness更新を行う。音源/Episodeのinventoryや変換はしない。共通runtime検証は初期化と共有し、未知token/PUTを時間で終了扱いにしない。公開CLI/実機gateは残る。[通常更新](./m6_compatible_updates.md)参照。 diff --git a/packages/cli/src/cloudflare-api.test.ts b/packages/cli/src/cloudflare-api.test.ts index 8eaebfe..5f1da6c 100644 --- a/packages/cli/src/cloudflare-api.test.ts +++ b/packages/cli/src/cloudflare-api.test.ts @@ -259,6 +259,11 @@ test("fresh M6 REST provisioning creates isolated resources and verifies its fro const effects = createFreshM6RestEffects(api, "private-admin-key", async () => { throw new Error("Runtime verification is separate"); }); await effects.createResources(fresh); expect(await effects.deploy(fresh, "export default {};", metadata)).toEqual({ deployment_id: deploymentId, worker_version_id: versionId }); + const before = [...writes]; + const observations = await api.collectM6DeploymentSnapshot(fresh, versionId); + expect(observations.deployments[0].deployments[0]!.id).toBe(deploymentId); + expect(JSON.stringify(observations)).not.toContain("private-admin-key"); + expect(writes).toEqual(before); }); expect(writes.filter((value) => value.includes(`/workers/scripts/${worker}`) && value.startsWith("PUT"))).toHaveLength(1); expect(writes.some((value) => value.startsWith("DELETE"))).toBe(false); diff --git a/packages/cli/src/cloudflare-api.ts b/packages/cli/src/cloudflare-api.ts index f60d527..a63b81e 100644 --- a/packages/cli/src/cloudflare-api.ts +++ b/packages/cli/src/cloudflare-api.ts @@ -1,7 +1,7 @@ -import { m6ServiceConfigHash, m6ServiceUpdateRequestSchema, migrationBootstrapRequestSchema, migrationBridgeDeploymentRequestSchema, migrationBridgeUploadSchema, migrationCandidateUploadSchema, serviceConfigSchema, stringifyToml, workerDeploymentsSnapshotSchema, +import { collectM6DeploymentSnapshot, m6ServiceConfigHash, m6ServiceUpdateRequestSchema, migrationBootstrapRequestSchema, migrationBridgeDeploymentRequestSchema, migrationBridgeUploadSchema, migrationCandidateUploadSchema, serviceConfigSchema, stringifyToml, workerDeploymentsSnapshotSchema, workerScriptUploadReceiptSchema, workerSettingsSnapshotSchema, workerSubdomainSnapshotSchema, workerVersionSnapshotSchema } from "@castloop/shared"; import type { LegacyWorkerInspection, MigrationBootstrapRequest, MigrationBridgeDeploymentEvidence, MigrationBridgeDeploymentRequest, MigrationBridgePreparation, MigrationBridgeUpload, MigrationCandidateUpload, - M6ServiceUpdateRequest, M6WorkerDeploymentEvidence, ServiceConfig } from "@castloop/shared"; + M6DeploymentReads, M6DeploymentSnapshot, M6ServiceUpdateRequest, M6WorkerDeploymentEvidence, ServiceConfig } from "@castloop/shared"; import { buildM6WorkerUploadMetadata, buildMigrationCandidateUpload, inspectM6WorkerDeployment, M6_FRESH_WORKER_COMPATIBILITY_DATE, M6_WORKER_COMPATIBILITY_DATE, requireMigrationBridgeSettings, requireMigrationBridgeVersion } from "./m6-worker-deployment"; import { migrationPayloadHash } from "./migration-deployment"; @@ -328,14 +328,24 @@ export class CloudflareApi { async inspectM6WorkerDeployment(config: ServiceConfig, expectedVersionId: string, expectedCompatibilityDate = M6_WORKER_COMPATIBILITY_DATE): Promise { + return inspectM6WorkerDeployment(config, expectedVersionId, this.m6DeploymentReads(config), expectedCompatibilityDate); + } + + async collectM6DeploymentSnapshot(config: ServiceConfig, expectedVersionId: string, + expectedCompatibilityDate = M6_FRESH_WORKER_COMPATIBILITY_DATE): Promise { + return collectM6DeploymentSnapshot(config, expectedVersionId, this.m6DeploymentReads(config), expectedCompatibilityDate); + } + + private m6DeploymentReads(input: ServiceConfig): M6DeploymentReads { + const config = serviceConfigSchema.parse(input); if (config.account_id !== this.accountId) throw new Error("Worker inspection targets another Cloudflare account"); const path = `/workers/scripts/${encodeURIComponent(config.worker_name)}`; - return inspectM6WorkerDeployment(config, expectedVersionId, { + return { deployments: () => this.json("GET", `${path}/deployments`), settings: () => this.json("GET", `${path}/settings`), version: (versionId) => this.json("GET", `${path}/versions/${encodeURIComponent(versionId)}`), subdomain: () => this.json("GET", `${path}/subdomain`), - }, expectedCompatibilityDate); + }; } private migrationWorkerPath(input: ServiceConfig): string { diff --git a/packages/cli/src/m6-admin-json.ts b/packages/cli/src/m6-admin-json.ts index a2046be..a4b3474 100644 --- a/packages/cli/src/m6-admin-json.ts +++ b/packages/cli/src/m6-admin-json.ts @@ -1,8 +1,10 @@ -import { serviceConfigSchema } from "@castloop/shared"; +import { m6RuntimeTargetSchema, serviceConfigSchema } from "@castloop/shared"; import type { ServiceConfig } from "@castloop/shared"; export type M6AdminTransport = (input: URL, init: RequestInit) => Promise; const RESPONSE_BUDGET = 65536; +const ROUTE_LABELS = { staging: "Staging", publication: "Publication", lifecycle: "Lifecycle", shows: "Show registration", target: "Target inspection", + "setup/prepare": "Setup preparation", "setup/status": "Setup status", "setup/complete": "Setup completion" }; async function readResponse(response: Response, maximumBytes = RESPONSE_BUDGET): Promise { const length = response.headers.get("Content-Length"); @@ -49,15 +51,26 @@ export class M6AdminJsonClient { this.transport = transport; } - async post(route: "staging" | "publication" | "lifecycle" | "shows" | "target", input: object): Promise { - if (!["staging", "publication", "lifecycle", "shows", "target"].includes(route)) throw new Error("Unknown M6 administration route"); - const label = { staging: "Staging", publication: "Publication", lifecycle: "Lifecycle", shows: "Show registration", target: "Target inspection" }[route]; + async post(route: keyof typeof ROUTE_LABELS, input: object): Promise { + if (!Object.hasOwn(ROUTE_LABELS, route)) throw new Error("Unknown M6 administration route"); + const label = ROUTE_LABELS[route]; const body = JSON.stringify(input); if (Buffer.byteLength(body) > 16384) throw new Error(`${label} request exceeds its record budget`); + return this.send(new URL(`/admin/${route}`, this.config.public_base_url), label, body, route === "target" ? 2_000_000 : RESPONSE_BUDGET); + } + + async getSetupProbe(operationId: string): Promise { + m6RuntimeTargetSchema.shape.operation_id.parse(operationId); + const url = new URL("/admin/setup/probe", this.config.public_base_url); + url.searchParams.set("operation_id", operationId); + return this.send(url, "Setup probe"); + } + + private async send(url: URL, label: string, body?: string, maximumBytes = RESPONSE_BUDGET): Promise { let response: Response; try { - response = await this.transport(new URL(`/admin/${route}`, this.config.public_base_url), { - method: "POST", redirect: "error", signal: AbortSignal.timeout(120000), cache: "no-store", + response = await this.transport(url, { + method: body === undefined ? "GET" : "POST", redirect: "error", signal: AbortSignal.timeout(120000), cache: "no-store", headers: { "X-Castloop-Key": this.adminKey, "User-Agent": "castloop-cli/0.1", "Content-Type": "application/json" }, body, }); } catch { @@ -68,7 +81,7 @@ export class M6AdminJsonClient { if (response.body) await response.body.cancel(); throw new Error("Management operation was not confirmed"); } - return await readResponse(response, route === "target" ? 2_000_000 : RESPONSE_BUDGET); + return await readResponse(response, maximumBytes); } catch { throw new Error(`${label} response was not verified; inspect retained ownership/progress without automatic retry`); } diff --git a/packages/cli/src/m6-initialization-rest.ts b/packages/cli/src/m6-initialization-rest.ts index 9c3aaee..27a8520 100644 --- a/packages/cli/src/m6-initialization-rest.ts +++ b/packages/cli/src/m6-initialization-rest.ts @@ -1,15 +1,16 @@ -import type { M6RuntimeReadiness, M6RuntimeTarget, ServiceConfig } from "@castloop/shared"; import type { CloudflareApi } from "./cloudflare-api"; import type { FreshM6InitializationEffects } from "./m6-service-initialization"; +import { M6SetupClient } from "./m6-setup-client"; +import type { M6AdminTransport } from "./m6-admin-json"; export function createFreshM6RestEffects(api: CloudflareApi, adminKey: string, - initialize: (config: ServiceConfig, target: M6RuntimeTarget) => Promise): FreshM6InitializationEffects { + transport?: M6AdminTransport): FreshM6InitializationEffects { return { createResources: (config) => api.createFreshM6Resources(config), deploy: async (config, source, metadata) => { const evidence = await api.uploadFreshM6Worker(config, source, adminKey, metadata); return { deployment_id: evidence.deployment_id, worker_version_id: evidence.worker_version_id }; }, - initialize, + initialize: (config, target) => new M6SetupClient(config, adminKey, api, transport).initialize(target), }; } diff --git a/packages/cli/src/m6-setup-client.test.ts b/packages/cli/src/m6-setup-client.test.ts new file mode 100644 index 0000000..0936489 --- /dev/null +++ b/packages/cli/src/m6-setup-client.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "bun:test"; +import { M6SetupClient } from "./m6-setup-client"; +import { consumeM6SetupProbe } from "../../../src/m6-setup-queue"; +import { readServiceAdmission } from "../../../src/service-admission"; +import { m6SetupFixture } from "../../../src/test-support/m6-setup"; + +test("setup client brackets authenticated HTTP/Queue checks with sanitized REST observations and finishes paused", async () => { + const setup = await m6SetupFixture(); + const calls: string[] = []; + const client = new M6SetupClient(setup.config, "private-secret", { collectM6DeploymentSnapshot: async () => { + calls.push("rest"); return setup.snapshot; + } }, async (url, init) => { + calls.push(`${init.method} ${url.pathname}`); + expect(init.redirect).toBe("error"); + expect(init.cache).toBe("no-store"); + expect(new Headers(init.headers).has("Authorization")).toBe(false); + expect(String(init.body)).not.toContain("private-secret"); + return setup.run(new Request(url, init)); + }); + const readiness = await client.initialize(setup.request.target, { maximumReads: 2, delay: async () => { + await consumeM6SetupProbe(setup.batch(setup.sent[0]), setup.env); + } }); + expect(readiness.worker_version_id).toBe(setup.versionId); + expect(calls).toEqual(["rest", "POST /admin/setup/prepare", "POST /admin/setup/status", "GET /admin/setup/probe", "GET /admin/setup/probe", "rest", "POST /admin/setup/complete"]); + expect((await readServiceAdmission(setup.env, setup.config.service_id))!.value.state).toBe("paused"); + expect(setup.sent).toHaveLength(1); +}); + +test("pending Queue observations and unknown HTTP outcomes never resend prepare or release initialization ownership", async () => { + for (const failure of ["pending", "prepare", "complete"] as const) { + const setup = await m6SetupFixture(); + const calls: string[] = []; + const client = new M6SetupClient(setup.config, "private-secret", { collectM6DeploymentSnapshot: async () => setup.snapshot }, async (url, init) => { + calls.push(url.pathname); + const response = await setup.run(new Request(url, init)); + if (url.pathname.endsWith(`/${failure}`)) { await response.body?.cancel(); throw new Error("Acknowledgement lost"); } + return response; + }); + await expect(client.initialize(setup.request.target, { maximumReads: 2, delay: async () => { + if (failure !== "pending") await consumeM6SetupProbe(setup.batch(setup.sent[0]), setup.env); + } })).rejects.toThrow(failure === "pending" ? "still pending" : "outcome is unknown"); + expect(calls.filter((path) => path.endsWith("/prepare"))).toHaveLength(1); + expect(calls.filter((path) => path.endsWith("/complete"))).toHaveLength(failure === "complete" ? 1 : 0); + expect(setup.sent).toHaveLength(1); + const admission = (await readServiceAdmission(setup.env, setup.config.service_id))!.value; + expect(admission.state).toBe(failure === "complete" ? "paused" : "initializing"); + expect(admission.invocations).toHaveLength(0); + } +}); + +test("foreign targets, origins, changed deployments and externally cached probes cannot complete setup", async () => { + for (const failure of ["target", "deployment", "cache", "receipt"] as const) { + const setup = await m6SetupFixture(); + let reads = 0; + let cached: Response | undefined; + const client = new M6SetupClient(setup.config, "private-secret", { collectM6DeploymentSnapshot: async () => { + const snapshot = structuredClone(setup.snapshot); + if (failure === "deployment" && ++reads === 2) for (const observation of snapshot.deployments) observation.deployments[0]!.id = crypto.randomUUID(); + return snapshot; + } }, async (url, init) => { + const response = await setup.run(new Request(url, init)); + if (failure === "cache" && url.pathname.endsWith("/probe")) { cached ??= response; return cached.clone(); } + if (failure === "receipt" && url.pathname.endsWith("/complete")) { + const body = await response.json<{ readiness: { worker_version_id: string } }>(); + body.readiness.worker_version_id = crypto.randomUUID(); + return Response.json(body, { headers: { "Cache-Control": "no-store" } }); + } + return response; + }); + await expect(client.initialize({ ...setup.request.target, ...(failure === "target" ? { service_config_sha256: "f".repeat(64) } : {}) }, + { maximumReads: 1, delay: async () => { await consumeM6SetupProbe(setup.batch(setup.sent[0]), setup.env); } })).rejects.toThrow(); + expect((await readServiceAdmission(setup.env, setup.config.service_id))?.value.state).toBe(failure === "target" ? undefined : failure === "receipt" ? "paused" : "initializing"); + if (failure === "target") expect(setup.sent).toHaveLength(0); + expect(() => new M6SetupClient({ ...setup.config, public_base_url: "https://another.example.workers.dev" }, "private-secret", { collectM6DeploymentSnapshot: async () => setup.snapshot })).toThrow("matching workers.dev"); + } +}); diff --git a/packages/cli/src/m6-setup-client.ts b/packages/cli/src/m6-setup-client.ts new file mode 100644 index 0000000..d5ddf7a --- /dev/null +++ b/packages/cli/src/m6-setup-client.ts @@ -0,0 +1,63 @@ +import { inspectM6WorkerDeployment, M6_FRESH_WORKER_COMPATIBILITY_DATE, m6ServiceConfigHash, m6SetupCompletedSchema, + m6SetupProbeSchema, m6SetupRequestSchema, m6SetupStatusSchema, m6SnapshotReads } from "@castloop/shared"; +import type { M6RuntimeReadiness, M6RuntimeTarget, M6SetupRequest, ServiceConfig } from "@castloop/shared"; +import type { CloudflareApi } from "./cloudflare-api"; +import { M6AdminJsonClient } from "./m6-admin-json"; +import type { M6AdminTransport } from "./m6-admin-json"; + +export type M6SetupWait = { maximumReads: number; delay: () => Promise }; +const DEFAULT_WAIT: M6SetupWait = { maximumReads: 30, delay: () => new Promise((resolve) => setTimeout(resolve, 1000)) }; + +function requireSameRequest(actual: M6SetupRequest, expected: M6SetupRequest): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error("Setup response differs from its frozen initialization target"); +} + +export class M6SetupClient { + private readonly admin: M6AdminJsonClient; + + constructor(config: ServiceConfig, adminKey: string, private readonly api: Pick, + transport: M6AdminTransport = fetch) { + this.admin = new M6AdminJsonClient(config, adminKey, transport); + const url = new URL(this.admin.config.public_base_url); + if (url.port || url.hostname.split(".").length !== 4 || !url.hostname.startsWith(`${this.admin.config.worker_name}.`) || !url.hostname.endsWith(".workers.dev")) { + throw new Error("Fresh M6 setup requires its matching workers.dev origin; custom-domain setup is not released"); + } + } + + async initialize(input: M6RuntimeTarget, wait: M6SetupWait = DEFAULT_WAIT): Promise { + const request = m6SetupRequestSchema.parse({ target: input }); + const config = this.admin.config; + if (request.target.service_config_sha256 !== await m6ServiceConfigHash(config)) throw new Error("Setup target has another service configuration"); + if (!Number.isSafeInteger(wait.maximumReads) || wait.maximumReads < 1 || wait.maximumReads > 120) throw new Error("Invalid setup observation limit"); + const inspect = async () => { + const snapshot = await this.api.collectM6DeploymentSnapshot(config, request.target.worker_version_id); + const evidence = await inspectM6WorkerDeployment(config, request.target.worker_version_id, m6SnapshotReads(snapshot), M6_FRESH_WORKER_COMPATIBILITY_DATE); + if (evidence.deployment_id !== request.target.deployment_id) throw new Error("Setup deployment changed from its frozen target"); + return snapshot; + }; + const before = await inspect(); + let status = m6SetupStatusSchema.parse(await this.admin.post("setup/prepare", request)); + requireSameRequest(status.record.request, request); + for (let read = 0; !status.record.queue_receipt && read < wait.maximumReads; read += 1) { + await wait.delay(); + status = m6SetupStatusSchema.parse(await this.admin.post("setup/status", request)); + requireSameRequest(status.record.request, request); + } + if (!status.record.queue_receipt) throw new Error("Setup Queue receipt is still pending; preserve initialization ownership without resending or releasing it"); + const probes = []; + for (let check = 0; check < 2; check += 1) { + const probe = m6SetupProbeSchema.parse(await this.admin.getSetupProbe(request.target.operation_id)); + requireSameRequest(probe.request, request); + if (probe.cached_runtime.worker_version_id !== request.target.worker_version_id) throw new Error("External setup probe has another cache owner version"); + probes.push(probe); + } + if (probes[0]!.invocation_id === probes[1]!.invocation_id) throw new Error("External setup probe returned a repeated cached invocation"); + const after = await inspect(); + const completed = m6SetupCompletedSchema.parse(await this.admin.post("setup/complete", { ...request, snapshots: [before, after] })); + requireSameRequest(completed.request, request); + const { default_cache_disabled: _defaultCache, cached_entrypoint: _entrypoint, cutover_verified: _cutover, + publication_routes_verified: _routes, ...target } = completed.readiness; + requireSameRequest({ target }, request); + return completed.readiness; + } +} diff --git a/packages/cli/src/m6-worker-deployment.test.ts b/packages/cli/src/m6-worker-deployment.test.ts index 803a485..7706cbf 100644 --- a/packages/cli/src/m6-worker-deployment.test.ts +++ b/packages/cli/src/m6-worker-deployment.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { m6WorkerDeploymentEvidenceSchema, serviceConfigSchema } from "@castloop/shared"; +import { collectM6DeploymentSnapshot, m6SnapshotReads, m6WorkerDeploymentEvidenceSchema, serviceConfigSchema } from "@castloop/shared"; import { buildM6WorkerUploadMetadata, inspectM6WorkerDeployment, M6_WORKER_COMPATIBILITY_DATE } from "./m6-worker-deployment"; import type { M6DeploymentReads } from "./m6-worker-deployment"; @@ -81,6 +81,11 @@ describe("read-only M6 deployment and runtime inspection", () => { expect(JSON.stringify(value)).not.toContain(privateValue); } expect(m6WorkerDeploymentEvidenceSchema.safeParse({ ...value, old_cache_purged: true }).success).toBe(false); + const snapshot = await collectM6DeploymentSnapshot(M6_DEPLOYMENT_CONFIG, setup.workerVersionId, setup.reads, M6_WORKER_COMPATIBILITY_DATE); + expect(await inspectM6WorkerDeployment(M6_DEPLOYMENT_CONFIG, setup.workerVersionId, m6SnapshotReads(snapshot))).toEqual(value); + for (const privateValue of ["private-admin-secret", "private@example.com", "Private deployment note", "unrelated"]) { + expect(JSON.stringify(snapshot)).not.toContain(privateValue); + } }); test("partial rollout, wrong version and missing deployment are rejected before runtime reads", async () => { diff --git a/packages/cli/src/m6-worker-deployment.ts b/packages/cli/src/m6-worker-deployment.ts index 8b90146..5516049 100644 --- a/packages/cli/src/m6-worker-deployment.ts +++ b/packages/cli/src/m6-worker-deployment.ts @@ -1,183 +1,3 @@ -import { migrationBootstrapRequestSchema, migrationCandidateUploadSchema, m6WorkerDeploymentEvidenceSchema, serviceConfigSchema, workerDeploymentsSnapshotSchema, workerSettingsSnapshotSchema, - workerSubdomainSnapshotSchema, workerVersionSnapshotSchema } from "@castloop/shared"; -import type { MigrationCandidateUpload, M6WorkerDeploymentEvidence, ServiceConfig, WorkerSettingsSnapshot } from "@castloop/shared"; - -export const M6_WORKER_COMPATIBILITY_DATE = "2026-10-01"; -export const M6_FRESH_WORKER_COMPATIBILITY_DATE = "2026-10-03"; -const MANAGED_BINDINGS = new Set(["CASTLOOP_BUCKET", "CASTLOOP_QUEUE", "CASTLOOP_DLQ_NAME", "CASTLOOP_ADMIN_KEY", "CASTLOOP_VERSION_METADATA"]); - -export type M6WorkerUploadMetadata = { - main_module: string; compatibility_date: string; compatibility_flags: string[]; - cache_options: { enabled: boolean; cross_version_cache: boolean }; - exports: { default: { type: string; cache: { enabled: boolean } }; CachedPublicAssets: { type: string; cache: { enabled: boolean } } }; - observability: { enabled: boolean; logs: { enabled: boolean; [key: string]: unknown }; - traces: { enabled: boolean; [key: string]: unknown }; [key: string]: unknown }; - bindings: Array<{ name: string; type: string; bucket_name?: string; queue_name?: string; text?: string; version_id?: string }>; - tags?: string[]; tail_consumers?: Array>; placement?: Record; logpush?: boolean; -}; - -export function buildM6WorkerUploadMetadata(input: ServiceConfig, previousInput: unknown, adminKey: string, - compatibilityDate = M6_WORKER_COMPATIBILITY_DATE): M6WorkerUploadMetadata { - const config = serviceConfigSchema.parse(input); - const previous = previousInput === null ? null : workerSettingsSnapshotSchema.parse(previousInput); - if (previous?.compatibility_flags?.includes("disable_ctx_exports")) throw new Error("M6 requires ctx.exports; inspect conflicting compatibility flags before migration"); - const existingAdmin = previous?.bindings.find((binding) => binding.name === "CASTLOOP_ADMIN_KEY"); - if (existingAdmin && existingAdmin.type !== "secret_text") throw new Error("Existing administrator binding is not a Worker secret"); - if (!existingAdmin && !adminKey) throw new Error("M6 Worker requires an administrator secret"); - const observability = previous?.observability; - return { - main_module: "index.js", compatibility_date: compatibilityDate, - compatibility_flags: [...new Set([...(previous?.compatibility_flags ?? []), "enable_ctx_exports"])], - cache_options: { enabled: true, cross_version_cache: false }, - exports: { default: { type: "worker", cache: { enabled: false } }, CachedPublicAssets: { type: "worker", cache: { enabled: true } } }, - observability: { ...observability, enabled: true, logs: { ...observability?.logs, enabled: true }, traces: { ...observability?.traces, enabled: true } }, - ...(previous?.tags ? { tags: previous.tags } : {}), - ...(previous?.tail_consumers ? { tail_consumers: previous.tail_consumers } : {}), - ...(previous?.placement ? { placement: previous.placement } : {}), - ...(previous?.logpush !== undefined ? { logpush: previous.logpush } : {}), - bindings: [ - { name: "CASTLOOP_BUCKET", type: "r2_bucket", bucket_name: config.bucket_name }, - { name: "CASTLOOP_QUEUE", type: "queue", queue_name: config.queue_name }, - { name: "CASTLOOP_DLQ_NAME", type: "plain_text", text: config.dlq_name }, - { name: "CASTLOOP_VERSION_METADATA", type: "version_metadata" }, - existingAdmin ? { name: "CASTLOOP_ADMIN_KEY", type: "inherit" } : { name: "CASTLOOP_ADMIN_KEY", type: "secret_text", text: adminKey }, - ...(previous?.bindings.filter((binding) => !MANAGED_BINDINGS.has(binding.name)).map((binding) => ({ name: binding.name, type: "inherit" })) ?? []), - ], - }; -} - -export type M6DeploymentReads = { - deployments: () => Promise; - settings: () => Promise; - version: (versionId: string) => Promise; - subdomain: () => Promise; -}; - -function latestDeployment(input: unknown, expectedVersionId: string) { - const deployment = workerDeploymentsSnapshotSchema.parse(input).deployments[0]!; - if (deployment.versions.length !== 1 || deployment.versions[0]!.version_id !== expectedVersionId || deployment.versions[0]!.percentage !== 100) { - throw new Error("M6 requires one expected Worker version serving 100% of traffic"); - } - return deployment; -} - -function compatibilityDate(input: string, expected: string): string { - const date = /^\d{4}-\d{2}-\d{2}(?:T00:00:00Z)?$/.test(input) ? input.slice(0, 10) : ""; - if (date !== expected) throw new Error("M6 Worker compatibility date does not match this runtime build"); - return date; -} - -function verifyBindings(bindings: WorkerSettingsSnapshot["bindings"], config: ServiceConfig): void { - if (new Set(bindings.map((binding) => binding.name)).size !== bindings.length) throw new Error("Worker version has duplicate bindings"); - const expected = [ - { name: "CASTLOOP_BUCKET", type: "r2_bucket", field: "bucket_name", value: config.bucket_name }, - { name: "CASTLOOP_QUEUE", type: "queue", field: "queue_name", value: config.queue_name }, - { name: "CASTLOOP_DLQ_NAME", type: "plain_text", field: "text", value: config.dlq_name }, - { name: "CASTLOOP_ADMIN_KEY", type: "secret_text" }, - { name: "CASTLOOP_VERSION_METADATA", type: "version_metadata" }, - ] as const; - for (const item of expected) { - const binding = bindings.find((binding) => binding.name === item.name); - if (!binding || binding.type !== item.type || "field" in item && binding[item.field] !== item.value) { - throw new Error("M6 Worker bindings do not match this service"); - } - } -} - -function verifyExports(input: WorkerSettingsSnapshot["exports"]): void { - if (!input || Object.keys(input).length !== 2 || input.default?.cache.enabled !== false || input.CachedPublicAssets?.cache.enabled !== true) { - throw new Error("M6 requires an uncached default export and exactly one CachedPublicAssets cache owner"); - } -} - -function settingsEvidence(input: unknown, config: ServiceConfig, expectedCompatibilityDate: string): string { - const settings = workerSettingsSnapshotSchema.parse(input); - verifyBindings(settings.bindings, config); - verifyExports(settings.exports); - if (settings.cache_options?.enabled !== true || settings.cache_options.cross_version_cache !== false) { - throw new Error("M6 requires version-isolated Workers Caching"); - } - const date = compatibilityDate(settings.compatibility_date ?? "", expectedCompatibilityDate); - const flags = settings.compatibility_flags ?? []; - if (!flags.includes("enable_ctx_exports") || flags.includes("disable_ctx_exports")) throw new Error("M6 loopback compatibility flags do not match this runtime build"); - if (settings.observability?.enabled !== true || settings.observability.logs?.enabled !== true || settings.observability.traces?.enabled !== true) { - throw new Error("M6 requires Worker logs and traces enabled"); - } - return JSON.stringify({ date, flags: [...flags].sort(), exports: { default: false, CachedPublicAssets: true }, - cache_options: { enabled: true, cross_version_cache: false }, - bindings: settings.bindings.filter((binding) => MANAGED_BINDINGS.has(binding.name)).map((binding) => ({ name: binding.name, - type: binding.type, ...(binding.name === "CASTLOOP_BUCKET" ? { bucket_name: binding.bucket_name } : {}), - ...(binding.name === "CASTLOOP_QUEUE" ? { queue_name: binding.queue_name } : {}), - ...(binding.name === "CASTLOOP_DLQ_NAME" ? { text: binding.text } : {}) })).sort((left, right) => left.name.localeCompare(right.name)), - observability: { enabled: true, logs: true, traces: true } }); -} - -export async function inspectM6WorkerDeployment(input: ServiceConfig, expectedVersionId: string, reads: M6DeploymentReads, - expectedCompatibilityDate = M6_WORKER_COMPATIBILITY_DATE): Promise { - const config = serviceConfigSchema.parse(input); - m6WorkerDeploymentEvidenceSchema.shape.worker_version_id.parse(expectedVersionId); - const first = latestDeployment(await reads.deployments(), expectedVersionId); - const version = workerVersionSnapshotSchema.parse(await reads.version(expectedVersionId)); - if (version.id !== expectedVersionId) throw new Error("Cloudflare returned a different Worker version"); - verifyBindings(version.resources.bindings, config); - verifyExports(version.resources.script_runtime.exports); - const runtime = version.resources.script_runtime; - const date = compatibilityDate(runtime.compatibility_date, expectedCompatibilityDate); - if (!runtime.compatibility_flags.includes("enable_ctx_exports") || runtime.compatibility_flags.includes("disable_ctx_exports")) { - throw new Error("M6 version does not enable the gateway loopback protocol"); - } - if (!version.resources.script.handlers.includes("fetch") || !version.resources.script.handlers.includes("queue") || - version.resources.script.named_handlers.length !== 1 || - version.resources.script.named_handlers[0]?.name !== "CachedPublicAssets" || !version.resources.script.named_handlers[0].handlers.includes("fetch")) { - throw new Error("M6 Worker does not export its gateway, consumer and named cache entrypoint"); - } - const settings = settingsEvidence(await reads.settings(), config, expectedCompatibilityDate); - const subdomain = workerSubdomainSnapshotSchema.parse(await reads.subdomain()); - if (!subdomain.enabled || subdomain.previews_enabled) throw new Error("M6 requires workers.dev enabled with old-version previews disabled"); - const currentSettings = settingsEvidence(await reads.settings(), config, expectedCompatibilityDate); - const currentSubdomain = workerSubdomainSnapshotSchema.parse(await reads.subdomain()); - const current = latestDeployment(await reads.deployments(), expectedVersionId); - if (current.id !== first.id || JSON.stringify(current) !== JSON.stringify(first) || currentSettings !== settings || - JSON.stringify(currentSubdomain) !== JSON.stringify(subdomain)) throw new Error("Worker deployment or runtime settings changed during M6 inspection"); - return m6WorkerDeploymentEvidenceSchema.parse({ schema_version: 1, service_id: config.service_id, account_id: config.account_id, - worker_name: config.worker_name, deployment_id: first.id, worker_version_id: expectedVersionId, compatibility_date: date, - traffic_percentage: 100, default_cache_disabled: true, cached_entrypoint: "CachedPublicAssets", cached_entrypoint_enabled: true, - cross_version_cache_disabled: true, version_metadata_binding_verified: true, service_bindings_verified: true, - observability_enabled: true, workers_dev_previews_disabled: true }); -} - -export function requireMigrationBridgeSettings(input: unknown, config: ServiceConfig): WorkerSettingsSnapshot { - const settings = workerSettingsSnapshotSchema.parse(input); - verifyBindings(settings.bindings, config); - if (!settings.exports || Object.keys(settings.exports).length !== 1 || settings.exports.default?.cache.enabled !== false || - settings.cache_options?.enabled !== true || settings.cache_options.cross_version_cache === undefined) { - throw new Error("Migration deploy requires explicit uncached bridge settings, not a legacy or M6 candidate Worker"); - } - compatibilityDate(settings.compatibility_date ?? "", M6_WORKER_COMPATIBILITY_DATE); - if (!settings.compatibility_flags?.includes("enable_ctx_exports") || settings.compatibility_flags.includes("disable_ctx_exports") || - settings.observability?.enabled !== true || settings.observability.logs?.enabled !== true || settings.observability.traces?.enabled !== true) { - throw new Error("Migration bridge compatibility/telemetry does not match this build"); - } - return settings; -} - -export function requireMigrationBridgeVersion(input: unknown, config: ServiceConfig, expectedVersionId: string): void { - const version = workerVersionSnapshotSchema.parse(input); - verifyBindings(version.resources.bindings, config); - const runtime = version.resources.script_runtime; - compatibilityDate(runtime.compatibility_date, M6_WORKER_COMPATIBILITY_DATE); - if (version.id !== expectedVersionId || Object.keys(runtime.exports).length !== 1 || runtime.exports.default?.cache.enabled !== false || - !runtime.compatibility_flags.includes("enable_ctx_exports") || runtime.compatibility_flags.includes("disable_ctx_exports") || - !version.resources.script.handlers.includes("fetch") || !version.resources.script.handlers.includes("queue") || - version.resources.script.named_handlers.length) throw new Error("Executing bridge version does not match its expected runtime profile"); -} - -export function buildMigrationCandidateUpload(config: ServiceConfig, previousInput: unknown, bootstrapId: string, - bridgeVersionId: string): MigrationCandidateUpload { - migrationBootstrapRequestSchema.shape.bootstrap_id.parse(bootstrapId); - migrationBootstrapRequestSchema.shape.bridge_worker_version_id.parse(bridgeVersionId); - const previous = requireMigrationBridgeSettings(previousInput, config); - const metadata = buildM6WorkerUploadMetadata(config, previous, ""); - return migrationCandidateUploadSchema.parse({ ...metadata, annotations: { "workers/tag": bootstrapId }, - bindings: metadata.bindings.map((binding) => binding.type === "inherit" ? { ...binding, version_id: bridgeVersionId } : binding) }); -} +export { buildM6WorkerUploadMetadata, buildMigrationCandidateUpload, inspectM6WorkerDeployment, M6_FRESH_WORKER_COMPATIBILITY_DATE, + M6_WORKER_COMPATIBILITY_DATE, requireMigrationBridgeSettings, requireMigrationBridgeVersion } from "@castloop/shared"; +export type { M6DeploymentReads, M6WorkerUploadMetadata } from "@castloop/shared"; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f932ddc..5b14716 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -40,6 +40,13 @@ export type { CachedDeliveryRuntime } from "./delivery-runtime"; export { workerSettingsSnapshotSchema, workerDeploymentsSnapshotSchema, workerVersionSnapshotSchema, workerSubdomainSnapshotSchema, workerScriptUploadReceiptSchema, m6WorkerDeploymentEvidenceSchema } from "./worker-deployment"; export type { WorkerSettingsSnapshot, M6WorkerDeploymentEvidence } from "./worker-deployment"; +export { buildM6WorkerUploadMetadata, buildMigrationCandidateUpload, inspectM6WorkerDeployment, collectM6DeploymentSnapshot, + m6DeploymentSnapshotSchema, m6SnapshotReads, M6_WORKER_COMPATIBILITY_DATE, M6_FRESH_WORKER_COMPATIBILITY_DATE, + requireMigrationBridgeSettings, requireMigrationBridgeVersion } from "./m6-worker-deployment"; +export type { M6DeploymentReads, M6DeploymentSnapshot, M6WorkerUploadMetadata } from "./m6-worker-deployment"; +export { m6SetupRequestSchema, m6SetupRecordSchema, m6SetupQueueProbeSchema, m6SetupProbeSchema, m6SetupStatusSchema, + m6SetupCompleteSchema, m6SetupCompletedSchema } from "./m6-setup"; +export type { M6SetupRequest, M6SetupRecord, M6SetupProbe } from "./m6-setup"; export { migrationCandidateUploadSchema } from "./worker-deployment"; export type { MigrationCandidateUpload } from "./worker-deployment"; export { migrationBridgeUploadSchema, migrationBridgePreparationSchema } from "./worker-deployment"; diff --git a/packages/shared/src/m6-setup.ts b/packages/shared/src/m6-setup.ts new file mode 100644 index 0000000..63cc501 --- /dev/null +++ b/packages/shared/src/m6-setup.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; +import { cachedDeliveryRuntimeSchema } from "./delivery-runtime"; +import { m6DeploymentSnapshotSchema } from "./m6-worker-deployment"; +import { m6RuntimeReadinessSchema, m6RuntimeTargetSchema } from "./service-admission"; + +export const m6SetupRequestSchema = z.object({ target: m6RuntimeTargetSchema }).strict(); +export const m6SetupRecordSchema = z.object({ schema_version: z.literal(1), request: m6SetupRequestSchema, + cache_purge_verified: z.literal(true), + queue_receipt: z.object({ worker_version_id: z.uuid() }).strict().optional(), +}).strict().superRefine((value, context) => { + if (value.queue_receipt && value.queue_receipt.worker_version_id !== value.request.target.worker_version_id) { + context.addIssue({ code: "custom", message: "Runtime probe Queue receipt differs from its frozen request" }); + } +}); +export const m6SetupQueueProbeSchema = m6SetupRequestSchema.extend({ type: z.literal("castloop-runtime-probe-v1") }).strict(); +export const m6SetupProbeSchema = z.object({ result: z.literal("runtime-probe"), request: m6SetupRequestSchema, + invocation_id: z.uuid(), cached_runtime: cachedDeliveryRuntimeSchema }).strict(); +export const m6SetupStatusSchema = z.object({ result: z.literal("setup-status"), record: m6SetupRecordSchema }).strict(); +export const m6SetupCompleteSchema = m6SetupRequestSchema.extend({ + snapshots: z.tuple([m6DeploymentSnapshotSchema, m6DeploymentSnapshotSchema]), +}).strict(); +export const m6SetupCompletedSchema = z.object({ result: z.literal("initialized"), request: m6SetupRequestSchema, + readiness: m6RuntimeReadinessSchema }).strict(); +export type M6SetupRequest = z.infer; +export type M6SetupRecord = z.infer; +export type M6SetupProbe = z.infer; diff --git a/packages/shared/src/m6-worker-deployment.ts b/packages/shared/src/m6-worker-deployment.ts new file mode 100644 index 0000000..f08bfaa --- /dev/null +++ b/packages/shared/src/m6-worker-deployment.ts @@ -0,0 +1,236 @@ +import { migrationBootstrapRequestSchema, migrationCandidateUploadSchema, m6WorkerDeploymentEvidenceSchema, serviceConfigSchema, workerDeploymentsSnapshotSchema, workerSettingsSnapshotSchema, + workerSubdomainSnapshotSchema, workerVersionSnapshotSchema } from "./index"; +import type { MigrationCandidateUpload, M6WorkerDeploymentEvidence, ServiceConfig, WorkerSettingsSnapshot } from "./index"; +import { z } from "zod"; + +export const M6_WORKER_COMPATIBILITY_DATE = "2026-10-01"; +export const M6_FRESH_WORKER_COMPATIBILITY_DATE = "2026-10-03"; +const MANAGED_BINDINGS = new Set(["CASTLOOP_BUCKET", "CASTLOOP_QUEUE", "CASTLOOP_DLQ_NAME", "CASTLOOP_ADMIN_KEY", "CASTLOOP_VERSION_METADATA"]); + +export type M6WorkerUploadMetadata = { + main_module: string; compatibility_date: string; compatibility_flags: string[]; + cache_options: { enabled: boolean; cross_version_cache: boolean }; + exports: { default: { type: string; cache: { enabled: boolean } }; CachedPublicAssets: { type: string; cache: { enabled: boolean } } }; + observability: { enabled: boolean; logs: { enabled: boolean; [key: string]: unknown }; + traces: { enabled: boolean; [key: string]: unknown }; [key: string]: unknown }; + bindings: Array<{ name: string; type: string; bucket_name?: string; queue_name?: string; text?: string; version_id?: string }>; + tags?: string[]; tail_consumers?: Array>; placement?: Record; logpush?: boolean; +}; + +export function buildM6WorkerUploadMetadata(input: ServiceConfig, previousInput: unknown, adminKey: string, + compatibilityDate = M6_WORKER_COMPATIBILITY_DATE): M6WorkerUploadMetadata { + const config = serviceConfigSchema.parse(input); + const previous = previousInput === null ? null : workerSettingsSnapshotSchema.parse(previousInput); + if (previous?.compatibility_flags?.includes("disable_ctx_exports")) throw new Error("M6 requires ctx.exports; inspect conflicting compatibility flags before migration"); + const existingAdmin = previous?.bindings.find((binding) => binding.name === "CASTLOOP_ADMIN_KEY"); + if (existingAdmin && existingAdmin.type !== "secret_text") throw new Error("Existing administrator binding is not a Worker secret"); + if (!existingAdmin && !adminKey) throw new Error("M6 Worker requires an administrator secret"); + const observability = previous?.observability; + return { + main_module: "index.js", compatibility_date: compatibilityDate, + compatibility_flags: [...new Set([...(previous?.compatibility_flags ?? []), "enable_ctx_exports"])], + cache_options: { enabled: true, cross_version_cache: false }, + exports: { default: { type: "worker", cache: { enabled: false } }, CachedPublicAssets: { type: "worker", cache: { enabled: true } } }, + observability: { ...observability, enabled: true, logs: { ...observability?.logs, enabled: true }, traces: { ...observability?.traces, enabled: true } }, + ...(previous?.tags ? { tags: previous.tags } : {}), + ...(previous?.tail_consumers ? { tail_consumers: previous.tail_consumers } : {}), + ...(previous?.placement ? { placement: previous.placement } : {}), + ...(previous?.logpush !== undefined ? { logpush: previous.logpush } : {}), + bindings: [ + { name: "CASTLOOP_BUCKET", type: "r2_bucket", bucket_name: config.bucket_name }, + { name: "CASTLOOP_QUEUE", type: "queue", queue_name: config.queue_name }, + { name: "CASTLOOP_DLQ_NAME", type: "plain_text", text: config.dlq_name }, + { name: "CASTLOOP_VERSION_METADATA", type: "version_metadata" }, + existingAdmin ? { name: "CASTLOOP_ADMIN_KEY", type: "inherit" } : { name: "CASTLOOP_ADMIN_KEY", type: "secret_text", text: adminKey }, + ...(previous?.bindings.filter((binding) => !MANAGED_BINDINGS.has(binding.name)).map((binding) => ({ name: binding.name, type: "inherit" })) ?? []), + ], + }; +} + +export type M6DeploymentReads = { + deployments: () => Promise; + settings: () => Promise; + version: (versionId: string) => Promise; + subdomain: () => Promise; +}; + +export const m6DeploymentSnapshotSchema = z.object({ + deployments: z.tuple([workerDeploymentsSnapshotSchema, workerDeploymentsSnapshotSchema]), + settings: z.tuple([workerSettingsSnapshotSchema, workerSettingsSnapshotSchema]), + subdomain: z.tuple([workerSubdomainSnapshotSchema, workerSubdomainSnapshotSchema]), + version: workerVersionSnapshotSchema, +}).strict(); +export type M6DeploymentSnapshot = z.infer; + +export function m6SnapshotReads(input: M6DeploymentSnapshot): M6DeploymentReads { + const snapshot = m6DeploymentSnapshotSchema.parse(input); + let deployment = 0; + let settings = 0; + let subdomain = 0; + return { deployments: async () => snapshot.deployments[deployment++], settings: async () => snapshot.settings[settings++], + subdomain: async () => snapshot.subdomain[subdomain++], version: async () => snapshot.version }; +} + +export async function collectM6DeploymentSnapshot(config: ServiceConfig, versionId: string, reads: M6DeploymentReads, + expectedCompatibilityDate: string): Promise { + const deployments: unknown[] = []; + const settings: WorkerSettingsSnapshot[] = []; + const subdomain: unknown[] = []; + let version: unknown; + await inspectM6WorkerDeployment(config, versionId, { + deployments: async () => { + const value = workerDeploymentsSnapshotSchema.parse(await reads.deployments()); + deployments.push({ deployments: [value.deployments[0]!] }); + return value; + }, + settings: async () => { + const value = workerSettingsSnapshotSchema.parse(await reads.settings()); + settings.push(value); + return value; + }, + subdomain: async () => { const value = workerSubdomainSnapshotSchema.parse(await reads.subdomain()); subdomain.push(value); return value; }, + version: async (id) => { version = workerVersionSnapshotSchema.parse(await reads.version(id)); return version; }, + }, expectedCompatibilityDate); + const safeBindings = (bindings: WorkerSettingsSnapshot["bindings"]) => bindings.filter((binding) => MANAGED_BINDINGS.has(binding.name)) + .map((binding) => ({ name: binding.name, type: binding.type, + ...(binding.name === "CASTLOOP_BUCKET" ? { bucket_name: binding.bucket_name } : {}), + ...(binding.name === "CASTLOOP_QUEUE" ? { queue_name: binding.queue_name } : {}), + ...(binding.name === "CASTLOOP_DLQ_NAME" ? { text: binding.text } : {}) })); + const parsedVersion = workerVersionSnapshotSchema.parse(version); + return m6DeploymentSnapshotSchema.parse({ deployments, subdomain, + version: { ...parsedVersion, resources: { ...parsedVersion.resources, bindings: safeBindings(parsedVersion.resources.bindings) } }, + settings: settings.map((value) => ({ bindings: safeBindings(value.bindings), cache_options: value.cache_options, exports: value.exports, + compatibility_date: value.compatibility_date, compatibility_flags: value.compatibility_flags, + observability: { enabled: value.observability?.enabled, logs: { enabled: value.observability?.logs?.enabled }, + traces: { enabled: value.observability?.traces?.enabled } } })), + }); +} + +function latestDeployment(input: unknown, expectedVersionId: string) { + const deployment = workerDeploymentsSnapshotSchema.parse(input).deployments[0]!; + if (deployment.versions.length !== 1 || deployment.versions[0]!.version_id !== expectedVersionId || deployment.versions[0]!.percentage !== 100) { + throw new Error("M6 requires one expected Worker version serving 100% of traffic"); + } + return deployment; +} + +function compatibilityDate(input: string, expected: string): string { + const date = /^\d{4}-\d{2}-\d{2}(?:T00:00:00Z)?$/.test(input) ? input.slice(0, 10) : ""; + if (date !== expected) throw new Error("M6 Worker compatibility date does not match this runtime build"); + return date; +} + +function verifyBindings(bindings: WorkerSettingsSnapshot["bindings"], config: ServiceConfig): void { + if (new Set(bindings.map((binding) => binding.name)).size !== bindings.length) throw new Error("Worker version has duplicate bindings"); + const expected = [ + { name: "CASTLOOP_BUCKET", type: "r2_bucket", field: "bucket_name", value: config.bucket_name }, + { name: "CASTLOOP_QUEUE", type: "queue", field: "queue_name", value: config.queue_name }, + { name: "CASTLOOP_DLQ_NAME", type: "plain_text", field: "text", value: config.dlq_name }, + { name: "CASTLOOP_ADMIN_KEY", type: "secret_text" }, + { name: "CASTLOOP_VERSION_METADATA", type: "version_metadata" }, + ] as const; + for (const item of expected) { + const binding = bindings.find((binding) => binding.name === item.name); + if (!binding || binding.type !== item.type || "field" in item && binding[item.field] !== item.value) { + throw new Error("M6 Worker bindings do not match this service"); + } + } +} + +function verifyExports(input: WorkerSettingsSnapshot["exports"]): void { + if (!input || Object.keys(input).length !== 2 || input.default?.cache.enabled !== false || input.CachedPublicAssets?.cache.enabled !== true) { + throw new Error("M6 requires an uncached default export and exactly one CachedPublicAssets cache owner"); + } +} + +function settingsEvidence(input: unknown, config: ServiceConfig, expectedCompatibilityDate: string): string { + const settings = workerSettingsSnapshotSchema.parse(input); + verifyBindings(settings.bindings, config); + verifyExports(settings.exports); + if (settings.cache_options?.enabled !== true || settings.cache_options.cross_version_cache !== false) { + throw new Error("M6 requires version-isolated Workers Caching"); + } + const date = compatibilityDate(settings.compatibility_date ?? "", expectedCompatibilityDate); + const flags = settings.compatibility_flags ?? []; + if (!flags.includes("enable_ctx_exports") || flags.includes("disable_ctx_exports")) throw new Error("M6 loopback compatibility flags do not match this runtime build"); + if (settings.observability?.enabled !== true || settings.observability.logs?.enabled !== true || settings.observability.traces?.enabled !== true) { + throw new Error("M6 requires Worker logs and traces enabled"); + } + return JSON.stringify({ date, flags: [...flags].sort(), exports: { default: false, CachedPublicAssets: true }, + cache_options: { enabled: true, cross_version_cache: false }, + bindings: settings.bindings.filter((binding) => MANAGED_BINDINGS.has(binding.name)).map((binding) => ({ name: binding.name, + type: binding.type, ...(binding.name === "CASTLOOP_BUCKET" ? { bucket_name: binding.bucket_name } : {}), + ...(binding.name === "CASTLOOP_QUEUE" ? { queue_name: binding.queue_name } : {}), + ...(binding.name === "CASTLOOP_DLQ_NAME" ? { text: binding.text } : {}) })).sort((left, right) => left.name.localeCompare(right.name)), + observability: { enabled: true, logs: true, traces: true } }); +} + +export async function inspectM6WorkerDeployment(input: ServiceConfig, expectedVersionId: string, reads: M6DeploymentReads, + expectedCompatibilityDate = M6_WORKER_COMPATIBILITY_DATE): Promise { + const config = serviceConfigSchema.parse(input); + m6WorkerDeploymentEvidenceSchema.shape.worker_version_id.parse(expectedVersionId); + const first = latestDeployment(await reads.deployments(), expectedVersionId); + const version = workerVersionSnapshotSchema.parse(await reads.version(expectedVersionId)); + if (version.id !== expectedVersionId) throw new Error("Cloudflare returned a different Worker version"); + verifyBindings(version.resources.bindings, config); + verifyExports(version.resources.script_runtime.exports); + const runtime = version.resources.script_runtime; + const date = compatibilityDate(runtime.compatibility_date, expectedCompatibilityDate); + if (!runtime.compatibility_flags.includes("enable_ctx_exports") || runtime.compatibility_flags.includes("disable_ctx_exports")) { + throw new Error("M6 version does not enable the gateway loopback protocol"); + } + if (!version.resources.script.handlers.includes("fetch") || !version.resources.script.handlers.includes("queue") || + version.resources.script.named_handlers.length !== 1 || + version.resources.script.named_handlers[0]?.name !== "CachedPublicAssets" || !version.resources.script.named_handlers[0].handlers.includes("fetch")) { + throw new Error("M6 Worker does not export its gateway, consumer and named cache entrypoint"); + } + const settings = settingsEvidence(await reads.settings(), config, expectedCompatibilityDate); + const subdomain = workerSubdomainSnapshotSchema.parse(await reads.subdomain()); + if (!subdomain.enabled || subdomain.previews_enabled) throw new Error("M6 requires workers.dev enabled with old-version previews disabled"); + const currentSettings = settingsEvidence(await reads.settings(), config, expectedCompatibilityDate); + const currentSubdomain = workerSubdomainSnapshotSchema.parse(await reads.subdomain()); + const current = latestDeployment(await reads.deployments(), expectedVersionId); + if (current.id !== first.id || JSON.stringify(current) !== JSON.stringify(first) || currentSettings !== settings || + JSON.stringify(currentSubdomain) !== JSON.stringify(subdomain)) throw new Error("Worker deployment or runtime settings changed during M6 inspection"); + return m6WorkerDeploymentEvidenceSchema.parse({ schema_version: 1, service_id: config.service_id, account_id: config.account_id, + worker_name: config.worker_name, deployment_id: first.id, worker_version_id: expectedVersionId, compatibility_date: date, + traffic_percentage: 100, default_cache_disabled: true, cached_entrypoint: "CachedPublicAssets", cached_entrypoint_enabled: true, + cross_version_cache_disabled: true, version_metadata_binding_verified: true, service_bindings_verified: true, + observability_enabled: true, workers_dev_previews_disabled: true }); +} + +export function requireMigrationBridgeSettings(input: unknown, config: ServiceConfig): WorkerSettingsSnapshot { + const settings = workerSettingsSnapshotSchema.parse(input); + verifyBindings(settings.bindings, config); + if (!settings.exports || Object.keys(settings.exports).length !== 1 || settings.exports.default?.cache.enabled !== false || + settings.cache_options?.enabled !== true || settings.cache_options.cross_version_cache === undefined) { + throw new Error("Migration deploy requires explicit uncached bridge settings, not a legacy or M6 candidate Worker"); + } + compatibilityDate(settings.compatibility_date ?? "", M6_WORKER_COMPATIBILITY_DATE); + if (!settings.compatibility_flags?.includes("enable_ctx_exports") || settings.compatibility_flags.includes("disable_ctx_exports") || + settings.observability?.enabled !== true || settings.observability.logs?.enabled !== true || settings.observability.traces?.enabled !== true) { + throw new Error("Migration bridge compatibility/telemetry does not match this build"); + } + return settings; +} + +export function requireMigrationBridgeVersion(input: unknown, config: ServiceConfig, expectedVersionId: string): void { + const version = workerVersionSnapshotSchema.parse(input); + verifyBindings(version.resources.bindings, config); + const runtime = version.resources.script_runtime; + compatibilityDate(runtime.compatibility_date, M6_WORKER_COMPATIBILITY_DATE); + if (version.id !== expectedVersionId || Object.keys(runtime.exports).length !== 1 || runtime.exports.default?.cache.enabled !== false || + !runtime.compatibility_flags.includes("enable_ctx_exports") || runtime.compatibility_flags.includes("disable_ctx_exports") || + !version.resources.script.handlers.includes("fetch") || !version.resources.script.handlers.includes("queue") || + version.resources.script.named_handlers.length) throw new Error("Executing bridge version does not match its expected runtime profile"); +} + +export function buildMigrationCandidateUpload(config: ServiceConfig, previousInput: unknown, bootstrapId: string, + bridgeVersionId: string): MigrationCandidateUpload { + migrationBootstrapRequestSchema.shape.bootstrap_id.parse(bootstrapId); + migrationBootstrapRequestSchema.shape.bridge_worker_version_id.parse(bridgeVersionId); + const previous = requireMigrationBridgeSettings(previousInput, config); + const metadata = buildM6WorkerUploadMetadata(config, previous, ""); + return migrationCandidateUploadSchema.parse({ ...metadata, annotations: { "workers/tag": bootstrapId }, + bindings: metadata.bindings.map((binding) => binding.type === "inherit" ? { ...binding, version_id: bridgeVersionId } : binding) }); +} diff --git a/src/admin-body.ts b/src/admin-body.ts index 01a6f5c..b609d2e 100644 --- a/src/admin-body.ts +++ b/src/admin-body.ts @@ -1,4 +1,4 @@ -export async function readBoundedAdminJson(request: Request, maximumBytes = 16384): Promise { +export async function readBoundedAdminJson(request: Pick, maximumBytes = 16384): Promise { if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1) throw new Error("Invalid administrator body budget"); const declared = request.headers.get("Content-Length"); if (declared !== null && (!/^\d+$/.test(declared) || !Number.isSafeInteger(Number(declared)) || Number(declared) > maximumBytes)) { diff --git a/src/index.ts b/src/index.ts index 0e81d7a..7ee2ea0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -97,7 +97,7 @@ async function claimPublication(env: Env, showId: string, jobId: string): Promis } export default { - async fetch(request, env): Promise { + async fetch(request: Request, env): Promise { const pathname = new URL(request.url).pathname; const asset = await publicAsset(request, env, pathname); if (asset) return asset; diff --git a/src/m6-management.ts b/src/m6-management.ts index 6b17243..de4683e 100644 --- a/src/m6-management.ts +++ b/src/m6-management.ts @@ -16,6 +16,7 @@ export async function withM6ManagementInvocation(env: LifecycleControlEnv, se if (!object || object.size < 1 || object.size > 16384) throw new Error("Invalid service configuration"); const config = parseServiceConfig(await object.text()); if (config.service_id !== serviceId) throw new M6ManagementServiceMismatch(); + await requireM6ServiceRuntime(env, config.service_id, bindings.versionMetadata.id); return withServiceInvocation(env, config.service_id, kind, async (invocation) => { const gate = createM6DeliveryGate(env, invocation, bindings); await gate(target); diff --git a/src/m6-routes.ts b/src/m6-routes.ts index 4267a18..868e129 100644 --- a/src/m6-routes.ts +++ b/src/m6-routes.ts @@ -22,6 +22,8 @@ import { handleMigrationAdmin } from "./migration-admin"; import type { BootstrapRuntime } from "./migration-bootstrap"; import { handleM6ShowRegistrationAdmin } from "./show-registration-admin"; import { handleM6TargetInspection } from "./target-inspection-admin"; +import { handleM6SetupAdmin } from "./m6-setup-admin"; +import type { M6SetupRuntime } from "./m6-setup-runtime"; export type M6CandidateEnv = { CASTLOOP_BUCKET: R2Bucket; @@ -68,12 +70,6 @@ async function m6ManagementRoute(request: Request, env: M6CandidateEnv, cachedAs if (pathname !== "/admin/staging" && pathname !== "/admin/publication" && pathname !== "/admin/lifecycle" && pathname !== "/admin/shows" && pathname !== "/admin/target") return null; if (request.method !== "POST") return reply(request, { error: "method not allowed" }, 405); - try { - await requireCandidateReadiness(env, await serviceConfig(env)); - } catch { - console.error(JSON.stringify({ event: "m6_management_not_ready", reason_code: "runtime_not_ready" })); - return reply(request, { error: "M6 management requires verified runtime readiness for this Worker version" }, 409); - } const bindings = { versionMetadata: env.CASTLOOP_VERSION_METADATA, gatewayProtocol: "m6-uncached-gateway-v1" as const, cachedAssets }; if (pathname === "/admin/target") return handleM6TargetInspection(request, env, bindings); if (pathname === "/admin/shows") return handleM6ShowRegistrationAdmin(request, env, bindings); @@ -82,19 +78,19 @@ async function m6ManagementRoute(request: Request, env: M6CandidateEnv, cachedAs return handleM6LifecycleAdmin(request, env, bindings); } -export async function fetchM6Candidate(request: Request, env: M6CandidateEnv, +export async function fetchM6Candidate(request: Request, env: M6CandidateEnv, cachedAssets: M6CachedLoopback, bootstrapRuntime?: BootstrapRuntime): Promise { return fetchM6Routes(request, env, cachedAssets, false, bootstrapRuntime); } -export async function fetchM6ManagementIntegration(request: Request, env: M6CandidateEnv, - cachedAssets: M6CachedLoopback, options: { digest?: StageStreamDigest } = {}): Promise { +export async function fetchM6ManagementIntegration(request: Request, env: M6CandidateEnv, + cachedAssets: M6CachedLoopback, options: { digest?: StageStreamDigest; setupRuntime?: M6SetupRuntime } = {}): Promise { return fetchM6Routes(request, env, cachedAssets, true, undefined, options); } -async function fetchM6Routes(request: Request, env: M6CandidateEnv, +async function fetchM6Routes(request: Request, env: M6CandidateEnv, cachedAssets: M6CachedLoopback, managementIntegration: boolean, bootstrapRuntime?: BootstrapRuntime, - options: { digest?: StageStreamDigest } = {}): Promise { + options: { digest?: StageStreamDigest; setupRuntime?: M6SetupRuntime } = {}): Promise { const pathname = new URL(request.url).pathname; const asset = parsePublicAssetPath(pathname); if (asset) { @@ -115,6 +111,10 @@ async function fetchM6Routes(request: Request; @@ -11,9 +12,10 @@ export type M6InitializationEnv = M6RuntimeEnv & { export type M6InitializationChecks = M6RuntimeChecks; export { matchesM6RuntimeTarget } from "./m6-runtime-readiness"; -async function requireEmptyService(env: M6InitializationEnv): Promise { - const page = await env.CASTLOOP_BUCKET.list({ prefix: "", limit: 3 }); - if (page.truncated || page.objects.some((object) => object.key !== "system/service.toml" && object.key !== SERVICE_ADMISSION_KEY)) { +async function requireEmptyService(env: M6InitializationEnv, target?: M6RuntimeTarget): Promise { + const page = await env.CASTLOOP_BUCKET.list({ prefix: "", limit: 4 }); + if (page.truncated || page.objects.some((object) => object.key !== "system/service.toml" && object.key !== SERVICE_ADMISSION_KEY && + (!target || object.key !== m6SetupRecordKey(target.operation_id)))) { throw new Error("Fresh M6 initialization requires an empty service; retained content or operational records must not be converted or deleted"); } } @@ -46,9 +48,9 @@ export async function completeM6ServiceInitialization(env: M6InitializationEnv, if (!snapshot || snapshot.value.state !== "initializing" || !matchesM6RuntimeTarget(snapshot.value.initialization, target)) { throw new Error("Only the exact retained fresh M6 initialization can complete"); } - await requireEmptyService(env); + await requireEmptyService(env, target); const readiness = await verifyM6RuntimeReadiness(env, target, configuration, checks); - await requireEmptyService(env); + await requireEmptyService(env, target); const { initialization: _initialization, ...value } = snapshot.value; const next = serviceAdmissionSchema.parse({ ...value, generation: value.generation + 1, state: "paused", pause_id: target.operation_id, runtime_readiness: readiness }); diff --git a/src/m6-setup-admin.test.ts b/src/m6-setup-admin.test.ts new file mode 100644 index 0000000..d5a08cb --- /dev/null +++ b/src/m6-setup-admin.test.ts @@ -0,0 +1,106 @@ +import { expect, test } from "bun:test"; +import { m6SetupCompletedSchema, m6SetupRecordSchema, m6SetupStatusSchema } from "../packages/shared/src/index"; +import { fetchM6Candidate } from "./m6-routes"; +import { consumeM6SetupProbe } from "./m6-setup-queue"; +import { m6SetupRecordKey } from "./m6-setup-record"; +import { readServiceAdmission, resumeServiceAdmission } from "./service-admission"; +import { m6SetupFixture as fixture } from "./test-support/m6-setup"; + +test("authenticated setup verifies live loopbacks and its Queue receipt, finishes paused and leaves the normal candidate closed", async () => { + const setup = await fixture(); + expect((await setup.run(setup.post("prepare", setup.request, "wrong-key"))).status).toBe(401); + expect(setup.sent).toHaveLength(0); + expect(setup.purges).toHaveLength(0); + expect((await setup.run(setup.post("prepare", setup.request))).status).toBe(200); + expect(setup.purges).toHaveLength(1); + expect(setup.sent).toHaveLength(1); + const input = { ...setup.request, snapshots: [setup.snapshot, setup.snapshot] }; + expect((await setup.run(setup.post("complete", input))).status).toBe(409); + expect((await readServiceAdmission(setup.env, setup.config.service_id))!.value.state).toBe("initializing"); + await consumeM6SetupProbe(setup.batch(setup.sent[0]), setup.env); + const completed = await setup.run(setup.post("complete", input)); + expect(completed.status).toBe(200); + const receipt = m6SetupCompletedSchema.parse(await completed.json()); + expect(receipt.request).toEqual(setup.request); + expect(receipt.readiness.worker_version_id).toBe(setup.versionId); + expect(setup.calls.filter((path) => path === "/admin/setup/probe")).toHaveLength(2); + expect(setup.calls).toContain("/admin/publication"); + expect((await readServiceAdmission(setup.env, setup.config.service_id))!.value.state).toBe("paused"); + expect([...setup.entries.keys()].sort()).toEqual(["system/service.toml", "system/lifecycle-service.json", m6SetupRecordKey(setup.request.target.operation_id)].sort()); + const before = [...setup.writes]; + const sent = [...setup.sent]; + expect((await setup.run(setup.post("prepare", setup.request))).status).toBe(200); + expect((await setup.run(setup.post("complete", input))).status).toBe(200); + expect(setup.writes).toEqual(before); + expect(setup.sent).toEqual(sent); + expect((await fetchM6Candidate(setup.post("prepare", setup.request), setup.env, setup.assets)).status).toBe(409); + await resumeServiceAdmission(setup.env, setup.config.service_id, setup.request.target.operation_id); + const resumed = [...setup.writes]; + await consumeM6SetupProbe(setup.batch(setup.sent[0]), setup.env); + expect(setup.writes).toEqual(resumed); +}); + +test("setup refuses body flags, missing routes, method changes and another permanent target without admitting content writes", async () => { + const setup = await fixture(); + expect((await setup.run(setup.post("prepare", { ...setup.request, ready: true }))).status).toBe(400); + expect((await setup.run(setup.post("unknown", setup.request))).status).toBe(404); + expect((await setup.run(new Request(`${setup.config.public_base_url}/admin/setup/prepare`, { headers: { "X-Castloop-Key": "private-secret" } }))).status).toBe(405); + await setup.run(setup.post("prepare", setup.request)); + const before = new Map(setup.entries); + expect((await setup.run(setup.post("prepare", { target: { ...setup.request.target, deployment_id: crypto.randomUUID() } }))).status).toBe(409); + expect((await setup.run(setup.post("complete", { ...setup.request, readiness: { cutover_verified: true } }))).status).toBe(400); + expect(setup.entries).toEqual(before); +}); + +test("foreign Queue, version or target cannot manufacture a runtime receipt; exact duplicate receipts do not write", async () => { + const setup = await fixture(); + await setup.run(setup.post("prepare", setup.request)); + const body = setup.sent[0]; + const before = new Map(setup.entries); + await expect(consumeM6SetupProbe(setup.batch(body, setup.config.dlq_name), setup.env)).rejects.toThrow("another Queue"); + await expect(consumeM6SetupProbe(setup.batch({ ...(body as object), target: { ...setup.request.target, deployment_id: crypto.randomUUID() } }), setup.env)).rejects.toThrow("permanent request"); + await expect(consumeM6SetupProbe(setup.batch(body), { ...setup.env, CASTLOOP_VERSION_METADATA: { id: crypto.randomUUID() } })).rejects.toThrow("Worker version"); + expect(setup.entries).toEqual(before); + expect(await consumeM6SetupProbe(setup.batch({ unknown: true }), setup.env)).toBe(false); + await consumeM6SetupProbe(setup.batch(body), setup.env); + const after = [...setup.writes]; + await consumeM6SetupProbe(setup.batch(body), setup.env); + expect(setup.writes).toEqual(after); +}); + +test("failed or cached HTTP checks, wrong cache owners and changed REST snapshots retain initializing admission", async () => { + for (const failure of ["cached", "cache-owner", "route", "http", "rest"] as const) { + const setup = await fixture(); + await setup.run(setup.post("prepare", setup.request)); + await consumeM6SetupProbe(setup.batch(setup.sent[0]), setup.env); + const original = setup.runtime.defaultFetch; + let cached: Response | undefined; + if (failure === "cache-owner") setup.runtime.cachedRuntime = async () => ({ worker_version_id: crypto.randomUUID() }); + else setup.runtime.defaultFetch = async (input) => { + if (failure === "http") return new Response("failure", { status: 503 }); + if (failure === "route" && new URL(input.url).pathname === "/admin/publication") return Response.json({ reason_code: "unsupported" }, { status: 400, headers: { "Cache-Control": "no-store" } }); + if (failure === "cached" && new URL(input.url).pathname === "/admin/setup/probe") { + cached ??= await original(input); + return cached.clone(); + } + return original(input); + }; + const changed = structuredClone(setup.snapshot); + if (failure === "rest") changed.settings[1].cache_options!.cross_version_cache = true; + expect((await setup.run(setup.post("complete", { ...setup.request, snapshots: [setup.snapshot, changed] }))).status).toBe(409); + expect((await readServiceAdmission(setup.env, setup.config.service_id))!.value.state).toBe("initializing"); + } +}); + +test("a lost Queue send acknowledgement keeps the pending probe and does not blindly resend on prepare", async () => { + const setup = await fixture(); + setup.env.CASTLOOP_QUEUE.send = async () => { throw new Error("Queue response lost"); }; + expect((await setup.run(setup.post("prepare", setup.request))).status).toBe(409); + const record = m6SetupRecordSchema.parse(JSON.parse(setup.text(m6SetupRecordKey(setup.request.target.operation_id))!)); + expect(record.queue_receipt).toBeUndefined(); + const before = [...setup.writes]; + expect((await setup.run(setup.post("prepare", setup.request))).status).toBe(200); + const status = await setup.run(setup.post("status", setup.request)); + expect(m6SetupStatusSchema.parse(await status.json()).record).toEqual(record); + expect(setup.writes).toEqual(before); +}); diff --git a/src/m6-setup-admin.ts b/src/m6-setup-admin.ts new file mode 100644 index 0000000..b327e05 --- /dev/null +++ b/src/m6-setup-admin.ts @@ -0,0 +1,75 @@ +import { inspectM6WorkerDeployment, M6_FRESH_WORKER_COMPATIBILITY_DATE, m6SetupCompleteSchema, m6SetupCompletedSchema, + m6SetupRecordSchema, m6SetupRequestSchema, m6SetupStatusSchema, m6SnapshotReads } from "../packages/shared/src/index"; +import { authenticated } from "./admin-auth"; +import { readBoundedAdminJson } from "./admin-body"; +import { completeM6ServiceInitialization, prepareM6ServiceInitialization } from "./m6-service-initialization"; +import type { M6InitializationEnv } from "./m6-service-initialization"; +import { m6SetupRecordKey, readM6SetupRecord, requireM6SetupOwner } from "./m6-setup-record"; +import { describeM6SetupProbe, verifyM6SetupRuntime } from "./m6-setup-runtime"; +import type { M6SetupRuntime } from "./m6-setup-runtime"; +import { readM6RuntimeConfiguration } from "./m6-runtime-readiness"; +import { readServiceAdmission } from "./service-admission"; + +type SetupEnv = M6InitializationEnv & { CASTLOOP_ADMIN_KEY: string; CASTLOOP_QUEUE: Pick }; +function reply(data: unknown, status = 200): Response { + return Response.json(data, { status, headers: { "Cache-Control": "no-store" } }); +} + +export async function handleM6SetupAdmin(request: Request, env: SetupEnv, runtime: M6SetupRuntime): Promise { + const url = new URL(request.url); + if (!url.pathname.startsWith("/admin/setup/")) return null; + if (!authenticated(request, env.CASTLOOP_ADMIN_KEY)) return reply({ reason_code: "setup_unauthorized" }, 401); + const route = url.pathname.slice("/admin/setup/".length); + if (!["prepare", "status", "probe", "complete"].includes(route)) return reply({ reason_code: "setup_route_unknown" }, 404); + if (request.method !== (route === "probe" ? "GET" : "POST")) return reply({ reason_code: "setup_method_invalid" }, 405); + let input: unknown; + try { + if (route === "probe") { + const operationId = m6SetupRequestSchema.shape.target.shape.operation_id.parse(url.searchParams.get("operation_id")); + const object = await env.CASTLOOP_BUCKET.get(m6SetupRecordKey(operationId)); + if (!object || object.size > 16384) throw new Error("Missing runtime probe"); + const record = m6SetupRecordSchema.parse(await object.json()); + input = m6SetupRequestSchema.parse({ target: record.request.target }); + } else input = await readBoundedAdminJson(request); + input = route === "complete" ? m6SetupCompleteSchema.parse(input) : m6SetupRequestSchema.parse(input); + } catch { return reply({ reason_code: "setup_input_invalid" }, 400); } + const frozen = route === "complete" ? m6SetupCompleteSchema.parse(input) : m6SetupRequestSchema.parse(input); + const owner = { target: frozen.target }; + try { + if (route === "prepare") { + await prepareM6ServiceInitialization(env, owner.target); + await requireM6SetupOwner(env, owner); + const existing = await readM6SetupRecord(env, owner); + if (!existing) { + const { config } = await readM6RuntimeConfiguration(env, owner.target.service_config_sha256, owner.target.worker_version_id); + const admission = (await readServiceAdmission(env, config.service_id))!.value; + if (admission.state !== "initializing") throw new Error("Completed initialization cannot acquire a new runtime probe"); + await runtime.invalidate(`setup-${owner.target.operation_id.slice(0, 20)}`); + await requireM6SetupOwner(env, owner); + const value = m6SetupRecordSchema.parse({ schema_version: 1, request: owner, cache_purge_verified: true }); + if (!await env.CASTLOOP_BUCKET.put(m6SetupRecordKey(owner.target.operation_id), JSON.stringify(value), { onlyIf: new Headers({ "If-None-Match": "*" }) })) { + throw new Error("Runtime probe preparation conflicted; do not replay Queue send"); + } + await env.CASTLOOP_QUEUE.send({ ...owner, type: "castloop-runtime-probe-v1" }); + } + } + await requireM6SetupOwner(env, owner); + if (route === "probe") return reply(await describeM6SetupProbe(env, owner, runtime)); + if (route === "complete") { + const completion = m6SetupCompleteSchema.parse(frozen); + let inspection = 0; + const readiness = await completeM6ServiceInitialization(env, owner.target, { + inspectDeployment: (config, versionId) => inspectM6WorkerDeployment(config, versionId, + m6SnapshotReads(completion.snapshots[inspection++]!), M6_FRESH_WORKER_COMPATIBILITY_DATE), + verifyRuntime: () => verifyM6SetupRuntime(env, owner, runtime), + }); + return reply(m6SetupCompletedSchema.parse({ result: "initialized", request: owner, readiness })); + } + const record = await readM6SetupRecord(env, owner); + if (!record) throw new Error("Runtime probe was not prepared"); + return reply(m6SetupStatusSchema.parse({ result: "setup-status", record: record.value })); + } catch { + console.error(JSON.stringify({ event: "m6_setup_blocked", reason_code: "setup_operation_blocked" })); + return reply({ reason_code: "setup_operation_blocked" }, 409); + } +} diff --git a/src/m6-setup-queue.ts b/src/m6-setup-queue.ts new file mode 100644 index 0000000..77cc687 --- /dev/null +++ b/src/m6-setup-queue.ts @@ -0,0 +1,28 @@ +import { m6SetupQueueProbeSchema, m6SetupRecordSchema } from "../packages/shared/src/index"; +import type { M6InitializationEnv } from "./m6-service-initialization"; +import { readM6RuntimeConfiguration } from "./m6-runtime-readiness"; +import { m6SetupRecordKey, readM6SetupRecord, requireM6SetupOwner } from "./m6-setup-record"; + +export async function consumeM6SetupProbe(batch: { queue: string; messages: readonly Pick, "body">[] }, + env: M6InitializationEnv): Promise { + if (batch.messages.length !== 1) return false; + const message = batch.messages[0]!; + if (!message.body || typeof message.body !== "object" || !("type" in message.body) || message.body.type !== "castloop-runtime-probe-v1") return false; + const { type: _type, ...request } = m6SetupQueueProbeSchema.parse(message.body); + const { config } = await readM6RuntimeConfiguration(env, request.target.service_config_sha256, env.CASTLOOP_VERSION_METADATA.id); + if (batch.queue !== config.queue_name) throw new Error("Runtime probe arrived through another Queue"); + const record = await readM6SetupRecord(env, request); + if (!record) throw new Error("Runtime Queue probe has no retained request"); + if (record.value.queue_receipt) return true; + await requireM6SetupOwner(env, request); + if (!record.value.queue_receipt) { + const next = m6SetupRecordSchema.parse({ ...record.value, queue_receipt: { + worker_version_id: env.CASTLOOP_VERSION_METADATA.id, + } }); + if (!await env.CASTLOOP_BUCKET.put(m6SetupRecordKey(request.target.operation_id), JSON.stringify(next), { onlyIf: { etagMatches: record.etag } })) { + const current = await readM6SetupRecord(env, request); + if (!current?.value.queue_receipt) throw new Error("Runtime Queue probe receipt conflicted"); + } + } + return true; +} diff --git a/src/m6-setup-record.ts b/src/m6-setup-record.ts new file mode 100644 index 0000000..82d7de3 --- /dev/null +++ b/src/m6-setup-record.ts @@ -0,0 +1,32 @@ +import { m6SetupRecordSchema, m6SetupRequestSchema } from "../packages/shared/src/index"; +import type { M6SetupRecord, M6SetupRequest } from "../packages/shared/src/index"; +import type { M6InitializationEnv } from "./m6-service-initialization"; +import { matchesM6RuntimeTarget, readM6RuntimeConfiguration } from "./m6-runtime-readiness"; +import { readServiceAdmission } from "./service-admission"; + +export function m6SetupRecordKey(operationId: string): string { + m6SetupRequestSchema.shape.target.shape.operation_id.parse(operationId); + return `system/runtime-checks/${operationId}.json`; +} + +export async function readM6SetupRecord(env: M6InitializationEnv, input: M6SetupRequest): Promise<{ value: M6SetupRecord; etag: string } | null> { + const request = m6SetupRequestSchema.parse(input); + const object = await env.CASTLOOP_BUCKET.get(m6SetupRecordKey(request.target.operation_id)); + if (!object) return null; + if (object.size < 1 || object.size > 16384) throw new Error("Runtime probe record is missing or oversized"); + const value = m6SetupRecordSchema.parse(await object.json()); + if (JSON.stringify(value.request) !== JSON.stringify(request)) throw new Error("Runtime probe has another permanent request"); + return { value, etag: object.etag }; +} + +export async function requireM6SetupOwner(env: M6InitializationEnv, input: M6SetupRequest): Promise { + const request = m6SetupRequestSchema.parse(input); + const { config } = await readM6RuntimeConfiguration(env, request.target.service_config_sha256, request.target.worker_version_id); + const admission = await readServiceAdmission(env, config.service_id); + if (!admission || admission.value.mode !== "m6" || admission.value.invocations.length || + !(admission.value.state === "initializing" && matchesM6RuntimeTarget(admission.value.initialization, request.target) || + admission.value.state === "paused" && matchesM6RuntimeTarget(admission.value.runtime_readiness, request.target))) { + throw new Error("Runtime probe requires its exact initializing or completed paused service owner"); + } + return config.service_id; +} diff --git a/src/m6-setup-runtime.ts b/src/m6-setup-runtime.ts new file mode 100644 index 0000000..0e08b52 --- /dev/null +++ b/src/m6-setup-runtime.ts @@ -0,0 +1,67 @@ +import { cachedDeliveryRuntimeSchema, m6RuntimeReadinessSchema, m6SetupProbeSchema, m6SetupRequestSchema } from "../packages/shared/src/index"; +import type { M6RuntimeReadiness, M6SetupRequest } from "../packages/shared/src/index"; +import { readBoundedAdminJson } from "./admin-body"; +import type { M6InitializationEnv } from "./m6-service-initialization"; +import { readM6SetupRecord, requireM6SetupOwner } from "./m6-setup-record"; + +export type M6SetupRuntime = { + defaultFetch: (request: Request) => Promise; + cachedRuntime: () => Promise; + invalidate: (showId: string) => Promise; +}; + +export async function readM6SetupReply(response: Response, expectedStatus = 200): Promise { + try { + if (response.status !== expectedStatus || response.headers.get("Cache-Control") !== "no-store" || + !/^application\/json(?:\s*;|$)/i.test(response.headers.get("Content-Type") ?? "")) throw new Error("Invalid runtime probe response"); + return await readBoundedAdminJson(response); + } catch { + if (response.body && !response.bodyUsed) await response.body.cancel(); + throw new Error("Runtime probe response was not verified"); + } +} + +export async function describeM6SetupProbe(env: M6InitializationEnv, input: M6SetupRequest, runtime: M6SetupRuntime): Promise { + const request = m6SetupRequestSchema.parse(input); + await requireM6SetupOwner(env, request); + if (!await readM6SetupRecord(env, request)) throw new Error("Runtime probe is not prepared"); + const cached = cachedDeliveryRuntimeSchema.parse(await runtime.cachedRuntime()); + if (cached.worker_version_id !== request.target.worker_version_id) throw new Error("Runtime probe has another cache owner version"); + return m6SetupProbeSchema.parse({ result: "runtime-probe", request, invocation_id: crypto.randomUUID(), cached_runtime: cached }); +} + +export async function verifyM6SetupRuntime(env: M6InitializationEnv & { CASTLOOP_ADMIN_KEY: string }, input: M6SetupRequest, + runtime: M6SetupRuntime): Promise { + const request = m6SetupRequestSchema.parse(input); + await requireM6SetupOwner(env, request); + const record = await readM6SetupRecord(env, request); + if (!record?.value.queue_receipt) throw new Error("Runtime verification requires its retained Queue round-trip receipt"); + const url = new URL("https://castloop.internal/admin/setup/probe"); + url.searchParams.set("operation_id", request.target.operation_id); + const headers = { "X-Castloop-Key": env.CASTLOOP_ADMIN_KEY }; + const probes = []; + for (let attempt = 0; attempt < 2; attempt += 1) { + const probe = m6SetupProbeSchema.parse(await readM6SetupReply(await runtime.defaultFetch(new Request(url, { headers })))); + if (JSON.stringify(probe.request) !== JSON.stringify(request) || probe.cached_runtime.worker_version_id !== request.target.worker_version_id) { + throw new Error("Default entrypoint runtime probe differs from its frozen target"); + } + probes.push(probe); + } + if (probes[0]!.invocation_id === probes[1]!.invocation_id) throw new Error("Default entrypoint returned a repeated runtime probe invocation"); + for (const [route, reason] of [["staging", "staging_input_invalid"], ["publication", "publication_input_invalid"], + ["lifecycle", "lifecycle_input_invalid"], ["shows", "show_registration_input_invalid"], ["target", "target_input_invalid"]]) { + const result = await readM6SetupReply(await runtime.defaultFetch(new Request(`https://castloop.internal/admin/${route}`, { + method: "POST", headers: { ...headers, "Content-Type": "application/json" }, body: "{}", + })), 400); + if (!result || typeof result !== "object" || !("reason_code" in result) || result.reason_code !== reason) { + throw new Error("Runtime verification did not reach its compiled management validation route"); + } + } + for (const [path, status] of [["/podcasts/runtime-probe/feed.xml", 503], ["/system/service.toml", 404], ["/staging/runtime-probe", 404]] as const) { + await readM6SetupReply(await runtime.defaultFetch(new Request(`https://castloop.internal${path}`)), status); + } + await requireM6SetupOwner(env, request); + if ((await readM6SetupRecord(env, request))?.etag !== record.etag) throw new Error("Runtime verification record changed during its checks"); + return m6RuntimeReadinessSchema.parse({ ...request.target, default_cache_disabled: true, cached_entrypoint: "CachedPublicAssets", + cutover_verified: true, publication_routes_verified: true }); +} diff --git a/src/m6-setup-worker.ts b/src/m6-setup-worker.ts new file mode 100644 index 0000000..3928df6 --- /dev/null +++ b/src/m6-setup-worker.ts @@ -0,0 +1,19 @@ +import { fetchM6ManagementIntegration, queueM6Candidate } from "./m6-routes"; +import type { M6CandidateEnv } from "./m6-routes"; +import { consumeM6SetupProbe } from "./m6-setup-queue"; + +export { CachedPublicAssets } from "./lifecycle-cached-entrypoint"; + +export default { + async fetch(request, env, ctx): Promise { + return fetchM6ManagementIntegration(request, env, ctx.exports.CachedPublicAssets, { setupRuntime: { + defaultFetch: (input) => ctx.exports.default.fetch(input), + cachedRuntime: () => ctx.exports.CachedPublicAssets.describeRuntime(), + invalidate: (showId) => ctx.exports.CachedPublicAssets.invalidate({ showId }), + } }); + }, + async queue(batch, env, ctx): Promise { + if (await consumeM6SetupProbe(batch, env)) return; + await queueM6Candidate(batch, env, ctx.exports.CachedPublicAssets); + }, +} satisfies ExportedHandler; diff --git a/src/test-support/m6-setup.ts b/src/test-support/m6-setup.ts new file mode 100644 index 0000000..7a8d700 --- /dev/null +++ b/src/test-support/m6-setup.ts @@ -0,0 +1,42 @@ +import { buildM6WorkerUploadMetadata, collectM6DeploymentSnapshot, M6_FRESH_WORKER_COMPATIBILITY_DATE, + m6ServiceConfigHash, stringifyToml } from "../../packages/shared/src/index"; +import { describeCachedDeliveryRuntime } from "../lifecycle-delivery-gate"; +import { fetchM6ManagementIntegration } from "../m6-routes"; +import type { M6CachedLoopback, M6CandidateEnv } from "../m6-routes"; +import type { M6SetupRuntime } from "../m6-setup-runtime"; +import { m6InitializationFixture } from "./m6-initialization"; + +export async function m6SetupFixture() { + const setup = await m6InitializationFixture(); + const config = { ...setup.config, public_base_url: `https://${setup.config.worker_name}.example.workers.dev` }; + await setup.bucket.put("system/service.toml", stringifyToml(config)); + const request = { target: { ...setup.target, service_config_sha256: await m6ServiceConfigHash(config) } }; + const sent: unknown[] = []; + const purges: string[] = []; + const calls: string[] = []; + const env: M6CandidateEnv = { CASTLOOP_BUCKET: setup.bucket as never, + CASTLOOP_QUEUE: { send: async (body: unknown) => { sent.push(body); } } as never, + CASTLOOP_ADMIN_KEY: "private-secret", CASTLOOP_DLQ_NAME: config.dlq_name, + CASTLOOP_VERSION_METADATA: { id: setup.versionId, tag: "", timestamp: "2026-10-03T12:00:00Z" } }; + const assets: M6CachedLoopback = Object.assign(() => ({ fetch: async () => new Response() }), { + invalidate: async ({ showId }: { showId: string }) => { purges.push(showId); }, + describeRuntime: async () => describeCachedDeliveryRuntime(env.CASTLOOP_VERSION_METADATA, { purge: async () => ({ success: true, errors: [] }) }), + }); + const runtime: M6SetupRuntime = { cachedRuntime: assets.describeRuntime, invalidate: (showId) => assets.invalidate({ showId }), + defaultFetch: async (input) => { calls.push(new URL(input.url).pathname); return fetchM6ManagementIntegration(input, env, assets, { setupRuntime: runtime }); } }; + const metadata = buildM6WorkerUploadMetadata(config, null, env.CASTLOOP_ADMIN_KEY, M6_FRESH_WORKER_COMPATIBILITY_DATE); + const snapshot = await collectM6DeploymentSnapshot(config, setup.versionId, { + deployments: async () => ({ deployments: [{ id: setup.target.deployment_id, strategy: "percentage", versions: [{ version_id: setup.versionId, percentage: 100 }] }] }), + settings: async () => metadata, + subdomain: async () => ({ enabled: true, previews_enabled: false }), + version: async () => ({ id: setup.versionId, resources: { bindings: metadata.bindings, + script: { handlers: ["fetch", "queue"], named_handlers: [{ name: "CachedPublicAssets", handlers: ["fetch"] }] }, + script_runtime: { compatibility_date: metadata.compatibility_date, compatibility_flags: metadata.compatibility_flags, exports: metadata.exports } } }), + }, M6_FRESH_WORKER_COMPATIBILITY_DATE); + const post = (route: string, body: unknown, key = env.CASTLOOP_ADMIN_KEY) => new Request(`${config.public_base_url}/admin/setup/${route}`, { + method: "POST", headers: { "X-Castloop-Key": key, "Content-Type": "application/json" }, body: JSON.stringify(body), + }); + const run = (input: Request) => fetchM6ManagementIntegration(input, env, assets, { setupRuntime: runtime }); + const batch = (body: unknown, queue = config.queue_name) => ({ queue, messages: [{ body }] }); + return { ...setup, config, request, env, assets, runtime, snapshot, sent, purges, calls, post, run, batch }; +} From 945a507340a0ab66c8b687d7066530b65e678dcc Mon Sep 17 00:00:00 2001 From: Akira Shimosako Date: Sun, 4 Oct 2026 00:27:22 +0900 Subject: [PATCH 02/16] feat(m6): verify fresh initialization and publication with isolated binary --- design/m6_fresh_initialization.md | 25 ++- design/m6_implementation_log.md | 10 + design/m6_review_queue.md | 4 +- experiments/m6/tsconfig.json | 2 +- experiments/m6/verify-fresh-service.ts | 193 ++++++++++++++++++ packages/cli/src/m6-admin-json.ts | 6 +- packages/cli/src/m6-service-client.test.ts | 84 ++++++++ packages/cli/src/m6-service-client.ts | 22 ++ .../cli/src/m6-service-initialization.test.ts | 9 +- packages/cli/src/m6-service-initialization.ts | 12 ++ packages/cli/src/m6-setup-client.test.ts | 20 +- packages/cli/src/m6-setup-client.ts | 14 +- packages/cli/src/m6-test-cli.ts | 114 +++++++++++ packages/cli/src/m6-worker-deployment.test.ts | 8 + packages/cli/src/staging-rest.test.ts | 7 +- packages/cli/src/staging-rest.ts | 6 +- .../verify-operation-status-binary.ts | 12 +- packages/shared/src/index.ts | 4 +- packages/shared/src/m6-service-admin.ts | 17 ++ packages/shared/src/m6-setup.ts | 1 + packages/shared/src/m6-worker-deployment.ts | 5 +- scripts/build-cli.ts | 21 +- src/m6-routes.ts | 13 +- src/m6-runtime-readiness.ts | 13 +- src/m6-service-admin.ts | 33 +++ src/m6-setup-runtime.ts | 2 +- src/service-admission.ts | 17 +- 27 files changed, 631 insertions(+), 43 deletions(-) create mode 100644 experiments/m6/verify-fresh-service.ts create mode 100644 packages/cli/src/m6-service-client.test.ts create mode 100644 packages/cli/src/m6-service-client.ts create mode 100644 packages/cli/src/m6-test-cli.ts create mode 100644 packages/shared/src/m6-service-admin.ts create mode 100644 src/m6-service-admin.ts diff --git a/design/m6_fresh_initialization.md b/design/m6_fresh_initialization.md index f593e79..cfba134 100644 --- a/design/m6_fresh_initialization.md +++ b/design/m6_fresh_initialization.md @@ -1,12 +1,12 @@ # M6: 新規初期化と稼働判定の分離 -更新日: 2026-10-03 +更新日: 2026-10-04 ## 範囲 旧サービスのデータ変換を経ず、別名のWorker/private R2/Queue/DLQから開始する内部経路を実装した。既存資源を削除・採用する機能ではない。legacy移行用の`readiness`は互換性と監査のため保持し、新規起動には独立した`runtime_readiness`を用いる。架空のmigration ID・旧IO終了証拠・旧cache purge証拠は作らない。 -通常の`init`とM6公開コマンドはまだこの経路へ接続していない。内部経路の自動テスト合格と、実Cloudflare上で利用可能なM6初期化の完成は区別する。 +通常の`init`とM6公開コマンドはまだこの経路へ接続していない。専用の試験バイナリで実Cloudflare初期化・Show/Episode公開・基本配信を確認済みだが、M6全体の公開ゲートとは区別する。 ## 小さい責務 @@ -30,6 +30,7 @@ serverはservice TOMLとadmission以外のobjectがあるbucketを拒否する 試験専用entry `src/m6-setup-worker.ts`にだけ初期化経路を接続する。通常candidate・既存binaryのready=falseと書込禁止は変えない。 1. CLIがCloudflare RESTからdeployment/version/settings/previewの前後snapshotを採取する。検査の実装はWorkerと共有し、account API tokenはCLIに留める。 + 実RESTのsettingsはexportsを省略することがある。前後の100%配備照合とimmutable versionの明示export設定を必須とし、settingsにexportがある場合も照合する。省略をready申告や架空の設定で補完しない。到達待ちは認証付きhealthの有限回GETだけで、対象versionが確認できるまでprepareを送らない。 2. prepareがexact初期化ownerを確認し、named cache entrypointでtag purgeを実行した後、小さい固定requestを条件付き保存し、一回Queueへ送信する。 3. main Queue consumerがQueue名・実行version・request・初期化ownerを照合し、同記録へCAS receiptを保存する。重複配信は記録を書き換えない。 4. CLIが公開originの認証付きprobeを同URLで二回GETし、request/version/cache ownerと異なるinvocation IDを確認する。Workerも実default loopback fetchを二回呼び出す。 @@ -45,4 +46,22 @@ Queue statusの有限回待機はCLIの観測終了だけである。時間だ 初期化・CAS競合・誤証拠・設定変更・応答喪失・journal/lock・REST資源衝突を自動テストした。既存の一つのcontent-flow結合テストも、新規初期化からShow登録・公開・改訂・停止/再開/削除へ通す経路に置き換えた。別の巨大なE2Eテストは追加していない。 -認証付き初期化HTTP入口と実HTTP/loopback/Queue検証adapterは試験entryへ接続済み。自動テストはsimulated REST/cache/Queueであり、公開CLI/単一binary接続と専用Cloudflare環境での起動・公開受け入れは残る。通常candidateのready=falseとmanagement書込禁止は維持する。300MB受け入れ・unknown IO回復等のM6共通gateも省略しない。 +自動テストはsimulated REST/cache/Queueである。通常candidateのready=falseとmanagement書込禁止は維持する。通常更新・六lifecycle操作・cache HITと停止/削除後のpurge・300MB受け入れ・unknown IO回復・公開CLI/案内等のM6共通gateも省略しない。 + +## 試験バイナリと実Cloudflare受け入れ + +`bun scripts/build-cli.ts linux-x64 --m6-test`は、通常binaryとは別の`dist/castloop-m6-test-linux-x64`へ試験entryを埋め込む。管理者のマシンにBun/Node/Wranglerや別R2 credentialを要求せず、envのaccount/tokenとprivate local admin keyを使う。service IDは`m6-test-`、全資源名は`castloop-m6-test-`だけを許す。通常buildのCLI/Worker entryは変えない。 + +試験CLIは既存のdurable登録/draft/staging/publication runnerを使用する。init完了はpausedで、明示service-resumeが必要。serviceの認証付きstatus/pause/resumeとtarget照会を接続し、pauseでは残存tokenを保持する。実行versionと空registryの再開条件は同じservice CAS snapshotで検査し、競合後にも再検査する。未知応答を自動再送せず、service操作の未知結果復旧は未完成である。 + +実RESTのR2 PUT receipt.sizeはdecimal stringだった。numberまたはdecimal integer stringだけを安全整数の固定lengthへ照合し、全量GET checksumも引き続き必須とする。aborted receiptをCLIのstaged成功として表示しない。 + +`experiments/m6/verify-fresh-service.ts`で新規専用資源を作り、standaloneから登録→TOML/cover/audio staging→明示commit→実notification/Queue consumer→公開まで検証した。feed書込はconsumer完了より先になるため、feedにGUIDが現れただけで媒体検証を始めず、targetのactive/owner解放とregistry返却も待つ。有限回の待機終了ではowner/tokenを解除しない。continuationは明示指定したacknowledged checkpointだけで、未知のPOST/PUT/commitを再送しない。 + +- 合格環境: Worker/R2/main Queue `castloop-m6-test-9c80a9b8`、DLQは同名`-dlq`。既存v0.1.1資源には変更しない。 +- 初期化operation `52190e82-ec82-41be-a90a-f4b099655b5e`、確認済みversion `5a1f489a-351e-42c1-b53a-d49a38518a8f`。実HTTP/loopback/Queue receipt、初期paused、明示再開を確認した。 +- Show job `57842670-da9d-4479-9b0f-91f2ee37088a`、Episode job `7e195537-32bf-4d62-a7e8-4cb8f5752b84`。GUID・revision・20,850-byte synthetic MP3 checksum、feed/68-byte PNG cover、GET/HEAD/Rangeと再検証headerを確認した。300MB/聴取品質/cache HIT/全coloの受け入れではない。 +- ローカル入力/REST応答形式/早期配信観測の修正後、保存済みのacknowledged checkpointから続行した。新規作成から無中断で一回成功した試験とは説明しない。最後に全tokenの通常返却を確認し、pause `088a5eca-8eab-4f45-b09f-aaae1af411c6`で保持した。privateな`/tmp/opencode/castloop-m6-test-9c80a9b8/acceptance.json`へsecret/本文なしの結果を保存する。 +- 最初の`castloop-m6-test-a3b60278`は旧inspectorのexports省略拒否でlocal deploy_requestedを保持した。PUT再送/採用/削除せず残し、初期化済み・pausedとは扱わない。Worker version `e63497ad-051a-4b08-87ac-22a9deddb571`のGET観測はdeploy acknowledgementの代用にしない。 + +処理途中のconsumerは最終的に通常完了した。CPU/subrequest limit障害とは断定しない。local fixtureではShow/Episodeに317/359 R2 callsがあり、重複guard整理の検討余地はあるが、安全条件を外して制限を回避する修正はしていない。観測permissionの不足や時間経過だけでtokenの終了を認定しない。 diff --git a/design/m6_implementation_log.md b/design/m6_implementation_log.md index 059b652..fbcf5bc 100644 --- a/design/m6_implementation_log.md +++ b/design/m6_implementation_log.md @@ -1,5 +1,15 @@ # M6: 公開停止・削除 実装ログ +## 2026-10-04: 試験用standaloneから新規M6初期化・コンテンツ公開を実機検証 + +- 通常buildを維持し、明示`--m6-test`で別binary/Worker entryを作る。専用資源prefix制限、既存durable登録/draft/upload/publication runner、初期pausedからの明示service再開、非書込target照会を接続した。 +- 実RESTで確認したsettings.exports省略は、100% deployment前後照合とimmutable versionの明示exportsで裏付ける。R2 PUT decimal-string size receiptを厳密に受理し、全量GET checksumを維持する。abortedをstaged成功表示しない。 +- 認証付きservice pause/resumeのversion・drain条件をservice CAS snapshotへ置き、競合後も検査する。statusは初期化/更新未完了の観測も可能だが、昇格/再送/owner返却を行わない。既存helperのversion指定なしの呼出契約は維持する。 +- `castloop-m6-test-9c80a9b8`でstandalone初期化/実Queue往復/明示再開/Show・Episode公開、GUID/revision/checksum、feed/cover/GET/HEAD/Rangeと再検証headerが合格。feed本文の出現だけを完了とせず、active/owner解放/token通常返却まで待ち、明示pausedで保持した。ローカル修正後のacknowledged checkpoint続行を含み、300MB/cache HIT/六lifecycle/正式releaseとは区別する。 +- 最初の`castloop-m6-test-a3b60278`はdeploy_requestedを保持したまま資源を残す。unknown PUTの再送/GETからの採用/削除をしない。既存v0.1.1環境や有料planは変更しない。詳細・対象ID・保存記録は`m6_fresh_initialization.md`。 +- 全868テスト/13207 assertions、TypeScript/M6実証tsconfig、通常/試験Linux binary build、既存四family local HTTPS status回帰と試験binaryのproduction資源拒否が合格した。 +- 次は別branchでcompatible updateの実HTTP/runtime/standalone接続と更新前後の媒体/revision/GUID/URL保持を検証する。今回の配備には更新受付APIが未接続なので、未知の直接deployでそれを追加せず、更新対応entryを新規専用資源に配備してから更新を通す。旧形式変換は必要時だけとする。 + ## 2026-10-03: 新規M6の認証付き初期化と起動時runtime検証を接続 - 配備検査をsharedへ移し、CLIのCloudflare REST応答をsecret/author/未管理設定なしのsnapshotへ絞る。Workerも同じ検査を使用し、account API tokenはCLIに留める。 diff --git a/design/m6_review_queue.md b/design/m6_review_queue.md index 5c8050d..6d4b860 100644 --- a/design/m6_review_queue.md +++ b/design/m6_review_queue.md @@ -1,6 +1,6 @@ # M6: 承認済み方針と残る技術ゲート -更新日: 2026-10-03 +更新日: 2026-10-04 管理者の指示に従い、判断待ちでも独立した開発は進める。確認待ちと明示的に承認されたpolicyを区別する。公開停止・再開・削除コマンドは未提供。現行REST単一PUTの維持と未解決懸念U1の非ブロッカー扱いは決定済みで、再承認を求めない。 @@ -48,7 +48,7 @@ 2026-10-03に管理者が「共通journalリファクタリングをmainへmerge → 別branchで新規M6初期化/稼働判定の分離 → 別branchで形式を変えない通常更新 → 旧形式変換は必要時だけ」を承認した。旧形式変換を新規M6サービスの必須経路にしない。既存環境の即時削除や公開gate解除の承認とは扱わない。 -新規初期化のserver CAS、runtime readinessと移行監査証拠の分離、CLI durable controller、新規資源/WorkerのREST配備adapterを内部実装した。認証付き初期化HTTP入口と実HTTP/loopback/Queue起動検証adapterも試験専用entryへ接続した。配備検査はCLI/Worker共通とし、account tokenをWorkerへ渡さない。完了はpausedであり、公開CLI/単一binary接続・実コンテンツ公開・Cloudflare受け入れは残る。[新規初期化](./m6_fresh_initialization.md)参照。 +新規初期化のserver CAS、runtime readinessと移行監査証拠の分離、CLI durable controller、新規資源/WorkerのREST配備adapterを実装した。認証付きHTTP/loopback/Queue起動検証と試験専用standaloneを接続し、別名Cloudflare環境で初期paused→明示再開→Show/Episode公開→GET/HEAD/Rangeを確認した。通常の公開CLI/entryは切り替えず、通常更新・全lifecycle/cache/300MB/unknown IO復旧の受け入れは残る。既存資源の削除/採用はしていない。[新規初期化](./m6_fresh_initialization.md)参照。 保存形式を変えないM6更新の内部経路も追加した。既存のpause/drainに続いてShow controlの未完了ownerだけを確認し、service CASで閉じてから配備・現在version検証・readiness更新を行う。音源/Episodeのinventoryや変換はしない。共通runtime検証は初期化と共有し、未知token/PUTを時間で終了扱いにしない。公開CLI/実機gateは残る。[通常更新](./m6_compatible_updates.md)参照。 diff --git a/experiments/m6/tsconfig.json b/experiments/m6/tsconfig.json index b50f088..52e0b0d 100644 --- a/experiments/m6/tsconfig.json +++ b/experiments/m6/tsconfig.json @@ -1,5 +1,5 @@ { "extends": "../../tsconfig.json", - "include": ["./cache-worker.ts", "./verify-cache.ts", "./admission-worker.ts", "./verify-admission.ts", "./probe-harness.ts"], + "include": ["./cache-worker.ts", "./verify-cache.ts", "./admission-worker.ts", "./verify-admission.ts", "./probe-harness.ts", "./verify-fresh-service.ts"], "compilerOptions": { "types": ["../../worker-configuration.d.ts", "node", "bun"] } } diff --git a/experiments/m6/verify-fresh-service.ts b/experiments/m6/verify-fresh-service.ts new file mode 100644 index 0000000..f3c4ea7 --- /dev/null +++ b/experiments/m6/verify-fresh-service.ts @@ -0,0 +1,193 @@ +import { m6ServiceAdminResponseSchema, parseEpisodeDraft, parseServiceConfig, parseShowMetadata, serviceConfigSchema, + stringifyToml, targetInspectionResponseSchema } from "../../packages/shared/src/index"; +import { readLocalDraft } from "../../packages/cli/src/local-draft-journal"; +import { readLocalPublicationJob } from "../../packages/cli/src/publication-journal"; +import { readLocalFreshM6Initialization } from "../../packages/cli/src/m6-service-initialization"; +import { readLocalShowRegistration } from "../../packages/cli/src/show-registration-journal"; +import { createHash } from "node:crypto"; +import assert from "node:assert/strict"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { existsSync } from "node:fs"; + +const account = process.env.CLOUDFLARE_ACCOUNT_ID; +const token = process.env.CLOUDFLARE_API_TOKEN; +if (!account || !token) throw new Error("Cloudflare administration credentials are required"); +const publishedEpisode = process.argv[2] === "--published-episode-workspace"; +const publishedShow = publishedEpisode || process.argv[2] === "--published-show-workspace"; +const continuing = publishedShow || process.argv[2] === "--initialized-workspace"; +if (continuing && (process.argv.length !== 4 || !process.argv[3])) throw new Error("A single explicit continuation workspace is required"); +const existingWorkspace = continuing ? resolve(process.argv[3]!) : undefined; +const binary = resolve(existingWorkspace ? "dist/castloop-m6-test-linux-x64" : process.argv[2] ?? "dist/castloop-m6-test-linux-x64"); +const suffix = crypto.randomUUID().slice(0, 8); +const existingConfig = existingWorkspace ? parseServiceConfig(await readFile(join(existingWorkspace, "castloop.toml"), "utf8")) : undefined; +const name = existingConfig?.worker_name ?? `castloop-m6-test-${suffix}`; +const directory = existingWorkspace ?? join("/tmp/opencode", name); +const retained = existingConfig ? readLocalFreshM6Initialization(directory, existingConfig) : undefined; +if (existingConfig && (retained?.state?.phase !== "initialized" || retained.lockPresent || existingConfig.account_id !== account || + !name.startsWith("castloop-m6-test-") || !publishedShow && readLocalShowRegistration(directory, existingConfig, "fresh").client_state || + readLocalShowRegistration(directory, existingConfig, "fresh").lock_present)) throw new Error("Continuation requires acknowledged initialization and its exact completed checkpoint"); +function acknowledgedJob(kind: "show" | "episode") { + if (!existingConfig) throw new Error("Acknowledged continuation needs its retained configuration"); + const draft = readLocalDraft(directory, existingConfig, { kind, show_id: "fresh", ...(kind === "episode" ? { episode_id: "first" } : {}) }); + const job = draft.client_state ? readLocalPublicationJob(directory, existingConfig, draft.client_state.draft_job_id) : undefined; + if (draft.lock_present || draft.client_state?.phase !== "frozen" || job?.lock_present || job?.client_state?.phase !== "committed") { + throw new Error("Continuation cannot replay an unfinished content command"); + } + return job.client_state; +} +if (publishedShow) { + acknowledgedJob("show"); + if (publishedEpisode) acknowledgedJob("episode"); + else if (existsSync(join(directory, "fresh", "episode-first.toml"))) throw new Error("Episode creation already started; do not replay it"); +} +const operationId = retained?.state?.request.operation_id ?? crypto.randomUUID(); +const observations: Array<{ command: string; result: string; job_id?: string }> = []; +if (!existingWorkspace) await mkdir(directory, { mode: 0o700 }); +console.log(JSON.stringify({ workspace: directory })); + +async function command(...args: string[]): Promise { + const child = Bun.spawn([binary, ...args], { cwd: directory, stdout: "pipe", stderr: "pipe" }); + const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + if (code !== 0) { + observations.push({ command: args[0]!, result: "binary_command_failed" }); + console.error(stderr); + throw new Error("Binary command did not complete; retain workspace/resources without replay"); + } + observations.push({ command: args[0]!, result: "confirmed" }); + return stdout.trim(); +} + +const digest = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex"); +try { + const subdomainResponse = await fetch(`https://api.cloudflare.com/client/v4/accounts/${account}/workers/subdomain`, { + headers: { Authorization: `Bearer ${token}` }, redirect: "error", signal: AbortSignal.timeout(30000), + }); + assert.equal(subdomainResponse.status, 200); + const subdomain = await subdomainResponse.json<{ success: boolean; result: { subdomain: string } }>(); + assert.equal(subdomain.success, true); + const config = existingConfig ?? serviceConfigSchema.parse({ schema_version: 1, service_id: `m6-test-${suffix}`, account_id: account, + worker_name: name, bucket_name: name, queue_name: name, dlq_name: `${name}-dlq`, + public_base_url: `https://${name}.${subdomain.result.subdomain}.workers.dev` }); + if (!existingConfig) { + await writeFile(join(directory, "castloop.toml"), stringifyToml(config), { mode: 0o600, flag: "wx" }); + await writeFile(join(directory, ".gitignore"), ".castloop/\n*.mp3\n", { flag: "wx" }); + } + assert.deepEqual(parseServiceConfig(await readFile(join(directory, "castloop.toml"), "utf8")), config); + if (!existingConfig) { + const initialized = JSON.parse(await command("init", operationId)) as { result: string }; + assert.equal(initialized.result, "initialized-paused"); + } + const waitSettledService = async (state: "open" | "paused") => { + for (let read = 0; read < 180; read += 1) { + const status = m6ServiceAdminResponseSchema.parse(JSON.parse(await command("service-status"))); + assert.equal(status.admission.state, state); + if (retained) assert.deepEqual(status.admission.runtime_readiness, retained.state!.runtime_readiness); + if (!status.admission.invocations.length) return status; + await Bun.sleep(5000); + } + throw new Error("Service invocations are still retained; no release, redeploy or automatic retry is authorized"); + }; + await waitSettledService(existingConfig ? "open" : "paused"); + const waitPublishedTarget = async (kind: "show" | "episode") => { + for (let read = 0; read < 180; read += 1) { + const args = kind === "show" ? ["target-show", "fresh"] : ["target-episode", "fresh", "first"]; + const target = targetInspectionResponseSchema.parse(JSON.parse(await command(...args))); + if (!target.unfinished_show_operation && target.show?.lifecycle === "active" && (kind === "show" || target.episode?.lifecycle === "active")) return target; + await Bun.sleep(5000); + } + throw new Error("Publication ownership is still retained; do not release or repeat its job"); + }; + if (!existingConfig) await command("service-resume", operationId); + if (!publishedShow) await command("create-show", "fresh", "https://example.com/m6-test"); + const showFile = join(directory, "fresh", "show.toml"); + const show = parseShowMetadata(await readFile(showFile, "utf8")); + const cover = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j2ioAAAAASUVORK5CYII=", "base64"); + if (!publishedShow) { + await writeFile(showFile, stringifyToml({ ...show, image_path: "cover.png" })); + await writeFile(join(directory, "fresh", "cover.png"), cover); + await command("update-show", "fresh"); + const showJob = JSON.parse(await command("publish-show", "fresh")) as { job_id: string }; + observations.push({ command: "show-publication", result: "committed", job_id: showJob.job_id }); + } else { + const target = targetInspectionResponseSchema.parse(JSON.parse(await command("target-episode", "fresh", "first"))); + assert.equal(target.show?.lifecycle, "active"); + assert.equal(target.unfinished_show_operation, false); + if (!publishedEpisode) { + assert.equal(target.episode, null); + assert.equal(target.current_revision, null); + } + assert.equal(show.image_path, "cover.png"); + assert.equal(digest(await readFile(join(directory, "fresh", "cover.png"))), digest(cover)); + observations.push({ command: "show-publication", result: "previous_acknowledgment_verified", + job_id: readLocalDraft(directory, config, { kind: "show", show_id: "fresh" }).client_state!.draft_job_id }); + } + const publicGet = (path: string, method = "GET", headers: Record = {}) => fetch(new URL(path, config.public_base_url), { + method, headers, redirect: "error", signal: AbortSignal.timeout(30000), + }); + const waitFeed = async (guid?: string): Promise => { + for (let read = 0; read < 180; read += 1) { + const response = await publicGet("/podcasts/fresh/feed.xml"); + const body = await response.text(); + if (response.status === 200 && body.includes(" Buffer.concat([Buffer.from([255, 251, 144, 100]), Buffer.alloc(413)]))); + const audioFile = join(directory, "fresh", "first.mp3"); + let episodeJobId: string; + if (!publishedEpisode) { + await writeFile(audioFile, audio); + await command("update-episode", "fresh", "first"); + await command("update-episode-audio", "fresh", "first", audioFile); + const episodeJob = JSON.parse(await command("publish-episode", "fresh", "first", audioFile)) as { job_id: string }; + episodeJobId = episodeJob.job_id; + observations.push({ command: "episode-publication", result: "committed", job_id: episodeJobId }); + } else { + episodeJobId = acknowledgedJob("episode").publication.request.job_id; + assert.equal(digest(await readFile(audioFile)), digest(audio)); + observations.push({ command: "episode-publication", result: "previous_acknowledgment_verified", job_id: episodeJobId }); + } + const target = await waitPublishedTarget("episode"); + assert.equal(target.current_revision?.revision_id, episodeJobId); + assert.equal(target.current_revision?.guid, episode.guid); + assert.equal(target.current_revision?.sha256, digest(audio)); + await waitSettledService("open"); + const feed = await waitFeed(episode.guid); + const mediaUrl = / Promise; const RESPONSE_BUDGET = 65536; -const ROUTE_LABELS = { staging: "Staging", publication: "Publication", lifecycle: "Lifecycle", shows: "Show registration", target: "Target inspection", +const ROUTE_LABELS = { staging: "Staging", publication: "Publication", lifecycle: "Lifecycle", shows: "Show registration", target: "Target inspection", service: "Service administration", "setup/prepare": "Setup preparation", "setup/status": "Setup status", "setup/complete": "Setup completion" }; async function readResponse(response: Response, maximumBytes = RESPONSE_BUDGET): Promise { @@ -66,6 +66,10 @@ export class M6AdminJsonClient { return this.send(url, "Setup probe"); } + async getRuntimeHealth(): Promise { + return this.send(new URL("/admin/health", this.config.public_base_url), "Runtime health"); + } + private async send(url: URL, label: string, body?: string, maximumBytes = RESPONSE_BUDGET): Promise { let response: Response; try { diff --git a/packages/cli/src/m6-service-client.test.ts b/packages/cli/src/m6-service-client.test.ts new file mode 100644 index 0000000..a69ae2e --- /dev/null +++ b/packages/cli/src/m6-service-client.test.ts @@ -0,0 +1,84 @@ +import { expect, test } from "bun:test"; +import { M6ServiceClient } from "./m6-service-client"; +import { consumeM6SetupProbe } from "../../../src/m6-setup-queue"; +import { acquireServiceInvocation, readServiceAdmission, releaseServiceInvocation, SERVICE_ADMISSION_KEY } from "../../../src/service-admission"; +import { m6SetupFixture } from "../../../src/test-support/m6-setup"; + +async function initialized() { + const setup = await m6SetupFixture(); + await setup.run(setup.post("prepare", setup.request)); + await consumeM6SetupProbe(setup.batch(setup.sent[0]), setup.env); + expect((await setup.run(setup.post("complete", { ...setup.request, snapshots: [setup.snapshot, setup.snapshot] }))).status).toBe(200); + const transport = (url: URL, init: RequestInit) => setup.run(new Request(url, init)); + const client = new M6ServiceClient(setup.config, "private-secret", transport); + return { ...setup, client, transport }; +} + +test("service API explicitly resumes initialized paused admission and pauses without clearing live invocation owners", async () => { + const setup = await initialized(); + const service = { service_id: setup.config.service_id }; + expect((await setup.client.call({ ...service, action: "status" })).admission.state).toBe("paused"); + const resume = { ...service, action: "resume" as const, pause_id: setup.request.target.operation_id }; + await expect(setup.client.call({ ...resume, pause_id: crypto.randomUUID() })).rejects.toThrow("not verified"); + expect((await setup.client.call(resume)).admission.state).toBe("open"); + const invocation = await acquireServiceInvocation(setup.env, service.service_id, "m6_admin"); + const pause = { ...service, action: "pause" as const, pause_id: crypto.randomUUID() }; + const paused = await setup.client.call(pause); + expect(paused.admission.invocations[0]!.token).toBe(invocation.token); + await expect(setup.client.call({ ...pause, action: "resume" })).rejects.toThrow("not verified"); + await releaseServiceInvocation(setup.env, invocation); + expect((await setup.client.call({ ...pause, action: "resume" })).admission.state).toBe("open"); + const before = [...setup.writes]; + await setup.client.call({ ...pause, action: "resume" }); + expect(setup.writes).toEqual(before); +}); + +test("service mutation rejects auth, malformed requests and a different runtime version; lost responses do not trigger another request", async () => { + const setup = await initialized(); + const input = { service_id: setup.config.service_id, action: "resume" as const, pause_id: setup.request.target.operation_id }; + const before = [...setup.writes]; + const wrong = new M6ServiceClient(setup.config, "wrong-key", setup.transport); + await expect(wrong.call(input)).rejects.toThrow("not verified"); + const request = () => new Request(`${setup.config.public_base_url}/admin/service`, { method: "POST", headers: { + "X-Castloop-Key": "private-secret", "Content-Type": "application/json" }, body: JSON.stringify(input) }); + const version = setup.env.CASTLOOP_VERSION_METADATA; + setup.env.CASTLOOP_VERSION_METADATA = { ...version, id: crypto.randomUUID() }; + await expect(setup.client.call(input)).rejects.toThrow("not verified"); + expect((await setup.client.call({ service_id: setup.config.service_id, action: "status" })).worker_version_id).not.toBe(version.id); + setup.env.CASTLOOP_VERSION_METADATA = version; + const malformed = request(); + expect((await setup.run(new Request(malformed.url, { method: "POST", headers: malformed.headers, body: JSON.stringify({ ...input, ready: true }) }))).status).toBe(400); + expect(setup.writes).toEqual(before); + let calls = 0; + const lost = new M6ServiceClient(setup.config, "private-secret", async (url, init) => { + calls += 1; const response = await setup.transport(url, init); await response.body?.cancel(); throw new Error("Response lost"); + }); + await expect(lost.call(input)).rejects.toThrow("outcome is unknown"); + expect(calls).toBe(1); + expect((await readServiceAdmission(setup.env, setup.config.service_id))!.value.state).toBe("open"); +}); + +test("resume CAS rechecks a newly admitted consumer or changed runtime instead of relying on a preflight snapshot", async () => { + for (const failure of ["consumer", "runtime"] as const) { + const setup = await initialized(); + const originalGet = setup.bucket.get.bind(setup.bucket); + let inject = true; + setup.bucket.get = (async (...args: Parameters) => { + const object = await originalGet(...args); + if (args[0] === SERVICE_ADMISSION_KEY && inject) { + inject = false; + if (failure === "consumer") await acquireServiceInvocation(setup.env, setup.config.service_id, "m6_consumer"); + else { + const snapshot = (await readServiceAdmission(setup.env, setup.config.service_id))!; + await setup.bucket.put(SERVICE_ADMISSION_KEY, JSON.stringify({ ...snapshot.value, generation: snapshot.value.generation + 1, + runtime_readiness: { ...snapshot.value.runtime_readiness, worker_version_id: crypto.randomUUID() } })); + } + } + return object; + }) as typeof setup.bucket.get; + await expect(setup.client.call({ service_id: setup.config.service_id, action: "resume", pause_id: setup.request.target.operation_id })).rejects.toThrow("not verified"); + const current = (await readServiceAdmission(setup.env, setup.config.service_id))!.value; + expect(current.state).toBe("paused"); + expect(current.invocations).toHaveLength(failure === "consumer" ? 1 : 0); + } +}); diff --git a/packages/cli/src/m6-service-client.ts b/packages/cli/src/m6-service-client.ts new file mode 100644 index 0000000..3c3c54e --- /dev/null +++ b/packages/cli/src/m6-service-client.ts @@ -0,0 +1,22 @@ +import { m6ServiceAdminRequestSchema, m6ServiceAdminResponseSchema } from "@castloop/shared"; +import type { M6ServiceAdminRequest, M6ServiceAdminResponse, ServiceConfig } from "@castloop/shared"; +import { M6AdminJsonClient } from "./m6-admin-json"; +import type { M6AdminTransport } from "./m6-admin-json"; + +export class M6ServiceClient { + private readonly admin: M6AdminJsonClient; + constructor(config: ServiceConfig, adminKey: string, transport?: M6AdminTransport) { + this.admin = new M6AdminJsonClient(config, adminKey, transport); + } + async call(input: M6ServiceAdminRequest): Promise { + const request = m6ServiceAdminRequestSchema.parse(input); + if (request.service_id !== this.admin.config.service_id) throw new Error("Service administration targets another service"); + const response = m6ServiceAdminResponseSchema.parse(await this.admin.post("service", request)); + if (JSON.stringify(response.request) !== JSON.stringify(request)) throw new Error("Service response differs from its frozen request"); + if (request.action === "pause" && (response.admission.state !== "paused" || response.admission.pause_id !== request.pause_id) || + request.action === "resume" && (response.admission.state !== "open" || response.admission.last_resumed_pause_id !== request.pause_id)) { + throw new Error("Service response did not confirm its requested pause or resume"); + } + return response; + } +} diff --git a/packages/cli/src/m6-service-initialization.test.ts b/packages/cli/src/m6-service-initialization.test.ts index c6fc522..9da7aae 100644 --- a/packages/cli/src/m6-service-initialization.test.ts +++ b/packages/cli/src/m6-service-initialization.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { createFreshM6InitializationJournal, resumeFreshM6Initialization, runFreshM6Initialization } from "./m6-service-initialization"; +import { createFreshM6InitializationJournal, readLocalFreshM6Initialization, resumeFreshM6Initialization, runFreshM6Initialization } from "./m6-service-initialization"; import { completeM6ServiceInitialization, prepareM6ServiceInitialization } from "../../../src/m6-service-initialization"; import { readServiceAdmission } from "../../../src/service-admission"; import { m6InitializationFixture } from "../../../src/test-support/m6-initialization"; @@ -14,6 +14,8 @@ test("fresh initialization journal drives only acknowledged steps, retains no se const root = mkdtempSync("/tmp/opencode/castloop-fresh-init-"); const calls: string[] = []; try { + expect(readLocalFreshM6Initialization(root, setup.config)).toEqual({ state: null, lockPresent: false }); + expect(existsSync(join(root, ".castloop"))).toBe(false); const journal = await createFreshM6InitializationJournal(root, setup.config, setup.target.operation_id, source, metadata); const effects = { createResources: async () => { calls.push("resources"); expect(journal.load().phase).toBe("resources_requested"); }, @@ -28,6 +30,8 @@ test("fresh initialization journal drives only acknowledged steps, retains no se await runFreshM6Initialization(journal, effects, source, metadata); expect(calls).toEqual(["resources", "deploy", "initialize"]); expect(journal.load().phase).toBe("initialized"); + expect(readLocalFreshM6Initialization(root, setup.config)).toEqual({ state: journal.load(), lockPresent: false }); + expect(() => readLocalFreshM6Initialization(root, { ...setup.config, worker_name: "another-worker" })).toThrow("another service configuration"); expect((await readServiceAdmission(setup.env, setup.config.service_id))!.value.state).toBe("paused"); const text = readFileSync(join(root, ".castloop", "service-initializations", `${setup.config.service_id}.json`), "utf8"); expect(text).not.toContain(metadata.secret); @@ -92,7 +96,8 @@ test("fresh initialization keeps its lock throughout live IO and resumes only an const running = runFreshM6Initialization(journal, effects, source, metadata); await ready; try { - expect(existsSync(join(root, ".castloop", "service-initializations", `${setup.config.service_id}.json.lock`))).toBe(true); + expect(existsSync(join(root, ".castloop", "service-initializations", `${setup.config.service_id}.json.lock`))).toBe(true); + expect(readLocalFreshM6Initialization(root, setup.config)).toEqual({ state: journal.load(), lockPresent: true }); await expect(other.exclusively(async () => {})).rejects.toThrow(); } finally { settled(); await running; } const secondRoot = mkdtempSync("/tmp/opencode/castloop-fresh-init-resume-"); diff --git a/packages/cli/src/m6-service-initialization.ts b/packages/cli/src/m6-service-initialization.ts index 2d3611f..4e1767c 100644 --- a/packages/cli/src/m6-service-initialization.ts +++ b/packages/cli/src/m6-service-initialization.ts @@ -2,7 +2,10 @@ import { z } from "zod"; import { m6RuntimeReadinessSchema, m6RuntimeTargetSchema, m6ServiceConfigHash, serviceConfigSchema } from "@castloop/shared"; import type { M6RuntimeReadiness, M6RuntimeTarget, ServiceConfig } from "@castloop/shared"; import { createLocalJournalStorage } from "./local-journal-storage"; +import { readBoundedLocalJournal } from "./local-journal-read"; +import { ensureLocalJournalParents, localJournalEntryExists } from "./local-journal-path"; import { workerPayloadHash as digest } from "./worker-upload-hash"; +import { join } from "node:path"; const checksum = z.string().regex(/^[a-f0-9]{64}$/); const requestSchema = z.object({ config: serviceConfigSchema, operation_id: z.uuid(), service_config_sha256: checksum, @@ -33,6 +36,15 @@ function validateState(input: unknown): FreshM6InitializationState { return state; } +export function readLocalFreshM6Initialization(root: string, input: ServiceConfig): { state: FreshM6InitializationState | null; lockPresent: boolean } { + const config = serviceConfigSchema.parse(input); + ensureLocalJournalParents(root, "service-initializations", undefined); + const file = join(root, ".castloop", "service-initializations", `${config.service_id}.json`); + const state = localJournalEntryExists(file) ? validateState(readBoundedLocalJournal(file)) : null; + if (state && JSON.stringify(state.request.config) !== JSON.stringify(config)) throw new Error("Local initialization has another service configuration"); + return { state, lockPresent: localJournalEntryExists(`${file}.lock`) }; +} + export async function createFreshM6InitializationJournal(root: string, input: ServiceConfig, operationId: string, source: string, metadata: object): Promise { const config = serviceConfigSchema.parse(input); diff --git a/packages/cli/src/m6-setup-client.test.ts b/packages/cli/src/m6-setup-client.test.ts index 0936489..b2ea510 100644 --- a/packages/cli/src/m6-setup-client.test.ts +++ b/packages/cli/src/m6-setup-client.test.ts @@ -21,7 +21,7 @@ test("setup client brackets authenticated HTTP/Queue checks with sanitized REST await consumeM6SetupProbe(setup.batch(setup.sent[0]), setup.env); } }); expect(readiness.worker_version_id).toBe(setup.versionId); - expect(calls).toEqual(["rest", "POST /admin/setup/prepare", "POST /admin/setup/status", "GET /admin/setup/probe", "GET /admin/setup/probe", "rest", "POST /admin/setup/complete"]); + expect(calls).toEqual(["rest", "GET /admin/health", "POST /admin/setup/prepare", "POST /admin/setup/status", "GET /admin/setup/probe", "GET /admin/setup/probe", "rest", "POST /admin/setup/complete"]); expect((await readServiceAdmission(setup.env, setup.config.service_id))!.value.state).toBe("paused"); expect(setup.sent).toHaveLength(1); }); @@ -48,6 +48,24 @@ test("pending Queue observations and unknown HTTP outcomes never resend prepare } }); +test("setup waits only for read-only health and never prepares against an unreachable or foreign runtime", async () => { + for (const failure of ["unreachable", "version"] as const) { + const setup = await m6SetupFixture(); + const calls: string[] = []; + let delays = 0; + const client = new M6SetupClient(setup.config, "private-secret", { collectM6DeploymentSnapshot: async () => setup.snapshot }, async (url) => { + calls.push(url.pathname); + return failure === "unreachable" ? new Response(null, { status: 503 }) : Response.json({ result: "candidate", m6_ready: false, + worker_version_id: crypto.randomUUID() }, { headers: { "Cache-Control": "no-store" } }); + }); + await expect(client.initialize(setup.request.target, { maximumReads: 2, delay: async () => { delays++; } })).rejects.toThrow("no preparation was sent"); + expect(calls).toEqual(["/admin/health", "/admin/health"]); + expect(delays).toBe(1); + expect(setup.sent).toEqual([]); + expect(await readServiceAdmission(setup.env, setup.config.service_id)).toBeNull(); + } +}); + test("foreign targets, origins, changed deployments and externally cached probes cannot complete setup", async () => { for (const failure of ["target", "deployment", "cache", "receipt"] as const) { const setup = await m6SetupFixture(); diff --git a/packages/cli/src/m6-setup-client.ts b/packages/cli/src/m6-setup-client.ts index d5ddf7a..1b9f7a8 100644 --- a/packages/cli/src/m6-setup-client.ts +++ b/packages/cli/src/m6-setup-client.ts @@ -1,5 +1,5 @@ import { inspectM6WorkerDeployment, M6_FRESH_WORKER_COMPATIBILITY_DATE, m6ServiceConfigHash, m6SetupCompletedSchema, - m6SetupProbeSchema, m6SetupRequestSchema, m6SetupStatusSchema, m6SnapshotReads } from "@castloop/shared"; + m6SetupHealthSchema, m6SetupProbeSchema, m6SetupRequestSchema, m6SetupStatusSchema, m6SnapshotReads } from "@castloop/shared"; import type { M6RuntimeReadiness, M6RuntimeTarget, M6SetupRequest, ServiceConfig } from "@castloop/shared"; import type { CloudflareApi } from "./cloudflare-api"; import { M6AdminJsonClient } from "./m6-admin-json"; @@ -36,6 +36,18 @@ export class M6SetupClient { return snapshot; }; const before = await inspect(); + let reachable = false; + for (let read = 0; read < wait.maximumReads; read += 1) { + try { + const health = m6SetupHealthSchema.parse(await this.admin.getRuntimeHealth()); + if (health.worker_version_id !== request.target.worker_version_id) throw new Error("Another runtime version is reachable"); + reachable = true; + break; + } catch { + if (read + 1 < wait.maximumReads) await wait.delay(); + } + } + if (!reachable) throw new Error("Expected setup Worker is not reachable; no preparation was sent"); let status = m6SetupStatusSchema.parse(await this.admin.post("setup/prepare", request)); requireSameRequest(status.record.request, request); for (let read = 0; !status.record.queue_receipt && read < wait.maximumReads; read += 1) { diff --git a/packages/cli/src/m6-test-cli.ts b/packages/cli/src/m6-test-cli.ts new file mode 100644 index 0000000..ce80d93 --- /dev/null +++ b/packages/cli/src/m6-test-cli.ts @@ -0,0 +1,114 @@ +import { buildM6WorkerUploadMetadata, parseServiceConfig, showMetadataSchema, validateId } from "@castloop/shared"; +import { CloudflareApi } from "./cloudflare-api"; +import { readBoundedLocalJournal } from "./local-journal-read"; +import { createFreshM6RestEffects } from "./m6-initialization-rest"; +import { createM6LocalEpisodeDraft, createM6LocalShowDraft } from "./m6-local-drafts"; +import { publishLocalM6Draft, updateLocalM6Draft } from "./m6-local-update"; +import { M6ServiceClient } from "./m6-service-client"; +import { createFreshM6InitializationJournal, runFreshM6Initialization } from "./m6-service-initialization"; +import { createShowRegistrationJournal } from "./show-registration-journal"; +import { createShowRegistrationEffects, runShowRegistration } from "./show-registration-operation"; +import { TargetInspectionClient } from "./target-inspection-client"; +import { embeddedWorkerSource, WORKER_COMPATIBILITY_DATE } from "./worker-payload"; +import { randomBytes } from "node:crypto"; +import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +const HELP = `Unreleased isolated M6 test binary. Requires castloop.toml in the working directory. +init OPERATION_UUID +service-status | service-pause PAUSE_UUID | service-resume PAUSE_UUID +target-show SHOW_ID | target-episode SHOW_ID EPISODE_ID +create-show SHOW_ID SITE_URL | create-episode SHOW_ID EPISODE_ID +update-show SHOW_ID | update-episode SHOW_ID EPISODE_ID +update-episode-audio SHOW_ID EPISODE_ID MP3_PATH +publish-show SHOW_ID | publish-episode SHOW_ID EPISODE_ID MP3_PATH +No production release, adoption, deletion, automatic resume or unknown-outcome replay.`; + +function argumentsFor(args: string[], count: number): void { + if (args.length !== count || args.some((arg) => !arg || arg.startsWith("--"))) throw new Error("Invalid test command arguments; use --help"); +} + +async function main(): Promise { + const [command, ...args] = process.argv.slice(2); + if (!command || command === "--help") { console.log(HELP); return; } + const counts: Record = { init: 1, "service-status": 0, "service-pause": 1, "service-resume": 1, + "target-show": 1, "target-episode": 2, + "create-show": 2, "create-episode": 2, "update-show": 1, "update-episode": 2, "update-episode-audio": 3, "publish-show": 1, "publish-episode": 3 }; + if (!Object.hasOwn(counts, command)) throw new Error("Unknown test command; use --help"); + argumentsFor(args, counts[command]!); + const root = process.cwd(); + if (!lstatSync(root).isDirectory()) throw new Error("Test workspace must be a real directory"); + const config = parseServiceConfig(readFileSync(join(root, "castloop.toml"), "utf8")); + if (!config.service_id.startsWith("m6-test-") || [config.worker_name, config.bucket_name, config.queue_name, config.dlq_name] + .some((name) => !name.startsWith("castloop-m6-test-"))) throw new Error("Only explicitly named isolated M6 test resources are permitted"); + const directory = join(root, ".castloop"); + const secretFile = join(directory, "secrets.json"); + if (existsSync(directory) && !lstatSync(directory).isDirectory()) throw new Error("Test state parent must not be a symlink"); + if (command === "init") { + if (!embeddedWorkerSource) throw new Error("Build the standalone --m6-test binary before initialization"); + if (!existsSync(directory)) mkdirSync(directory, { mode: 0o700 }); + if (!lstatSync(directory).isDirectory()) throw new Error("Test state parent must not be a symlink"); + if (!existsSync(secretFile)) { + const fd = openSync(secretFile, "wx", 0o600); + try { writeFileSync(fd, JSON.stringify({ CASTLOOP_ADMIN_KEY: randomBytes(32).toString("hex") })); fsyncSync(fd); } + finally { closeSync(fd); } + const parent = openSync(directory, "r"); + try { fsyncSync(parent); } finally { closeSync(parent); } + } + } + const secret = readBoundedLocalJournal(secretFile); + if (!secret || typeof secret !== "object" || !("CASTLOOP_ADMIN_KEY" in secret) || typeof secret.CASTLOOP_ADMIN_KEY !== "string") throw new Error("Missing local administrator key"); + const key = secret.CASTLOOP_ADMIN_KEY; + if (command === "init") { + const source = embeddedWorkerSource!; + const metadata = buildM6WorkerUploadMetadata(config, null, key, WORKER_COMPATIBILITY_DATE); + const journal = await createFreshM6InitializationJournal(root, config, args[0]!, source, metadata); + await runFreshM6Initialization(journal, createFreshM6RestEffects(new CloudflareApi(config), key), source, metadata); + console.log(JSON.stringify({ result: "initialized-paused", operation_id: args[0], runtime_readiness: journal.load().runtime_readiness })); + return; + } + if (command.startsWith("service-")) { + const action = command.slice("service-".length) as "status" | "pause" | "resume"; + const client = new M6ServiceClient(config, key); + const response = await client.call(action === "status" ? { service_id: config.service_id, action } : { service_id: config.service_id, action, pause_id: args[0]! }); + console.log(JSON.stringify(response)); + return; + } + const showId = validateId(args[0]!, "show"); + if (command.startsWith("target-")) { + const target = command === "target-show" ? { kind: "show" as const, show_id: showId } : + { kind: "episode" as const, show_id: showId, episode_id: validateId(args[1]!, "episode") }; + console.log(JSON.stringify(await new TargetInspectionClient(config, key).inspect({ schema_version: 1, service_id: config.service_id, ...target }))); + return; + } + if (command === "create-show") { + showMetadataSchema.shape.site_url.parse(args[1]); + if (existsSync(join(root, showId))) throw new Error("Local Show directory already exists; do not create another registration"); + const reserve = { schema_version: 1 as const, action: "reserve" as const, service_id: config.service_id, show_id: showId, + reservation_id: crypto.randomUUID() }; + const journal = createShowRegistrationJournal(root, config, reserve); + await runShowRegistration(journal, createShowRegistrationEffects(config, key)); + console.log(await createM6LocalShowDraft(root, config, showId, args[1]!)); + return; + } + const episodeId = command.endsWith("show") ? undefined : validateId(args[1]!, "episode"); + if (command === "create-episode") { console.log(await createM6LocalEpisodeDraft(root, config, showId, episodeId!)); return; } + const target = episodeId ? { kind: "episode" as const, show_id: showId, episode_id: episodeId } : { kind: "show" as const, show_id: showId }; + if (command.startsWith("publish-")) { + const state = await publishLocalM6Draft(root, config, target, key, args[2] ? { audioPath: resolve(args[2]) } : {}); + console.log(JSON.stringify({ result: "publication-committed", job_id: state.publication.request.job_id })); + return; + } + const state = await updateLocalM6Draft(root, config, target, command === "update-show" ? { asset: "show" } : command === "update-episode" ? + { asset: "episode_metadata" } : { asset: "audio", audio_path: resolve(args[2]!) }, key, { + rest: { accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "", apiToken: process.env.CLOUDFLARE_API_TOKEN ?? "" }, + }); + if (state.finish_receipt !== "staged") throw new Error("Staging was explicitly aborted; its receipt was retained and publication was not authorized"); + console.log(JSON.stringify({ result: "staged", operation_id: state.upload.operation_id })); +} + +await main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : "M6 test command failed"); + console.error("M6 test command did not complete. Preserve journals, locks and remote owners; do not automatically replay unknown requests."); + process.exitCode = 1; +}); diff --git a/packages/cli/src/m6-worker-deployment.test.ts b/packages/cli/src/m6-worker-deployment.test.ts index 7706cbf..bdf215e 100644 --- a/packages/cli/src/m6-worker-deployment.test.ts +++ b/packages/cli/src/m6-worker-deployment.test.ts @@ -86,6 +86,14 @@ describe("read-only M6 deployment and runtime inspection", () => { for (const privateValue of ["private-admin-secret", "private@example.com", "Private deployment note", "unrelated"]) { expect(JSON.stringify(snapshot)).not.toContain(privateValue); } + const { exports: _exports, ...settingsWithoutExports } = setup.settings; + const observed = { ...setup.reads, settings: async () => settingsWithoutExports }; + expect(await inspectM6WorkerDeployment(M6_DEPLOYMENT_CONFIG, setup.workerVersionId, observed)).toEqual(value); + const withoutExports = await collectM6DeploymentSnapshot(M6_DEPLOYMENT_CONFIG, setup.workerVersionId, observed, M6_WORKER_COMPATIBILITY_DATE); + expect(withoutExports.settings[0].exports).toBeUndefined(); + expect(await inspectM6WorkerDeployment(M6_DEPLOYMENT_CONFIG, setup.workerVersionId, m6SnapshotReads(withoutExports))).toEqual(value); + setup.version.resources.script_runtime.exports.default.cache.enabled = true; + await expect(inspectM6WorkerDeployment(M6_DEPLOYMENT_CONFIG, setup.workerVersionId, observed)).rejects.toThrow("uncached default"); }); test("partial rollout, wrong version and missing deployment are rejected before runtime reads", async () => { diff --git a/packages/cli/src/staging-rest.test.ts b/packages/cli/src/staging-rest.test.ts index 8566b4e..7f37c4b 100644 --- a/packages/cli/src/staging-rest.test.ts +++ b/packages/cli/src/staging-rest.test.ts @@ -34,7 +34,7 @@ async function fixture(kind: "show" | "audio" | "episode_metadata" = "audio") { if (request.method === "PUT") { const bytes = new Uint8Array(await request.arrayBuffer()); await setup.bucket.put(key, bytes); - return Response.json({ success: true, result: { size: bytes.length } }); + return Response.json({ success: true, result: { size: kind === "show" ? String(bytes.length) : bytes.length } }); } const object = await setup.bucket.get(key); return new Response(object!.bytes); @@ -241,7 +241,7 @@ test("REST credentials cannot target another account and session must match dura await setup.sources.dispose(); }); -for (const failure of ["early", "lost", "http", "oversized", "receipt", "hash", "range"] as const) { +for (const failure of ["early", "lost", "http", "oversized", "receipt", "receipt_exponent", "hash", "range"] as const) { test(`REST ${failure} failure never retries PUT or returns arbitrary API diagnostics`, async () => { const setup = await fixture(); const calls: string[] = []; @@ -253,7 +253,8 @@ for (const failure of ["early", "lost", "http", "oversized", "receipt", "hash", if (failure === "lost") throw new Error("private-token arbitrary server exception"); if (failure === "http") return new Response("private-token", { status: 403 }); if (failure === "oversized") return new Response("x".repeat(65537)); - return Response.json({ success: true, result: { size: failure === "receipt" ? 1 : setup.upload.payloads[0]!.length_bytes } }); + const length = setup.upload.payloads[0]!.length_bytes; + return Response.json({ success: true, result: { size: failure === "receipt" ? 1 : failure === "receipt_exponent" ? `${length}e0` : length } }); } return new Response(new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array(setup.upload.payloads[0]!.length_bytes)); if (failure !== "range") controller.close(); }, cancel() { cancelled = true; } }), failure === "range" ? { status: 206, headers: { "content-range": "bytes 0-1/2" } } : undefined); diff --git a/packages/cli/src/staging-rest.ts b/packages/cli/src/staging-rest.ts index c4532d6..c1984c3 100644 --- a/packages/cli/src/staging-rest.ts +++ b/packages/cli/src/staging-rest.ts @@ -28,7 +28,11 @@ async function readReceipt(response: Response, length: number): Promise { } const value: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks))); if (!value || typeof value !== "object" || !("success" in value) || value.success !== true || !("result" in value) || - !value.result || typeof value.result !== "object" || !("size" in value.result) || value.result.size !== length) { + !value.result || typeof value.result !== "object" || !("size" in value.result)) { + throw new Error("Staging PUT receipt size/success was not verified"); + } + const received = typeof value.result.size === "string" && /^\d+$/.test(value.result.size) ? Number(value.result.size) : value.result.size; + if (typeof received !== "number" || !Number.isSafeInteger(received) || received !== length) { throw new Error("Staging PUT receipt size/success was not verified"); } } finally { await reader.cancel(); reader.releaseLock(); } diff --git a/packages/cli/src/test-support/verify-operation-status-binary.ts b/packages/cli/src/test-support/verify-operation-status-binary.ts index 383f08a..98a36a7 100644 --- a/packages/cli/src/test-support/verify-operation-status-binary.ts +++ b/packages/cli/src/test-support/verify-operation-status-binary.ts @@ -9,6 +9,7 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, u import { join, resolve } from "node:path"; const binary = resolve(process.argv[2] ?? "dist/castloop-linux-x64"); +const m6TestBinary = process.argv[3] ? resolve(process.argv[3]) : undefined; if (!existsSync(binary)) throw new Error("Build the Linux CLI binary before this local-only verification"); const root = mkdtempSync("/tmp/opencode/castloop-status-binary-"); const cert = join(root, "localhost-cert.pem"); @@ -87,13 +88,22 @@ try { report.server_status.result === "status" && !output.includes("private-secret"), "Standalone report did not preserve read-only boundaries"); requireCheck(JSON.stringify(snapshot(root)) === before, "Standalone inspection changed local files or old locks"); } + if (m6TestBinary) { + for (const args of [["service-status"], ["init", crypto.randomUUID()], ["delete-show", "daily"], ["create-show", "daily"], ["--help"]]) { + const child = Bun.spawnSync([m6TestBinary, ...args], { cwd: root, env: { PATH: process.env.PATH ?? "" }, stdout: "pipe", stderr: "pipe" }); + requireCheck(child.exitCode === (args[0] === "--help" ? 0 : 1), "Test binary did not reject a production resource or unsupported command"); + requireCheck(!Buffer.concat([child.stdout, child.stderr]).toString().includes("private-secret"), "Test binary printed a secret"); + } + requireCheck(JSON.stringify(snapshot(root)) === before, "Test binary changed a rejected workspace"); + } requireCheck(calls.length === 4 && calls.every((call) => call.action === "status"), "Standalone inspection sent an unexpected request"); requireCheck(setup.writes.length === writes && JSON.stringify(setup.sent) === sent && JSON.stringify(setup.purges) === purges && JSON.stringify([...setup.entries].map(([name, value]) => ({ name, etag: value.etag, hash: new Bun.CryptoHasher("sha256").update(value.bytes).digest("hex") }))) === remoteBefore, "Standalone inspection changed simulated remote records"); console.log(JSON.stringify({ result: "standalone_readonly_operation_status_passed", families: operations.map((operation) => operation.family), transport: "local_https", remote: "simulated_m6_management", requests: calls.length, cloudflare_credentials_present: false, - local_files_unchanged: true, old_locks_preserved: true, remote_records_unchanged: true, authorizes_mutation: false, authorizes_recovery: false })); + local_files_unchanged: true, old_locks_preserved: true, remote_records_unchanged: true, + ...(m6TestBinary ? { test_binary_production_resources_rejected: true } : {}), authorizes_mutation: false, authorizes_recovery: false })); } finally { server?.stop(true); rmSync(root, { recursive: true, force: true }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5b14716..7eafdbf 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -45,8 +45,10 @@ export { buildM6WorkerUploadMetadata, buildMigrationCandidateUpload, inspectM6Wo requireMigrationBridgeSettings, requireMigrationBridgeVersion } from "./m6-worker-deployment"; export type { M6DeploymentReads, M6DeploymentSnapshot, M6WorkerUploadMetadata } from "./m6-worker-deployment"; export { m6SetupRequestSchema, m6SetupRecordSchema, m6SetupQueueProbeSchema, m6SetupProbeSchema, m6SetupStatusSchema, - m6SetupCompleteSchema, m6SetupCompletedSchema } from "./m6-setup"; + m6SetupCompleteSchema, m6SetupCompletedSchema, m6SetupHealthSchema } from "./m6-setup"; export type { M6SetupRequest, M6SetupRecord, M6SetupProbe } from "./m6-setup"; +export { m6ServiceAdminRequestSchema, m6ServiceAdminResponseSchema } from "./m6-service-admin"; +export type { M6ServiceAdminRequest, M6ServiceAdminResponse } from "./m6-service-admin"; export { migrationCandidateUploadSchema } from "./worker-deployment"; export type { MigrationCandidateUpload } from "./worker-deployment"; export { migrationBridgeUploadSchema, migrationBridgePreparationSchema } from "./worker-deployment"; diff --git a/packages/shared/src/m6-service-admin.ts b/packages/shared/src/m6-service-admin.ts new file mode 100644 index 0000000..ae99b40 --- /dev/null +++ b/packages/shared/src/m6-service-admin.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; +import { serviceAdmissionSchema } from "./service-admission"; + +const base = z.object({ service_id: serviceAdmissionSchema.shape.service_id }).strict(); +export const m6ServiceAdminRequestSchema = z.discriminatedUnion("action", [ + base.extend({ action: z.literal("status") }), + base.extend({ action: z.literal("pause"), pause_id: z.uuid() }), + base.extend({ action: z.literal("resume"), pause_id: z.uuid() }), +]); +export const m6ServiceAdminResponseSchema = z.object({ result: z.literal("service"), request: m6ServiceAdminRequestSchema, + worker_version_id: z.uuid(), admission: serviceAdmissionSchema }).strict().superRefine((value, context) => { + if (value.admission.service_id !== value.request.service_id || value.admission.mode !== "m6") { + context.addIssue({ code: "custom", message: "Service response has another service or admission mode" }); + } +}); +export type M6ServiceAdminRequest = z.infer; +export type M6ServiceAdminResponse = z.infer; diff --git a/packages/shared/src/m6-setup.ts b/packages/shared/src/m6-setup.ts index 63cc501..2947f6e 100644 --- a/packages/shared/src/m6-setup.ts +++ b/packages/shared/src/m6-setup.ts @@ -21,6 +21,7 @@ export const m6SetupCompleteSchema = m6SetupRequestSchema.extend({ }).strict(); export const m6SetupCompletedSchema = z.object({ result: z.literal("initialized"), request: m6SetupRequestSchema, readiness: m6RuntimeReadinessSchema }).strict(); +export const m6SetupHealthSchema = z.object({ result: z.literal("candidate"), m6_ready: z.literal(false), worker_version_id: z.uuid() }).strict(); export type M6SetupRequest = z.infer; export type M6SetupRecord = z.infer; export type M6SetupProbe = z.infer; diff --git a/packages/shared/src/m6-worker-deployment.ts b/packages/shared/src/m6-worker-deployment.ts index f08bfaa..b126d12 100644 --- a/packages/shared/src/m6-worker-deployment.ts +++ b/packages/shared/src/m6-worker-deployment.ts @@ -146,7 +146,7 @@ function verifyExports(input: WorkerSettingsSnapshot["exports"]): void { function settingsEvidence(input: unknown, config: ServiceConfig, expectedCompatibilityDate: string): string { const settings = workerSettingsSnapshotSchema.parse(input); verifyBindings(settings.bindings, config); - verifyExports(settings.exports); + if (settings.exports !== undefined) verifyExports(settings.exports); if (settings.cache_options?.enabled !== true || settings.cache_options.cross_version_cache !== false) { throw new Error("M6 requires version-isolated Workers Caching"); } @@ -156,7 +156,8 @@ function settingsEvidence(input: unknown, config: ServiceConfig, expectedCompati if (settings.observability?.enabled !== true || settings.observability.logs?.enabled !== true || settings.observability.traces?.enabled !== true) { throw new Error("M6 requires Worker logs and traces enabled"); } - return JSON.stringify({ date, flags: [...flags].sort(), exports: { default: false, CachedPublicAssets: true }, + return JSON.stringify({ date, flags: [...flags].sort(), exports_present: settings.exports !== undefined, + exports: { default: false, CachedPublicAssets: true }, cache_options: { enabled: true, cross_version_cache: false }, bindings: settings.bindings.filter((binding) => MANAGED_BINDINGS.has(binding.name)).map((binding) => ({ name: binding.name, type: binding.type, ...(binding.name === "CASTLOOP_BUCKET" ? { bucket_name: binding.bucket_name } : {}), diff --git a/scripts/build-cli.ts b/scripts/build-cli.ts index 3a68510..40e9ca0 100644 --- a/scripts/build-cli.ts +++ b/scripts/build-cli.ts @@ -1,4 +1,5 @@ import { WORKER_COMPATIBILITY_DATE } from "../packages/cli/src/worker-payload"; +import { M6_FRESH_WORKER_COMPATIBILITY_DATE } from "../packages/shared/src/index"; import { mkdirSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -10,20 +11,24 @@ const TARGETS = { "macos-arm64": "bun-darwin-arm64", "windows-x64": "bun-windows-x64", } as const; -const requestedTarget = process.argv[2]; -if (process.argv.length > 3 || (requestedTarget && !(requestedTarget in TARGETS))) { - throw new Error(`Usage: bun scripts/build-cli.ts [${Object.keys(TARGETS).join(" | ")}]`); +const args = process.argv.slice(2); +const m6Test = args.includes("--m6-test"); +const targets = args.filter((argument) => argument !== "--m6-test"); +const requestedTarget = targets[0]; +if (args.length > 2 || targets.length > 1 || args.filter((argument) => argument === "--m6-test").length > 1 || (requestedTarget && !(requestedTarget in TARGETS))) { + throw new Error(`Usage: bun scripts/build-cli.ts [${Object.keys(TARGETS).join(" | ")}] [--m6-test]`); } const target = requestedTarget as keyof typeof TARGETS | undefined; +const name = m6Test ? "castloop-m6-test" : "castloop"; const outfile = join(root, "dist", target - ? `castloop-${target}${target === "windows-x64" ? ".exe" : ""}` : "castloop"); -const worker = await Bun.build({ entrypoints: [join(root, "src/index.ts")], - target: "browser", minify: true }); + ? `${name}-${target}${target === "windows-x64" ? ".exe" : ""}` : name); +const worker = await Bun.build({ entrypoints: [join(root, m6Test ? "src/m6-setup-worker.ts" : "src/index.ts")], + target: "browser", minify: true, external: ["cloudflare:workers"] }); if (!worker.success || worker.outputs.length !== 1) throw new Error("Worker bundle build failed"); const source = await worker.outputs[0].text(); mkdirSync(join(root, "dist"), { recursive: true }); const result = await Bun.build({ - entrypoints: [join(root, "packages/cli/src/index.ts")], + entrypoints: [join(root, m6Test ? "packages/cli/src/m6-test-cli.ts" : "packages/cli/src/index.ts")], compile: { outfile, ...(target ? { target: TARGETS[target] } : {}), autoloadDotenv: false, autoloadBunfig: false }, env: "disable", @@ -31,7 +36,7 @@ const result = await Bun.build({ name: "castloop-worker-bundle", setup(build) { build.onLoad({ filter: /worker-payload\.ts$/ }, () => ({ - contents: `export const WORKER_COMPATIBILITY_DATE = ${JSON.stringify(WORKER_COMPATIBILITY_DATE)};\n` + + contents: `export const WORKER_COMPATIBILITY_DATE = ${JSON.stringify(m6Test ? M6_FRESH_WORKER_COMPATIBILITY_DATE : WORKER_COMPATIBILITY_DATE)};\n` + `export const embeddedWorkerSource = ${JSON.stringify(source)};\n`, loader: "ts", })); diff --git a/src/m6-routes.ts b/src/m6-routes.ts index 868e129..b62d435 100644 --- a/src/m6-routes.ts +++ b/src/m6-routes.ts @@ -1,4 +1,3 @@ -import { parseServiceConfig } from "../packages/shared/src/index"; import type { ServiceConfig } from "../packages/shared/src/index"; import { authenticated } from "./admin-auth"; import legacyWorker from "./index"; @@ -24,6 +23,8 @@ import { handleM6ShowRegistrationAdmin } from "./show-registration-admin"; import { handleM6TargetInspection } from "./target-inspection-admin"; import { handleM6SetupAdmin } from "./m6-setup-admin"; import type { M6SetupRuntime } from "./m6-setup-runtime"; +import { handleM6ServiceAdmin } from "./m6-service-admin"; +import { readM6ServiceConfiguration } from "./m6-runtime-readiness"; export type M6CandidateEnv = { CASTLOOP_BUCKET: R2Bucket; @@ -44,9 +45,7 @@ function reply(request: Request, data: object, status: number): Response { } async function serviceConfig(env: M6CandidateEnv): Promise { - const object = await env.CASTLOOP_BUCKET.get("system/service.toml"); - if (!object || object.size < 1 || object.size > 16384) throw new Error("M6 service configuration is missing or oversized"); - const config = parseServiceConfig(await object.text()); + const { config } = await readM6ServiceConfiguration(env); if (config.dlq_name !== env.CASTLOOP_DLQ_NAME) throw new Error("M6 dead-letter binding does not match this service"); return config; } @@ -68,9 +67,10 @@ async function m6ManagementRoute(request: Request, env: M6CandidateEnv, cachedAs options: { digest?: StageStreamDigest }): Promise { const pathname = new URL(request.url).pathname; if (pathname !== "/admin/staging" && pathname !== "/admin/publication" && pathname !== "/admin/lifecycle" && pathname !== "/admin/shows" && - pathname !== "/admin/target") return null; + pathname !== "/admin/target" && pathname !== "/admin/service") return null; if (request.method !== "POST") return reply(request, { error: "method not allowed" }, 405); const bindings = { versionMetadata: env.CASTLOOP_VERSION_METADATA, gatewayProtocol: "m6-uncached-gateway-v1" as const, cachedAssets }; + if (pathname === "/admin/service") return handleM6ServiceAdmin(request, env); if (pathname === "/admin/target") return handleM6TargetInspection(request, env, bindings); if (pathname === "/admin/shows") return handleM6ShowRegistrationAdmin(request, env, bindings); if (pathname === "/admin/staging") return handleM6StagingAdmin(request, env, bindings, options); @@ -124,7 +124,8 @@ async function fetchM6Routes(request: Request, env: M6CandidateEnv, if (management) return management; } if (request.method !== "GET") return reply(request, { error: "Mutation routes are not released on the M6 candidate" }, 409); - if (pathname === "/admin/health") return reply(request, { result: "candidate", m6_ready: false }, 200); + if (pathname === "/admin/health") return reply(request, { result: "candidate", m6_ready: false, + ...(options.setupRuntime ? { worker_version_id: env.CASTLOOP_VERSION_METADATA.id } : {}) }, 200); if (pathname === "/admin/capabilities") { try { return reply(request, await readServiceCapabilities(env, (await serviceConfig(env)).service_id, "m6_candidate"), 200); diff --git a/src/m6-runtime-readiness.ts b/src/m6-runtime-readiness.ts index a561f61..ff5cbe2 100644 --- a/src/m6-runtime-readiness.ts +++ b/src/m6-runtime-readiness.ts @@ -1,6 +1,6 @@ import { m6RuntimeReadinessSchema, m6ServiceConfigHash, m6WorkerDeploymentEvidenceSchema, parseServiceConfig } from "../packages/shared/src/index"; import type { M6RuntimeReadiness, M6RuntimeTarget, ServiceConfig } from "../packages/shared/src/index"; -import type { LifecycleControlEnv } from "./lifecycle-control"; +import type { LifecycleControlEnv, LifecycleReadEnv } from "./lifecycle-control"; export type M6RuntimeEnv = LifecycleControlEnv & { CASTLOOP_VERSION_METADATA: Pick }; export type M6RuntimeChecks = { @@ -14,15 +14,20 @@ export function matchesM6RuntimeTarget(value: M6RuntimeTarget | undefined, targe value.worker_version_id === target.worker_version_id && value.service_config_sha256 === target.service_config_sha256; } -export async function readM6RuntimeConfiguration(env: M6RuntimeEnv, configHash: string, versionId: string): Promise { - if (versionId !== env.CASTLOOP_VERSION_METADATA.id) throw new Error("Runtime verification targets another executing Worker version"); +export async function readM6ServiceConfiguration(env: LifecycleReadEnv): Promise { const object = await env.CASTLOOP_BUCKET.get("system/service.toml"); if (!object || object.size < 1 || object.size > 16384) throw new Error("M6 service configuration is missing or oversized"); const config = parseServiceConfig(await object.text()); - if (await m6ServiceConfigHash(config) !== configHash) throw new Error("M6 service configuration differs from its frozen target"); return { config, etag: object.etag }; } +export async function readM6RuntimeConfiguration(env: M6RuntimeEnv, configHash: string, versionId: string): Promise { + if (versionId !== env.CASTLOOP_VERSION_METADATA.id) throw new Error("Runtime verification targets another executing Worker version"); + const { config, etag } = await readM6ServiceConfiguration(env); + if (await m6ServiceConfigHash(config) !== configHash) throw new Error("M6 service configuration differs from its frozen target"); + return { config, etag }; +} + export async function verifyM6RuntimeReadiness(env: M6RuntimeEnv, target: M6RuntimeTarget, configuration: M6RuntimeConfiguration, checks: M6RuntimeChecks): Promise { const { config, etag } = configuration; diff --git a/src/m6-service-admin.ts b/src/m6-service-admin.ts new file mode 100644 index 0000000..4302b2a --- /dev/null +++ b/src/m6-service-admin.ts @@ -0,0 +1,33 @@ +import { m6ServiceAdminRequestSchema, m6ServiceAdminResponseSchema } from "../packages/shared/src/index"; +import { authenticated } from "./admin-auth"; +import { readBoundedAdminJson } from "./admin-body"; +import { readM6ServiceConfiguration } from "./m6-runtime-readiness"; +import type { M6RuntimeEnv } from "./m6-runtime-readiness"; +import { pauseServiceAdmission, readServiceAdmission, resumeServiceAdmission } from "./service-admission"; + +function reply(data: unknown, status = 200): Response { + return Response.json(data, { status, headers: { "Cache-Control": "no-store" } }); +} + +export async function handleM6ServiceAdmin(request: Request, env: M6RuntimeEnv & { CASTLOOP_ADMIN_KEY: string }): Promise { + if (!authenticated(request, env.CASTLOOP_ADMIN_KEY)) return reply({ reason_code: "service_unauthorized" }, 401); + if (request.method !== "POST") return reply({ reason_code: "service_method_invalid" }, 405); + let input; + try { input = m6ServiceAdminRequestSchema.parse(await readBoundedAdminJson(request)); } + catch { return reply({ reason_code: "service_input_invalid" }, 400); } + try { + const { config } = await readM6ServiceConfiguration(env); + if (config.service_id !== input.service_id) throw new Error("Another service was requested"); + if (input.action !== "status") { + const change = input.action === "pause" ? pauseServiceAdmission : resumeServiceAdmission; + await change(env, input.service_id, input.pause_id, env.CASTLOOP_VERSION_METADATA.id); + } + const admission = await readServiceAdmission(env, input.service_id); + if (!admission) throw new Error("Service has no retained admission"); + return reply(m6ServiceAdminResponseSchema.parse({ result: "service", request: input, + worker_version_id: env.CASTLOOP_VERSION_METADATA.id, admission: admission.value })); + } catch { + console.error(JSON.stringify({ event: "m6_service_blocked", reason_code: "service_operation_blocked" })); + return reply({ reason_code: "service_operation_blocked" }, 409); + } +} diff --git a/src/m6-setup-runtime.ts b/src/m6-setup-runtime.ts index 0e08b52..31f6cb7 100644 --- a/src/m6-setup-runtime.ts +++ b/src/m6-setup-runtime.ts @@ -49,7 +49,7 @@ export async function verifyM6SetupRuntime(env: M6InitializationEnv & { CASTLOOP } if (probes[0]!.invocation_id === probes[1]!.invocation_id) throw new Error("Default entrypoint returned a repeated runtime probe invocation"); for (const [route, reason] of [["staging", "staging_input_invalid"], ["publication", "publication_input_invalid"], - ["lifecycle", "lifecycle_input_invalid"], ["shows", "show_registration_input_invalid"], ["target", "target_input_invalid"]]) { + ["lifecycle", "lifecycle_input_invalid"], ["shows", "show_registration_input_invalid"], ["target", "target_input_invalid"], ["service", "service_input_invalid"]]) { const result = await readM6SetupReply(await runtime.defaultFetch(new Request(`https://castloop.internal/admin/${route}`, { method: "POST", headers: { ...headers, "Content-Type": "application/json" }, body: "{}", })), 400); diff --git a/src/service-admission.ts b/src/service-admission.ts index 07d6c8f..d5e0a32 100644 --- a/src/service-admission.ts +++ b/src/service-admission.ts @@ -29,9 +29,8 @@ async function requireService(env: LifecycleControlEnv, serviceId: string): Prom return snapshot; } -export async function requireM6ServiceRuntime(env: LifecycleReadEnv, serviceId: string, workerVersionId: string | undefined): - Promise { - const snapshot = await readServiceAdmission(env, serviceId); +function verifiedM6Snapshot(snapshot: ServiceAdmissionSnapshot | null, workerVersionId: string | undefined): + ServiceAdmissionSnapshot & { readiness: M6ServiceReadiness } { const readiness = snapshot?.value.runtime_readiness ?? snapshot?.value.readiness; if (!snapshot || snapshot.value.mode !== "m6" || !["open", "paused"].includes(snapshot.value.state) || !readiness) { throw new Error("M6 requires completed service migration readiness or verified runtime readiness"); @@ -40,6 +39,11 @@ export async function requireM6ServiceRuntime(env: LifecycleReadEnv, serviceId: return { ...snapshot, readiness }; } +export async function requireM6ServiceRuntime(env: LifecycleReadEnv, serviceId: string, workerVersionId: string | undefined): + Promise { + return verifiedM6Snapshot(await readServiceAdmission(env, serviceId), workerVersionId); +} + async function write(env: LifecycleControlEnv, snapshot: ServiceAdmissionSnapshot, input: ServiceAdmission): Promise { if (snapshot.value.generation === Number.MAX_SAFE_INTEGER) throw new Error("Service admission generation is exhausted"); const value = serviceAdmissionSchema.parse({ ...input, generation: snapshot.value.generation + 1 }); @@ -99,10 +103,11 @@ export async function withServiceInvocation(env: LifecycleControlEnv, service finally { await releaseServiceInvocation(env, invocation); } } -export async function pauseServiceAdmission(env: LifecycleControlEnv, serviceId: string, pauseId: string): Promise { +export async function pauseServiceAdmission(env: LifecycleControlEnv, serviceId: string, pauseId: string, workerVersionId?: string): Promise { serviceAdmissionSchema.shape.pause_id.unwrap().parse(pauseId); for (let attempt = 0; attempt < CONFLICT_ATTEMPTS; attempt += 1) { const snapshot = await requireService(env, serviceId); + if (workerVersionId !== undefined) verifiedM6Snapshot(snapshot, workerVersionId); if (snapshot.value.state !== "open") { if (snapshot.value.pause_id === pauseId) return; throw new Error("Service admission is paused by another operation"); @@ -113,12 +118,14 @@ export async function pauseServiceAdmission(env: LifecycleControlEnv, serviceId: throw new Error("Service pause conflicted; retry the same pause ID"); } -export async function resumeServiceAdmission(env: LifecycleControlEnv, serviceId: string, pauseId: string): Promise { +export async function resumeServiceAdmission(env: LifecycleControlEnv, serviceId: string, pauseId: string, workerVersionId?: string): Promise { serviceAdmissionSchema.shape.pause_id.unwrap().parse(pauseId); for (let attempt = 0; attempt < CONFLICT_ATTEMPTS; attempt += 1) { const snapshot = await requireService(env, serviceId); + if (workerVersionId !== undefined) verifiedM6Snapshot(snapshot, workerVersionId); if (snapshot.value.state === "open" && snapshot.value.last_resumed_pause_id === pauseId) return; if (snapshot.value.state !== "paused" || snapshot.value.pause_id !== pauseId) throw new Error("Only the current pause owner can resume admission"); + if (workerVersionId !== undefined && snapshot.value.invocations.length) throw new Error("Do not resume while M6 service invocations remain"); const { pause_id: _pause, ...withoutPause } = snapshot.value; if (await write(env, snapshot, { ...withoutPause, state: "open", last_resumed_pause_id: pauseId })) return; } From 7fb62901100beecfad538c2b273cc2db5057c48a Mon Sep 17 00:00:00 2001 From: Akira Shimosako Date: Sun, 4 Oct 2026 01:31:23 +0900 Subject: [PATCH 03/16] feat(m6): connect compatible updates and durable deployment receipts --- design/m6_compatible_updates.md | 20 +++- design/m6_implementation_log.md | 10 ++ design/m6_review_queue.md | 2 +- experiments/m6/tsconfig.json | 2 +- experiments/m6/verify-compatible-update.ts | 111 ++++++++++++++++++ experiments/m6/verify-fresh-service.ts | 12 +- packages/cli/src/cloudflare-api.test.ts | 72 ++++++++---- packages/cli/src/cloudflare-api.ts | 91 +++++++++++--- packages/cli/src/m6-admin-json.ts | 2 +- packages/cli/src/m6-service-update.test.ts | 46 +++++++- packages/cli/src/m6-service-update.ts | 58 +++++++-- packages/cli/src/m6-setup-client.ts | 15 ++- packages/cli/src/m6-test-cli.ts | 39 +++++- packages/cli/src/m6-update-client.test.ts | 81 +++++++++++++ packages/cli/src/m6-update-client.ts | 32 +++++ packages/cli/src/m6-update-rest.ts | 12 +- .../verify-operation-status-binary.ts | 3 +- packages/shared/src/index.ts | 7 +- packages/shared/src/m6-setup.ts | 23 +++- packages/shared/src/worker-deployment.ts | 6 + src/m6-management-routes.test.ts | 2 +- src/m6-routes.ts | 3 + src/m6-service-update.test.ts | 5 +- src/m6-service-update.ts | 25 ++-- src/m6-setup-admin.ts | 19 +-- src/m6-setup-record.ts | 19 +-- src/m6-setup-runtime.ts | 3 +- src/m6-update-admin.ts | 22 ++++ 28 files changed, 643 insertions(+), 99 deletions(-) create mode 100644 experiments/m6/verify-compatible-update.ts create mode 100644 packages/cli/src/m6-update-client.test.ts create mode 100644 packages/cli/src/m6-update-client.ts create mode 100644 src/m6-update-admin.ts diff --git a/design/m6_compatible_updates.md b/design/m6_compatible_updates.md index 1813e8c..4d791e9 100644 --- a/design/m6_compatible_updates.md +++ b/design/m6_compatible_updates.md @@ -1,6 +1,6 @@ # M6: 保存形式を変えない通常更新 -更新日: 2026-10-03 +更新日: 2026-10-04 ## 範囲 @@ -13,8 +13,8 @@ M6稼働済みサービスのWorkerだけを更新する内部経路を追加し 1. 明示pause後にdraining consumer/recoveryを完了する。新規管理操作は既存のservice境界で拒否される。 2. 凍結要求のpause ID・service generation・旧Worker version・config hashを照合する。live service token、unfinished Show owner(uploading/reserved/processing)、未完了Show登録があれば更新しない。 3. Showの小さいcontrol recordだけを検査し、service ETagへのCASで`updating`を獲得する。このCASにより、検査中に起きたconsumer/token変更も拒否する。以降は管理/consumer/recovery/通常配信を閉じる。 -4. durable journalの`deploy_requested`を保存してから、一度だけWorkerをPUTする。秘密値は既存の特定versionからinheritし、compatibility dateや既存のtags等を保持する。 -5. PUT応答のscript ETagと対象versionのscript ETagを照合する。最新versionを見つけたことだけで自分のuploadとは扱わない。REST検査とは別にruntimeの配信・管理・consumerを検証する。 +4. 準備時とupload直前に、Versions APIの最新uploadが旧Worker versionと一致することを確認する。durable journalの`deploy_requested`を保存してから、一度だけVersion Upload APIへPOSTする。凍結metadataは継承元UUIDと保持設定を含む意図全体であり、実multipartにはversion単位のmetadataだけを送り、APIが受理するinherit `version_id: "latest"`へ変換する。script単位のtags/logpush/tail consumer/placement/observabilityは変更しない。 +5. POST応答のversion ID・連番・script ETagを検証し、allowlist receiptだけを即座にjournalへfsync保存する。新versionの連番が旧versionの直後であること、最新二件がacknowledged versionと旧versionそのものであること、そのIDのimmutable version GETのscript ETag、旧deployment/凍結設定が維持されていることを確認してから、そのacknowledged versionだけを100%にするDeployment APIへ一度POSTする。途中の別upload/secret変更・未知の最新version・連番欠落では配備しない。Deployment POSTのIDを保存し、そのIDとGETの100% version・保持設定を照合する。POSTのstrategy/versionsが省略されていてもID自体は必須で、省略値を応答へ捏造しない。応答にversionsがあれば照合する。最新versionを見つけたことだけで自分のuploadとは扱わない。REST検査とは別にruntimeの配信・管理・consumerを検証する。 6. new targetをserver admissionへ固定し、検証前後のdeployment/settings/configを照合してreadinessをCAS更新する。完了はpausedのまま。再開は別の明示操作とする。 admissionの旧`readiness`(legacy移行監査)は変更せず、新versionの稼働判定は`runtime_readiness`へ置く。停止/削除等の通常content制御も変更しない。requestは`system/service-updates//request.json`へ保持し、操作IDを別内容で再利用しない。 @@ -29,14 +29,22 @@ admissionの旧`readiness`(legacy移行監査)は変更せず、新version ## 失敗と制約 -時間/HEAD不在だけでtokenやShow ownerを解放しない。PUTやHTTP応答喪失ではrequestedを保持し、再送・旧readinessへのrollback・自動resumeは行わない。server上の同一targetに対する読み取り専用再検証と、clientによる不明な書込要求の再送は区別する。 +時間/HEAD不在だけでtokenやShow ownerを解放しない。upload/deployment/HTTP応答喪失ではrequestedを保持し、再送・旧readinessへのrollback・自動resumeは行わない。server上の同一targetに対する読み取り専用再検証と、clientによる不明な書込要求の再送は区別する。既存runtime-check記録に属するoperation IDは受付前に拒否し、初期化ID再利用による配備後の記録衝突を防ぐ。 現段階のShow-control検査は一回100件のbounded listであり、truncatedなら配備前に拒否する。runtime budgetを超える大規模サービスの更新手順は未対応で、この上限をサービス全体の公開仕様として決定したものではない。実機のruntime制約に適合することも公開前の受け入れ対象である。 -service境界は、このprotocolを無視した第三者のCloudflare deployを禁止できない。メンテナンス中は別deployを行わない運用を前提とし、REST snapshotやupload ETagの不一致では閉じた状態を保持する。任意の将来の非互換コードや外部deployerを安全にrollbackする一般機構ではない。 +service境界は、このprotocolを無視した第三者のCloudflare操作を禁止できない。メンテナンス中は別deploy/upload/version削除/secret変更を行わない運用を前提とし、REST snapshot・version履歴・upload ETagの不一致では閉じた状態を保持する。最新versionの前後照合はCloudflare上の原子的lockやUUID指定inheritの保証ではない。任意の将来の非互換コードや外部deployerを安全にrollbackする一般機構ではない。 ## 検証と残件 content/Show control/公開音源/revision/staging/job記録の非変更、live owner拒否、遅いconsumerとのCAS競合、別update競合、targetの固定、旧監査証拠保持、journal/要求喪失、RESTの入力凍結/secret inherit/ETag照合を自動テストした。共通runtime検証の異常系は既存初期化テストを利用し、同じ安全条件の重複テストを増やしていない。 -これは内部実装であり、通常`deploy`や公開CLI/HTTP入口には未接続である。新規初期化と同様、実runtime検証adapter・専用Cloudflare受け入れ・unknown outcomeの外部回復は残る。M6 ready=falseと公開書込gateを維持し、実機合格/正式リリースとは区別する。旧形式変換の追加開発は、必要になった場合だけ行う。 +認証付き`/admin/update/begin`と初期化共通のsetup検証、試験専用standaloneの`update-service OPERATION_UUID`を接続した。明示pause/drainが必要で、自動resumeやlegacy変換はしない。通常`deploy`や公開CLI/HTTP入口には未接続で、M6 ready=falseと公開書込gateを維持する。 + +`update-service-verify OPERATION_UUID`は、保存済みのupload/deployment receiptが両方揃った`deploy_requested`だけをGET-only RESTで再検証し、その後に未実行のruntime completionへ進む。upload/deployment/subdomainは再送せず、旧version previewも既に無効であることを検査するだけで書き換えない。acknowledged `deployed`からのcompletion続行も可能である。応答喪失でreceiptが欠けた要求、completion応答喪失、残存lockは昇格せず、今回の続行経路の対象外である。 + +実機の最初の更新試験`castloop-m6-test-49c82745`では、Script PUT APIがinherit bindingの特定version UUIDを拒否した(HTTP 400/code 10057、`latest`のみ対応)。別名`castloop-m6-test-9fcbd823`でもVersion Upload APIが同じUUID指定を拒否した。公開API referenceのUUID対応記述だけを根拠にせず、上記の最新upload/直後の連番/履歴照合を伴うVersion Upload/Deployment APIへ修正した。両環境はserver `updating`/local `deploy_requested`と凍結要求を保持し、再送・GETからの採用・rollback・resume・削除はしていない。workspaceはそれぞれ`/tmp/opencode/`。拒否応答を受けたことだけで復旧済みとは扱わない。 + +`castloop-m6-test-7fefb49f`ではVersion/Deployment POSTまで進んだが、Deployment応答のstrategy/versions省略で停止した。改善前のjournalには中間receiptがないため、GETから自分の要求と推定して続行しない。修正後binaryの`update-service-verify`もこの要求(`38dc81dc-1ac5-42c1-ae78-320f405ec56e`)を拒否した。資源とupdating/requestedはそのまま保持している。 + +修正後の専用Cloudflare受け入れとunknown outcomeの外部回復、M6全体のlifecycle/cache/300MB受け入れは継続する。旧形式変換の追加開発は、必要になった場合だけ行う。 diff --git a/design/m6_implementation_log.md b/design/m6_implementation_log.md index fbcf5bc..e1adacd 100644 --- a/design/m6_implementation_log.md +++ b/design/m6_implementation_log.md @@ -1,5 +1,15 @@ # M6: 公開停止・削除 実装ログ +## 2026-10-04: 通常更新を試験HTTP/standaloneと共通runtime検証へ接続 + +- 更新固有のpaused→updating→paused CASとjournalを維持し、認証付きbeginと初期化共通のHTTP/loopback/Queue/cache検証へ接続した。試験binaryの`update-service`は明示pause/drainが必要で、自動resume/legacy変換/新規資源作成はしない。既存runtime-checkのoperation IDは受付前に拒否する。 +- Script PUTとVersion POSTの両方が実機でinherit UUIDを拒否したため、UUID対応という公開referenceだけを信用して成功扱いにしない。凍結意図は旧version UUIDを保持し、実Version POSTには`latest`を使うが、最新uploadが旧versionであること、acknowledged ID/直後の連番/ETag/最新二件の履歴を照合してからDeployment POSTでそのversionだけを100%にする。外部Cloudflare操作を防ぐ原子的lockとは扱わない。 +- tags/監視等のscript設定はversion metadataから除いて非変更を確認する。欠落/旧ID/別script receipt、設定変更、未知最新version/途中upload/履歴変更、別deploymentを配備前またはreadiness前に拒否する。保持recordに任意exception本文は保存しない。 +- `castloop-m6-test-49c82745`と`castloop-m6-test-9fcbd823`は拒否後のrequested/updatingと凍結要求を保持した。再送/採用/rollback/resume/削除をしていない。別名の隔離環境で修正後の実機受け入れを継続する。 +- 次の`castloop-m6-test-7fefb49f`ではVersion/Deployment POSTまで進み、Deployment応答の省略項目で停止した。受領済みID/連番/ETagを後続検証前にjournalへ即時保存し、ID-only deployment応答をGETの同じdeploymentへ結び付けるよう改善した。中間receiptなしの旧要求は引き続き拒否し、保存済みreceiptが両方ある要求だけ明示`update-service-verify`から非再配備の検証を続行する。preview設定は保持を検査するだけに変更した。 +- `verify-compatible-update.ts`は既知の小さい10 objectのhash/ETag、target/current revision/GUID/URL、paused完了と明示再開/配信/再pauseを検査する。consumer処理中のreadonly照会競合は有限観測で扱い、mutationは再送しない。通常release gateは閉じたままで、unknown IO復旧と全lifecycle/cache/300MBの受け入れは後続。 +- 全881テスト/13370 assertionsとTypeScript/M6実証tsconfig、通常/試験Linux standalone build、四family local HTTPS status・試験binaryのproduction資源拒否が合格した。実機の修正後更新は引き続き受け入れ中であり、この自動テスト合格を実機合格とは扱わない。 + ## 2026-10-04: 試験用standaloneから新規M6初期化・コンテンツ公開を実機検証 - 通常buildを維持し、明示`--m6-test`で別binary/Worker entryを作る。専用資源prefix制限、既存durable登録/draft/upload/publication runner、初期pausedからの明示service再開、非書込target照会を接続した。 diff --git a/design/m6_review_queue.md b/design/m6_review_queue.md index 6d4b860..c0e6067 100644 --- a/design/m6_review_queue.md +++ b/design/m6_review_queue.md @@ -50,7 +50,7 @@ 新規初期化のserver CAS、runtime readinessと移行監査証拠の分離、CLI durable controller、新規資源/WorkerのREST配備adapterを実装した。認証付きHTTP/loopback/Queue起動検証と試験専用standaloneを接続し、別名Cloudflare環境で初期paused→明示再開→Show/Episode公開→GET/HEAD/Rangeを確認した。通常の公開CLI/entryは切り替えず、通常更新・全lifecycle/cache/300MB/unknown IO復旧の受け入れは残る。既存資源の削除/採用はしていない。[新規初期化](./m6_fresh_initialization.md)参照。 -保存形式を変えないM6更新の内部経路も追加した。既存のpause/drainに続いてShow controlの未完了ownerだけを確認し、service CASで閉じてから配備・現在version検証・readiness更新を行う。音源/Episodeのinventoryや変換はしない。共通runtime検証は初期化と共有し、未知token/PUTを時間で終了扱いにしない。公開CLI/実機gateは残る。[通常更新](./m6_compatible_updates.md)参照。 +保存形式を変えないM6更新を、試験専用の認証HTTP/standaloneへ接続した。既存のpause/drainに続いてShow controlの未完了ownerだけを確認し、service CASで閉じてから配備・現在version検証・readiness更新を行う。音源/Episodeのinventoryや変換はしない。共通HTTP/Queue/cache runtime検証は初期化と共有する。実機で二つのupload APIがinheritのUUID指定を拒否したため、最新upload/連番/履歴とacknowledged versionを照合する配備経路へ修正中である。失敗した専用環境はrequested/updatingを保持し、再送/採用/自動再開しない。公開CLI/実機gateは残る。[通常更新](./m6_compatible_updates.md)参照。 2026-10-03に管理者から、MVP前の旧環境は破棄・再作成も許容されるため、メンテナンス移行の必要性を再評価するよう指示された。[移行範囲の再評価](./m6_migration_scope_review.md)では、legacyデータ変換と新規初期化/runtime readinessの分離を推奨している。下記の移行残件を新規M6環境にも一律必須とするかは、この見直しの対象である。現時点では移行実装の撤去・公開gate解除・既存Cloudflare環境削除は行っていない。 diff --git a/experiments/m6/tsconfig.json b/experiments/m6/tsconfig.json index 52e0b0d..07c578b 100644 --- a/experiments/m6/tsconfig.json +++ b/experiments/m6/tsconfig.json @@ -1,5 +1,5 @@ { "extends": "../../tsconfig.json", - "include": ["./cache-worker.ts", "./verify-cache.ts", "./admission-worker.ts", "./verify-admission.ts", "./probe-harness.ts", "./verify-fresh-service.ts"], + "include": ["./cache-worker.ts", "./verify-cache.ts", "./admission-worker.ts", "./verify-admission.ts", "./probe-harness.ts", "./verify-fresh-service.ts", "./verify-compatible-update.ts"], "compilerOptions": { "types": ["../../worker-configuration.d.ts", "node", "bun"] } } diff --git a/experiments/m6/verify-compatible-update.ts b/experiments/m6/verify-compatible-update.ts new file mode 100644 index 0000000..78a0794 --- /dev/null +++ b/experiments/m6/verify-compatible-update.ts @@ -0,0 +1,111 @@ +import { m6RuntimeReadinessSchema, m6ServiceAdminResponseSchema, parseServiceConfig, targetInspectionResponseSchema } from "../../packages/shared/src/index"; +import assert from "node:assert/strict"; +import { readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +if (process.argv.length !== 3) throw new Error("Provide one explicitly acknowledged fresh-binary workspace"); +const root = resolve(process.argv[2]!); +const binary = resolve("dist/castloop-m6-test-linux-x64"); +const config = parseServiceConfig(await readFile(join(root, "castloop.toml"), "utf8")); +const acceptance = JSON.parse(await readFile(join(root, "acceptance.json"), "utf8")) as { result: string; name: string; pause_id: string }; +if (acceptance.result !== "fresh_binary_publication_passed" || acceptance.name !== config.worker_name || + config.account_id !== process.env.CLOUDFLARE_ACCOUNT_ID || !config.worker_name.startsWith("castloop-m6-test-")) { + throw new Error("Compatible acceptance requires its isolated, acknowledged and paused fresh service"); +} +const token = process.env.CLOUDFLARE_API_TOKEN; +if (!token) throw new Error("Cloudflare administration credentials are required"); +const operationId = crypto.randomUUID(); +const observations: Array<{ command: string; result: string }> = []; +let phase = "preflight"; +async function command(...args: string[]): Promise { + const child = Bun.spawn([binary, ...args], { cwd: root, stdout: "pipe", stderr: "pipe" }); + const [code, output, error] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + if (code) { console.error(error); throw new Error("Compatible binary command was not acknowledged; do not replay it"); } + observations.push({ command: args[0]!, result: "confirmed" }); + return output.trim(); +} +const status = async () => m6ServiceAdminResponseSchema.parse(JSON.parse(await command("service-status"))); +const target = async () => targetInspectionResponseSchema.parse(JSON.parse(await command("target-episode", "fresh", "first"))); +async function digestResponse(response: Response): Promise<{ bytes: number; sha256: string }> { + assert.equal(response.status, 200); + assert.ok(response.body); + const reader = response.body.getReader(); + const hash = new Bun.CryptoHasher("sha256"); + let bytes = 0; + try { + for (;;) { + const chunk = await reader.read(); + if (chunk.done) break; + bytes += chunk.value.byteLength; + assert.ok(bytes <= 2_000_000, "This isolated acceptance uses only small known fixtures"); + hash.update(chunk.value); + } + } finally { await reader.cancel(); reader.releaseLock(); } + return { bytes, sha256: hash.digest("hex") }; +} +try { + const before = await status(); + assert.equal(before.admission.state, "paused"); + assert.equal(before.admission.pause_id, acceptance.pause_id); + assert.equal(before.admission.invocations.length, 0); + const original = await target(); + assert.equal(original.unfinished_show_operation, false); + assert.equal(original.episode?.lifecycle, "active"); + assert.ok(original.current_revision); + const revision = original.current_revision; + const keys = ["system/service.toml", "system/shows/fresh/show.toml", "system/show-publications/fresh.json", + "system/show-reservations/fresh.json", "system/episode-lifecycle/fresh/first.toml", "public/episodes/fresh/first/metadata.toml", + `public/episodes/fresh/first/revisions/${revision.revision_id}.toml`, "public/podcasts/fresh/feed.xml", "public/podcasts/fresh/cover.png", + `public/podcasts/fresh/episodes/first/${revision.revision_id}.mp3`]; + const snapshot = async () => { + const objects = []; + for (const key of keys) { + const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${config.account_id}/r2/buckets/${config.bucket_name}/objects/${key}`, { + headers: { Authorization: `Bearer ${token}`, "Accept-Encoding": "identity" }, redirect: "error", signal: AbortSignal.timeout(30000), + }); + objects.push({ key, etag: response.headers.get("ETag"), ...await digestResponse(response) }); + } + return objects; + }; + const payloads = await snapshot(); + await writeFile(join(root, "compatible-update-before.json"), JSON.stringify({ operation_id: operationId, payloads }, null, 2), { mode: 0o600, flag: "wx" }); + phase = "update-request"; + const result = JSON.parse(await command("update-service", operationId)) as { result: string; runtime_readiness: unknown }; + phase = "paused-receipt-verification"; + assert.equal(result.result, "updated-paused"); + const readiness = m6RuntimeReadinessSchema.parse(result.runtime_readiness); + assert.equal(readiness.operation_id, operationId); + assert.notEqual(readiness.worker_version_id, before.worker_version_id); + const completed = await status(); + assert.equal(completed.worker_version_id, readiness.worker_version_id); + assert.equal(completed.admission.state, "paused"); + assert.equal(completed.admission.invocations.length, 0); + assert.deepEqual(completed.admission.runtime_readiness, readiness); + assert.deepEqual(await target(), original); + assert.deepEqual(await snapshot(), payloads); + phase = "explicit-resume"; + await command("service-resume", acceptance.pause_id); + phase = "public-delivery-verification"; + for (const path of ["/podcasts/fresh/feed.xml", "/podcasts/fresh/cover.png", new URL(revision.enclosure_url).pathname]) { + const response = await fetch(new URL(path, config.public_base_url), { redirect: "error", signal: AbortSignal.timeout(30000) }); + assert.equal(response.headers.get("X-Castloop-Worker-Version"), readiness.worker_version_id); + assert.equal(response.headers.get("Cache-Control"), "public, max-age=0, must-revalidate"); + const actual = await digestResponse(response); + const expected = payloads.find((object) => object.key === `public${path}`)!; + assert.equal(actual.sha256, expected.sha256); + assert.equal(actual.bytes, expected.bytes); + } + const pauseId = crypto.randomUUID(); + phase = "explicit-pause"; + await command("service-pause", pauseId); + assert.equal((await status()).admission.invocations.length, 0); + await writeFile(join(root, "compatible-update-acceptance.json"), JSON.stringify({ result: "compatible_binary_update_passed", operation_id: operationId, + name: config.worker_name, previous_worker_version_id: before.worker_version_id, runtime_readiness: readiness, pause_id: pauseId, + objects_unchanged: payloads.length, observations, guid_revision_url_preserved: true, resources_retained_paused: true, authorizes_release: false }, null, 2), { mode: 0o600, flag: "wx" }); + console.log(JSON.stringify({ result: "compatible_binary_update_passed", workspace: root, resources_retained_paused: true })); +} catch { + await writeFile(join(root, "compatible-update-incomplete.json"), JSON.stringify({ result: "compatible_acceptance_incomplete", operation_id: operationId, + phase, observations, resources_not_deleted: true, authorizes_recovery: false }, null, 2), { mode: 0o600, flag: "wx" }); + console.error(`Compatible acceptance did not complete (${phase}). Preserve deployment/requested journals and owners; no automatic replay, rollback or resume.`); + process.exitCode = 1; +} diff --git a/experiments/m6/verify-fresh-service.ts b/experiments/m6/verify-fresh-service.ts index f3c4ea7..f0d2e40 100644 --- a/experiments/m6/verify-fresh-service.ts +++ b/experiments/m6/verify-fresh-service.ts @@ -50,6 +50,10 @@ async function command(...args: string[]): Promise { const child = Bun.spawn([binary, ...args], { cwd: directory, stdout: "pipe", stderr: "pipe" }); const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); if (code !== 0) { + if (["service-status", "target-show", "target-episode"].includes(args[0]!)) { + observations.push({ command: args[0]!, result: "readonly_observation_unconfirmed" }); + return ""; + } observations.push({ command: args[0]!, result: "binary_command_failed" }); console.error(stderr); throw new Error("Binary command did not complete; retain workspace/resources without replay"); @@ -80,7 +84,9 @@ try { } const waitSettledService = async (state: "open" | "paused") => { for (let read = 0; read < 180; read += 1) { - const status = m6ServiceAdminResponseSchema.parse(JSON.parse(await command("service-status"))); + const output = await command("service-status"); + if (!output) { await Bun.sleep(5000); continue; } + const status = m6ServiceAdminResponseSchema.parse(JSON.parse(output)); assert.equal(status.admission.state, state); if (retained) assert.deepEqual(status.admission.runtime_readiness, retained.state!.runtime_readiness); if (!status.admission.invocations.length) return status; @@ -92,7 +98,9 @@ try { const waitPublishedTarget = async (kind: "show" | "episode") => { for (let read = 0; read < 180; read += 1) { const args = kind === "show" ? ["target-show", "fresh"] : ["target-episode", "fresh", "first"]; - const target = targetInspectionResponseSchema.parse(JSON.parse(await command(...args))); + const output = await command(...args); + if (!output) { await Bun.sleep(5000); continue; } + const target = targetInspectionResponseSchema.parse(JSON.parse(output)); if (!target.unfinished_show_operation && target.show?.lifecycle === "active" && (kind === "show" || target.episode?.lifecycle === "active")) return target; await Bun.sleep(5000); } diff --git a/packages/cli/src/cloudflare-api.test.ts b/packages/cli/src/cloudflare-api.test.ts index 5f1da6c..a9f9d7f 100644 --- a/packages/cli/src/cloudflare-api.test.ts +++ b/packages/cli/src/cloudflare-api.test.ts @@ -290,7 +290,9 @@ test("fresh M6 refuses existing or unknown resources and name collisions without } }); -test("compatible REST updates preserve secrets/settings, reject unfrozen inputs and only replace the admitted Worker", async () => { +test.each([null, "missing-receipt", "old-version", "foreign-content", "settings-drift", "foreign-deployment", + "unknown-latest", "intervening-version", "latest-drift", "missing-deployment-receipt", "deployment-id-drift"] as const)( + "compatible REST updates activate only an acknowledged version and preserve secrets/settings (%s)", async (failure) => { const service = { ...config, public_base_url: `https://${worker}.example.workers.dev` }; const previousVersion = crypto.randomUUID(); const nextVersion = crypto.randomUUID(); @@ -301,27 +303,51 @@ test("compatible REST updates preserve secrets/settings, reject unfrozen inputs oldMetadata.tags = ["preserved-tag"]; const writes: string[] = []; let uploaded = false; + let deployed = false; let changed = false; let nextMetadata: typeof oldMetadata | undefined; await withCloudflare(async (request) => { const url = new URL(request.url); if (request.method !== "GET") writes.push(`${request.method} ${url.pathname}`); - if (url.pathname.endsWith(`/workers/scripts/${worker}`) && request.method === "PUT") { + if (url.pathname.endsWith("/versions") && request.method === "POST") { + expect(url.searchParams.get("bindings_inherit")).toBe("strict"); const form = await request.formData(); - expect(JSON.parse(String(form.get("metadata")))).toEqual(nextMetadata); + expect(JSON.parse(String(form.get("metadata")))).toEqual({ main_module: nextMetadata!.main_module, + compatibility_date: nextMetadata!.compatibility_date, compatibility_flags: nextMetadata!.compatibility_flags, + cache_options: nextMetadata!.cache_options, exports: nextMetadata!.exports, + bindings: nextMetadata!.bindings.map((binding) => binding.type === "inherit" ? { ...binding, version_id: "latest" } : binding) }); expect(String(form.get("metadata"))).not.toContain("private-key"); uploaded = true; - return reply({ etag: "compatible-script-content" }); + return reply(failure === "missing-receipt" ? {} : { id: failure === "old-version" ? previousVersion : nextVersion, + number: failure === "intervening-version" ? 3 : 2, resources: { script: { etag: "compatible-script-content" } } }); + } + if (url.pathname.endsWith("/versions")) return reply({ items: uploaded ? [ + { id: failure === "latest-drift" ? crypto.randomUUID() : nextVersion, number: 2 }, { id: previousVersion, number: 1 }, + ] : [{ id: failure === "unknown-latest" && nextMetadata ? crypto.randomUUID() : previousVersion, number: 1 }] }); + const nextRuntime = { ...nextMetadata!, bindings: nextMetadata?.bindings.map((binding) => binding.type === "inherit" ? + oldMetadata.bindings.find((old) => old.name === binding.name)! : binding) ?? [] }; + const runtime = deployed ? nextRuntime : oldMetadata; + if (url.pathname.endsWith("/settings")) return reply(changed || uploaded && failure === "settings-drift" ? { ...runtime, tags: ["changed-tag"] } : runtime); + const deployment = { id: deployed ? nextDeployment : previousDeployment, strategy: "percentage", + versions: [{ version_id: deployed ? nextVersion : previousVersion, percentage: 100 }] }; + if (url.pathname.endsWith("/deployments")) { + if (request.method === "POST") { + expect(uploaded).toBe(true); + expect(await request.json()).toEqual({ strategy: "percentage", versions: [{ version_id: nextVersion, percentage: 100 }] }); + deployed = true; + return reply(failure === "missing-deployment-receipt" ? {} : failure === "foreign-deployment" ? + { id: nextDeployment, strategy: "percentage", versions: [{ version_id: previousVersion, percentage: 100 }] } : + { id: failure === "deployment-id-drift" ? crypto.randomUUID() : nextDeployment }); + } + return reply({ deployments: [deployment] }); + } + for (const version of [previousVersion, ...(uploaded ? [nextVersion] : [])]) { + const value = version === previousVersion ? oldMetadata : nextRuntime; + if (url.pathname.endsWith(`/versions/${version}`)) return reply({ id: version, resources: { bindings: value.bindings, + script: { etag: version === nextVersion && failure === "foreign-content" ? "foreign-script" : "compatible-script-content", + handlers: ["fetch", "queue"], named_handlers: [{ name: "CachedPublicAssets", handlers: ["fetch"] }] }, + script_runtime: { compatibility_date: value.compatibility_date, compatibility_flags: value.compatibility_flags, exports: value.exports } } }); } - const runtime = uploaded ? { ...nextMetadata!, bindings: nextMetadata!.bindings.map((binding) => binding.type === "inherit" ? - oldMetadata.bindings.find((old) => old.name === binding.name)! : binding) } : oldMetadata; - const version = uploaded ? nextVersion : previousVersion; - if (url.pathname.endsWith("/settings")) return reply(changed ? { ...runtime, tags: ["changed-tag"] } : runtime); - if (url.pathname.endsWith("/deployments")) return reply({ deployments: [{ id: uploaded ? nextDeployment : previousDeployment, - strategy: "percentage", versions: [{ version_id: version, percentage: 100 }] }] }); - if (url.pathname.endsWith(`/versions/${version}`)) return reply({ id: version, resources: { bindings: runtime.bindings, - script: { etag: "compatible-script-content", handlers: ["fetch", "queue"], named_handlers: [{ name: "CachedPublicAssets", handlers: ["fetch"] }] }, - script_runtime: { compatibility_date: runtime.compatibility_date, compatibility_flags: runtime.compatibility_flags, exports: runtime.exports } } }); if (url.pathname.endsWith("/subdomain")) return reply({ enabled: true, previews_enabled: false }); if (url.pathname.endsWith("/workers/domains")) return reply([]); throw new Error("Compatible updates cannot create resources or write content"); @@ -336,10 +362,12 @@ test("compatible REST updates preserve secrets/settings, reject unfrozen inputs pause_id: crypto.randomUUID(), expected_service_generation: 1, previous_worker_version_id: previousVersion, service_config_sha256: await m6ServiceConfigHash(service), worker_source_sha256: migrationPayloadHash("new-worker"), worker_metadata_sha256: migrationPayloadHash(nextMetadata) }); - await expect(api.uploadCompatibleM6Worker(service, request, "changed-worker", nextMetadata)).rejects.toThrow("frozen"); - changed = true; - await expect(api.uploadCompatibleM6Worker(service, request, "new-worker", nextMetadata)).rejects.toThrow("metadata was frozen"); - changed = false; + if (failure === null) { + await expect(api.uploadCompatibleM6Worker(service, request, "changed-worker", nextMetadata)).rejects.toThrow("frozen"); + changed = true; + await expect(api.uploadCompatibleM6Worker(service, request, "new-worker", nextMetadata)).rejects.toThrow("metadata was frozen"); + changed = false; + } const readiness = { operation_id: crypto.randomUUID(), deployment_id: previousDeployment, worker_version_id: previousVersion, service_config_sha256: request.service_config_sha256, default_cache_disabled: true, cached_entrypoint: "CachedPublicAssets", cutover_verified: true, publication_routes_verified: true }; @@ -348,13 +376,15 @@ test("compatible REST updates preserve secrets/settings, reject unfrozen inputs let admission = paused; const effects = createM6UpdateRestEffects(api, { begin: async () => {}, complete: async () => { throw new Error("Separate runtime verification"); }, admission: async () => admission }); - await expect(effects.deploy(service, request, "new-worker", nextMetadata)).rejects.toThrow("admitted"); + if (failure === null) await expect(effects.deploy(service, request, "new-worker", nextMetadata)).rejects.toThrow("admitted"); expect(writes).toEqual([]); admission = serviceAdmissionSchema.parse({ ...paused, state: "updating", generation: 2, update: { request } }); - expect(await effects.deploy(service, request, "new-worker", nextMetadata)).toEqual({ deployment_id: nextDeployment, worker_version_id: nextVersion }); + if (failure) await expect(effects.deploy(service, request, "new-worker", nextMetadata)).rejects.toThrow(); + else expect(await effects.deploy(service, request, "new-worker", nextMetadata)).toEqual({ deployment_id: nextDeployment, worker_version_id: nextVersion }); }); - expect(writes).toEqual([`PUT /client/v4/accounts/${accountId}/workers/scripts/${worker}`, - `POST /client/v4/accounts/${accountId}/workers/scripts/${worker}/subdomain`]); + const path = `/client/v4/accounts/${accountId}/workers/scripts/${worker}`; + expect(writes).toEqual([...(failure === "unknown-latest" ? [] : [`POST ${path}/versions`]), + ...(failure === null || ["foreign-deployment", "missing-deployment-receipt", "deployment-id-drift"].includes(failure) ? [`POST ${path}/deployments`] : [])]); }); test("M6 uploads reject missing receipts or another script's content without replaying PUT or activating the deployment", async () => { diff --git a/packages/cli/src/cloudflare-api.ts b/packages/cli/src/cloudflare-api.ts index a63b81e..946b3d1 100644 --- a/packages/cli/src/cloudflare-api.ts +++ b/packages/cli/src/cloudflare-api.ts @@ -1,7 +1,9 @@ -import { collectM6DeploymentSnapshot, m6ServiceConfigHash, m6ServiceUpdateRequestSchema, migrationBootstrapRequestSchema, migrationBridgeDeploymentRequestSchema, migrationBridgeUploadSchema, migrationCandidateUploadSchema, serviceConfigSchema, stringifyToml, workerDeploymentsSnapshotSchema, - workerScriptUploadReceiptSchema, workerSettingsSnapshotSchema, workerSubdomainSnapshotSchema, workerVersionSnapshotSchema } from "@castloop/shared"; +import { collectM6DeploymentSnapshot, m6RuntimeTargetSchema, m6ServiceConfigHash, m6ServiceUpdateRequestSchema, migrationBootstrapRequestSchema, migrationBridgeDeploymentRequestSchema, migrationBridgeUploadSchema, migrationCandidateUploadSchema, serviceConfigSchema, stringifyToml, workerDeploymentsSnapshotSchema, + workerScriptUploadReceiptSchema, workerSettingsSnapshotSchema, workerSubdomainSnapshotSchema, workerVersionSnapshotSchema, + workerVersionUploadReceiptSchema, workerVersionsSnapshotSchema } from "@castloop/shared"; import type { LegacyWorkerInspection, MigrationBootstrapRequest, MigrationBridgeDeploymentEvidence, MigrationBridgeDeploymentRequest, MigrationBridgePreparation, MigrationBridgeUpload, MigrationCandidateUpload, - M6DeploymentReads, M6DeploymentSnapshot, M6ServiceUpdateRequest, M6WorkerDeploymentEvidence, ServiceConfig } from "@castloop/shared"; + M6DeploymentReads, M6DeploymentSnapshot, M6RuntimeTarget, M6ServiceUpdateRequest, M6WorkerDeploymentEvidence, ServiceConfig, WorkerVersionUploadReceipt } from "@castloop/shared"; +import type { M6UpdateDeployReceipts } from "./m6-service-update"; import { buildM6WorkerUploadMetadata, buildMigrationCandidateUpload, inspectM6WorkerDeployment, M6_FRESH_WORKER_COMPATIBILITY_DATE, M6_WORKER_COMPATIBILITY_DATE, requireMigrationBridgeSettings, requireMigrationBridgeVersion } from "./m6-worker-deployment"; import { migrationPayloadHash } from "./migration-deployment"; @@ -142,7 +144,8 @@ export class CloudflareApi { return evidence; } - async prepareCompatibleM6WorkerUpload(input: ServiceConfig, previousVersionId: string): Promise { + async prepareCompatibleM6WorkerUpload(input: ServiceConfig, previousVersionId: string, + expectedLatestVersionId = previousVersionId): Promise { const config = serviceConfigSchema.parse(input); const path = this.migrationWorkerPath(config); const previous = workerSettingsSnapshotSchema.parse(await this.json("GET", `${path}/settings`)); @@ -155,12 +158,13 @@ export class CloudflareApi { if (migrationPayloadHash(current) !== migrationPayloadHash(metadata) || deployment.id !== evidence.deployment_id) { throw new Error("M6 settings or deployment changed during compatible upload preparation"); } + await this.latestM6Version(path, expectedLatestVersionId); metadata.bindings = metadata.bindings.map((binding) => binding.type === "inherit" ? { ...binding, version_id: previousVersionId } : binding); return metadata; } async uploadCompatibleM6Worker(configInput: ServiceConfig, input: M6ServiceUpdateRequest, source: string, - metadata: object): Promise { + metadata: object, receipts?: M6UpdateDeployReceipts): Promise { const config = serviceConfigSchema.parse(configInput); const request = m6ServiceUpdateRequestSchema.parse(input); const metadataSource = JSON.stringify(metadata); @@ -171,18 +175,63 @@ export class CloudflareApi { const path = this.migrationWorkerPath(config); const expected = await this.prepareCompatibleM6WorkerUpload(config, request.previous_worker_version_id); if (migrationPayloadHash(expected) !== request.worker_metadata_sha256) throw new Error("Compatible settings changed after metadata was frozen"); + const previousDeployment = await this.singleMigrationDeployment(path, request.previous_worker_version_id); const form = new FormData(); - form.set("metadata", metadataSource); + form.set("metadata", JSON.stringify({ main_module: expected.main_module, compatibility_date: expected.compatibility_date, + compatibility_flags: expected.compatibility_flags, cache_options: expected.cache_options, exports: expected.exports, + bindings: expected.bindings.map((binding) => binding.type === "inherit" ? { ...binding, version_id: "latest" } : binding) })); form.set("index.js", new Blob([source], { type: "application/javascript+module" }), "index.js"); - const uploaded = await this.jsonUpload(`${path}?bindings_inherit=strict`, form); - const deployment = workerDeploymentsSnapshotSchema.parse(await this.json("GET", `${path}/deployments`)).deployments[0]!; - if (deployment.versions.length !== 1 || deployment.versions[0]!.percentage !== 100 || - deployment.versions[0]!.version_id === request.previous_worker_version_id) throw new Error("Compatible upload has not acknowledged one new version at 100%"); - const versionId = deployment.versions[0]!.version_id; - await this.requireUploadedM6Version(path, versionId, uploaded); - await this.disableMigrationWorkerPreviews(config, versionId); - const evidence = await this.inspectM6WorkerDeployment(config, versionId, expected.compatibility_date); - if (evidence.deployment_id !== deployment.id) throw new Error("Compatible deployment changed after upload"); + const previousVersion = await this.latestM6Version(path, request.previous_worker_version_id); + const uploaded = workerVersionUploadReceiptSchema.parse(await this.jsonUpload(`${path}/versions?bindings_inherit=strict`, form, "POST")); + const versionId = uploaded.id; + if (versionId === request.previous_worker_version_id || uploaded.number !== previousVersion.number + 1) { + throw new Error("Compatible upload did not acknowledge the next version after its exact inheritance source"); + } + receipts?.uploaded(uploaded); + await this.requireUploadedM6Version(path, versionId, uploaded.resources.script); + const current = await this.prepareCompatibleM6WorkerUpload(config, request.previous_worker_version_id, versionId); + if (migrationPayloadHash(current) !== request.worker_metadata_sha256 || + (await this.singleMigrationDeployment(path, request.previous_worker_version_id)).id !== previousDeployment.id) { + throw new Error("Compatible settings or deployment changed before activation; preserve the acknowledged version"); + } + const versions = workerVersionsSnapshotSchema.parse(await this.json("GET", `${path}/versions?page=1&per_page=2`)).items; + if (versions[0]!.id !== versionId || versions[0]!.number !== uploaded.number || + versions[1]?.id !== previousVersion.id || versions[1].number !== previousVersion.number) { + throw new Error("Compatible inheritance history changed; preserve the acknowledged version without deployment"); + } + const deployment = workerDeploymentsSnapshotSchema.shape.deployments.element.partial({ strategy: true, versions: true }).parse(await this.json("POST", `${path}/deployments`, { + strategy: "percentage", versions: [{ version_id: versionId, percentage: 100 }], + })); + if (deployment.versions && (deployment.versions.length !== 1 || deployment.versions[0]!.version_id !== versionId || deployment.versions[0]!.percentage !== 100)) { + throw new Error("Compatible deployment did not acknowledge the uploaded version at 100%"); + } + receipts?.deployed({ deployment_id: deployment.id, worker_version_id: versionId }); + return this.verifyCompatibleM6WorkerDeployment(config, request, uploaded, { deployment_id: deployment.id, worker_version_id: versionId, + operation_id: request.operation_id, service_config_sha256: request.service_config_sha256 }); + } + + async verifyCompatibleM6WorkerDeployment(configInput: ServiceConfig, input: M6ServiceUpdateRequest, upload: WorkerVersionUploadReceipt, + deploymentInput: M6RuntimeTarget): Promise { + const config = serviceConfigSchema.parse(configInput); + const request = m6ServiceUpdateRequestSchema.parse(input); + const uploaded = workerVersionUploadReceiptSchema.parse(upload); + const deployment = m6RuntimeTargetSchema.parse(deploymentInput); + if (config.service_id !== request.service_id || await m6ServiceConfigHash(config) !== request.service_config_sha256 || + deployment.operation_id !== request.operation_id || deployment.service_config_sha256 !== request.service_config_sha256 || + deployment.worker_version_id !== uploaded.id || uploaded.id === request.previous_worker_version_id) throw new Error("Compatible verification has foreign receipts"); + const path = this.migrationWorkerPath(config); + const versionId = uploaded.id; + await this.requireUploadedM6Version(path, versionId, uploaded.resources.script); + const versions = workerVersionsSnapshotSchema.parse(await this.json("GET", `${path}/versions?page=1&per_page=2`)).items; + if (versions[0]!.id !== versionId || versions[0]!.number !== uploaded.number || versions[1]?.id !== request.previous_worker_version_id || + versions[1].number !== uploaded.number - 1 || (await this.singleMigrationDeployment(path, versionId)).id !== deployment.deployment_id) { + throw new Error("Compatible version history or acknowledged deployment changed during verification"); + } + const preserved = await this.prepareCompatibleM6WorkerUpload(config, versionId); + preserved.bindings = preserved.bindings.map((binding) => binding.type === "inherit" ? { ...binding, version_id: request.previous_worker_version_id } : binding); + if (migrationPayloadHash(preserved) !== request.worker_metadata_sha256) throw new Error("Compatible update changed the preserved Worker settings"); + const evidence = await this.inspectM6WorkerDeployment(config, versionId, preserved.compatibility_date); + if (evidence.deployment_id !== deployment.deployment_id) throw new Error("Compatible deployment changed after upload"); return evidence; } @@ -462,10 +511,16 @@ export class CloudflareApi { } } - private async jsonUpload(path: string, body: FormData): Promise { - const response = await this.request("PUT", path, body, undefined, 120000); + private async latestM6Version(path: string, expectedVersionId: string): Promise<{ id: string; number: number }> { + const latest = workerVersionsSnapshotSchema.parse(await this.json("GET", `${path}/versions?page=1&per_page=2`)).items[0]!; + if (latest.id !== expectedVersionId) throw new Error("Compatible inheritance requires the exact previous version to be the latest upload"); + return latest; + } + + private async jsonUpload(path: string, body: FormData, method: "PUT" | "POST" = "PUT"): Promise { + const response = await this.request(method, path, body, undefined, 120000); const data: ApiResult = await response.json(); - if (!data.success) throw new Error(`Cloudflare PUT ${path} did not succeed`); + if (!data.success) throw new Error(`Cloudflare ${method} ${path} did not succeed`); return data.result; } diff --git a/packages/cli/src/m6-admin-json.ts b/packages/cli/src/m6-admin-json.ts index 3a44e03..813f732 100644 --- a/packages/cli/src/m6-admin-json.ts +++ b/packages/cli/src/m6-admin-json.ts @@ -4,7 +4,7 @@ import type { ServiceConfig } from "@castloop/shared"; export type M6AdminTransport = (input: URL, init: RequestInit) => Promise; const RESPONSE_BUDGET = 65536; const ROUTE_LABELS = { staging: "Staging", publication: "Publication", lifecycle: "Lifecycle", shows: "Show registration", target: "Target inspection", service: "Service administration", - "setup/prepare": "Setup preparation", "setup/status": "Setup status", "setup/complete": "Setup completion" }; + "setup/prepare": "Setup preparation", "setup/status": "Setup status", "setup/complete": "Setup completion", "update/begin": "Compatible update admission" }; async function readResponse(response: Response, maximumBytes = RESPONSE_BUDGET): Promise { const length = response.headers.get("Content-Length"); diff --git a/packages/cli/src/m6-service-update.test.ts b/packages/cli/src/m6-service-update.test.ts index fa0fb01..998c55e 100644 --- a/packages/cli/src/m6-service-update.test.ts +++ b/packages/cli/src/m6-service-update.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; -import { createM6UpdateJournal, resumeM6UpdateCompletion, runM6Update } from "./m6-service-update"; +import { createM6UpdateJournal, readLocalM6Update, resumeM6UpdateCompletion, resumeM6UpdateDeploymentVerification, runM6Update } from "./m6-service-update"; +import type { M6UpdateEffects } from "./m6-service-update"; import { migrationPayloadHash } from "./migration-deployment"; import { beginM6ServiceUpdate, completeM6ServiceUpdate } from "../../../src/m6-service-update"; import { readServiceAdmission } from "../../../src/service-admission"; @@ -63,3 +64,46 @@ test("unknown ordinary update requests are retained; changed payloads, phase ski } finally { rmSync(root, { recursive: true, force: true }); } } }); + +test("durable upload/deployment receipts permit explicit verification only, never a second deployment", async () => { + for (const receipt of ["none", "upload-only", "both"] as const) { + const setup = await m6UpdateFixture(); + const request = { ...setup.request, worker_source_sha256: migrationPayloadHash(source), worker_metadata_sha256: migrationPayloadHash(metadata) }; + const root = mkdtempSync("/tmp/opencode/castloop-compatible-receipts-"); + try { + const journal = await createM6UpdateJournal(root, setup.config, request); + const uploaded = { id: setup.newTarget.worker_version_id, number: 2, resources: { script: { etag: "acknowledged-script" } } }; + const calls: string[] = []; + const effects: M6UpdateEffects = { begin: async () => { await beginM6ServiceUpdate(setup.env, request); }, + deploy: async (_config, _request, _source, _metadata, receipts) => { + calls.push("deploy"); + if (receipt !== "none") receipts!.uploaded(uploaded); + if (receipt === "both") receipts!.deployed(setup.newTarget); + throw new Error("Verification interrupted after acknowledged writes"); + }, + verifyDeployment: async (_config, _request, upload, deployment) => { + calls.push("verify"); expect(upload).toEqual(uploaded); expect(deployment).toEqual(setup.newTarget); return deployment; + }, + complete: async () => completeM6ServiceUpdate(setup.newEnv, request, setup.newTarget, setup.updateChecks) }; + await expect(runM6Update(journal, effects, source, metadata)).rejects.toThrow("interrupted"); + expect(readLocalM6Update(root, setup.config, request.operation_id).state).toEqual(journal.load()); + expect(journal.load().phase).toBe("deploy_requested"); + if (receipt === "both") { + await journal.exclusively(async () => { + expect(() => journal.save({ ...journal.load(), upload_receipt: { ...uploaded, number: 3 } })).toThrow("rewrite receipts"); + }); + await expect(resumeM6UpdateDeploymentVerification(journal, { ...effects, + verifyDeployment: async () => { throw new Error("Readonly verification rejected"); } })).rejects.toThrow("rejected"); + expect(journal.load().phase).toBe("deploy_requested"); + await resumeM6UpdateDeploymentVerification(journal, effects); + expect(journal.load().phase).toBe("completed"); + expect((await readServiceAdmission(setup.env, setup.config.service_id))!.value.state).toBe("paused"); + expect(calls).toEqual(["deploy", "verify"]); + } else { + await expect(resumeM6UpdateDeploymentVerification(journal, effects)).rejects.toThrow("both durable"); + expect(calls).toEqual(["deploy"]); + } + await expect(runM6Update(journal, effects, source, metadata)).rejects.toThrow("without replay"); + } finally { rmSync(root, { recursive: true, force: true }); } + } +}); diff --git a/packages/cli/src/m6-service-update.ts b/packages/cli/src/m6-service-update.ts index 45f6916..10d9b48 100644 --- a/packages/cli/src/m6-service-update.ts +++ b/packages/cli/src/m6-service-update.ts @@ -1,18 +1,25 @@ import { z } from "zod"; -import { m6RuntimeReadinessSchema, m6RuntimeTargetSchema, m6ServiceConfigHash, m6ServiceUpdateRequestSchema, serviceConfigSchema } from "@castloop/shared"; -import type { M6RuntimeReadiness, M6RuntimeTarget, M6ServiceUpdateRequest, ServiceConfig } from "@castloop/shared"; +import { m6RuntimeReadinessSchema, m6RuntimeTargetSchema, m6ServiceConfigHash, m6ServiceUpdateRequestSchema, serviceConfigSchema, workerVersionUploadReceiptSchema } from "@castloop/shared"; +import type { M6RuntimeReadiness, M6RuntimeTarget, M6ServiceUpdateRequest, ServiceConfig, WorkerVersionUploadReceipt } from "@castloop/shared"; import { createLocalJournalStorage } from "./local-journal-storage"; +import { ensureLocalJournalParents, localJournalEntryExists } from "./local-journal-path"; import { workerPayloadHash } from "./worker-upload-hash"; const stateSchema = z.object({ schema_version: z.literal(1), config: serviceConfigSchema, request: m6ServiceUpdateRequestSchema, phase: z.enum(["prepared", "begin_requested", "admitted", "deploy_requested", "deployed", "completion_requested", "completed"]), + upload_receipt: workerVersionUploadReceiptSchema.optional(), deployment_receipt: m6RuntimeTargetSchema.optional(), target: m6RuntimeTargetSchema.optional(), runtime_readiness: m6RuntimeReadinessSchema.optional() }).strict(); export type M6UpdateClientState = z.infer; export type M6UpdateJournal = { load: () => M6UpdateClientState; save: (state: M6UpdateClientState) => void; exclusively: (callback: () => Promise) => Promise }; +export type M6UpdateDeployReceipts = { uploaded: (receipt: WorkerVersionUploadReceipt) => void; + deployed: (receipt: { deployment_id: string; worker_version_id: string }) => void }; export type M6UpdateEffects = { begin: (request: M6ServiceUpdateRequest) => Promise; - deploy: (config: ServiceConfig, request: M6ServiceUpdateRequest, source: string, metadata: object) => Promise<{ deployment_id: string; worker_version_id: string }>; + deploy: (config: ServiceConfig, request: M6ServiceUpdateRequest, source: string, metadata: object, + receipts?: M6UpdateDeployReceipts) => Promise<{ deployment_id: string; worker_version_id: string }>; + verifyDeployment?: (config: ServiceConfig, request: M6ServiceUpdateRequest, upload: WorkerVersionUploadReceipt, + deployment: M6RuntimeTarget) => Promise<{ deployment_id: string; worker_version_id: string }>; complete: (request: M6ServiceUpdateRequest, target: M6RuntimeTarget) => Promise; }; @@ -22,7 +29,11 @@ function validate(input: unknown): M6UpdateClientState { if (state.config.service_id !== state.request.service_id || (phase >= 4) !== (state.target !== undefined) || (phase === 6) !== (state.runtime_readiness !== undefined) || state.target && (state.target.operation_id !== state.request.operation_id || state.target.service_config_sha256 !== state.request.service_config_sha256 || - state.target.worker_version_id === state.request.previous_worker_version_id) || state.runtime_readiness && + state.target.worker_version_id === state.request.previous_worker_version_id) || + state.upload_receipt && (phase < 3 || state.upload_receipt.id === state.request.previous_worker_version_id) || + state.deployment_receipt && (!state.upload_receipt || state.deployment_receipt.worker_version_id !== state.upload_receipt.id || + state.deployment_receipt.operation_id !== state.request.operation_id || state.deployment_receipt.service_config_sha256 !== state.request.service_config_sha256 || + state.target && JSON.stringify(state.target) !== JSON.stringify(state.deployment_receipt)) || state.runtime_readiness && (state.runtime_readiness.operation_id !== state.target?.operation_id || state.runtime_readiness.deployment_id !== state.target.deployment_id || state.runtime_readiness.worker_version_id !== state.target.worker_version_id || state.runtime_readiness.service_config_sha256 !== state.target.service_config_sha256)) { throw new Error("Compatible update journal has inconsistent phase or runtime receipts"); @@ -30,6 +41,16 @@ function validate(input: unknown): M6UpdateClientState { return state; } +export function readLocalM6Update(root: string, input: ServiceConfig, operationId: string): { state: M6UpdateClientState | null; lockPresent: boolean } { + const config = serviceConfigSchema.parse(input); + const id = m6ServiceUpdateRequestSchema.shape.operation_id.parse(operationId); + ensureLocalJournalParents(root, "service-updates", config.service_id); + const storage = createLocalJournalStorage(root, "service-updates", config.service_id, `${id}.json`); + const state = localJournalEntryExists(storage.path) ? validate(storage.read()) : null; + if (state && (JSON.stringify(state.config) !== JSON.stringify(config) || state.request.operation_id !== id)) throw new Error("Local update has another frozen identity"); + return { state, lockPresent: localJournalEntryExists(`${storage.path}.lock`) }; +} + export async function createM6UpdateJournal(root: string, configInput: ServiceConfig, requestInput: M6ServiceUpdateRequest): Promise { const config = serviceConfigSchema.parse(configInput); const request = m6ServiceUpdateRequestSchema.parse(requestInput); @@ -52,8 +73,14 @@ export async function createM6UpdateJournal(root: string, configInput: ServiceCo const previous = load(); const phases = stateSchema.shape.phase.options; const distance = phases.indexOf(next.phase) - phases.indexOf(previous.phase); + const { upload_receipt: _previousUpload, deployment_receipt: _previousDeployment, ...previousState } = previous; + const { upload_receipt: _nextUpload, deployment_receipt: _nextDeployment, ...nextState } = next; if (JSON.stringify(next.config) !== JSON.stringify(config) || JSON.stringify(next.request) !== JSON.stringify(request) || - distance < 0 || distance > 1 || distance === 0 && JSON.stringify(next) !== JSON.stringify(previous) || + distance < 0 || distance > 1 || distance === 0 && JSON.stringify(nextState) !== JSON.stringify(previousState) || + previous.upload_receipt && JSON.stringify(previous.upload_receipt) !== JSON.stringify(next.upload_receipt) || + previous.deployment_receipt && JSON.stringify(previous.deployment_receipt) !== JSON.stringify(next.deployment_receipt) || + previous.phase !== "deploy_requested" && (JSON.stringify(previous.upload_receipt) !== JSON.stringify(next.upload_receipt) || + JSON.stringify(previous.deployment_receipt) !== JSON.stringify(next.deployment_receipt)) || previous.target && JSON.stringify(previous.target) !== JSON.stringify(next.target)) throw new Error("Compatible update cannot rewrite receipts or skip phases"); storage.replace(next); } }; @@ -80,8 +107,12 @@ export async function runM6Update(journal: M6UpdateJournal, effects: M6UpdateEff await effects.begin(state.request); journal.save({ ...state, phase: "admitted" }); journal.save({ ...state, phase: "deploy_requested" }); - const deployment = await effects.deploy(state.config, state.request, source, frozenMetadata); - state = { ...state, phase: "deployed", target: m6RuntimeTargetSchema.parse({ ...deployment, + const deployment = await effects.deploy(state.config, state.request, source, frozenMetadata, { + uploaded: (receipt) => journal.save({ ...journal.load(), upload_receipt: receipt }), + deployed: (receipt) => journal.save({ ...journal.load(), deployment_receipt: m6RuntimeTargetSchema.parse({ ...receipt, + operation_id: state.request.operation_id, service_config_sha256: state.request.service_config_sha256 }) }), + }); + state = { ...journal.load(), phase: "deployed", target: m6RuntimeTargetSchema.parse({ ...deployment, operation_id: state.request.operation_id, service_config_sha256: state.request.service_config_sha256 }) }; journal.save(state); await complete(journal, effects); @@ -91,3 +122,16 @@ export async function runM6Update(journal: M6UpdateJournal, effects: M6UpdateEff export async function resumeM6UpdateCompletion(journal: M6UpdateJournal, effects: M6UpdateEffects): Promise { await journal.exclusively(async () => { await complete(journal, effects); }); } + +export async function resumeM6UpdateDeploymentVerification(journal: M6UpdateJournal, effects: M6UpdateEffects): Promise { + await journal.exclusively(async () => { + const state = journal.load(); + if (state.phase !== "deploy_requested" || !state.upload_receipt || !state.deployment_receipt || !effects.verifyDeployment) { + throw new Error("Preserve unknown deployment requests; verification requires both durable upload and deployment receipts"); + } + const deployment = await effects.verifyDeployment(state.config, state.request, state.upload_receipt, state.deployment_receipt); + journal.save({ ...state, phase: "deployed", target: m6RuntimeTargetSchema.parse({ ...deployment, + operation_id: state.request.operation_id, service_config_sha256: state.request.service_config_sha256 }) }); + await complete(journal, effects); + }); +} diff --git a/packages/cli/src/m6-setup-client.ts b/packages/cli/src/m6-setup-client.ts index 1b9f7a8..00dce67 100644 --- a/packages/cli/src/m6-setup-client.ts +++ b/packages/cli/src/m6-setup-client.ts @@ -1,6 +1,6 @@ import { inspectM6WorkerDeployment, M6_FRESH_WORKER_COMPATIBILITY_DATE, m6ServiceConfigHash, m6SetupCompletedSchema, m6SetupHealthSchema, m6SetupProbeSchema, m6SetupRequestSchema, m6SetupStatusSchema, m6SnapshotReads } from "@castloop/shared"; -import type { M6RuntimeReadiness, M6RuntimeTarget, M6SetupRequest, ServiceConfig } from "@castloop/shared"; +import type { M6RuntimeReadiness, M6RuntimeTarget, M6ServiceUpdateRequest, M6SetupRequest, ServiceConfig } from "@castloop/shared"; import type { CloudflareApi } from "./cloudflare-api"; import { M6AdminJsonClient } from "./m6-admin-json"; import type { M6AdminTransport } from "./m6-admin-json"; @@ -25,7 +25,15 @@ export class M6SetupClient { } async initialize(input: M6RuntimeTarget, wait: M6SetupWait = DEFAULT_WAIT): Promise { - const request = m6SetupRequestSchema.parse({ target: input }); + return this.verify({ target: input }, wait); + } + + async verifyUpdate(update: M6ServiceUpdateRequest, target: M6RuntimeTarget, wait: M6SetupWait = DEFAULT_WAIT): Promise { + return this.verify({ target, update_request: update }, wait); + } + + private async verify(input: M6SetupRequest, wait: M6SetupWait): Promise { + const request = m6SetupRequestSchema.parse(input); const config = this.admin.config; if (request.target.service_config_sha256 !== await m6ServiceConfigHash(config)) throw new Error("Setup target has another service configuration"); if (!Number.isSafeInteger(wait.maximumReads) || wait.maximumReads < 1 || wait.maximumReads > 120) throw new Error("Invalid setup observation limit"); @@ -67,9 +75,10 @@ export class M6SetupClient { const after = await inspect(); const completed = m6SetupCompletedSchema.parse(await this.admin.post("setup/complete", { ...request, snapshots: [before, after] })); requireSameRequest(completed.request, request); + if (completed.result !== (request.update_request ? "updated" : "initialized")) throw new Error("Runtime completion acknowledged another operation kind"); const { default_cache_disabled: _defaultCache, cached_entrypoint: _entrypoint, cutover_verified: _cutover, publication_routes_verified: _routes, ...target } = completed.readiness; - requireSameRequest({ target }, request); + requireSameRequest({ ...request, target }, request); return completed.readiness; } } diff --git a/packages/cli/src/m6-test-cli.ts b/packages/cli/src/m6-test-cli.ts index ce80d93..738439d 100644 --- a/packages/cli/src/m6-test-cli.ts +++ b/packages/cli/src/m6-test-cli.ts @@ -1,4 +1,4 @@ -import { buildM6WorkerUploadMetadata, parseServiceConfig, showMetadataSchema, validateId } from "@castloop/shared"; +import { buildM6WorkerUploadMetadata, m6ServiceConfigHash, m6ServiceUpdateRequestSchema, parseServiceConfig, showMetadataSchema, validateId } from "@castloop/shared"; import { CloudflareApi } from "./cloudflare-api"; import { readBoundedLocalJournal } from "./local-journal-read"; import { createFreshM6RestEffects } from "./m6-initialization-rest"; @@ -6,16 +6,22 @@ import { createM6LocalEpisodeDraft, createM6LocalShowDraft } from "./m6-local-dr import { publishLocalM6Draft, updateLocalM6Draft } from "./m6-local-update"; import { M6ServiceClient } from "./m6-service-client"; import { createFreshM6InitializationJournal, runFreshM6Initialization } from "./m6-service-initialization"; +import { createM6UpdateJournal, readLocalM6Update, resumeM6UpdateCompletion, resumeM6UpdateDeploymentVerification, runM6Update } from "./m6-service-update"; +import { M6UpdateClient } from "./m6-update-client"; +import { createM6UpdateRestEffects } from "./m6-update-rest"; import { createShowRegistrationJournal } from "./show-registration-journal"; import { createShowRegistrationEffects, runShowRegistration } from "./show-registration-operation"; import { TargetInspectionClient } from "./target-inspection-client"; import { embeddedWorkerSource, WORKER_COMPATIBILITY_DATE } from "./worker-payload"; +import { workerPayloadHash } from "./worker-upload-hash"; import { randomBytes } from "node:crypto"; import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; const HELP = `Unreleased isolated M6 test binary. Requires castloop.toml in the working directory. init OPERATION_UUID +update-service OPERATION_UUID (requires explicit pause and settled owners) +update-service-verify OPERATION_UUID (only durable acknowledged deployment; never re-upload) service-status | service-pause PAUSE_UUID | service-resume PAUSE_UUID target-show SHOW_ID | target-episode SHOW_ID EPISODE_ID create-show SHOW_ID SITE_URL | create-episode SHOW_ID EPISODE_ID @@ -31,7 +37,7 @@ function argumentsFor(args: string[], count: number): void { async function main(): Promise { const [command, ...args] = process.argv.slice(2); if (!command || command === "--help") { console.log(HELP); return; } - const counts: Record = { init: 1, "service-status": 0, "service-pause": 1, "service-resume": 1, + const counts: Record = { init: 1, "update-service": 1, "update-service-verify": 1, "service-status": 0, "service-pause": 1, "service-resume": 1, "target-show": 1, "target-episode": 2, "create-show": 2, "create-episode": 2, "update-show": 1, "update-episode": 2, "update-episode-audio": 3, "publish-show": 1, "publish-episode": 3 }; if (!Object.hasOwn(counts, command)) throw new Error("Unknown test command; use --help"); @@ -67,6 +73,35 @@ async function main(): Promise { console.log(JSON.stringify({ result: "initialized-paused", operation_id: args[0], runtime_readiness: journal.load().runtime_readiness })); return; } + if (command === "update-service" || command === "update-service-verify") { + const operationId = m6ServiceUpdateRequestSchema.shape.operation_id.parse(args[0]); + const api = new CloudflareApi(config); + const client = new M6UpdateClient(config, key, api); + const effects = createM6UpdateRestEffects(api, { begin: (input) => client.begin(input), + admission: () => client.admission(), complete: (input, target) => client.complete(input, target) }); + if (command === "update-service-verify") { + const retained = readLocalM6Update(root, config, operationId); + if (!retained.state || retained.lockPresent) throw new Error("Verification requires its retained journal without an unknown lock"); + const journal = await createM6UpdateJournal(root, config, retained.state.request); + if (retained.state.phase === "deploy_requested") await resumeM6UpdateDeploymentVerification(journal, effects); + else await resumeM6UpdateCompletion(journal, effects); + console.log(JSON.stringify({ result: "updated-paused", operation_id: operationId, runtime_readiness: journal.load().runtime_readiness })); + return; + } + const source = embeddedWorkerSource; + if (!source) throw new Error("Build the standalone --m6-test binary before a compatible update"); + const status = await new M6ServiceClient(config, key).call({ service_id: config.service_id, action: "status" }); + if (status.admission.state !== "paused" || status.admission.invocations.length) throw new Error("Explicitly pause and drain before a compatible update"); + const metadata = await api.prepareCompatibleM6WorkerUpload(config, status.worker_version_id); + const request = m6ServiceUpdateRequestSchema.parse({ operation_id: operationId, service_id: config.service_id, + pause_id: status.admission.pause_id, expected_service_generation: status.admission.generation, + previous_worker_version_id: status.worker_version_id, service_config_sha256: await m6ServiceConfigHash(config), + worker_source_sha256: workerPayloadHash(source), worker_metadata_sha256: workerPayloadHash(metadata) }); + const journal = await createM6UpdateJournal(root, config, request); + await runM6Update(journal, effects, source, metadata); + console.log(JSON.stringify({ result: "updated-paused", operation_id: operationId, runtime_readiness: journal.load().runtime_readiness })); + return; + } if (command.startsWith("service-")) { const action = command.slice("service-".length) as "status" | "pause" | "resume"; const client = new M6ServiceClient(config, key); diff --git a/packages/cli/src/m6-update-client.test.ts b/packages/cli/src/m6-update-client.test.ts new file mode 100644 index 0000000..70a0a11 --- /dev/null +++ b/packages/cli/src/m6-update-client.test.ts @@ -0,0 +1,81 @@ +import { expect, test } from "bun:test"; +import { m6ServiceUpdateRequestSchema } from "@castloop/shared"; +import { M6UpdateClient } from "./m6-update-client"; +import { createM6UpdateJournal, runM6Update } from "./m6-service-update"; +import { workerPayloadHash } from "./worker-upload-hash"; +import { consumeM6SetupProbe } from "../../../src/m6-setup-queue"; +import { readServiceAdmission } from "../../../src/service-admission"; +import { m6SetupFixture } from "../../../src/test-support/m6-setup"; +import { mkdtempSync, rmSync } from "node:fs"; + +async function fixture() { + const setup = await m6SetupFixture(); + await setup.run(setup.post("prepare", setup.request)); + await consumeM6SetupProbe(setup.batch(setup.sent[0]), setup.env); + expect((await setup.run(setup.post("complete", { ...setup.request, snapshots: [setup.snapshot, setup.snapshot] }))).status).toBe(200); + const source = "compatible-worker-source"; + const metadata = { frozen: "metadata" }; + const request = m6ServiceUpdateRequestSchema.parse({ operation_id: crypto.randomUUID(), service_id: setup.config.service_id, + pause_id: setup.request.target.operation_id, expected_service_generation: (await readServiceAdmission(setup.env, setup.config.service_id))!.value.generation, + previous_worker_version_id: setup.versionId, service_config_sha256: setup.request.target.service_config_sha256, + worker_source_sha256: workerPayloadHash(source), worker_metadata_sha256: workerPayloadHash(metadata) }); + const target = { ...setup.request.target, operation_id: request.operation_id, deployment_id: crypto.randomUUID(), worker_version_id: crypto.randomUUID() }; + const snapshot = structuredClone(setup.snapshot); + snapshot.version.id = target.worker_version_id; + for (const item of snapshot.deployments) { + item.deployments[0]!.id = target.deployment_id; + item.deployments[0]!.versions[0]!.version_id = target.worker_version_id; + } + const paths: string[] = []; + const transport = (url: URL, init: RequestInit) => { paths.push(url.pathname); return setup.run(new Request(url, init)); }; + const client = new M6UpdateClient(setup.config, "private-secret", { collectM6DeploymentSnapshot: async () => snapshot }, transport); + const deployed = () => { setup.env.CASTLOOP_VERSION_METADATA.id = target.worker_version_id; }; + const wait = { maximumReads: 1, delay: async () => { await consumeM6SetupProbe(setup.batch(setup.sent.at(-1)), setup.env); } }; + return { ...setup, source, metadata, update: request, target, client, paths, transport, deployed, wait }; +} + +test("compatible update journal reaches authenticated CAS admission and reuses actual setup HTTP/Queue checks without changing payloads", async () => { + const setup = await fixture(); + const root = mkdtempSync("/tmp/opencode/castloop-update-http-"); + try { + await setup.bucket.put("public/unchanged.mp3", "immutable payload"); + const before = setup.text("public/unchanged.mp3"); + const journal = await createM6UpdateJournal(root, setup.config, setup.update); + await runM6Update(journal, { begin: (request) => setup.client.begin(request), deploy: async () => { + expect((await setup.client.admission()).state).toBe("updating"); + setup.deployed(); return setup.target; + }, complete: (request, target) => setup.client.complete(request, target, setup.wait) }, setup.source, setup.metadata); + expect(journal.load().phase).toBe("completed"); + expect(journal.load().runtime_readiness?.worker_version_id).toBe(setup.target.worker_version_id); + expect((await setup.client.admission()).state).toBe("paused"); + expect(setup.text("public/unchanged.mp3")).toBe(before); + expect(setup.paths.filter((path) => path === "/admin/update/begin")).toHaveLength(1); + expect(setup.paths.filter((path) => path === "/admin/setup/prepare")).toHaveLength(1); + expect(setup.sent).toHaveLength(2); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("compatible update rejects foreign/extra inputs and cannot turn a lost begin response into an automatic redeploy", async () => { + const setup = await fixture(); + const malformed = new Request(`${setup.config.public_base_url}/admin/update/begin`, { method: "POST", headers: { + "X-Castloop-Key": "private-secret" }, body: JSON.stringify({ request: setup.update, ready: true }) }); + expect((await setup.run(new Request(malformed.url, { method: "POST" }))).status).toBe(401); + expect((await setup.run(new Request(malformed.url, { headers: { "X-Castloop-Key": "private-secret" } }))).status).toBe(405); + expect((await setup.run(malformed)).status).toBe(400); + await expect(setup.client.begin({ ...setup.update, service_id: "another" })).rejects.toThrow("another service"); + const root = mkdtempSync("/tmp/opencode/castloop-update-http-unknown-"); + try { + const journal = await createM6UpdateJournal(root, setup.config, setup.update); + const lost = new M6UpdateClient(setup.config, "private-secret", { collectM6DeploymentSnapshot: async () => setup.snapshot }, async (url, init) => { + const response = await setup.transport(url, init); await response.body?.cancel(); throw new Error("Response lost"); + }); + let uploads = 0; + const effects = { begin: (request: typeof setup.update) => lost.begin(request), deploy: async () => { uploads++; return setup.target; }, + complete: () => Promise.resolve(setup.readiness) }; + await expect(runM6Update(journal, effects, setup.source, setup.metadata)).rejects.toThrow("outcome is unknown"); + await expect(runM6Update(journal, effects, setup.source, setup.metadata)).rejects.toThrow("without replay"); + expect(journal.load().phase).toBe("begin_requested"); + expect(uploads).toBe(0); + expect((await setup.client.admission()).state).toBe("updating"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/packages/cli/src/m6-update-client.ts b/packages/cli/src/m6-update-client.ts new file mode 100644 index 0000000..ae3f326 --- /dev/null +++ b/packages/cli/src/m6-update-client.ts @@ -0,0 +1,32 @@ +import { m6ServiceUpdateRequestSchema, m6UpdateAdmittedSchema } from "@castloop/shared"; +import type { M6RuntimeReadiness, M6RuntimeTarget, M6ServiceUpdateRequest, ServiceAdmission, ServiceConfig } from "@castloop/shared"; +import type { CloudflareApi } from "./cloudflare-api"; +import { M6AdminJsonClient } from "./m6-admin-json"; +import type { M6AdminTransport } from "./m6-admin-json"; +import { M6ServiceClient } from "./m6-service-client"; +import { M6SetupClient } from "./m6-setup-client"; +import type { M6SetupWait } from "./m6-setup-client"; + +export class M6UpdateClient { + private readonly admin: M6AdminJsonClient; + private readonly service: M6ServiceClient; + private readonly setup: M6SetupClient; + constructor(config: ServiceConfig, adminKey: string, api: Pick, transport?: M6AdminTransport) { + this.admin = new M6AdminJsonClient(config, adminKey, transport); + this.service = new M6ServiceClient(config, adminKey, transport); + this.setup = new M6SetupClient(config, adminKey, api, transport); + } + async begin(input: M6ServiceUpdateRequest): Promise { + const request = m6ServiceUpdateRequestSchema.parse(input); + if (request.service_id !== this.admin.config.service_id) throw new Error("Compatible update targets another service"); + const response = m6UpdateAdmittedSchema.parse(await this.admin.post("update/begin", { request })); + if (JSON.stringify(response.request) !== JSON.stringify(request)) throw new Error("Compatible admission differs from its frozen request"); + } + async admission(): Promise { + return (await this.service.call({ service_id: this.admin.config.service_id, action: "status" })).admission; + } + async complete(request: M6ServiceUpdateRequest, target: M6RuntimeTarget, wait?: M6SetupWait): Promise { + if (request.service_id !== this.admin.config.service_id) throw new Error("Compatible completion targets another service"); + return this.setup.verifyUpdate(request, target, wait); + } +} diff --git a/packages/cli/src/m6-update-rest.ts b/packages/cli/src/m6-update-rest.ts index fd0957c..62371de 100644 --- a/packages/cli/src/m6-update-rest.ts +++ b/packages/cli/src/m6-update-rest.ts @@ -8,12 +8,20 @@ export function createM6UpdateRestEffects(api: CloudflareApi, controls: Pick { + deploy: async (config, request, source, metadata, receipts) => { const admission = serviceAdmissionSchema.parse(await controls.admission()); if (admission.state !== "updating" || JSON.stringify(admission.update?.request) !== JSON.stringify(request) || admission.update?.target) { throw new Error("Compatible REST upload requires its exact admitted, undeployed service owner"); } - const evidence = await api.uploadCompatibleM6Worker(config, request, source, metadata); + const evidence = await api.uploadCompatibleM6Worker(config, request, source, metadata, receipts); + return { deployment_id: evidence.deployment_id, worker_version_id: evidence.worker_version_id }; + }, + verifyDeployment: async (config, request, upload, deployment) => { + const admission = serviceAdmissionSchema.parse(await controls.admission()); + if (admission.state !== "updating" || JSON.stringify(admission.update?.request) !== JSON.stringify(request) || admission.update?.target) { + throw new Error("Compatible verification requires its exact retained, unverified update owner"); + } + const evidence = await api.verifyCompatibleM6WorkerDeployment(config, request, upload, deployment); return { deployment_id: evidence.deployment_id, worker_version_id: evidence.worker_version_id }; }, }; diff --git a/packages/cli/src/test-support/verify-operation-status-binary.ts b/packages/cli/src/test-support/verify-operation-status-binary.ts index 98a36a7..e6b81c2 100644 --- a/packages/cli/src/test-support/verify-operation-status-binary.ts +++ b/packages/cli/src/test-support/verify-operation-status-binary.ts @@ -89,7 +89,8 @@ try { requireCheck(JSON.stringify(snapshot(root)) === before, "Standalone inspection changed local files or old locks"); } if (m6TestBinary) { - for (const args of [["service-status"], ["init", crypto.randomUUID()], ["delete-show", "daily"], ["create-show", "daily"], ["--help"]]) { + for (const args of [["service-status"], ["init", crypto.randomUUID()], ["update-service", crypto.randomUUID()], ["update-service-verify", crypto.randomUUID()], + ["delete-show", "daily"], ["create-show", "daily"], ["--help"]]) { const child = Bun.spawnSync([m6TestBinary, ...args], { cwd: root, env: { PATH: process.env.PATH ?? "" }, stdout: "pipe", stderr: "pipe" }); requireCheck(child.exitCode === (args[0] === "--help" ? 0 : 1), "Test binary did not reject a production resource or unsupported command"); requireCheck(!Buffer.concat([child.stdout, child.stderr]).toString().includes("private-secret"), "Test binary printed a secret"); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7eafdbf..f73b758 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -38,15 +38,16 @@ export type { ServiceCapabilities } from "./service-capabilities"; export { cachedDeliveryRuntimeSchema } from "./delivery-runtime"; export type { CachedDeliveryRuntime } from "./delivery-runtime"; export { workerSettingsSnapshotSchema, workerDeploymentsSnapshotSchema, workerVersionSnapshotSchema, workerSubdomainSnapshotSchema, - workerScriptUploadReceiptSchema, m6WorkerDeploymentEvidenceSchema } from "./worker-deployment"; + workerScriptUploadReceiptSchema, workerVersionUploadReceiptSchema, workerVersionsSnapshotSchema, m6WorkerDeploymentEvidenceSchema } from "./worker-deployment"; export type { WorkerSettingsSnapshot, M6WorkerDeploymentEvidence } from "./worker-deployment"; export { buildM6WorkerUploadMetadata, buildMigrationCandidateUpload, inspectM6WorkerDeployment, collectM6DeploymentSnapshot, m6DeploymentSnapshotSchema, m6SnapshotReads, M6_WORKER_COMPATIBILITY_DATE, M6_FRESH_WORKER_COMPATIBILITY_DATE, requireMigrationBridgeSettings, requireMigrationBridgeVersion } from "./m6-worker-deployment"; export type { M6DeploymentReads, M6DeploymentSnapshot, M6WorkerUploadMetadata } from "./m6-worker-deployment"; -export { m6SetupRequestSchema, m6SetupRecordSchema, m6SetupQueueProbeSchema, m6SetupProbeSchema, m6SetupStatusSchema, - m6SetupCompleteSchema, m6SetupCompletedSchema, m6SetupHealthSchema } from "./m6-setup"; +export { m6SetupRecordKey, m6SetupRequestSchema, m6SetupRecordSchema, m6SetupQueueProbeSchema, m6SetupProbeSchema, m6SetupStatusSchema, + m6SetupCompleteSchema, m6SetupCompletedSchema, m6SetupHealthSchema, m6UpdateBeginSchema, m6UpdateAdmittedSchema } from "./m6-setup"; export type { M6SetupRequest, M6SetupRecord, M6SetupProbe } from "./m6-setup"; +export type { WorkerVersionUploadReceipt } from "./worker-deployment"; export { m6ServiceAdminRequestSchema, m6ServiceAdminResponseSchema } from "./m6-service-admin"; export type { M6ServiceAdminRequest, M6ServiceAdminResponse } from "./m6-service-admin"; export { migrationCandidateUploadSchema } from "./worker-deployment"; diff --git a/packages/shared/src/m6-setup.ts b/packages/shared/src/m6-setup.ts index 2947f6e..522930c 100644 --- a/packages/shared/src/m6-setup.ts +++ b/packages/shared/src/m6-setup.ts @@ -1,9 +1,15 @@ import { z } from "zod"; import { cachedDeliveryRuntimeSchema } from "./delivery-runtime"; import { m6DeploymentSnapshotSchema } from "./m6-worker-deployment"; -import { m6RuntimeReadinessSchema, m6RuntimeTargetSchema } from "./service-admission"; +import { m6RuntimeReadinessSchema, m6RuntimeTargetSchema, m6ServiceUpdateRequestSchema } from "./service-admission"; -export const m6SetupRequestSchema = z.object({ target: m6RuntimeTargetSchema }).strict(); +export const m6SetupRequestSchema = z.object({ target: m6RuntimeTargetSchema, update_request: m6ServiceUpdateRequestSchema.optional() }).strict().superRefine((value, context) => { + const update = value.update_request; + if (update && (update.operation_id !== value.target.operation_id || update.service_config_sha256 !== value.target.service_config_sha256 || + update.previous_worker_version_id === value.target.worker_version_id)) { + context.addIssue({ code: "custom", message: "Runtime verification differs from its frozen compatible update" }); + } +}); export const m6SetupRecordSchema = z.object({ schema_version: z.literal(1), request: m6SetupRequestSchema, cache_purge_verified: z.literal(true), queue_receipt: z.object({ worker_version_id: z.uuid() }).strict().optional(), @@ -12,16 +18,23 @@ export const m6SetupRecordSchema = z.object({ schema_version: z.literal(1), requ context.addIssue({ code: "custom", message: "Runtime probe Queue receipt differs from its frozen request" }); } }); -export const m6SetupQueueProbeSchema = m6SetupRequestSchema.extend({ type: z.literal("castloop-runtime-probe-v1") }).strict(); +export const m6SetupQueueProbeSchema = m6SetupRequestSchema.safeExtend({ type: z.literal("castloop-runtime-probe-v1") }).strict(); export const m6SetupProbeSchema = z.object({ result: z.literal("runtime-probe"), request: m6SetupRequestSchema, invocation_id: z.uuid(), cached_runtime: cachedDeliveryRuntimeSchema }).strict(); export const m6SetupStatusSchema = z.object({ result: z.literal("setup-status"), record: m6SetupRecordSchema }).strict(); -export const m6SetupCompleteSchema = m6SetupRequestSchema.extend({ +export const m6SetupCompleteSchema = m6SetupRequestSchema.safeExtend({ snapshots: z.tuple([m6DeploymentSnapshotSchema, m6DeploymentSnapshotSchema]), }).strict(); -export const m6SetupCompletedSchema = z.object({ result: z.literal("initialized"), request: m6SetupRequestSchema, +export const m6SetupCompletedSchema = z.object({ result: z.enum(["initialized", "updated"]), request: m6SetupRequestSchema, readiness: m6RuntimeReadinessSchema }).strict(); +export const m6UpdateBeginSchema = z.object({ request: m6ServiceUpdateRequestSchema }).strict(); +export const m6UpdateAdmittedSchema = z.object({ result: z.literal("update-admitted"), request: m6ServiceUpdateRequestSchema }).strict(); export const m6SetupHealthSchema = z.object({ result: z.literal("candidate"), m6_ready: z.literal(false), worker_version_id: z.uuid() }).strict(); export type M6SetupRequest = z.infer; export type M6SetupRecord = z.infer; export type M6SetupProbe = z.infer; + +export function m6SetupRecordKey(operationId: string): string { + m6RuntimeTargetSchema.shape.operation_id.parse(operationId); + return `system/runtime-checks/${operationId}.json`; +} diff --git a/packages/shared/src/worker-deployment.ts b/packages/shared/src/worker-deployment.ts index 390faa6..14e7604 100644 --- a/packages/shared/src/worker-deployment.ts +++ b/packages/shared/src/worker-deployment.ts @@ -38,6 +38,12 @@ export const workerVersionSnapshotSchema = z.object({ id: z.uuid(), resources: z script_runtime: z.object({ compatibility_date: z.string(), compatibility_flags: z.array(z.string()), exports: exportsSchema }), }) }); export const workerScriptUploadReceiptSchema = z.object({ etag: z.string().min(1).max(256) }); +const workerVersionIdentitySchema = z.object({ id: z.uuid(), number: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER) }); +export const workerVersionsSnapshotSchema = z.object({ items: z.array(workerVersionIdentitySchema).min(1).max(2) }); +export const workerVersionUploadReceiptSchema = workerVersionIdentitySchema.extend({ resources: z.object({ + script: workerScriptUploadReceiptSchema, +}) }); +export type WorkerVersionUploadReceipt = z.infer; export const workerSubdomainSnapshotSchema = z.object({ enabled: z.boolean(), previews_enabled: z.boolean() }); export const m6WorkerDeploymentEvidenceSchema = z.object({ diff --git a/src/m6-management-routes.test.ts b/src/m6-management-routes.test.ts index d28239e..d077275 100644 --- a/src/m6-management-routes.test.ts +++ b/src/m6-management-routes.test.ts @@ -148,7 +148,7 @@ describe("unreleased M6 management fetch integration", () => { const sent: unknown[] = []; const env = routeEnv(setup, sent, setup.versionId); const assets = cachedLoopback(setup); - for (const path of ["/admin/shows/reserve", "/admin/publications/claim", "/admin/jobs/retry", "/admin/lifecycle/delete"]) { + for (const path of ["/admin/shows/reserve", "/admin/publications/claim", "/admin/jobs/retry", "/admin/lifecycle/delete", "/admin/update/begin"]) { expect((await fetchM6Candidate(post(path, {}), env, assets)).status).toBe(409); expect((await fetchM6ManagementIntegration(post(path, {}), env, assets)).status).toBe(409); } diff --git a/src/m6-routes.ts b/src/m6-routes.ts index b62d435..5a250e9 100644 --- a/src/m6-routes.ts +++ b/src/m6-routes.ts @@ -25,6 +25,7 @@ import { handleM6SetupAdmin } from "./m6-setup-admin"; import type { M6SetupRuntime } from "./m6-setup-runtime"; import { handleM6ServiceAdmin } from "./m6-service-admin"; import { readM6ServiceConfiguration } from "./m6-runtime-readiness"; +import { handleM6UpdateAdmin } from "./m6-update-admin"; export type M6CandidateEnv = { CASTLOOP_BUCKET: R2Bucket; @@ -112,6 +113,8 @@ async function fetchM6Routes(request: Request, env: M6CandidateEnv, if (!pathname.startsWith("/admin/")) return reply(request, { error: "not found" }, 404); if (!authenticated(request, env.CASTLOOP_ADMIN_KEY)) return reply(request, { error: "unauthorized" }, 401); if (managementIntegration && options.setupRuntime) { + const update = await handleM6UpdateAdmin(request, env); + if (update) return update; const setup = await handleM6SetupAdmin(request, env, options.setupRuntime); if (setup) return setup; } diff --git a/src/m6-service-update.test.ts b/src/m6-service-update.test.ts index 32c1ede..04ea5e2 100644 --- a/src/m6-service-update.test.ts +++ b/src/m6-service-update.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { parseShowControl, serviceAdmissionSchema } from "../packages/shared/src/index"; +import { m6SetupRecordKey, parseShowControl, serviceAdmissionSchema } from "../packages/shared/src/index"; import { beginM6ServiceUpdate, completeM6ServiceUpdate } from "./m6-service-update"; import { acquireServiceInvocation, readServiceAdmission, releaseServiceInvocation, requireM6ServiceRuntime, resumeServiceAdmission, SERVICE_ADMISSION_KEY } from "./service-admission"; @@ -37,9 +37,10 @@ test("compatible update closes IO, verifies the new runtime, preserves every con }); test("compatible updates retain unknown invocation/upload/publication owners and refuse incomplete registration", async () => { - for (const state of ["token", "uploading", "reserved", "processing", "registration", "deleting"] as const) { + for (const state of ["token", "uploading", "reserved", "processing", "registration", "deleting", "runtime-id"] as const) { const setup = await fixture(); if (state === "token") await acquireServiceInvocation(setup.env, setup.config.service_id, "m6_consumer"); + else if (state === "runtime-id") await setup.bucket.put(m6SetupRecordKey(setup.request.operation_id), "retained-runtime-check"); else await setup.bucket.put("system/show-publications/daily.json", JSON.stringify(parseShowControl({ schema_version: 2, show_id: "daily", lifecycle: state === "deleting" ? "deleting" : "active", generation: 0, feed_generation: 0, ...(state === "registration" ? { reservation_id: crypto.randomUUID() } : state === "deleting" ? {} : { owner: { job_id: crypto.randomUUID(), kind: "show", diff --git a/src/m6-service-update.ts b/src/m6-service-update.ts index a54fc9e..23c7ee9 100644 --- a/src/m6-service-update.ts +++ b/src/m6-service-update.ts @@ -1,14 +1,15 @@ -import { m6RuntimeTargetSchema, m6ServiceUpdateRequestSchema, serviceAdmissionSchema } from "../packages/shared/src/index"; +import { m6RuntimeTargetSchema, m6ServiceUpdateRequestSchema, m6SetupRecordKey, serviceAdmissionSchema } from "../packages/shared/src/index"; import type { M6RuntimeReadiness, M6RuntimeTarget, M6ServiceUpdateRequest } from "../packages/shared/src/index"; import { readShowControl } from "./lifecycle-control"; import { matchesM6RuntimeTarget, readM6RuntimeConfiguration, verifyM6RuntimeReadiness } from "./m6-runtime-readiness"; -import type { M6RuntimeChecks, M6RuntimeEnv } from "./m6-runtime-readiness"; +import type { M6RuntimeChecks, M6RuntimeConfiguration, M6RuntimeEnv } from "./m6-runtime-readiness"; import { readServiceAdmission, SERVICE_ADMISSION_KEY } from "./service-admission"; +import type { ServiceAdmissionSnapshot } from "./service-admission"; import { requireShowReservationReady } from "./show-reservation-record"; export type M6UpdateEnv = M6RuntimeEnv & { CASTLOOP_BUCKET: Pick }; -async function requireFrozenUpdateRequest(env: M6UpdateEnv, request: M6ServiceUpdateRequest): Promise { +export async function requireFrozenUpdateRequest(env: M6UpdateEnv, request: M6ServiceUpdateRequest): Promise { const object = await env.CASTLOOP_BUCKET.get(`system/service-updates/${request.operation_id}/request.json`); if (!object || object.size < 1 || object.size > 16384 || JSON.stringify(m6ServiceUpdateRequestSchema.parse(await object.json())) !== JSON.stringify(request)) { @@ -43,6 +44,9 @@ export async function beginM6ServiceUpdate(env: M6UpdateEnv, input: M6ServiceUpd (snapshot.value.runtime_readiness ?? snapshot.value.readiness)?.worker_version_id !== request.previous_worker_version_id) { throw new Error("Compatible update requires its exact paused M6 service, previous runtime and no live invocations"); } + if (await env.CASTLOOP_BUCKET.head(m6SetupRecordKey(request.operation_id))) { + throw new Error("Compatible update ID already belongs to permanent runtime verification"); + } await requireSettledShowOwners(env); if ((await readM6RuntimeConfiguration(env, request.service_config_sha256, request.previous_worker_version_id)).etag !== configuration.etag) { throw new Error("Service configuration changed before compatible update admission"); @@ -61,8 +65,8 @@ export async function beginM6ServiceUpdate(env: M6UpdateEnv, input: M6ServiceUpd } } -export async function completeM6ServiceUpdate(env: M6UpdateEnv, input: M6ServiceUpdateRequest, targetInput: M6RuntimeTarget, - checks: M6RuntimeChecks): Promise { +export async function prepareM6ServiceUpdate(env: M6UpdateEnv, input: M6ServiceUpdateRequest, targetInput: M6RuntimeTarget): + Promise<{ configuration: M6RuntimeConfiguration; snapshot: ServiceAdmissionSnapshot; target: M6RuntimeTarget }> { const request = m6ServiceUpdateRequestSchema.parse(input); const target = m6RuntimeTargetSchema.parse(targetInput); if (target.operation_id !== request.operation_id || target.service_config_sha256 !== request.service_config_sha256 || @@ -71,7 +75,7 @@ export async function completeM6ServiceUpdate(env: M6UpdateEnv, input: M6Service const configuration = await readM6RuntimeConfiguration(env, request.service_config_sha256, target.worker_version_id); if (configuration.config.service_id !== request.service_id) throw new Error("Compatible update belongs to another service"); let snapshot = await readServiceAdmission(env, request.service_id); - if (matchesM6RuntimeTarget(snapshot?.value.runtime_readiness, target)) return snapshot!.value.runtime_readiness!; + if (matchesM6RuntimeTarget(snapshot?.value.runtime_readiness, target)) return { configuration, snapshot: snapshot!, target }; if (!snapshot || snapshot.value.state !== "updating" || JSON.stringify(snapshot.value.update?.request) !== JSON.stringify(request)) { throw new Error("Only the exact retained compatible update can complete"); } @@ -84,11 +88,18 @@ export async function completeM6ServiceUpdate(env: M6UpdateEnv, input: M6Service } if (!snapshot || JSON.stringify(snapshot.value.update?.request) !== JSON.stringify(request) || !matchesM6RuntimeTarget(snapshot.value.update?.target, target)) throw new Error("Compatible update already targets another deployment"); + return { configuration, snapshot, target }; +} + +export async function completeM6ServiceUpdate(env: M6UpdateEnv, input: M6ServiceUpdateRequest, targetInput: M6RuntimeTarget, + checks: M6RuntimeChecks): Promise { + const { configuration, snapshot, target } = await prepareM6ServiceUpdate(env, input, targetInput); + if (matchesM6RuntimeTarget(snapshot.value.runtime_readiness, target)) return snapshot.value.runtime_readiness!; const readiness = await verifyM6RuntimeReadiness(env, target, configuration, checks); const { update: _update, ...value } = snapshot.value; const next = serviceAdmissionSchema.parse({ ...value, generation: value.generation + 1, state: "paused", runtime_readiness: readiness }); if (!await env.CASTLOOP_BUCKET.put(SERVICE_ADMISSION_KEY, JSON.stringify(next), { onlyIf: { etagMatches: snapshot.etag } })) { - const current = await readServiceAdmission(env, request.service_id); + const current = await readServiceAdmission(env, configuration.config.service_id); if (!matchesM6RuntimeTarget(current?.value.runtime_readiness, target)) throw new Error("Compatible update admission changed during completion"); return current!.value.runtime_readiness!; } diff --git a/src/m6-setup-admin.ts b/src/m6-setup-admin.ts index b327e05..a9a0c8b 100644 --- a/src/m6-setup-admin.ts +++ b/src/m6-setup-admin.ts @@ -8,7 +8,9 @@ import { m6SetupRecordKey, readM6SetupRecord, requireM6SetupOwner } from "./m6-s import { describeM6SetupProbe, verifyM6SetupRuntime } from "./m6-setup-runtime"; import type { M6SetupRuntime } from "./m6-setup-runtime"; import { readM6RuntimeConfiguration } from "./m6-runtime-readiness"; +import type { M6RuntimeChecks } from "./m6-runtime-readiness"; import { readServiceAdmission } from "./service-admission"; +import { completeM6ServiceUpdate, prepareM6ServiceUpdate } from "./m6-service-update"; type SetupEnv = M6InitializationEnv & { CASTLOOP_ADMIN_KEY: string; CASTLOOP_QUEUE: Pick }; function reply(data: unknown, status = 200): Response { @@ -29,21 +31,22 @@ export async function handleM6SetupAdmin(request: Request, env: SetupEnv, runtim const object = await env.CASTLOOP_BUCKET.get(m6SetupRecordKey(operationId)); if (!object || object.size > 16384) throw new Error("Missing runtime probe"); const record = m6SetupRecordSchema.parse(await object.json()); - input = m6SetupRequestSchema.parse({ target: record.request.target }); + input = m6SetupRequestSchema.parse(record.request); } else input = await readBoundedAdminJson(request); input = route === "complete" ? m6SetupCompleteSchema.parse(input) : m6SetupRequestSchema.parse(input); } catch { return reply({ reason_code: "setup_input_invalid" }, 400); } const frozen = route === "complete" ? m6SetupCompleteSchema.parse(input) : m6SetupRequestSchema.parse(input); - const owner = { target: frozen.target }; + const owner = { target: frozen.target, ...(frozen.update_request ? { update_request: frozen.update_request } : {}) }; try { if (route === "prepare") { - await prepareM6ServiceInitialization(env, owner.target); + if (owner.update_request) await prepareM6ServiceUpdate(env, owner.update_request, owner.target); + else await prepareM6ServiceInitialization(env, owner.target); await requireM6SetupOwner(env, owner); const existing = await readM6SetupRecord(env, owner); if (!existing) { const { config } = await readM6RuntimeConfiguration(env, owner.target.service_config_sha256, owner.target.worker_version_id); const admission = (await readServiceAdmission(env, config.service_id))!.value; - if (admission.state !== "initializing") throw new Error("Completed initialization cannot acquire a new runtime probe"); + if (!["initializing", "updating"].includes(admission.state)) throw new Error("Completed runtime cannot acquire a new runtime probe"); await runtime.invalidate(`setup-${owner.target.operation_id.slice(0, 20)}`); await requireM6SetupOwner(env, owner); const value = m6SetupRecordSchema.parse({ schema_version: 1, request: owner, cache_purge_verified: true }); @@ -58,12 +61,14 @@ export async function handleM6SetupAdmin(request: Request, env: SetupEnv, runtim if (route === "complete") { const completion = m6SetupCompleteSchema.parse(frozen); let inspection = 0; - const readiness = await completeM6ServiceInitialization(env, owner.target, { + const checks: M6RuntimeChecks = { inspectDeployment: (config, versionId) => inspectM6WorkerDeployment(config, versionId, m6SnapshotReads(completion.snapshots[inspection++]!), M6_FRESH_WORKER_COMPATIBILITY_DATE), verifyRuntime: () => verifyM6SetupRuntime(env, owner, runtime), - }); - return reply(m6SetupCompletedSchema.parse({ result: "initialized", request: owner, readiness })); + }; + const readiness = owner.update_request ? await completeM6ServiceUpdate(env, owner.update_request, owner.target, checks) : + await completeM6ServiceInitialization(env, owner.target, checks); + return reply(m6SetupCompletedSchema.parse({ result: owner.update_request ? "updated" : "initialized", request: owner, readiness })); } const record = await readM6SetupRecord(env, owner); if (!record) throw new Error("Runtime probe was not prepared"); diff --git a/src/m6-setup-record.ts b/src/m6-setup-record.ts index 82d7de3..04ebec5 100644 --- a/src/m6-setup-record.ts +++ b/src/m6-setup-record.ts @@ -1,13 +1,11 @@ -import { m6SetupRecordSchema, m6SetupRequestSchema } from "../packages/shared/src/index"; +import { m6SetupRecordKey, m6SetupRecordSchema, m6SetupRequestSchema } from "../packages/shared/src/index"; import type { M6SetupRecord, M6SetupRequest } from "../packages/shared/src/index"; import type { M6InitializationEnv } from "./m6-service-initialization"; import { matchesM6RuntimeTarget, readM6RuntimeConfiguration } from "./m6-runtime-readiness"; import { readServiceAdmission } from "./service-admission"; +import { requireFrozenUpdateRequest } from "./m6-service-update"; -export function m6SetupRecordKey(operationId: string): string { - m6SetupRequestSchema.shape.target.shape.operation_id.parse(operationId); - return `system/runtime-checks/${operationId}.json`; -} +export { m6SetupRecordKey } from "../packages/shared/src/index"; export async function readM6SetupRecord(env: M6InitializationEnv, input: M6SetupRequest): Promise<{ value: M6SetupRecord; etag: string } | null> { const request = m6SetupRequestSchema.parse(input); @@ -22,11 +20,18 @@ export async function readM6SetupRecord(env: M6InitializationEnv, input: M6Setup export async function requireM6SetupOwner(env: M6InitializationEnv, input: M6SetupRequest): Promise { const request = m6SetupRequestSchema.parse(input); const { config } = await readM6RuntimeConfiguration(env, request.target.service_config_sha256, request.target.worker_version_id); + if (request.update_request) { + if (request.update_request.service_id !== config.service_id) throw new Error("Runtime verification targets another update service"); + await requireFrozenUpdateRequest(env, request.update_request); + } const admission = await readServiceAdmission(env, config.service_id); if (!admission || admission.value.mode !== "m6" || admission.value.invocations.length || - !(admission.value.state === "initializing" && matchesM6RuntimeTarget(admission.value.initialization, request.target) || + !(admission.value.state === "initializing" && !request.update_request && matchesM6RuntimeTarget(admission.value.initialization, request.target) || + admission.value.state === "updating" && request.update_request && + JSON.stringify(admission.value.update?.request) === JSON.stringify(request.update_request) && + matchesM6RuntimeTarget(admission.value.update?.target, request.target) || admission.value.state === "paused" && matchesM6RuntimeTarget(admission.value.runtime_readiness, request.target))) { - throw new Error("Runtime probe requires its exact initializing or completed paused service owner"); + throw new Error("Runtime probe requires its exact initialization/update or completed paused service owner"); } return config.service_id; } diff --git a/src/m6-setup-runtime.ts b/src/m6-setup-runtime.ts index 31f6cb7..b7ca105 100644 --- a/src/m6-setup-runtime.ts +++ b/src/m6-setup-runtime.ts @@ -49,7 +49,8 @@ export async function verifyM6SetupRuntime(env: M6InitializationEnv & { CASTLOOP } if (probes[0]!.invocation_id === probes[1]!.invocation_id) throw new Error("Default entrypoint returned a repeated runtime probe invocation"); for (const [route, reason] of [["staging", "staging_input_invalid"], ["publication", "publication_input_invalid"], - ["lifecycle", "lifecycle_input_invalid"], ["shows", "show_registration_input_invalid"], ["target", "target_input_invalid"], ["service", "service_input_invalid"]]) { + ["lifecycle", "lifecycle_input_invalid"], ["shows", "show_registration_input_invalid"], ["target", "target_input_invalid"], ["service", "service_input_invalid"], + ["update/begin", "update_input_invalid"]]) { const result = await readM6SetupReply(await runtime.defaultFetch(new Request(`https://castloop.internal/admin/${route}`, { method: "POST", headers: { ...headers, "Content-Type": "application/json" }, body: "{}", })), 400); diff --git a/src/m6-update-admin.ts b/src/m6-update-admin.ts new file mode 100644 index 0000000..09f7efa --- /dev/null +++ b/src/m6-update-admin.ts @@ -0,0 +1,22 @@ +import { m6UpdateAdmittedSchema, m6UpdateBeginSchema } from "../packages/shared/src/index"; +import { authenticated } from "./admin-auth"; +import { readBoundedAdminJson } from "./admin-body"; +import { beginM6ServiceUpdate } from "./m6-service-update"; +import type { M6UpdateEnv } from "./m6-service-update"; + +export async function handleM6UpdateAdmin(request: Request, env: M6UpdateEnv & { CASTLOOP_ADMIN_KEY: string }): Promise { + if (new URL(request.url).pathname !== "/admin/update/begin") return null; + const reply = (body: object, status: number) => Response.json(body, { status, headers: { "Cache-Control": "no-store" } }); + if (!authenticated(request, env.CASTLOOP_ADMIN_KEY)) return reply({ reason_code: "update_unauthorized" }, 401); + if (request.method !== "POST") return reply({ reason_code: "update_method_invalid" }, 405); + let input; + try { input = m6UpdateBeginSchema.parse(await readBoundedAdminJson(request)); } + catch { return reply({ reason_code: "update_input_invalid" }, 400); } + try { + await beginM6ServiceUpdate(env, input.request); + return reply(m6UpdateAdmittedSchema.parse({ result: "update-admitted", request: input.request }), 200); + } catch { + console.error(JSON.stringify({ event: "m6_update_blocked", reason_code: "update_admission_blocked" })); + return reply({ reason_code: "update_admission_blocked" }, 409); + } +} From 2870ea2aead37c66887b5ee6e487da8358754901 Mon Sep 17 00:00:00 2001 From: Akira Shimosako Date: Sun, 4 Oct 2026 01:38:38 +0900 Subject: [PATCH 04/16] docs(m6): record verified standalone compatible update --- design/m6_compatible_updates.md | 4 +++- design/m6_implementation_log.md | 3 ++- design/m6_review_queue.md | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/design/m6_compatible_updates.md b/design/m6_compatible_updates.md index 4d791e9..2d1b334 100644 --- a/design/m6_compatible_updates.md +++ b/design/m6_compatible_updates.md @@ -47,4 +47,6 @@ content/Show control/公開音源/revision/staging/job記録の非変更、live `castloop-m6-test-7fefb49f`ではVersion/Deployment POSTまで進んだが、Deployment応答のstrategy/versions省略で停止した。改善前のjournalには中間receiptがないため、GETから自分の要求と推定して続行しない。修正後binaryの`update-service-verify`もこの要求(`38dc81dc-1ac5-42c1-ae78-320f405ec56e`)を拒否した。資源とupdating/requestedはそのまま保持している。 -修正後の専用Cloudflare受け入れとunknown outcomeの外部回復、M6全体のlifecycle/cache/300MB受け入れは継続する。旧形式変換の追加開発は、必要になった場合だけ行う。 +修正後の`castloop-m6-test-ff3bfd8c`(`/tmp/opencode/castloop-m6-test-ff3bfd8c`)でstandalone通常更新が合格した。operation `4a778ece-9d99-4993-b076-cf132ce99c40`、旧version `5fed1fdd-a4b9-49e3-ba07-7562bb1e94a4`から新version `1e29cc26-676a-430f-95a8-1040b84c6f9c`、deployment `3b5232b1-2d7d-4240-99a3-ec3bba31cc7e`を確認した。service/Show/Episode controls、metadata/revision/feed/cover/audioの既知10 objectはbytes/hash/ETagとも非変更、GUID/revision/URLも維持された。実HTTP/Queue/cache起動検証後にpausedで完了し、明示再開後のnew version配信と媒体checksum/再検証header、明示再pause・空invocation registryを確認した。記録はworkspaceの`compatible-update-acceptance.json`。 + +これは小さいfixtureの通常更新受け入れであり、unknown outcomeの外部回復やM6全体のlifecycle/cache HIT/300MB受け入れ、正式releaseとは区別する。これらは④で継続する。旧形式変換の追加開発は、必要になった場合だけ行う。 diff --git a/design/m6_implementation_log.md b/design/m6_implementation_log.md index e1adacd..fef57d3 100644 --- a/design/m6_implementation_log.md +++ b/design/m6_implementation_log.md @@ -8,7 +8,8 @@ - `castloop-m6-test-49c82745`と`castloop-m6-test-9fcbd823`は拒否後のrequested/updatingと凍結要求を保持した。再送/採用/rollback/resume/削除をしていない。別名の隔離環境で修正後の実機受け入れを継続する。 - 次の`castloop-m6-test-7fefb49f`ではVersion/Deployment POSTまで進み、Deployment応答の省略項目で停止した。受領済みID/連番/ETagを後続検証前にjournalへ即時保存し、ID-only deployment応答をGETの同じdeploymentへ結び付けるよう改善した。中間receiptなしの旧要求は引き続き拒否し、保存済みreceiptが両方ある要求だけ明示`update-service-verify`から非再配備の検証を続行する。preview設定は保持を検査するだけに変更した。 - `verify-compatible-update.ts`は既知の小さい10 objectのhash/ETag、target/current revision/GUID/URL、paused完了と明示再開/配信/再pauseを検査する。consumer処理中のreadonly照会競合は有限観測で扱い、mutationは再送しない。通常release gateは閉じたままで、unknown IO復旧と全lifecycle/cache/300MBの受け入れは後続。 -- 全881テスト/13370 assertionsとTypeScript/M6実証tsconfig、通常/試験Linux standalone build、四family local HTTPS status・試験binaryのproduction資源拒否が合格した。実機の修正後更新は引き続き受け入れ中であり、この自動テスト合格を実機合格とは扱わない。 +- 全881テスト/13370 assertionsとTypeScript/M6実証tsconfig、通常/試験Linux standalone build、四family local HTTPS status・試験binaryのproduction資源拒否が合格した。 +- 修正後の`castloop-m6-test-ff3bfd8c`でstandalone更新→共通runtime検証→paused完了→明示再開/配信→再pauseが合格した。既知10 objectのbytes/hash/ETag、GUID/revision/URLを維持した。資源と小さいfixtureはpausedで保持し、失敗した旧要求は採用しない。詳細・対象IDは`m6_compatible_updates.md`。次は④のunknown IO復旧と全lifecycle/cache/300MB/CLI利用案内へ進み、通常release gateは維持する。 ## 2026-10-04: 試験用standaloneから新規M6初期化・コンテンツ公開を実機検証 diff --git a/design/m6_review_queue.md b/design/m6_review_queue.md index c0e6067..9ffcbc8 100644 --- a/design/m6_review_queue.md +++ b/design/m6_review_queue.md @@ -50,7 +50,7 @@ 新規初期化のserver CAS、runtime readinessと移行監査証拠の分離、CLI durable controller、新規資源/WorkerのREST配備adapterを実装した。認証付きHTTP/loopback/Queue起動検証と試験専用standaloneを接続し、別名Cloudflare環境で初期paused→明示再開→Show/Episode公開→GET/HEAD/Rangeを確認した。通常の公開CLI/entryは切り替えず、通常更新・全lifecycle/cache/300MB/unknown IO復旧の受け入れは残る。既存資源の削除/採用はしていない。[新規初期化](./m6_fresh_initialization.md)参照。 -保存形式を変えないM6更新を、試験専用の認証HTTP/standaloneへ接続した。既存のpause/drainに続いてShow controlの未完了ownerだけを確認し、service CASで閉じてから配備・現在version検証・readiness更新を行う。音源/Episodeのinventoryや変換はしない。共通HTTP/Queue/cache runtime検証は初期化と共有する。実機で二つのupload APIがinheritのUUID指定を拒否したため、最新upload/連番/履歴とacknowledged versionを照合する配備経路へ修正中である。失敗した専用環境はrequested/updatingを保持し、再送/採用/自動再開しない。公開CLI/実機gateは残る。[通常更新](./m6_compatible_updates.md)参照。 +保存形式を変えないM6更新を、試験専用の認証HTTP/standaloneへ接続した。既存のpause/drainに続いてShow controlの未完了ownerだけを確認し、service CASで閉じてから配備・現在version検証・readiness更新を行う。音源/Episodeのinventoryや変換はしない。共通HTTP/Queue/cache runtime検証は初期化と共有する。実機で二つのupload APIがinheritのUUID指定を拒否したため、最新upload/連番/履歴とacknowledged versionを照合する配備経路へ修正し、中間receiptもdurable保存する。`castloop-m6-test-ff3bfd8c`で通常更新・10 object非変更・GUID/revision/URL保持・明示再開/再pauseが合格した。失敗した専用環境はrequested/updatingを保持し、再送/採用/自動再開しない。全lifecycle/cache/300MB/unknown IO復旧と公開CLI gateは残る。[通常更新](./m6_compatible_updates.md)参照。 2026-10-03に管理者から、MVP前の旧環境は破棄・再作成も許容されるため、メンテナンス移行の必要性を再評価するよう指示された。[移行範囲の再評価](./m6_migration_scope_review.md)では、legacyデータ変換と新規初期化/runtime readinessの分離を推奨している。下記の移行残件を新規M6環境にも一律必須とするかは、この見直しの対象である。現時点では移行実装の撤去・公開gate解除・既存Cloudflare環境削除は行っていない。 From 6c788737ac9ac1f2e46f9310c87646250ffa7fe0 Mon Sep 17 00:00:00 2001 From: Akira Shimosako Date: Sun, 4 Oct 2026 01:58:25 +0900 Subject: [PATCH 05/16] feat(m6): reconcile lost runtime completion without remote writes --- design/m6_compatible_updates.md | 2 +- design/m6_completion_recovery.md | 27 ++++++++++ design/m6_implementation_log.md | 7 +++ .../cli/src/m6-service-initialization.test.ts | 30 ++++++++++- packages/cli/src/m6-service-initialization.ts | 25 ++++++++- packages/cli/src/m6-service-update.ts | 10 ++++ packages/cli/src/m6-setup-client.test.ts | 36 +++++++++++++ packages/cli/src/m6-setup-client.ts | 52 ++++++++++++++----- packages/cli/src/m6-test-cli.ts | 25 ++++++--- packages/cli/src/m6-update-client.test.ts | 27 +++++++++- packages/cli/src/m6-update-client.ts | 4 ++ .../verify-operation-status-binary.ts | 1 + 12 files changed, 222 insertions(+), 24 deletions(-) create mode 100644 design/m6_completion_recovery.md diff --git a/design/m6_compatible_updates.md b/design/m6_compatible_updates.md index 2d1b334..a65f909 100644 --- a/design/m6_compatible_updates.md +++ b/design/m6_compatible_updates.md @@ -41,7 +41,7 @@ content/Show control/公開音源/revision/staging/job記録の非変更、live 認証付き`/admin/update/begin`と初期化共通のsetup検証、試験専用standaloneの`update-service OPERATION_UUID`を接続した。明示pause/drainが必要で、自動resumeやlegacy変換はしない。通常`deploy`や公開CLI/HTTP入口には未接続で、M6 ready=falseと公開書込gateを維持する。 -`update-service-verify OPERATION_UUID`は、保存済みのupload/deployment receiptが両方揃った`deploy_requested`だけをGET-only RESTで再検証し、その後に未実行のruntime completionへ進む。upload/deployment/subdomainは再送せず、旧version previewも既に無効であることを検査するだけで書き換えない。acknowledged `deployed`からのcompletion続行も可能である。応答喪失でreceiptが欠けた要求、completion応答喪失、残存lockは昇格せず、今回の続行経路の対象外である。 +`update-service-verify OPERATION_UUID`は、保存済みのupload/deployment receiptが両方揃った`deploy_requested`だけをGET-only RESTで再検証し、その後に未実行のruntime completionへ進む。upload/deployment/subdomainは再送せず、旧version previewも既に無効であることを検査するだけで書き換えない。acknowledged `deployed`からのcompletion続行も可能である。completion応答だけが失われた場合には、別の`update-service-reconcile`で初期化共通の永続完了証拠を非書込照合する。receiptが欠けた要求や残存lockは昇格しない。[完了応答喪失の照合](./m6_completion_recovery.md)参照。 実機の最初の更新試験`castloop-m6-test-49c82745`では、Script PUT APIがinherit bindingの特定version UUIDを拒否した(HTTP 400/code 10057、`latest`のみ対応)。別名`castloop-m6-test-9fcbd823`でもVersion Upload APIが同じUUID指定を拒否した。公開API referenceのUUID対応記述だけを根拠にせず、上記の最新upload/直後の連番/履歴照合を伴うVersion Upload/Deployment APIへ修正した。両環境はserver `updating`/local `deploy_requested`と凍結要求を保持し、再送・GETからの採用・rollback・resume・削除はしていない。workspaceはそれぞれ`/tmp/opencode/`。拒否応答を受けたことだけで復旧済みとは扱わない。 diff --git a/design/m6_completion_recovery.md b/design/m6_completion_recovery.md new file mode 100644 index 0000000..f3b3945 --- /dev/null +++ b/design/m6_completion_recovery.md @@ -0,0 +1,27 @@ +# M6: 完了応答喪失の明示照合 + +更新日: 2026-10-04 + +## 対象と安全条件 + +初期化・通常更新がserverで正常に完了した後、HTTP応答またはlocal保存だけが失われた場合を扱う。未知のupload/deploymentや実行中consumerを終了扱いにする機構ではない。 + +- localの`initialization_requested`/`completion_requested`には、応答確認済みの凍結targetが必要。record欠落/異なるconfig/残存lockでは続行しない。 +- 初期化と更新で共通の`M6SetupClient.observeCompleted`を使う。更新では永久update requestもserverで照合する。 +- 現在の100% deployment/version/settingsを前後検査し、認証付きservice statusのpaused/pause ID・空invocation registry・exact runtime readinessを確認する。永続runtime-checkの凍結requestとQueue receiptも必須とする。 +- 前後のservice statusが完全一致する場合だけlocal journalを完了へ進める。`true`の完了申告、時間経過、HEAD不在、最新versionを見つけたことだけでは昇格しない。 +- remote呼出は読み取り専用statusとREST GETだけ。prepare/complete、Queue送信、purge、upload/deployment、resume、lock除去は行わない。 + +file保存とlockは既存共通storageへ置き、初期化/更新固有のphase遷移は各journalへ残す。全操作を扱う汎用回復状態機械は追加しない。 + +## 試験専用binary + +`init-reconcile OPERATION_UUID`と`update-service-reconcile OPERATION_UUID`を接続した。後者の`update-service-verify`(保存済みupload/deployment receiptから未実行の検証を続行)とは別責務である。通常公開CLI/entryのgateは開けない。 + +現在のbinary/source bytesが元のbuildと異なっても、回復時に再uploadしないため、保存済み初期化journalをそのまま開く。config hashと既知targetは厳密に照合する。 + +## 検証と残件 + +実HTTP handler/Queue/CASを使うlocal結合で、初期化・更新のcompletion応答喪失→永続証拠の非書込照合→local完了→paused維持を確認した。未完了、欠落/別request receipt、別pause、live token、途中generation変更を拒否し、remote writes/Queue送信が増えないことを回帰する。 + +専用Cloudflareでの応答喪失/強制終了受け入れ、未知の実行中PUT/consumer、残存lockの外部終了確認は残る。この実装を任意のunknown IOの安全な解放や、M6全体の受け入れ合格とは扱わない。 diff --git a/design/m6_implementation_log.md b/design/m6_implementation_log.md index fef57d3..6c9fd66 100644 --- a/design/m6_implementation_log.md +++ b/design/m6_implementation_log.md @@ -1,5 +1,12 @@ # M6: 公開停止・削除 実装ログ +## 2026-10-04: 初期化・通常更新の完了応答喪失を共通の非書込証拠で照合 + +- `M6SetupClient.observeCompleted`へ、REST前後検査・exact paused/pause owner/空registry/runtime readiness・永久request/Queue receipt・service前後非変更を集約した。初期化/更新のjournalは固有のrequested→completed遷移だけを持ち、再配備/Queue/purge/completeの再送や自動resumeはしない。 +- source bytesを再構築せず保存済み初期化journalを開くhelper、試験専用`init-reconcile`/`update-service-reconcile`を追加した。欠落receipt/未完了/別owner/途中変更/残存lockは解放せず、通常release gateも維持する。 +- 実handler/Queue/CASのlocal結合で両completion応答を失わせ、非書込照合後のlocal完了/paused維持を回帰した。Cloudflareでのfault受け入れ、実行中unknown IO/残存lockの外部終了確認、全lifecycle/cache/300MBは引き続き残る。詳細は`m6_completion_recovery.md`。 +- 全884テスト/13407 assertions、TypeScript/M6実証tsconfig、通常/試験Linux binary build、四family local HTTPS statusと新しい照合commandを含むproduction資源拒否が合格した。 + ## 2026-10-04: 通常更新を試験HTTP/standaloneと共通runtime検証へ接続 - 更新固有のpaused→updating→paused CASとjournalを維持し、認証付きbeginと初期化共通のHTTP/loopback/Queue/cache検証へ接続した。試験binaryの`update-service`は明示pause/drainが必要で、自動resume/legacy変換/新規資源作成はしない。既存runtime-checkのoperation IDは受付前に拒否する。 diff --git a/packages/cli/src/m6-service-initialization.test.ts b/packages/cli/src/m6-service-initialization.test.ts index 9da7aae..4eb6061 100644 --- a/packages/cli/src/m6-service-initialization.test.ts +++ b/packages/cli/src/m6-service-initialization.test.ts @@ -1,5 +1,9 @@ import { expect, test } from "bun:test"; -import { createFreshM6InitializationJournal, readLocalFreshM6Initialization, resumeFreshM6Initialization, runFreshM6Initialization } from "./m6-service-initialization"; +import { createFreshM6InitializationJournal, openFreshM6InitializationJournal, readLocalFreshM6Initialization, reconcileFreshM6Initialization, + resumeFreshM6Initialization, runFreshM6Initialization } from "./m6-service-initialization"; +import { M6SetupClient } from "./m6-setup-client"; +import { consumeM6SetupProbe } from "../../../src/m6-setup-queue"; +import { m6SetupFixture } from "../../../src/test-support/m6-setup"; import { completeM6ServiceInitialization, prepareM6ServiceInitialization } from "../../../src/m6-service-initialization"; import { readServiceAdmission } from "../../../src/service-admission"; import { m6InitializationFixture } from "../../../src/test-support/m6-initialization"; @@ -62,6 +66,30 @@ test("lost resource/deploy/initialization responses retain requested phases with } }); +test("fresh initialization reconciles a lost completion using retained target and read-only server receipts, not current source bytes", async () => { + const setup = await m6SetupFixture(); + const root = mkdtempSync("/tmp/opencode/castloop-fresh-reconcile-"); + try { + const journal = await createFreshM6InitializationJournal(root, setup.config, setup.request.target.operation_id, source, metadata); + const client = new M6SetupClient(setup.config, "private-secret", { collectM6DeploymentSnapshot: async () => setup.snapshot }, async (url, init) => { + const response = await setup.run(new Request(url, init)); + if (url.pathname.endsWith("/complete")) { await response.body?.cancel(); throw new Error("Response lost"); } + return response; + }); + await expect(runFreshM6Initialization(journal, { createResources: async () => {}, deploy: async () => setup.request.target, + initialize: (_config, target) => client.initialize(target, { maximumReads: 1, + delay: async () => { await consumeM6SetupProbe(setup.batch(setup.sent[0]), setup.env); } }) }, source, metadata)).rejects.toThrow("outcome is unknown"); + expect(journal.load().phase).toBe("initialization_requested"); + const retained = await openFreshM6InitializationJournal(root, setup.config); + const writes = [...setup.writes]; + await reconcileFreshM6Initialization(retained, (_config, target) => client.observeCompleted({ target })); + expect(retained.load().phase).toBe("initialized"); + expect(setup.writes).toEqual(writes); + expect(setup.sent).toHaveLength(1); + expect((await readServiceAdmission(setup.env, setup.config.service_id))!.value.state).toBe("paused"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + test("source/metadata changes and a different operation identity cannot change a frozen fresh initialization", async () => { const setup = await m6InitializationFixture(); const root = mkdtempSync("/tmp/opencode/castloop-fresh-init-frozen-"); diff --git a/packages/cli/src/m6-service-initialization.ts b/packages/cli/src/m6-service-initialization.ts index 4e1767c..9d4e904 100644 --- a/packages/cli/src/m6-service-initialization.ts +++ b/packages/cli/src/m6-service-initialization.ts @@ -50,13 +50,26 @@ export async function createFreshM6InitializationJournal(root: string, input: Se const config = serviceConfigSchema.parse(input); const request = requestSchema.parse({ config, operation_id: operationId, service_config_sha256: await m6ServiceConfigHash(config), worker_source_sha256: digest(source), worker_metadata_sha256: digest(metadata) }); + return initializationJournal(root, request, true); +} + +export async function openFreshM6InitializationJournal(root: string, config: ServiceConfig): Promise { + const retained = readLocalFreshM6Initialization(root, config); + if (!retained.state || retained.state.request.service_config_sha256 !== await m6ServiceConfigHash(config)) { + throw new Error("Initialization reconciliation requires its retained frozen configuration"); + } + return initializationJournal(root, retained.state.request, false); +} + +function initializationJournal(root: string, request: FreshM6InitializationState["request"], initialize: boolean): FreshM6InitializationJournal { + const config = request.config; const storage = createLocalJournalStorage(root, "service-initializations", undefined, `${config.service_id}.json`); const load = (): FreshM6InitializationState => { const state = validateState(storage.read()); if (JSON.stringify(state.request) !== JSON.stringify(request)) throw new Error("This workspace already has a different frozen fresh M6 initialization"); return state; }; - storage.initialize({ schema_version: 1, request, phase: "prepared" }); + if (initialize) storage.initialize({ schema_version: 1, request, phase: "prepared" }); load(); return { load, exclusively: storage.exclusively, save: (input) => { storage.requireLock(); @@ -105,3 +118,13 @@ export async function runFreshM6Initialization(journal: FreshM6InitializationJou export async function resumeFreshM6Initialization(journal: FreshM6InitializationJournal, effects: FreshM6InitializationEffects): Promise { await journal.exclusively(async () => { await initialize(journal, effects); }); } + +export async function reconcileFreshM6Initialization(journal: FreshM6InitializationJournal, + observe: (config: ServiceConfig, target: M6RuntimeTarget) => Promise): Promise { + await journal.exclusively(async () => { + const state = journal.load(); + if (state.phase !== "initialization_requested" || !state.target) throw new Error("Reconciliation requires its retained initialization request and acknowledged target"); + const readiness = await observe(state.request.config, state.target); + journal.save({ ...state, phase: "initialized", runtime_readiness: readiness }); + }); +} diff --git a/packages/cli/src/m6-service-update.ts b/packages/cli/src/m6-service-update.ts index 10d9b48..8885d51 100644 --- a/packages/cli/src/m6-service-update.ts +++ b/packages/cli/src/m6-service-update.ts @@ -123,6 +123,16 @@ export async function resumeM6UpdateCompletion(journal: M6UpdateJournal, effects await journal.exclusively(async () => { await complete(journal, effects); }); } +export async function reconcileM6UpdateCompletion(journal: M6UpdateJournal, + observe: (request: M6ServiceUpdateRequest, target: M6RuntimeTarget) => Promise): Promise { + await journal.exclusively(async () => { + const state = journal.load(); + if (state.phase !== "completion_requested" || !state.target) throw new Error("Reconciliation requires its retained completion request and acknowledged target"); + const readiness = await observe(state.request, state.target); + journal.save({ ...state, phase: "completed", runtime_readiness: readiness }); + }); +} + export async function resumeM6UpdateDeploymentVerification(journal: M6UpdateJournal, effects: M6UpdateEffects): Promise { await journal.exclusively(async () => { const state = journal.load(); diff --git a/packages/cli/src/m6-setup-client.test.ts b/packages/cli/src/m6-setup-client.test.ts index b2ea510..d8e89a9 100644 --- a/packages/cli/src/m6-setup-client.test.ts +++ b/packages/cli/src/m6-setup-client.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test"; +import { m6ServiceAdminResponseSchema, m6SetupStatusSchema } from "@castloop/shared"; import { M6SetupClient } from "./m6-setup-client"; import { consumeM6SetupProbe } from "../../../src/m6-setup-queue"; import { readServiceAdmission } from "../../../src/service-admission"; @@ -45,6 +46,41 @@ test("pending Queue observations and unknown HTTP outcomes never resend prepare const admission = (await readServiceAdmission(setup.env, setup.config.service_id))!.value; expect(admission.state).toBe(failure === "complete" ? "paused" : "initializing"); expect(admission.invocations).toHaveLength(0); + const writes = setup.writes.length; + const sent = setup.sent.length; + if (failure === "complete") expect(await client.observeCompleted(setup.request)).toEqual(admission.runtime_readiness!); + else await expect(client.observeCompleted(setup.request)).rejects.toThrow("already-completed paused"); + expect(setup.writes).toHaveLength(writes); + expect(setup.sent).toHaveLength(sent); + } +}); + +test("completion reconciliation rejects missing/foreign receipts, another pause, live tokens and changing admission without writes", async () => { + for (const failure of ["receipt", "foreign", "pause", "live", "changing"] as const) { + const setup = await m6SetupFixture(); + const original = new M6SetupClient(setup.config, "private-secret", { collectM6DeploymentSnapshot: async () => setup.snapshot }, + (url, init) => setup.run(new Request(url, init))); + await original.initialize(setup.request.target, { maximumReads: 1, delay: async () => { await consumeM6SetupProbe(setup.batch(setup.sent[0]), setup.env); } }); + let serviceReads = 0; + const client = new M6SetupClient(setup.config, "private-secret", { collectM6DeploymentSnapshot: async () => setup.snapshot }, async (url, init) => { + const response = await setup.run(new Request(url, init)); + if (url.pathname.endsWith("/service")) { + const body = m6ServiceAdminResponseSchema.parse(await response.json()); + serviceReads++; + if (failure === "pause") body.admission.pause_id = crypto.randomUUID(); + if (failure === "live") body.admission.invocations.push({ token: crypto.randomUUID(), kind: "m6_consumer" }); + if (failure === "changing" && serviceReads === 2) body.admission.generation++; + return Response.json(body, { headers: { "Cache-Control": "no-store" } }); + } + const body = m6SetupStatusSchema.parse(await response.json()); + if (failure === "receipt") delete body.record.queue_receipt; + else if (failure === "foreign") body.record.request.target.operation_id = crypto.randomUUID(); + return Response.json(body, { headers: { "Cache-Control": "no-store" } }); + }); + const before = [...setup.writes]; + await expect(client.observeCompleted(setup.request)).rejects.toThrow(); + expect(setup.writes).toEqual(before); + expect(setup.sent).toHaveLength(1); } }); diff --git a/packages/cli/src/m6-setup-client.ts b/packages/cli/src/m6-setup-client.ts index 00dce67..f391d70 100644 --- a/packages/cli/src/m6-setup-client.ts +++ b/packages/cli/src/m6-setup-client.ts @@ -4,6 +4,7 @@ import type { M6RuntimeReadiness, M6RuntimeTarget, M6ServiceUpdateRequest, M6Set import type { CloudflareApi } from "./cloudflare-api"; import { M6AdminJsonClient } from "./m6-admin-json"; import type { M6AdminTransport } from "./m6-admin-json"; +import { M6ServiceClient } from "./m6-service-client"; export type M6SetupWait = { maximumReads: number; delay: () => Promise }; const DEFAULT_WAIT: M6SetupWait = { maximumReads: 30, delay: () => new Promise((resolve) => setTimeout(resolve, 1000)) }; @@ -12,12 +13,19 @@ function requireSameRequest(actual: M6SetupRequest, expected: M6SetupRequest): v if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error("Setup response differs from its frozen initialization target"); } +function requireReadinessTarget(readiness: M6RuntimeReadiness, request: M6SetupRequest): void { + requireSameRequest({ ...request, target: { operation_id: readiness.operation_id, deployment_id: readiness.deployment_id, + worker_version_id: readiness.worker_version_id, service_config_sha256: readiness.service_config_sha256 } }, request); +} + export class M6SetupClient { private readonly admin: M6AdminJsonClient; + private readonly service: M6ServiceClient; constructor(config: ServiceConfig, adminKey: string, private readonly api: Pick, transport: M6AdminTransport = fetch) { this.admin = new M6AdminJsonClient(config, adminKey, transport); + this.service = new M6ServiceClient(config, adminKey, transport); const url = new URL(this.admin.config.public_base_url); if (url.port || url.hostname.split(".").length !== 4 || !url.hostname.startsWith(`${this.admin.config.worker_name}.`) || !url.hostname.endsWith(".workers.dev")) { throw new Error("Fresh M6 setup requires its matching workers.dev origin; custom-domain setup is not released"); @@ -32,18 +40,38 @@ export class M6SetupClient { return this.verify({ target, update_request: update }, wait); } - private async verify(input: M6SetupRequest, wait: M6SetupWait): Promise { - const request = m6SetupRequestSchema.parse(input); + private async inspect(request: M6SetupRequest) { const config = this.admin.config; if (request.target.service_config_sha256 !== await m6ServiceConfigHash(config)) throw new Error("Setup target has another service configuration"); + const snapshot = await this.api.collectM6DeploymentSnapshot(config, request.target.worker_version_id); + const evidence = await inspectM6WorkerDeployment(config, request.target.worker_version_id, m6SnapshotReads(snapshot), M6_FRESH_WORKER_COMPATIBILITY_DATE); + if (evidence.deployment_id !== request.target.deployment_id) throw new Error("Setup deployment changed from its frozen target"); + return snapshot; + } + + async observeCompleted(input: M6SetupRequest): Promise { + const request = m6SetupRequestSchema.parse(input); + await this.inspect(request); + const before = await this.service.call({ service_id: this.admin.config.service_id, action: "status" }); + const admission = before.admission; + if (before.worker_version_id !== request.target.worker_version_id || admission.state !== "paused" || admission.invocations.length || + admission.pause_id !== (request.update_request?.pause_id ?? request.target.operation_id) || !admission.runtime_readiness) { + throw new Error("Completion reconciliation requires its exact already-completed paused runtime and no live invocations"); + } + requireReadinessTarget(admission.runtime_readiness, request); + const status = m6SetupStatusSchema.parse(await this.admin.post("setup/status", request)); + requireSameRequest(status.record.request, request); + if (!status.record.queue_receipt) throw new Error("Completion reconciliation requires its retained runtime verification receipt"); + const after = await this.service.call({ service_id: this.admin.config.service_id, action: "status" }); + await this.inspect(request); + if (JSON.stringify(before) !== JSON.stringify(after)) throw new Error("Completed service changed during read-only reconciliation"); + return admission.runtime_readiness; + } + + private async verify(input: M6SetupRequest, wait: M6SetupWait): Promise { + const request = m6SetupRequestSchema.parse(input); if (!Number.isSafeInteger(wait.maximumReads) || wait.maximumReads < 1 || wait.maximumReads > 120) throw new Error("Invalid setup observation limit"); - const inspect = async () => { - const snapshot = await this.api.collectM6DeploymentSnapshot(config, request.target.worker_version_id); - const evidence = await inspectM6WorkerDeployment(config, request.target.worker_version_id, m6SnapshotReads(snapshot), M6_FRESH_WORKER_COMPATIBILITY_DATE); - if (evidence.deployment_id !== request.target.deployment_id) throw new Error("Setup deployment changed from its frozen target"); - return snapshot; - }; - const before = await inspect(); + const before = await this.inspect(request); let reachable = false; for (let read = 0; read < wait.maximumReads; read += 1) { try { @@ -72,13 +100,11 @@ export class M6SetupClient { probes.push(probe); } if (probes[0]!.invocation_id === probes[1]!.invocation_id) throw new Error("External setup probe returned a repeated cached invocation"); - const after = await inspect(); + const after = await this.inspect(request); const completed = m6SetupCompletedSchema.parse(await this.admin.post("setup/complete", { ...request, snapshots: [before, after] })); requireSameRequest(completed.request, request); if (completed.result !== (request.update_request ? "updated" : "initialized")) throw new Error("Runtime completion acknowledged another operation kind"); - const { default_cache_disabled: _defaultCache, cached_entrypoint: _entrypoint, cutover_verified: _cutover, - publication_routes_verified: _routes, ...target } = completed.readiness; - requireSameRequest({ ...request, target }, request); + requireReadinessTarget(completed.readiness, request); return completed.readiness; } } diff --git a/packages/cli/src/m6-test-cli.ts b/packages/cli/src/m6-test-cli.ts index 738439d..12ad7d8 100644 --- a/packages/cli/src/m6-test-cli.ts +++ b/packages/cli/src/m6-test-cli.ts @@ -5,8 +5,9 @@ import { createFreshM6RestEffects } from "./m6-initialization-rest"; import { createM6LocalEpisodeDraft, createM6LocalShowDraft } from "./m6-local-drafts"; import { publishLocalM6Draft, updateLocalM6Draft } from "./m6-local-update"; import { M6ServiceClient } from "./m6-service-client"; -import { createFreshM6InitializationJournal, runFreshM6Initialization } from "./m6-service-initialization"; -import { createM6UpdateJournal, readLocalM6Update, resumeM6UpdateCompletion, resumeM6UpdateDeploymentVerification, runM6Update } from "./m6-service-update"; +import { createFreshM6InitializationJournal, openFreshM6InitializationJournal, reconcileFreshM6Initialization, runFreshM6Initialization } from "./m6-service-initialization"; +import { createM6UpdateJournal, readLocalM6Update, reconcileM6UpdateCompletion, resumeM6UpdateCompletion, resumeM6UpdateDeploymentVerification, runM6Update } from "./m6-service-update"; +import { M6SetupClient } from "./m6-setup-client"; import { M6UpdateClient } from "./m6-update-client"; import { createM6UpdateRestEffects } from "./m6-update-rest"; import { createShowRegistrationJournal } from "./show-registration-journal"; @@ -20,8 +21,10 @@ import { join, resolve } from "node:path"; const HELP = `Unreleased isolated M6 test binary. Requires castloop.toml in the working directory. init OPERATION_UUID +init-reconcile OPERATION_UUID (only an already-completed paused initialization) update-service OPERATION_UUID (requires explicit pause and settled owners) update-service-verify OPERATION_UUID (only durable acknowledged deployment; never re-upload) +update-service-reconcile OPERATION_UUID (only an already-completed paused update) service-status | service-pause PAUSE_UUID | service-resume PAUSE_UUID target-show SHOW_ID | target-episode SHOW_ID EPISODE_ID create-show SHOW_ID SITE_URL | create-episode SHOW_ID EPISODE_ID @@ -37,7 +40,8 @@ function argumentsFor(args: string[], count: number): void { async function main(): Promise { const [command, ...args] = process.argv.slice(2); if (!command || command === "--help") { console.log(HELP); return; } - const counts: Record = { init: 1, "update-service": 1, "update-service-verify": 1, "service-status": 0, "service-pause": 1, "service-resume": 1, + const counts: Record = { init: 1, "init-reconcile": 1, "update-service": 1, "update-service-verify": 1, + "update-service-reconcile": 1, "service-status": 0, "service-pause": 1, "service-resume": 1, "target-show": 1, "target-episode": 2, "create-show": 2, "create-episode": 2, "update-show": 1, "update-episode": 2, "update-episode-audio": 3, "publish-show": 1, "publish-episode": 3 }; if (!Object.hasOwn(counts, command)) throw new Error("Unknown test command; use --help"); @@ -73,17 +77,26 @@ async function main(): Promise { console.log(JSON.stringify({ result: "initialized-paused", operation_id: args[0], runtime_readiness: journal.load().runtime_readiness })); return; } - if (command === "update-service" || command === "update-service-verify") { + if (command === "init-reconcile") { + const journal = await openFreshM6InitializationJournal(root, config); + if (journal.load().request.operation_id !== args[0]) throw new Error("Initialization reconciliation has another operation identity"); + const client = new M6SetupClient(config, key, new CloudflareApi(config)); + await reconcileFreshM6Initialization(journal, (_config, target) => client.observeCompleted({ target })); + console.log(JSON.stringify({ result: "initialized-paused", operation_id: args[0], runtime_readiness: journal.load().runtime_readiness })); + return; + } + if (["update-service", "update-service-verify", "update-service-reconcile"].includes(command)) { const operationId = m6ServiceUpdateRequestSchema.shape.operation_id.parse(args[0]); const api = new CloudflareApi(config); const client = new M6UpdateClient(config, key, api); const effects = createM6UpdateRestEffects(api, { begin: (input) => client.begin(input), admission: () => client.admission(), complete: (input, target) => client.complete(input, target) }); - if (command === "update-service-verify") { + if (command !== "update-service") { const retained = readLocalM6Update(root, config, operationId); if (!retained.state || retained.lockPresent) throw new Error("Verification requires its retained journal without an unknown lock"); const journal = await createM6UpdateJournal(root, config, retained.state.request); - if (retained.state.phase === "deploy_requested") await resumeM6UpdateDeploymentVerification(journal, effects); + if (command === "update-service-reconcile") await reconcileM6UpdateCompletion(journal, (request, target) => client.observeCompleted(request, target)); + else if (retained.state.phase === "deploy_requested") await resumeM6UpdateDeploymentVerification(journal, effects); else await resumeM6UpdateCompletion(journal, effects); console.log(JSON.stringify({ result: "updated-paused", operation_id: operationId, runtime_readiness: journal.load().runtime_readiness })); return; diff --git a/packages/cli/src/m6-update-client.test.ts b/packages/cli/src/m6-update-client.test.ts index 70a0a11..7947ffd 100644 --- a/packages/cli/src/m6-update-client.test.ts +++ b/packages/cli/src/m6-update-client.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; import { m6ServiceUpdateRequestSchema } from "@castloop/shared"; import { M6UpdateClient } from "./m6-update-client"; -import { createM6UpdateJournal, runM6Update } from "./m6-service-update"; +import { createM6UpdateJournal, reconcileM6UpdateCompletion, runM6Update } from "./m6-service-update"; import { workerPayloadHash } from "./worker-upload-hash"; import { consumeM6SetupProbe } from "../../../src/m6-setup-queue"; import { readServiceAdmission } from "../../../src/service-admission"; @@ -31,7 +31,7 @@ async function fixture() { const client = new M6UpdateClient(setup.config, "private-secret", { collectM6DeploymentSnapshot: async () => snapshot }, transport); const deployed = () => { setup.env.CASTLOOP_VERSION_METADATA.id = target.worker_version_id; }; const wait = { maximumReads: 1, delay: async () => { await consumeM6SetupProbe(setup.batch(setup.sent.at(-1)), setup.env); } }; - return { ...setup, source, metadata, update: request, target, client, paths, transport, deployed, wait }; + return { ...setup, source, metadata, update: request, target, updatedSnapshot: snapshot, client, paths, transport, deployed, wait }; } test("compatible update journal reaches authenticated CAS admission and reuses actual setup HTTP/Queue checks without changing payloads", async () => { @@ -55,6 +55,29 @@ test("compatible update journal reaches authenticated CAS admission and reuses a } finally { rmSync(root, { recursive: true, force: true }); } }); +test("a lost update completion is reconciled from its permanent request/runtime receipt without replaying any writes", async () => { + const setup = await fixture(); + const root = mkdtempSync("/tmp/opencode/castloop-update-http-reconcile-"); + try { + const journal = await createM6UpdateJournal(root, setup.config, setup.update); + const client = new M6UpdateClient(setup.config, "private-secret", { collectM6DeploymentSnapshot: async () => setup.updatedSnapshot }, async (url, init) => { + const response = await setup.transport(url, init); + if (url.pathname.endsWith("/complete")) { await response.body?.cancel(); throw new Error("Completion response lost"); } + return response; + }); + await expect(runM6Update(journal, { begin: (request) => client.begin(request), deploy: async () => { setup.deployed(); return setup.target; }, + complete: (request, target) => client.complete(request, target, setup.wait) }, setup.source, setup.metadata)).rejects.toThrow("outcome is unknown"); + expect(journal.load().phase).toBe("completion_requested"); + const before = [...setup.writes]; + const sent = setup.sent.length; + await reconcileM6UpdateCompletion(journal, (request, target) => setup.client.observeCompleted(request, target)); + expect(journal.load().phase).toBe("completed"); + expect(setup.writes).toEqual(before); + expect(setup.sent).toHaveLength(sent); + expect((await setup.client.admission()).state).toBe("paused"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + test("compatible update rejects foreign/extra inputs and cannot turn a lost begin response into an automatic redeploy", async () => { const setup = await fixture(); const malformed = new Request(`${setup.config.public_base_url}/admin/update/begin`, { method: "POST", headers: { diff --git a/packages/cli/src/m6-update-client.ts b/packages/cli/src/m6-update-client.ts index ae3f326..58da2e1 100644 --- a/packages/cli/src/m6-update-client.ts +++ b/packages/cli/src/m6-update-client.ts @@ -29,4 +29,8 @@ export class M6UpdateClient { if (request.service_id !== this.admin.config.service_id) throw new Error("Compatible completion targets another service"); return this.setup.verifyUpdate(request, target, wait); } + async observeCompleted(request: M6ServiceUpdateRequest, target: M6RuntimeTarget): Promise { + if (request.service_id !== this.admin.config.service_id) throw new Error("Compatible reconciliation targets another service"); + return this.setup.observeCompleted({ target, update_request: request }); + } } diff --git a/packages/cli/src/test-support/verify-operation-status-binary.ts b/packages/cli/src/test-support/verify-operation-status-binary.ts index e6b81c2..5c6faa6 100644 --- a/packages/cli/src/test-support/verify-operation-status-binary.ts +++ b/packages/cli/src/test-support/verify-operation-status-binary.ts @@ -90,6 +90,7 @@ try { } if (m6TestBinary) { for (const args of [["service-status"], ["init", crypto.randomUUID()], ["update-service", crypto.randomUUID()], ["update-service-verify", crypto.randomUUID()], + ["init-reconcile", crypto.randomUUID()], ["update-service-reconcile", crypto.randomUUID()], ["delete-show", "daily"], ["create-show", "daily"], ["--help"]]) { const child = Bun.spawnSync([m6TestBinary, ...args], { cwd: root, env: { PATH: process.env.PATH ?? "" }, stdout: "pipe", stderr: "pipe" }); requireCheck(child.exitCode === (args[0] === "--help" ? 0 : 1), "Test binary did not reject a production resource or unsupported command"); From 834f829ded06b5ce105d4b6630588e1640331bf6 Mon Sep 17 00:00:00 2001 From: Akira Shimosako Date: Sun, 4 Oct 2026 09:11:58 +0900 Subject: [PATCH 06/16] feat(m6): connect explicitly confirmed lifecycle commands in test binary --- design/m6_implementation_log.md | 6 +++ design/m6_local_lifecycle.md | 19 +++++++- packages/cli/src/m6-local-lifecycle.test.ts | 8 ++-- packages/cli/src/m6-local-lifecycle.ts | 9 ++++ packages/cli/src/m6-test-cli.ts | 43 ++++++++++++++++++- .../verify-operation-status-binary.ts | 3 ++ 6 files changed, 82 insertions(+), 6 deletions(-) diff --git a/design/m6_implementation_log.md b/design/m6_implementation_log.md index 6c9fd66..4b3282c 100644 --- a/design/m6_implementation_log.md +++ b/design/m6_implementation_log.md @@ -1,5 +1,11 @@ # M6: 公開停止・削除 実装ログ +## 2026-10-04: 明示確認付きlifecycle操作を試験専用standaloneへ接続 + +- 既存preview/claim/commit/retry/statusを薄いCLI adapterへ接続した。共通confirmation helperは入力されたhashとaction別の明示確認文字列を渡すだけで、状態遷移・receipt検査をCLIへ複製しない。 +- deleteは不可逆payload削除と永久管理記録保持の両方を示す確認文字列が必要。planはbounded/no-followで読み、通常binaryとproduction資源は拒否する。commitとconsumer完了を区別し、unknown outcome/残存lock/live tokenの解除・Cloudflare資源削除は追加しない。 +- TypeScript、関連6テスト/39 assertions、通常/試験Linux binary build、local HTTPSの四family非書込statusと新commandのproduction資源拒否が合格した。実Cloudflareの六lifecycle/配信/cache/300MBと安全な外部復旧は後続であり、通常release gateは維持する。 + ## 2026-10-04: 初期化・通常更新の完了応答喪失を共通の非書込証拠で照合 - `M6SetupClient.observeCompleted`へ、REST前後検査・exact paused/pause owner/空registry/runtime readiness・永久request/Queue receipt・service前後非変更を集約した。初期化/更新のjournalは固有のrequested→completed遷移だけを持ち、再配備/Queue/purge/completeの再送や自動resumeはしない。 diff --git a/design/m6_local_lifecycle.md b/design/m6_local_lifecycle.md index 761a1b2..996333f 100644 --- a/design/m6_local_lifecycle.md +++ b/design/m6_local_lifecycle.md @@ -10,4 +10,21 @@ previewはoperation/delete authorizationを与えず、missing/ineligible/paused new Show/二Episodeの全体flowにも接続し、六操作をsnapshot→dry-run→明示確認→local journal→管理API→consumerまで通した。確認欠落/hash変更/foreign plan/blockers/stale generation/lost acknowledgementを個別回帰した。実Cloudflare/公開CLIではない。 -公開CLIの入力・確認表示、明示retryとunknown outcome/lock/tokenの外部復旧、移行/実機受け入れ・release案内は残る。 +## 試験専用standaloneへの接続(2026-10-04) + +`--m6-test` buildだけに次の入口を接続した。service IDとWorker/R2/Queue/DLQの専用prefixを先に検査し、通常binaryでは使えない。 + +```sh +castloop-m6-test-linux-x64 preview-show-lifecycle SHOW unpublish +castloop-m6-test-linux-x64 preview-episode-lifecycle SHOW EPISODE delete +castloop-m6-test-linux-x64 lifecycle-execute PLAN_JSON REQUEST_SHA256 confirm +castloop-m6-test-linux-x64 lifecycle-execute PLAN_JSON REQUEST_SHA256 confirm-delete-retain-records +castloop-m6-test-linux-x64 operation-status lifecycle JOB_UUID +castloop-m6-test-linux-x64 lifecycle-retry JOB_UUID REQUEST_SHA256 confirm-delete-retain-records +``` + +previewのJSONを保存し、対象・action・blockers・hashを確認してから別途confirmationを渡す。実行時にpreviewからconfirmationを自動生成しない。deleteの確認文字列はpayloadの不可逆削除と小さい管理記録/IDの永久保持を承認する。planはbounded/no-follow readerから読み、既存helperでidentity/hash/eligibleを検査する。 + +`lifecycle-committed`はQueue処理完了ではない。完了はstatus/progressのfinished・purge確認・owner解放・invocation返却を照会する。retryは元のprivate journal、同じhash、明示confirmationを使い、serverのowner/token条件を省略しない。unknown outcome・残存lock・実行中tokenをこのcommandで解除しない。Worker/bucket/QueueなどのCloudflare資源自体は削除しない。 + +公開CLI、unknown outcome/lock/tokenの外部復旧、実機受け入れ・release案内は残る。 diff --git a/packages/cli/src/m6-local-lifecycle.test.ts b/packages/cli/src/m6-local-lifecycle.test.ts index 8057c0e..01bd51f 100644 --- a/packages/cli/src/m6-local-lifecycle.test.ts +++ b/packages/cli/src/m6-local-lifecycle.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; import { LifecycleAdminClient } from "./lifecycle-client"; import { readLocalLifecycleJob } from "./lifecycle-journal"; -import { executeLocalM6Lifecycle, prepareLocalM6Lifecycle, previewLocalM6Lifecycle, validateM6LifecyclePlan } from "./m6-local-lifecycle"; +import { confirmLocalM6Lifecycle, executeLocalM6Lifecycle, prepareLocalM6Lifecycle, previewLocalM6Lifecycle, validateM6LifecyclePlan } from "./m6-local-lifecycle"; import { TargetInspectionClient } from "./target-inspection-client"; import { handleM6TargetInspection } from "../../../src/target-inspection-admin"; import { lifecycleAdminFixture } from "../../../src/test-support/lifecycle-admin"; @@ -41,7 +41,8 @@ test("lifecycle planning performs only bounded reads and dry-run, with a canonic expect(setup.writes).toEqual(writes); expect([...setup.entries]).toEqual(before); expect(existsSync(join(setup.root, ".castloop"))).toBe(false); - const confirmation = { operator_confirmed: true as const, request_sha256: plan.preview.request_sha256 }; + expect(() => confirmLocalM6Lifecycle(plan.request.action, plan.preview.request_sha256, "")).toThrow("Explicit confirmation"); + const confirmation = confirmLocalM6Lifecycle(plan.request.action, plan.preview.request_sha256, "confirm"); const journal = prepareLocalM6Lifecycle(setup.root, setup.config, plan, confirmation); expect(journal.load().phase).toBe("prepared"); expect(journal.load().claim.request).toEqual(plan.request); @@ -57,6 +58,7 @@ test("deletion planning cannot become a journal without explicit irreversible an const setup = await fixture(); try { const plan = await setup.preview("delete"); + expect(() => confirmLocalM6Lifecycle("delete", plan.preview.request_sha256, "confirm")).toThrow("irreversible"); expect(plan.preview.deletion_page?.authorizes_deletion).toBe(false); expect(() => prepareLocalM6Lifecycle(setup.root, setup.config, plan, { operator_confirmed: true, request_sha256: plan.preview.request_sha256 })).toThrow(); @@ -65,7 +67,7 @@ test("deletion planning cannot become a journal without explicit irreversible an { operator_confirmed: true, request_sha256: "0".repeat(64), irreversible_delete_acknowledged: true, retained_records_acknowledged: true })).toThrow(); expect(existsSync(join(setup.root, ".castloop"))).toBe(false); const journal = prepareLocalM6Lifecycle(setup.root, setup.config, plan, - { operator_confirmed: true, request_sha256: plan.preview.request_sha256, irreversible_delete_acknowledged: true, retained_records_acknowledged: true }); + confirmLocalM6Lifecycle("delete", plan.preview.request_sha256, "confirm-delete-retain-records")); expect(journal.load().phase).toBe("prepared"); expect(setup.requestCount()).toBe(1); } finally { setup.dispose(); } diff --git a/packages/cli/src/m6-local-lifecycle.ts b/packages/cli/src/m6-local-lifecycle.ts index dac55f6..9ea1162 100644 --- a/packages/cli/src/m6-local-lifecycle.ts +++ b/packages/cli/src/m6-local-lifecycle.ts @@ -17,6 +17,15 @@ export type M6LifecyclePlanningOptions = { inspector?: Pick; now?: () => Date }; const planSchema = z.object({ request: lifecycleOperationRequestSchema, preview: lifecycleAdminResponseSchema }).strict(); +export function confirmLocalM6Lifecycle(action: LifecycleOperationRequest["action"], requestSha256: string, acknowledgement: string): Confirmation { + const deletion = action === "delete"; + if (acknowledgement !== (deletion ? "confirm-delete-retain-records" : "confirm")) { + throw new Error("Explicit confirmation is required; deletion must acknowledge irreversible payload deletion and retained operational records"); + } + return { operator_confirmed: true, request_sha256: requestSha256, + ...(deletion ? { irreversible_delete_acknowledged: true, retained_records_acknowledged: true } : {}) }; +} + function requestHash(request: LifecycleOperationRequest): string { return createHash("sha256").update(JSON.stringify(request)).digest("hex"); } diff --git a/packages/cli/src/m6-test-cli.ts b/packages/cli/src/m6-test-cli.ts index 12ad7d8..e23ce89 100644 --- a/packages/cli/src/m6-test-cli.ts +++ b/packages/cli/src/m6-test-cli.ts @@ -1,14 +1,18 @@ -import { buildM6WorkerUploadMetadata, m6ServiceConfigHash, m6ServiceUpdateRequestSchema, parseServiceConfig, showMetadataSchema, validateId } from "@castloop/shared"; +import { buildM6WorkerUploadMetadata, lifecycleOperationRequestSchema, m6ServiceConfigHash, m6ServiceUpdateRequestSchema, parseServiceConfig, showMetadataSchema, validateId } from "@castloop/shared"; import { CloudflareApi } from "./cloudflare-api"; import { readBoundedLocalJournal } from "./local-journal-read"; +import { createLifecycleJournal, readLocalLifecycleJob } from "./lifecycle-journal"; +import { createLifecycleOperationEffects, runLifecycleRetry } from "./lifecycle-operation"; import { createFreshM6RestEffects } from "./m6-initialization-rest"; import { createM6LocalEpisodeDraft, createM6LocalShowDraft } from "./m6-local-drafts"; +import { confirmLocalM6Lifecycle, executeLocalM6Lifecycle, previewLocalM6Lifecycle, validateM6LifecyclePlan } from "./m6-local-lifecycle"; import { publishLocalM6Draft, updateLocalM6Draft } from "./m6-local-update"; import { M6ServiceClient } from "./m6-service-client"; import { createFreshM6InitializationJournal, openFreshM6InitializationJournal, reconcileFreshM6Initialization, runFreshM6Initialization } from "./m6-service-initialization"; import { createM6UpdateJournal, readLocalM6Update, reconcileM6UpdateCompletion, resumeM6UpdateCompletion, resumeM6UpdateDeploymentVerification, runM6Update } from "./m6-service-update"; import { M6SetupClient } from "./m6-setup-client"; import { M6UpdateClient } from "./m6-update-client"; +import { readRemoteOperationStatus } from "./remote-operation-status"; import { createM6UpdateRestEffects } from "./m6-update-rest"; import { createShowRegistrationJournal } from "./show-registration-journal"; import { createShowRegistrationEffects, runShowRegistration } from "./show-registration-operation"; @@ -31,7 +35,13 @@ create-show SHOW_ID SITE_URL | create-episode SHOW_ID EPISODE_ID update-show SHOW_ID | update-episode SHOW_ID EPISODE_ID update-episode-audio SHOW_ID EPISODE_ID MP3_PATH publish-show SHOW_ID | publish-episode SHOW_ID EPISODE_ID MP3_PATH -No production release, adoption, deletion, automatic resume or unknown-outcome replay.`; +preview-show-lifecycle SHOW_ID unpublish|restore|delete +preview-episode-lifecycle SHOW_ID EPISODE_ID unpublish|restore|delete +lifecycle-execute PLAN_JSON REQUEST_SHA256 confirm|confirm-delete-retain-records +lifecycle-retry JOB_UUID REQUEST_SHA256 confirm|confirm-delete-retain-records +operation-status FAMILY ID +Deletion physically removes payloads and permanently retains operational records/IDs. +No production release, resource adoption/deletion, automatic resume or unknown-outcome replay.`; function argumentsFor(args: string[], count: number): void { if (args.length !== count || args.some((arg) => !arg || arg.startsWith("--"))) throw new Error("Invalid test command arguments; use --help"); @@ -43,6 +53,7 @@ async function main(): Promise { const counts: Record = { init: 1, "init-reconcile": 1, "update-service": 1, "update-service-verify": 1, "update-service-reconcile": 1, "service-status": 0, "service-pause": 1, "service-resume": 1, "target-show": 1, "target-episode": 2, + "preview-show-lifecycle": 2, "preview-episode-lifecycle": 3, "lifecycle-execute": 3, "lifecycle-retry": 3, "operation-status": 2, "create-show": 2, "create-episode": 2, "update-show": 1, "update-episode": 2, "update-episode-audio": 3, "publish-show": 1, "publish-episode": 3 }; if (!Object.hasOwn(counts, command)) throw new Error("Unknown test command; use --help"); argumentsFor(args, counts[command]!); @@ -69,6 +80,34 @@ async function main(): Promise { const secret = readBoundedLocalJournal(secretFile); if (!secret || typeof secret !== "object" || !("CASTLOOP_ADMIN_KEY" in secret) || typeof secret.CASTLOOP_ADMIN_KEY !== "string") throw new Error("Missing local administrator key"); const key = secret.CASTLOOP_ADMIN_KEY; + if (command === "operation-status") { + console.log(JSON.stringify(await readRemoteOperationStatus(root, config, args[0]!, args[1]!, () => key))); + return; + } + if (command === "lifecycle-execute") { + const plan = validateM6LifecyclePlan(config, readBoundedLocalJournal(resolve(args[0]!))); + const confirmation = confirmLocalM6Lifecycle(plan.request.action, args[1]!, args[2]!); + const state = await executeLocalM6Lifecycle(root, config, plan, confirmation, key); + console.log(JSON.stringify({ result: "lifecycle-committed", job_id: state.claim.request.job_id })); + return; + } + if (command === "lifecycle-retry") { + const retained = readLocalLifecycleJob(root, config, args[0]!); + if (!retained.client_state || retained.lock_present) throw new Error("Retry requires its retained confirmed journal without an unknown lock"); + const claim = retained.client_state.claim; + const journal = createLifecycleJournal(root, config, { ...claim, confirmation: confirmLocalM6Lifecycle(claim.request.action, args[1]!, args[2]!) }); + await runLifecycleRetry(journal, createLifecycleOperationEffects(config, journal.load(), key)); + console.log(JSON.stringify({ result: "lifecycle-requeued", job_id: claim.request.job_id })); + return; + } + if (command.startsWith("preview-")) { + const showId = validateId(args[0]!, "show"); + const episode = command === "preview-episode-lifecycle"; + const target = episode ? { kind: "episode" as const, show_id: showId, episode_id: validateId(args[1]!, "episode") } : { kind: "show" as const, show_id: showId }; + const action = lifecycleOperationRequestSchema.shape.action.parse(args[episode ? 2 : 1]); + console.log(JSON.stringify(await previewLocalM6Lifecycle(config, target, action, key))); + return; + } if (command === "init") { const source = embeddedWorkerSource!; const metadata = buildM6WorkerUploadMetadata(config, null, key, WORKER_COMPATIBILITY_DATE); diff --git a/packages/cli/src/test-support/verify-operation-status-binary.ts b/packages/cli/src/test-support/verify-operation-status-binary.ts index 5c6faa6..abc6fe9 100644 --- a/packages/cli/src/test-support/verify-operation-status-binary.ts +++ b/packages/cli/src/test-support/verify-operation-status-binary.ts @@ -91,6 +91,9 @@ try { if (m6TestBinary) { for (const args of [["service-status"], ["init", crypto.randomUUID()], ["update-service", crypto.randomUUID()], ["update-service-verify", crypto.randomUUID()], ["init-reconcile", crypto.randomUUID()], ["update-service-reconcile", crypto.randomUUID()], + ["preview-show-lifecycle", "daily", "delete"], ["preview-episode-lifecycle", "daily", "first", "delete"], + ["lifecycle-execute", "plan.json", "a".repeat(64), "confirm-delete-retain-records"], + ["lifecycle-retry", crypto.randomUUID(), "a".repeat(64), "confirm-delete-retain-records"], ["operation-status", "lifecycle", crypto.randomUUID()], ["delete-show", "daily"], ["create-show", "daily"], ["--help"]]) { const child = Bun.spawnSync([m6TestBinary, ...args], { cwd: root, env: { PATH: process.env.PATH ?? "" }, stdout: "pipe", stderr: "pipe" }); requireCheck(child.exitCode === (args[0] === "--help" ? 0 : 1), "Test binary did not reject a production resource or unsupported command"); From 9fad835e8c1ec0740dbbe432341828924d08c5b0 Mon Sep 17 00:00:00 2001 From: Akira Shimosako Date: Sun, 4 Oct 2026 09:31:27 +0900 Subject: [PATCH 07/16] test(m6): verify lifecycle and completion recovery in isolated Cloudflare --- design/m6_completion_recovery.md | 4 +- design/m6_implementation_log.md | 8 + design/m6_review_queue.md | 6 + design/m6_standalone_acceptance.md | 44 ++++ experiments/m6/README.md | 20 ++ experiments/m6/acceptance-audio.ts | 32 +++ experiments/m6/standalone-harness.ts | 53 +++++ experiments/m6/verify-completion-recovery.ts | 68 ++++++ experiments/m6/verify-large-media.ts | 205 ++++++++++++++++++ experiments/m6/verify-lifecycle.ts | 202 +++++++++++++++++ packages/cli/src/m6-test-cli.ts | 23 +- packages/cli/src/m6-update-client.test.ts | 7 +- .../src/test-support/drop-setup-completion.ts | 12 + .../verify-operation-status-binary.ts | 4 +- src/m6-setup-worker.ts | 18 +- 15 files changed, 692 insertions(+), 14 deletions(-) create mode 100644 design/m6_standalone_acceptance.md create mode 100644 experiments/m6/acceptance-audio.ts create mode 100644 experiments/m6/standalone-harness.ts create mode 100644 experiments/m6/verify-completion-recovery.ts create mode 100644 experiments/m6/verify-large-media.ts create mode 100644 experiments/m6/verify-lifecycle.ts create mode 100644 packages/cli/src/test-support/drop-setup-completion.ts diff --git a/design/m6_completion_recovery.md b/design/m6_completion_recovery.md index f3b3945..2768ac6 100644 --- a/design/m6_completion_recovery.md +++ b/design/m6_completion_recovery.md @@ -24,4 +24,6 @@ file保存とlockは既存共通storageへ置き、初期化/更新固有のphas 実HTTP handler/Queue/CASを使うlocal結合で、初期化・更新のcompletion応答喪失→永続証拠の非書込照合→local完了→paused維持を確認した。未完了、欠落/別request receipt、別pause、live token、途中generation変更を拒否し、remote writes/Queue送信が増えないことを回帰する。 -専用Cloudflareでの応答喪失/強制終了受け入れ、未知の実行中PUT/consumer、残存lockの外部終了確認は残る。この実装を任意のunknown IOの安全な解放や、M6全体の受け入れ合格とは扱わない。 +専用Cloudflareで通常更新のcompletion応答だけを失わせたstandalone照合が合格した。local requested/server pausedからの非書込照合で、service generation/readinessと永久runtime-checkのETag/checksumを保持した。対象operation/versionは`m6_standalone_acceptance.md`。新規初期化の同種fault実機試験、強制終了受け入れ、未知の実行中PUT/consumer、残存lockの外部終了確認は残る。この実装を任意のunknown IOの安全な解放や、M6全体の受け入れ合格とは扱わない。 + +試験binaryの`update-service-drop-completion`は通常更新を一度実行し、serverの200 completion応答だけを消費して破棄する専用fault入口。production資源prefixを先に拒否し、通常binaryには含めない。`verify-completion-recovery.ts`がlocal requested・server pausedと新targetを検査してから別commandで照合する。通信障害の結果を捏造したreceiptや、強制終了したIOのsettlement申告は作らない。 diff --git a/design/m6_implementation_log.md b/design/m6_implementation_log.md index 4b3282c..b3c2ccf 100644 --- a/design/m6_implementation_log.md +++ b/design/m6_implementation_log.md @@ -1,5 +1,13 @@ # M6: 公開停止・削除 実装ログ +## 2026-10-04: 六lifecycle操作のCloudflare/standalone受け入れ + +- 通常更新済みの専用`castloop-m6-test-ff3bfd8c`を再利用し、Show/Episode各unpublish/restore/deleteを試験binary→実HTTP→通知/Queue→consumerまで通した。GUID/revision/checksum保持、GET/HEAD/Range/304/416と停止404/削除410、payload物理不在と永久管理記録保持が合格した。 +- owner解放・invocation返却まで確認し、最後に明示pausedで残した。旧環境/Cloudflare資源の削除・通常release gate解除はしていない。試験harnessだけのbounded stream/process処理を共有し、operation固有の受付条件は既存moduleへ残す。 +- 後続の応答喪失fault・300MB/audio-only/metadata-only・実cache HITの有限受け入れharnessを追加した。正確な300MB fixtureのlocal stream解析は合格。試験Workerだけにcache/gateway nonceを追加し、公開Workerには追加しない。試験専用publication retryも既存runnerへ接続し、live/unknown tokenを奪わない。 +- 更新operation `6bb77144-ded9-427e-88c9-171ba5be8735`で成功completion応答だけを破棄し、local requested/server paused→別standalone commandの非書込照合→local completedも実機合格した。照合前後のservice statusとruntime-check ETag/checksumを維持した。全884テスト/13409 assertions、TypeScript/M6試験tsconfig、試験Linux build/local HTTPS production拒否も合格。 +- 実機証拠と残件は`m6_standalone_acceptance.md`。六操作の合格をcache HIT/300MB/強制終了IO収束やM6正式release全体の合格と混同しない。 + ## 2026-10-04: 明示確認付きlifecycle操作を試験専用standaloneへ接続 - 既存preview/claim/commit/retry/statusを薄いCLI adapterへ接続した。共通confirmation helperは入力されたhashとaction別の明示確認文字列を渡すだけで、状態遷移・receipt検査をCLIへ複製しない。 diff --git a/design/m6_review_queue.md b/design/m6_review_queue.md index 9ffcbc8..e717990 100644 --- a/design/m6_review_queue.md +++ b/design/m6_review_queue.md @@ -46,6 +46,12 @@ ## 管理者の判断待ちではない技術残件 +### 現在の到達点(2026-10-04) + +下記の各module詳細には実装当時の未接続項目も残る。最新の到達点は新規初期化・通常更新の試験HTTP/standalone接続と実Cloudflare合格、Show/Episode六lifecycle操作の実機合格まで進んでいる。後者は配信404/410・復元時GUID/revision保持・物理削除・永久記録保持・owner/token通常返却を含む。通常公開CLI/entryはまだ切り替えていない。 + +通常更新の完了応答喪失→別standaloneの非書込照合も実機合格した。残件は実cache HIT、300MBと二種改訂の受け入れ、新規初期化fault、安全なunknown IO/残存lockの外部終了確認・復旧手順、公開CLI/利用案内・release gateである。専用試験harnessを追加して継続し、未実行のharnessを合格扱いしない。詳細は[standalone受け入れ](./m6_standalone_acceptance.md)。旧形式変換は必要時だけとする。 + 2026-10-03に管理者が「共通journalリファクタリングをmainへmerge → 別branchで新規M6初期化/稼働判定の分離 → 別branchで形式を変えない通常更新 → 旧形式変換は必要時だけ」を承認した。旧形式変換を新規M6サービスの必須経路にしない。既存環境の即時削除や公開gate解除の承認とは扱わない。 新規初期化のserver CAS、runtime readinessと移行監査証拠の分離、CLI durable controller、新規資源/WorkerのREST配備adapterを実装した。認証付きHTTP/loopback/Queue起動検証と試験専用standaloneを接続し、別名Cloudflare環境で初期paused→明示再開→Show/Episode公開→GET/HEAD/Rangeを確認した。通常の公開CLI/entryは切り替えず、通常更新・全lifecycle/cache/300MB/unknown IO復旧の受け入れは残る。既存資源の削除/採用はしていない。[新規初期化](./m6_fresh_initialization.md)参照。 diff --git a/design/m6_standalone_acceptance.md b/design/m6_standalone_acceptance.md new file mode 100644 index 0000000..35b12ec --- /dev/null +++ b/design/m6_standalone_acceptance.md @@ -0,0 +1,44 @@ +# M6: standaloneと専用Cloudflare環境の受け入れ + +更新日: 2026-10-04 + +## 対象と公開制限 + +新規初期化・通常更新が確認済みの`castloop-m6-test-ff3bfd8c`を再利用する。workspaceは`/tmp/opencode/castloop-m6-test-ff3bfd8c`。既存v0.1.1環境・独自domain・有料planは変更せず、Worker/R2/Queue/DLQ資源自体も削除しない。通常binary/WorkerのM6公開gateは閉じたままである。 + +実行scriptは`experiments/m6/README.md`参照。before/plan/acceptance/incompleteはprivate fileであり、secretを含むlocal manifestを報告へ添付しない。mutationは一度だけ、readonly観測は有限回とし、commitの受付とconsumer完了・owner解放・invocation返却を区別する。 + +## 六lifecycle操作: 合格 + +`verify-lifecycle.ts`からLinux standaloneを使用し、Worker version `1e29cc26-676a-430f-95a8-1040b84c6f9c`の実HTTP/通知/Queue/consumerで小さい`fresh/first` fixtureを検証した。 + +| 操作 | job ID | +| --- | --- | +| Episode unpublish | `fe9fa769-509e-4340-bbe3-011725d021b1` | +| Episode restore | `32419732-5141-4445-965a-4f4d2a41fc9a` | +| Show unpublish | `503c94a1-0812-485f-b8ec-42c1bda4185d` | +| Show restore | `70c3d39d-5643-460a-a6cb-735d71d445db` | +| Episode delete | `ad1d38fa-ad5b-4bf2-b21e-e60bd49c7cae` | +| Show delete | `26fcafdb-4ce4-46ff-b930-9f38572316d9` | + +- 公開GET/HEAD/Range、ETag付き304と範囲外416、毎回再検証する外向けheaderを確認。 +- warm要求後の停止404・削除410を、GET/HEAD/Range/If-None-Match/query付き要求で確認。Episode停止/削除後もShow feedは200で当該GUIDを含まず、Show停止/削除はfeed/cover/音源すべてを隠す。 +- restoreでGUID/revision/URL・metadata/history/audio checksumを保持。 +- private R2の既知payload物理不在と対象scopeの残存markerを確認。Show control/永久reservation/Episode tombstone、六jobのrequest/status/progress/commitは保持。 +- 実行終了時はpause `a9a75d6b-aa49-410c-baad-536757f197fd`、空registry。これは当該試験の終了checkpointであり、以後の試験中も同じpause/versionとは限らない。 + +結果は`lifecycle-acceptance.json`。二回GETしたことだけでは内部cache HITを証明しないため、この試験の`cache_hit_proven`はfalseである。 + +## 完了応答喪失の照合: 合格 + +`verify-completion-recovery.ts`の更新operation `6bb77144-ded9-427e-88c9-171ba5be8735`で、実serverの成功completion応答だけを試験transportが破棄した。local journalは`completion_requested`、serverは新version `756ab58d-9289-4fbd-a0df-cdc9377b71f4`でpaused/空registryを保持した。 + +別のstandalone `update-service-reconcile`が永久request/Queue receiptと現在の配備をreadonly照合し、localをcompletedへ進めた。照合前後でservice status(generation/readiness/ownerを含む)とruntime-checkのETag/全量checksumが同じであり、再配備・complete再送・自動resumeはしていない。結果は`completion-recovery-acceptance.json`。強制終了IOの収束証明ではない。 + +全884テスト/13409 assertions、TypeScript/M6試験tsconfig、通常/試験Linux build、local HTTPS四family statusとproduction資源拒否も合格した。 + +## 後続の受け入れ + +- `verify-large-media.ts`: 正確な300,000,000-byte MP3、過大入力のlocal拒否、audio-only/metadata-only改訂、全量stream照合、GUID/date/history/旧媒体保持、配信・明示deleteを検証する。試験Workerだけのnonce/headerでfeed/cover/小さい音源の実cache HITとdefault gatewayの毎回実行を確認する。 + +これらのscriptの実装・型検査は実機合格を意味しない。実行結果は別途追記する。unknown live PUT/consumerや残存lockを安全に解放する外部終了確認、復旧手順、通常CLI/README/help/version/releaseの完成は引き続き残る。elapsed time・HEAD不在・traceの終了通知だけを旧IO収束の保証として扱わない。 diff --git a/experiments/m6/README.md b/experiments/m6/README.md index d6a23cb..78ccc1a 100644 --- a/experiments/m6/README.md +++ b/experiments/m6/README.md @@ -53,3 +53,23 @@ bun experiments/m6/verify-admission.ts **2026-10-01の方針更新**: 上記の「uploadゲート未通過」は条件付きREST PUT fenceを検討した時点の判断。この試験はクライアント切断後の保存継続を検証していない。M6では管理者判断によりREST単一PUTを維持し、切断後の遅延object作成がないと仮定する。[未解決懸念U1](../../design/m6_upload_recovery_options.md)として記録し、その解消・分割upload実証を公開条件にしない。既存JSON/試験コードは変更せず、方針決定を実測合格として扱わない。通常のupload排他・照合・回復ゲートは残る。 `abandonReservedShowOperation`は凍結requestとreserved ownerを同じCASで失効させる基礎関数だけ。processing/uploadingは拒否し、CLI/管理APIにはまだ接続していない。bindingでのCAS成功をREST object PUTの条件付き動作の根拠として流用しない。 + +## M6 standaloneの全体受け入れ(2026-10-04) + +以下は上のseed/prefix cleanup試験とは別経路で、実M6管理API/consumerを試験専用binaryから使う。既存サービスを対象にしない。全resourceの専用prefixと同account、直前のacknowledged acceptance・paused owner・空registryを検査し、privateなbefore fileを`wx`で作ってから一度だけmutationする。 + +```sh +bun scripts/build-cli.ts linux-x64 --m6-test +bun experiments/m6/verify-lifecycle.ts /tmp/opencode/ACKNOWLEDGED_TEST_WORKSPACE +bun experiments/m6/verify-completion-recovery.ts /tmp/opencode/ACKNOWLEDGED_TEST_WORKSPACE +bun experiments/m6/verify-large-media.ts /tmp/opencode/ACKNOWLEDGED_TEST_WORKSPACE +``` + +- lifecycle: compatible-update受け入れ済みの小さい`fresh/first` fixtureで六操作を実行し、GET/HEAD/Range/304/416、404/410、GUID/revision保持、payload物理削除と永久記録保持を確認する。二回GETしただけでcache HIT合格とはしない。 +- completion recovery: lifecycle合格後の同じpaused環境を通常更新し、serverの成功completion応答だけを試験transportで破棄する。local requested/server pausedを確認し、別のbinary commandで非書込照合する。強制終了したIOの収束証明とは別である。 +- large media: recovery合格後に別Show `large`を作る。719424個の417-byte MPEG framesと192-byte ID3 prefixで正確な300,000,000-byte MP3をchunk書込・stream解析する。300,000,001 bytes拒否、audio-only/metadata-only改訂、全量stream checksum、GUID/date/history/旧音源保持、HEAD/suffix Range/304、最後に明示Show deleteを検証する。音声内容の聴取品質を検証するfixtureではない。 +- cache: 試験Workerだけが内部/外部invocation nonceとinner cache statusをresponse headerへ追加する。feed/cover/小さい音源で、内部HITの同じnonceと毎要求異なるgateway nonceを照合する。通常Worker/公開binaryへ計測headerを追加しない。 + +mutationの自動再送、未知lock/tokenの解放、Worker/bucket/Queueの削除はしない。readonlyな観測競合だけ有限回待ち、失敗数を記録する。途中失敗は固定phase/codeだけ保存し、任意exception本文・secret・metadata本文は保存しない。before/incompleteを消して再実行してはいけない。既知の完了checkpointからの続行は個別に証拠を照合する。 + +各試験は成功時もserviceを明示pausedで残す。これらのscriptの実装・local型検査だけをCloudflare合格として扱わず、実行結果を設計logへ別途記録する。複数colo・独自domain・請求額・無停止移行・unknown live IOの解放・正式releaseは対象外。 diff --git a/experiments/m6/acceptance-audio.ts b/experiments/m6/acceptance-audio.ts new file mode 100644 index 0000000..c1d04b0 --- /dev/null +++ b/experiments/m6/acceptance-audio.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { open } from "node:fs/promises"; + +export async function createAcceptanceMp3(file: string, frames: number, paddingBytes = 0): Promise<{ bytes: number; sha256: string }> { + assert.ok(Number.isSafeInteger(frames) && frames > 0 && Number.isSafeInteger(paddingBytes) && paddingBytes >= 0 && paddingBytes <= 1024); + const frame = Buffer.alloc(417); + frame.set([255, 251, 144, 100]); + const block = Buffer.concat(Array.from({ length: 1000 }, () => frame)); + const hasher = new Bun.CryptoHasher("sha256"); + const output = await open(file, "wx", 0o600); + let bytes = 0; + async function write(chunk: Buffer): Promise { + let offset = 0; + while (offset < chunk.length) { + const result = await output.write(chunk, offset, chunk.length - offset); + assert.ok(result.bytesWritten > 0); + offset += result.bytesWritten; + } + bytes += chunk.length; + hasher.update(chunk); + } + try { + if (paddingBytes) { + const id3 = Buffer.alloc(10 + paddingBytes); + id3.set([73, 68, 51, 3, 0, 0, 0, 0, paddingBytes >> 7, paddingBytes & 127]); + await write(id3); + } + for (let remaining = frames; remaining > 0; remaining -= 1000) await write(block.subarray(0, Math.min(remaining, 1000) * 417)); + await output.sync(); + } finally { await output.close(); } + return { bytes, sha256: hasher.digest("hex") }; +} diff --git a/experiments/m6/standalone-harness.ts b/experiments/m6/standalone-harness.ts new file mode 100644 index 0000000..4b316c5 --- /dev/null +++ b/experiments/m6/standalone-harness.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { resolve } from "node:path"; + +async function run(root: string, args: string[]): Promise<{ code: number; output: string; error: string }> { + const child = Bun.spawn([resolve("dist/castloop-m6-test-linux-x64"), ...args], { cwd: root, stdout: "pipe", stderr: "pipe" }); + const [code, output, error] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + return { code, output, error }; +} + +export async function standaloneOutput(root: string, ...args: string[]): Promise { + const { code, output } = await run(root, args); + if (code) throw new Error("Standalone command was not acknowledged; preserve journals without replay"); + return output.trim(); +} + +export async function standaloneCommand(root: string, ...args: string[]): Promise { + return JSON.parse(await standaloneOutput(root, ...args)); +} + +export async function expectStandaloneRejection(root: string, ...args: string[]): Promise { + const result = await run(root, args); + assert.notEqual(result.code, 0, "Standalone must reject the invalid operation"); + return result.error; +} + +async function consumeBody(response: Response, maximumBytes: number, consume: (chunk: Uint8Array) => void): Promise { + assert.equal(response.status, 200); + assert.ok(response.body); + const reader = response.body.getReader(); + let length = 0; + try { + for (;;) { + const chunk = await reader.read(); + if (chunk.done) break; + length += chunk.value.byteLength; + assert.ok(length <= maximumBytes, "Acceptance response exceeds its explicit byte budget"); + consume(chunk.value); + } + } finally { await reader.cancel(); reader.releaseLock(); } + return length; +} + +export async function readAcceptanceBytes(response: Response): Promise { + const chunks: Uint8Array[] = []; + await consumeBody(response, 2_000_000, (chunk) => chunks.push(chunk)); + return Buffer.concat(chunks); +} + +export async function digestAcceptanceResponse(response: Response, maximumBytes: number): Promise<{ bytes: number; sha256: string }> { + const hasher = new Bun.CryptoHasher("sha256"); + const bytes = await consumeBody(response, maximumBytes, (chunk) => { hasher.update(chunk); }); + return { bytes, sha256: hasher.digest("hex") }; +} diff --git a/experiments/m6/verify-completion-recovery.ts b/experiments/m6/verify-completion-recovery.ts new file mode 100644 index 0000000..bd94c33 --- /dev/null +++ b/experiments/m6/verify-completion-recovery.ts @@ -0,0 +1,68 @@ +import { m6ServiceAdminResponseSchema, parseServiceConfig } from "../../packages/shared/src/index"; +import { readLocalM6Update } from "../../packages/cli/src/m6-service-update"; +import { digestAcceptanceResponse, expectStandaloneRejection, standaloneCommand } from "./standalone-harness"; +import assert from "node:assert/strict"; +import { readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +if (process.argv.length !== 3) throw new Error("Provide the acknowledged lifecycle-acceptance workspace"); +const root = resolve(process.argv[2]!); +const config = parseServiceConfig(await readFile(join(root, "castloop.toml"), "utf8")); +const previous = JSON.parse(await readFile(join(root, "lifecycle-acceptance.json"), "utf8")) as { + result: string; name: string; pause_id: string; worker_version_id: string; +}; +assert.ok(previous.result === "lifecycle_binary_acceptance_passed" && previous.name === config.worker_name && + config.account_id === process.env.CLOUDFLARE_ACCOUNT_ID && config.service_id.startsWith("m6-test-") && + [config.worker_name, config.bucket_name, config.queue_name, config.dlq_name].every((name) => name.startsWith("castloop-m6-test-")), + "Only the acknowledged isolated service is permitted"); +const token = process.env.CLOUDFLARE_API_TOKEN; +assert.ok(token); +const command = (...args: string[]) => standaloneCommand(root, ...args); +const status = async () => m6ServiceAdminResponseSchema.parse(await command("service-status")); +const operationId = crypto.randomUUID(); +let phase = "preflight"; +try { + const before = await status(); + assert.equal(before.admission.state, "paused"); + assert.equal(before.admission.pause_id, previous.pause_id); + assert.equal(before.worker_version_id, previous.worker_version_id); + assert.equal(before.admission.invocations.length, 0); + await writeFile(join(root, "completion-recovery-before.json"), JSON.stringify({ name: config.worker_name, operation_id: operationId, + previous_worker_version_id: before.worker_version_id }), { mode: 0o600, flag: "wx" }); + phase = "update-and-deliberate-completion-loss"; + const rejection = await expectStandaloneRejection(root, "update-service-drop-completion", operationId); + assert.ok(rejection.includes("Setup completion request outcome is unknown"), "Fault must occur after the completion request, not at an earlier step"); + const requested = readLocalM6Update(root, config, operationId); + assert.equal(requested.state?.phase, "completion_requested"); + assert.equal(requested.lockPresent, false); + const completed = await status(); + assert.equal(completed.admission.state, "paused"); + assert.equal(completed.admission.pause_id, previous.pause_id); + assert.equal(completed.admission.invocations.length, 0); + assert.equal(completed.admission.runtime_readiness?.operation_id, operationId); + assert.equal(completed.worker_version_id, requested.state?.target?.worker_version_id); + assert.notEqual(completed.worker_version_id, before.worker_version_id); + const recordUrl = `https://api.cloudflare.com/client/v4/accounts/${config.account_id}/r2/buckets/${config.bucket_name}/objects/system/runtime-checks/${operationId}.json`; + const snapshot = async () => { + const response = await fetch(recordUrl, { headers: { Authorization: `Bearer ${token}` }, redirect: "error", signal: AbortSignal.timeout(30000) }); + return { etag: response.headers.get("ETag"), ...await digestAcceptanceResponse(response, 16384) }; + }; + const record = await snapshot(); + phase = "readonly-standalone-reconciliation"; + const receipt = await command("update-service-reconcile", operationId) as { result: string }; + assert.equal(receipt.result, "updated-paused"); + assert.equal(readLocalM6Update(root, config, operationId).state?.phase, "completed"); + assert.deepEqual(await status(), completed); + assert.deepEqual(await snapshot(), record); + await writeFile(join(root, "completion-recovery-acceptance.json"), JSON.stringify({ result: "completion_recovery_binary_passed", + name: config.worker_name, operation_id: operationId, worker_version_id: completed.worker_version_id, + pause_id: previous.pause_id, requested_after_lost_completion: true, completed_after_readonly_reconciliation: true, + service_and_runtime_receipt_unchanged: true, resources_retained_paused: true, forced_termination_io_proven: false, + authorizes_release: false }, null, 2), { mode: 0o600, flag: "wx" }); + console.log(JSON.stringify({ result: "completion_recovery_binary_passed", workspace: root, resources_retained_paused: true })); +} catch { + await writeFile(join(root, "completion-recovery-incomplete.json"), JSON.stringify({ result: "completion_recovery_acceptance_incomplete", + operation_id: operationId, phase, authorizes_recovery: false, resources_not_deleted: true }, null, 2), { mode: 0o600, flag: "wx" }); + console.error(`Completion recovery acceptance did not complete (${phase}); no replay, resource adoption or automatic resume.`); + process.exitCode = 1; +} diff --git a/experiments/m6/verify-large-media.ts b/experiments/m6/verify-large-media.ts new file mode 100644 index 0000000..1f2220c --- /dev/null +++ b/experiments/m6/verify-large-media.ts @@ -0,0 +1,205 @@ +import { m6ServiceAdminResponseSchema, parseEpisodeDraft, parseServiceConfig, parseShowMetadata, stringifyToml, targetInspectionResponseSchema } from "../../packages/shared/src/index"; +import type { EpisodeRevision } from "../../packages/shared/src/index"; +import { validateM6LifecyclePlan } from "../../packages/cli/src/m6-local-lifecycle"; +import { createAcceptanceMp3 } from "./acceptance-audio"; +import { digestAcceptanceResponse, expectStandaloneRejection, readAcceptanceBytes, standaloneCommand, standaloneOutput } from "./standalone-harness"; +import assert from "node:assert/strict"; +import { open, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +if (process.argv.length !== 3) throw new Error("Provide the explicitly acknowledged completion-recovery workspace"); +const root = resolve(process.argv[2]!); +const config = parseServiceConfig(await readFile(join(root, "castloop.toml"), "utf8")); +const previous = JSON.parse(await readFile(join(root, "completion-recovery-acceptance.json"), "utf8")) as { + result: string; name: string; pause_id: string; worker_version_id: string; +}; +assert.ok(previous.result === "completion_recovery_binary_passed" && previous.name === config.worker_name && + config.account_id === process.env.CLOUDFLARE_ACCOUNT_ID && config.service_id.startsWith("m6-test-") && + [config.worker_name, config.bucket_name, config.queue_name, config.dlq_name].every((name) => name.startsWith("castloop-m6-test-")), + "Only the acknowledged isolated service is permitted"); +const command = (...args: string[]) => standaloneCommand(root, ...args); +const status = async () => m6ServiceAdminResponseSchema.parse(await command("service-status")); +const target = async () => targetInspectionResponseSchema.parse(await command("target-episode", "large", "boundary")); +const jobs: string[] = []; +const cacheHits: string[] = []; +let phase = "preflight"; +let readonlyFailures = 0; +async function waitPublished(job: string, episode: boolean): Promise { + for (let read = 0; read < 240; read += 1) { + try { + const current = episode ? await target() : targetInspectionResponseSchema.parse(await command("target-show", "large")); + if (!current.unfinished_show_operation && current.show?.lifecycle === "active" && + (!episode || current.episode?.lifecycle === "active" && current.current_revision?.revision_id === job) && + (await status()).admission.invocations.length === 0) return current.current_revision; + } catch { readonlyFailures += 1; } + await Bun.sleep(3000); + } + throw new Error("Publication did not settle; retain owner/token without retry or elapsed-time release"); +} +async function publish(episode: boolean, audio?: string): Promise { + const receipt = await command(...(episode ? ["publish-episode", "large", "boundary", audio!] : ["publish-show", "large"])) as { result: string; job_id: string }; + assert.equal(receipt.result, "publication-committed"); + jobs.push(receipt.job_id); + return waitPublished(receipt.job_id, episode); +} +const get = (path: string, method = "GET", headers: Record = {}) => fetch(new URL(path, config.public_base_url), { + method, headers, redirect: "error", signal: AbortSignal.timeout(120000) }); +async function checkMedia(revision: EpisodeRevision, expected: { bytes: number; sha256: string }): Promise { + const response = await get(new URL(revision.enclosure_url).pathname); + assert.equal(response.headers.get("Cache-Control"), "public, max-age=0, must-revalidate"); + assert.equal(response.headers.get("X-Castloop-Worker-Version"), previous.worker_version_id); + assert.deepEqual(await digestAcceptanceResponse(response, expected.bytes), expected); +} +async function warmCache(path: string): Promise { + let previousIds: { inner: string | null; gateway: string | null } | undefined; + for (let read = 0; read < 12; read += 1) { + const response = await get(path); + await readAcceptanceBytes(response); + const inner = response.headers.get("X-Castloop-Test-Cache-Invocation"); + const gateway = response.headers.get("X-Castloop-Test-Gateway-Invocation"); + assert.ok(inner && gateway); + if (response.headers.get("X-Castloop-Test-Inner-Cache") === "HIT" && previousIds?.inner === inner) { + assert.notEqual(gateway, previousIds.gateway, "Default gateway must still execute on an inner cache hit"); + cacheHits.push(path); + return; + } + previousIds = { inner, gateway }; + await Bun.sleep(500); + } + throw new Error("Actual named-entrypoint cache hit was not observed"); +} + +try { + const before = await status(); + assert.equal(before.admission.state, "paused"); + assert.equal(before.admission.pause_id, previous.pause_id); + assert.equal(before.worker_version_id, previous.worker_version_id); + assert.equal(before.admission.invocations.length, 0); + const missing = targetInspectionResponseSchema.parse(await command("target-show", "large")); + assert.equal(missing.show, null); + await writeFile(join(root, "large-media-before.json"), JSON.stringify({ name: config.worker_name, maximum_bytes: 300_000_000, + planned_show: "large", planned_episode: "boundary" }), { mode: 0o600, flag: "wx" }); + phase = "explicit-resume"; + await command("service-resume", previous.pause_id); + phase = "new-show"; + await standaloneOutput(root, "create-show", "large", "https://example.org/castloop-m6-large-media-test"); + const directory = join(root, "large"); + const showFile = join(directory, "show.toml"); + const show = parseShowMetadata(await readFile(showFile, "utf8")); + const cover = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6nxsAAAAASUVORK5CYII=", "base64"); + await writeFile(join(directory, "cover.png"), cover, { mode: 0o600, flag: "wx" }); + await writeFile(showFile, stringifyToml({ ...show, image_path: "cover.png" })); + await command("update-show", "large"); + await publish(false); + phase = "small-original-publication"; + await standaloneOutput(root, "create-episode", "large", "boundary"); + const episodeFile = join(directory, "episode-boundary.toml"); + const episode = parseEpisodeDraft(await readFile(episodeFile, "utf8")); + const smallFile = join(directory, "small.mp3"); + const small = await createAcceptanceMp3(smallFile, 50); + await command("update-episode", "large", "boundary"); + await command("update-episode-audio", "large", "boundary", smallFile); + const original = await publish(true, smallFile); + assert.ok(original); + await checkMedia(original, small); + const retryRejection = await expectStandaloneRejection(root, "publication-retry", original.revision_id); + assert.ok(retryRejection.includes("unfinished held owner"), "A completed publication cannot be requeued"); + phase = "actual-cache-hits"; + for (const path of ["/podcasts/large/feed.xml", "/podcasts/large/cover.png", new URL(original.enclosure_url).pathname]) await warmCache(path); + phase = "oversized-local-rejection"; + const oversizedFile = join(directory, "oversized.mp3"); + const oversized = await open(oversizedFile, "wx", 0o600); + try { await oversized.truncate(300_000_001); await oversized.sync(); } finally { await oversized.close(); } + const targetBefore = await target(); + const rejection = await expectStandaloneRejection(root, "update-episode-audio", "large", "boundary", oversizedFile); + assert.ok(rejection.includes("300000000") || rejection.includes("300,000,000"), "Rejection must identify the local size limit"); + assert.deepEqual(await target(), targetBefore); + assert.equal((await status()).admission.invocations.length, 0); + phase = "boundary-fixture"; + const largeFile = join(directory, "boundary.mp3"); + const large = await createAcceptanceMp3(largeFile, 719424, 182); + assert.equal(large.bytes, 300_000_000); + phase = "boundary-audio-staging"; + console.log(JSON.stringify({ phase, bytes: large.bytes, result: "starting" })); + await command("update-episode-audio", "large", "boundary", largeFile); + phase = "boundary-audio-publication"; + const updated = await publish(true, largeFile); + assert.ok(updated); + assert.equal(updated.length_bytes, large.bytes); + assert.equal(updated.sha256, large.sha256); + assert.equal(updated.guid, episode.guid); + assert.equal(updated.published_at, episode.published_at); + assert.notEqual(updated.enclosure_url, original.enclosure_url); + await checkMedia(updated, large); + await checkMedia(original, small); + console.log(JSON.stringify({ phase, bytes: large.bytes, result: "published" })); + phase = "metadata-only-publication"; + await writeFile(episodeFile, stringifyToml({ ...episode, title: "M6 exact 300 MB metadata revision" })); + await command("update-episode", "large", "boundary"); + const metadata = await publish(true, largeFile); + assert.ok(metadata); + assert.equal(metadata.guid, episode.guid); + assert.equal(metadata.published_at, episode.published_at); + assert.equal(metadata.enclosure_url, updated.enclosure_url); + assert.equal(metadata.sha256, large.sha256); + assert.equal(metadata.length_bytes, large.bytes); + const token = process.env.CLOUDFLARE_API_TOKEN; + assert.ok(token); + const object = (key: string) => fetch(`https://api.cloudflare.com/client/v4/accounts/${config.account_id}/r2/buckets/${config.bucket_name}/objects/${key}`, + { headers: { Authorization: `Bearer ${token}` }, redirect: "error", signal: AbortSignal.timeout(30000) }); + for (const revision of [original, updated, metadata]) assert.ok((await readAcceptanceBytes(await object( + `public/episodes/large/boundary/revisions/${revision.revision_id}.toml`))).length > 0); + const path = new URL(updated.enclosure_url).pathname; + const head = await get(path, "HEAD"); + await head.body?.cancel(); + assert.equal(head.status, 200); + assert.equal(head.headers.get("Content-Length"), String(large.bytes)); + const tail = await get(path, "GET", { Range: "bytes=-10" }); + assert.equal(tail.status, 206); + assert.equal(tail.headers.get("Content-Range"), "bytes 299999990-299999999/300000000"); + assert.deepEqual(new Uint8Array(await tail.arrayBuffer()), new Uint8Array(10)); + const conditional = await get(path, "GET", { "If-None-Match": head.headers.get("ETag")! }); + await conditional.body?.cancel(); + assert.equal(conditional.status, 304); + phase = "explicit-payload-delete"; + const plan = validateM6LifecyclePlan(config, await command("preview-show-lifecycle", "large", "delete")); + assert.ok(plan.preview.eligible); + const planFile = join(root, `large-media-delete-${plan.request.job_id}.json`); + await writeFile(planFile, JSON.stringify(plan), { flag: "wx", mode: 0o600 }); + await command("lifecycle-execute", planFile, plan.preview.request_sha256, "confirm-delete-retain-records"); + let deleted = false; + for (let read = 0; read < 240; read += 1) { + try { + const current = await target(); + if (current.show?.lifecycle === "deleted" && !current.unfinished_show_operation && (await status()).admission.invocations.length === 0) { + deleted = true; break; + } + } catch { readonlyFailures += 1; } + await Bun.sleep(3000); + } + assert.ok(deleted, "Explicit deletion remains unfinished; retain ownership"); + for (const revision of [original, updated]) { + const key = `public${new URL(revision.enclosure_url).pathname}`; + const response = await object(key); + await response.body?.cancel(); + assert.equal(response.status, 404); + const publicResponse = await get(new URL(revision.enclosure_url).pathname); + await publicResponse.body?.cancel(); + assert.equal(publicResponse.status, 410); + } + phase = "explicit-pause"; + const pauseId = crypto.randomUUID(); + await command("service-pause", pauseId); + assert.equal((await status()).admission.invocations.length, 0); + await writeFile(join(root, "large-media-acceptance.json"), JSON.stringify({ result: "large_media_binary_acceptance_passed", name: config.worker_name, + worker_version_id: previous.worker_version_id, maximum_audio: large, oversized_rejected: true, guid_date_history_preserved: true, + audio_only_and_metadata_only_verified: true, get_head_suffix_range_conditional_verified: true, cache_hit_paths: cacheHits, publication_jobs: jobs, + deletion_job: plan.request.job_id, pause_id: pauseId, payloads_deleted: true, resources_retained_paused: true, + readonly_observation_failures: readonlyFailures, authorizes_release: false }, null, 2), { mode: 0o600, flag: "wx" }); + console.log(JSON.stringify({ result: "large_media_binary_acceptance_passed", workspace: root, resources_retained_paused: true })); +} catch { + await writeFile(join(root, "large-media-incomplete.json"), JSON.stringify({ result: "large_media_acceptance_incomplete", phase, publication_jobs: jobs, + readonly_observation_failures: readonlyFailures, resources_not_deleted: true, authorizes_recovery: false }, null, 2), { mode: 0o600, flag: "wx" }); + console.error(`Large media acceptance did not complete (${phase}); preserve journals, streams and owners without replay or automatic release.`); + process.exitCode = 1; +} diff --git a/experiments/m6/verify-lifecycle.ts b/experiments/m6/verify-lifecycle.ts new file mode 100644 index 0000000..b9360e7 --- /dev/null +++ b/experiments/m6/verify-lifecycle.ts @@ -0,0 +1,202 @@ +import { lifecycleAdminResponseSchema, m6ServiceAdminResponseSchema, parseServiceConfig, targetInspectionResponseSchema } from "../../packages/shared/src/index"; +import type { LifecycleOperationRequest } from "../../packages/shared/src/index"; +import { readLocalM6Update } from "../../packages/cli/src/m6-service-update"; +import { validateM6LifecyclePlan } from "../../packages/cli/src/m6-local-lifecycle"; +import { classifyLifecycleDeletionKey } from "../../src/lifecycle-deletion"; +import { readAcceptanceBytes as bytes, standaloneCommand } from "./standalone-harness"; +import assert from "node:assert/strict"; +import { readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +if (process.argv.length !== 3) throw new Error("Provide the explicitly acknowledged compatible-update workspace"); +const root = resolve(process.argv[2]!); +const config = parseServiceConfig(await readFile(join(root, "castloop.toml"), "utf8")); +const acceptance = JSON.parse(await readFile(join(root, "compatible-update-acceptance.json"), "utf8")) as { + result: string; name: string; operation_id: string; pause_id: string; runtime_readiness: { worker_version_id: string }; +}; +const retained = readLocalM6Update(root, config, acceptance.operation_id); +assert.ok(acceptance.result === "compatible_binary_update_passed" && acceptance.name === config.worker_name && + config.account_id === process.env.CLOUDFLARE_ACCOUNT_ID && config.service_id.startsWith("m6-test-") && + [config.worker_name, config.bucket_name, config.queue_name, config.dlq_name].every((name) => name.startsWith("castloop-m6-test-")) && + retained.state?.phase === "completed" && !retained.lockPresent, "Only the acknowledged isolated service is permitted"); +const token = process.env.CLOUDFLARE_API_TOKEN; +assert.ok(token); +const base = `https://api.cloudflare.com/client/v4/accounts/${config.account_id}/r2/buckets/${config.bucket_name}/objects`; +const observations: Array<{ kind: string; action: string; job_id: string }> = []; +let phase = "preflight"; +let readonlyFailures = 0; + +const command = (...args: string[]) => standaloneCommand(root, ...args); +const status = async () => m6ServiceAdminResponseSchema.parse(await command("service-status")); +const target = async () => targetInspectionResponseSchema.parse(await command("target-episode", "fresh", "first")); +const hash = (value: Uint8Array) => new Bun.CryptoHasher("sha256").update(value).digest("hex"); +const object = (key: string) => fetch(`${base}/${key}`, { headers: { Authorization: `Bearer ${token}`, "Accept-Encoding": "identity" }, + redirect: "error", signal: AbortSignal.timeout(30000) }); +const get = (path: string, method = "GET", headers: Record = {}) => fetch(new URL(path, config.public_base_url), { + method, headers, redirect: "error", signal: AbortSignal.timeout(30000) }); +async function inventory(): Promise { + const keys: string[] = []; + for (const prefix of ["public/podcasts/fresh/", "public/episodes/fresh/", "staging/shows/fresh/", "staging/episodes/fresh/"]) { + const response = await fetch(`${base}?prefix=${encodeURIComponent(prefix)}&limit=100`, { headers: { Authorization: `Bearer ${token}` }, + redirect: "error", signal: AbortSignal.timeout(30000) }); + const value = JSON.parse(new TextDecoder().decode(await bytes(response))) as { success: boolean; result: Array<{ key: string }> }; + assert.ok(value.success && Array.isArray(value.result) && value.result.length < 100 && !response.headers.get("cf-r2-cursor"), + "Only a bounded complete small-fixture inventory is accepted"); + for (const entry of value.result) { assert.ok(entry.key.startsWith(prefix)); keys.push(entry.key); } + } + assert.equal(new Set(keys).size, keys.length); + return keys.sort(); +} +async function absent(key: string): Promise { + const response = await object(key); + await response.body?.cancel(); + assert.equal(response.status, 404, "Deleted payload must be physically absent in private R2"); +} +async function hidden(paths: string[], code: 404 | 410, etags: Map): Promise { + for (const path of paths) { + for (const [method, headers] of [["GET", {}], ["HEAD", {}], ["GET", { Range: "bytes=0-9" }], + ["GET", { "If-None-Match": etags.get(path)! }]] as Array<[string, Record]>) { + const response = await get(`${path}?lifecycle-check=1`, method, headers); + await response.body?.cancel(); + assert.equal(response.status, code, "Warm public data must not bypass lifecycle visibility"); + assert.equal(response.headers.get("Cache-Control"), "no-store"); + } + } +} +async function settled(): Promise { + for (let read = 0; read < 180; read += 1) { + try { + if (!(await target()).unfinished_show_operation && (await status()).admission.invocations.length === 0) return; + } catch { readonlyFailures += 1; } + await Bun.sleep(3000); + } + throw new Error("Owner or invocation remains unfinished; elapsed time does not authorize release"); +} +async function lifecycle(kind: "show" | "episode", action: LifecycleOperationRequest["action"]): Promise { + phase = `${kind}-${action}`; + console.log(JSON.stringify({ phase, result: "starting" })); + const args = kind === "show" ? ["preview-show-lifecycle", "fresh", action] : ["preview-episode-lifecycle", "fresh", "first", action]; + const plan = validateM6LifecyclePlan(config, await command(...args)); + assert.ok(plan.preview.eligible); + const file = join(root, `lifecycle-plan-${plan.request.job_id}.json`); + await writeFile(file, JSON.stringify(plan), { flag: "wx", mode: 0o600 }); + const receipt = await command("lifecycle-execute", file, plan.preview.request_sha256, + action === "delete" ? "confirm-delete-retain-records" : "confirm") as { result: string; job_id: string }; + assert.equal(receipt.result, "lifecycle-committed"); + assert.equal(receipt.job_id, plan.request.job_id); + observations.push({ kind, action, job_id: receipt.job_id }); + let completed = false; + for (let read = 0; read < 180; read += 1) { + let remote; + try { remote = await command("operation-status", "lifecycle", receipt.job_id) as { server_status: unknown }; } + catch { readonlyFailures += 1; await Bun.sleep(3000); continue; } + const result = lifecycleAdminResponseSchema.parse(remote.server_status); + assert.equal(result.result, "status"); + if (result.result !== "status") throw new Error("Unexpected lifecycle status"); + assert.notEqual(result.status?.state, "failed", "Consumer failure requires explicit diagnosis, not automatic retry"); + if (result.status?.state === "completed" && result.ownership === "released" && !result.execution_active) { + assert.ok(result.progress?.purge_confirmed && result.progress.phase === "finished" && result.marker_present); + completed = true; + break; + } + await Bun.sleep(3000); + } + assert.ok(completed, "Unfinished lifecycle owner must remain retained"); + await settled(); + const expected = action === "unpublish" ? "unpublished" : action === "restore" ? "active" : "deleted"; + const current = await target(); + assert.equal(kind === "show" ? current.show?.lifecycle : current.episode?.lifecycle, expected); + console.log(JSON.stringify({ phase, result: "completed", job_id: receipt.job_id })); +} + +try { + const before = await status(); + assert.equal(before.admission.state, "paused"); + assert.equal(before.admission.pause_id, acceptance.pause_id); + assert.equal(before.worker_version_id, acceptance.runtime_readiness.worker_version_id); + assert.equal(before.admission.invocations.length, 0); + const original = await target(); + assert.equal(original.unfinished_show_operation, false); + assert.equal(original.episode?.lifecycle, "active"); + const revision = original.current_revision; + assert.ok(revision && revision.length_bytes < 2_000_000); + const keys = await inventory(); + assert.ok(keys.length > 0); + const fixedKeys = [`public/episodes/fresh/first/metadata.toml`, `public/episodes/fresh/first/revisions/${revision.revision_id}.toml`, + `public/podcasts/fresh/episodes/first/${revision.revision_id}.mp3`]; + const fixed = await Promise.all(fixedKeys.map(async (key) => ({ key, sha256: hash(await bytes(await object(key))) }))); + await writeFile(join(root, "lifecycle-before.json"), JSON.stringify({ name: config.worker_name, keys, fixed }, null, 2), { mode: 0o600, flag: "wx" }); + phase = "explicit-resume"; + await command("service-resume", acceptance.pause_id); + const feedPath = "/podcasts/fresh/feed.xml"; + const coverPath = "/podcasts/fresh/cover.png"; + const mediaPath = new URL(revision.enclosure_url).pathname; + const paths = [feedPath, coverPath, mediaPath]; + const etags = new Map(); + phase = "warm-delivery"; + for (const path of paths) { + for (let read = 0; read < 2; read += 1) { + const response = await get(path); + assert.equal(response.headers.get("Cache-Control"), "public, max-age=0, must-revalidate"); + assert.equal(response.headers.get("X-Castloop-Worker-Version"), before.worker_version_id); + assert.ok((await bytes(response)).length > 0); + assert.ok(response.headers.get("ETag")); + etags.set(path, response.headers.get("ETag")!); + } + const head = await get(path, "HEAD"); + await head.body?.cancel(); + assert.equal(head.status, 200); + const conditional = await get(path, "GET", { "If-None-Match": etags.get(path)! }); + await conditional.body?.cancel(); + assert.equal(conditional.status, 304); + } + const range = await get(mediaPath, "GET", { Range: "bytes=0-9" }); + assert.equal(range.status, 206); + assert.equal((await range.arrayBuffer()).byteLength, 10); + const invalid = await get(mediaPath, "GET", { Range: `bytes=${revision.length_bytes}-` }); + await invalid.body?.cancel(); + assert.equal(invalid.status, 416); + await lifecycle("episode", "unpublish"); + await hidden([mediaPath], 404, etags); + assert.ok(!new TextDecoder().decode(await bytes(await get(feedPath))).includes(revision.guid)); + await lifecycle("episode", "restore"); + assert.deepEqual((await target()).current_revision, revision); + await lifecycle("show", "unpublish"); + await hidden(paths, 404, etags); + await lifecycle("show", "restore"); + assert.deepEqual((await target()).current_revision, revision); + assert.ok(new TextDecoder().decode(await bytes(await get(feedPath))).includes(revision.guid)); + for (const expected of fixed) assert.equal(hash(await bytes(await object(expected.key))), expected.sha256); + await lifecycle("episode", "delete"); + await hidden([mediaPath], 410, etags); + assert.ok(!new TextDecoder().decode(await bytes(await get(feedPath))).includes(revision.guid)); + const episodeTarget = { kind: "episode" as const, showId: "fresh", episodeId: "first" }; + for (const key of keys.filter((key) => classifyLifecycleDeletionKey(episodeTarget, key) === "payload")) await absent(key); + assert.ok((await inventory()).filter((key) => classifyLifecycleDeletionKey(episodeTarget, key) === "payload").length === 0); + await lifecycle("show", "delete"); + await hidden(paths, 410, etags); + await absent("system/shows/fresh/show.toml"); + assert.ok((await inventory()).every((key) => classifyLifecycleDeletionKey({ kind: "show", showId: "fresh" }, key) === "marker")); + for (const key of ["system/show-publications/fresh.json", "system/show-reservations/fresh.json", "system/episode-lifecycle/fresh/first.toml"]) { + assert.ok((await bytes(await object(key))).length > 0); + } + for (const observation of observations) { + for (const name of ["request.toml", "status.toml", "progress.toml"]) assert.ok((await bytes(await object(`system/jobs/${observation.job_id}/${name}`))).length > 0); + const prefix = observation.kind === "show" ? "shows/fresh" : "episodes/fresh/first"; + assert.ok((await bytes(await object(`staging/lifecycle/${prefix}/${observation.job_id}/commit.json`))).length > 0); + } + phase = "explicit-pause"; + const pauseId = crypto.randomUUID(); + await command("service-pause", pauseId); + assert.equal((await status()).admission.invocations.length, 0); + await writeFile(join(root, "lifecycle-acceptance.json"), JSON.stringify({ result: "lifecycle_binary_acceptance_passed", name: config.worker_name, + worker_version_id: before.worker_version_id, observations, pause_id: pauseId, permanent_records_retained: true, known_payloads_deleted: true, + guid_revision_preserved_on_restore: true, get_head_range_conditional_visibility_verified: true, cache_hit_proven: false, + readonly_observation_failures: readonlyFailures, resources_retained_paused: true, authorizes_release: false }, null, 2), { mode: 0o600, flag: "wx" }); + console.log(JSON.stringify({ result: "lifecycle_binary_acceptance_passed", workspace: root, resources_retained_paused: true })); +} catch { + await writeFile(join(root, "lifecycle-incomplete.json"), JSON.stringify({ result: "lifecycle_acceptance_incomplete", phase, observations, + readonly_observation_failures: readonlyFailures, authorizes_recovery: false, resources_not_deleted: true }, null, 2), { mode: 0o600, flag: "wx" }); + console.error(`Lifecycle acceptance did not complete (${phase}); preserve journals and owners without mutation replay or automatic resume.`); + process.exitCode = 1; +} diff --git a/packages/cli/src/m6-test-cli.ts b/packages/cli/src/m6-test-cli.ts index e23ce89..123818a 100644 --- a/packages/cli/src/m6-test-cli.ts +++ b/packages/cli/src/m6-test-cli.ts @@ -12,11 +12,14 @@ import { createFreshM6InitializationJournal, openFreshM6InitializationJournal, r import { createM6UpdateJournal, readLocalM6Update, reconcileM6UpdateCompletion, resumeM6UpdateCompletion, resumeM6UpdateDeploymentVerification, runM6Update } from "./m6-service-update"; import { M6SetupClient } from "./m6-setup-client"; import { M6UpdateClient } from "./m6-update-client"; +import { createPublicationJournal, readLocalPublicationJob } from "./publication-journal"; +import { createPublicationOperationEffects, runPublicationRetry } from "./publication-operation"; import { readRemoteOperationStatus } from "./remote-operation-status"; import { createM6UpdateRestEffects } from "./m6-update-rest"; import { createShowRegistrationJournal } from "./show-registration-journal"; import { createShowRegistrationEffects, runShowRegistration } from "./show-registration-operation"; import { TargetInspectionClient } from "./target-inspection-client"; +import { dropSetupCompletion } from "./test-support/drop-setup-completion"; import { embeddedWorkerSource, WORKER_COMPATIBILITY_DATE } from "./worker-payload"; import { workerPayloadHash } from "./worker-upload-hash"; import { randomBytes } from "node:crypto"; @@ -27,6 +30,7 @@ const HELP = `Unreleased isolated M6 test binary. Requires castloop.toml in the init OPERATION_UUID init-reconcile OPERATION_UUID (only an already-completed paused initialization) update-service OPERATION_UUID (requires explicit pause and settled owners) +update-service-drop-completion OPERATION_UUID (isolated fault test; deliberately loses the completion response) update-service-verify OPERATION_UUID (only durable acknowledged deployment; never re-upload) update-service-reconcile OPERATION_UUID (only an already-completed paused update) service-status | service-pause PAUSE_UUID | service-resume PAUSE_UUID @@ -35,6 +39,7 @@ create-show SHOW_ID SITE_URL | create-episode SHOW_ID EPISODE_ID update-show SHOW_ID | update-episode SHOW_ID EPISODE_ID update-episode-audio SHOW_ID EPISODE_ID MP3_PATH publish-show SHOW_ID | publish-episode SHOW_ID EPISODE_ID MP3_PATH +publication-retry JOB_UUID (same acknowledged commit; refuses active or unknown execution) preview-show-lifecycle SHOW_ID unpublish|restore|delete preview-episode-lifecycle SHOW_ID EPISODE_ID unpublish|restore|delete lifecycle-execute PLAN_JSON REQUEST_SHA256 confirm|confirm-delete-retain-records @@ -50,10 +55,10 @@ function argumentsFor(args: string[], count: number): void { async function main(): Promise { const [command, ...args] = process.argv.slice(2); if (!command || command === "--help") { console.log(HELP); return; } - const counts: Record = { init: 1, "init-reconcile": 1, "update-service": 1, "update-service-verify": 1, + const counts: Record = { init: 1, "init-reconcile": 1, "update-service": 1, "update-service-drop-completion": 1, "update-service-verify": 1, "update-service-reconcile": 1, "service-status": 0, "service-pause": 1, "service-resume": 1, "target-show": 1, "target-episode": 2, - "preview-show-lifecycle": 2, "preview-episode-lifecycle": 3, "lifecycle-execute": 3, "lifecycle-retry": 3, "operation-status": 2, + "preview-show-lifecycle": 2, "preview-episode-lifecycle": 3, "lifecycle-execute": 3, "lifecycle-retry": 3, "publication-retry": 1, "operation-status": 2, "create-show": 2, "create-episode": 2, "update-show": 1, "update-episode": 2, "update-episode-audio": 3, "publish-show": 1, "publish-episode": 3 }; if (!Object.hasOwn(counts, command)) throw new Error("Unknown test command; use --help"); argumentsFor(args, counts[command]!); @@ -80,6 +85,14 @@ async function main(): Promise { const secret = readBoundedLocalJournal(secretFile); if (!secret || typeof secret !== "object" || !("CASTLOOP_ADMIN_KEY" in secret) || typeof secret.CASTLOOP_ADMIN_KEY !== "string") throw new Error("Missing local administrator key"); const key = secret.CASTLOOP_ADMIN_KEY; + if (command === "publication-retry") { + const retained = readLocalPublicationJob(root, config, args[0]!); + if (!retained.client_state || retained.lock_present) throw new Error("Retry requires its retained publication journal without an unknown lock"); + const journal = createPublicationJournal(root, config, retained.client_state.publication); + await runPublicationRetry(journal, createPublicationOperationEffects(config, journal.load(), key)); + console.log(JSON.stringify({ result: "publication-requeued", job_id: args[0] })); + return; + } if (command === "operation-status") { console.log(JSON.stringify(await readRemoteOperationStatus(root, config, args[0]!, args[1]!, () => key))); return; @@ -124,13 +137,13 @@ async function main(): Promise { console.log(JSON.stringify({ result: "initialized-paused", operation_id: args[0], runtime_readiness: journal.load().runtime_readiness })); return; } - if (["update-service", "update-service-verify", "update-service-reconcile"].includes(command)) { + if (["update-service", "update-service-drop-completion", "update-service-verify", "update-service-reconcile"].includes(command)) { const operationId = m6ServiceUpdateRequestSchema.shape.operation_id.parse(args[0]); const api = new CloudflareApi(config); - const client = new M6UpdateClient(config, key, api); + const client = new M6UpdateClient(config, key, api, command === "update-service-drop-completion" ? dropSetupCompletion() : undefined); const effects = createM6UpdateRestEffects(api, { begin: (input) => client.begin(input), admission: () => client.admission(), complete: (input, target) => client.complete(input, target) }); - if (command !== "update-service") { + if (command === "update-service-verify" || command === "update-service-reconcile") { const retained = readLocalM6Update(root, config, operationId); if (!retained.state || retained.lockPresent) throw new Error("Verification requires its retained journal without an unknown lock"); const journal = await createM6UpdateJournal(root, config, retained.state.request); diff --git a/packages/cli/src/m6-update-client.test.ts b/packages/cli/src/m6-update-client.test.ts index 7947ffd..b8c1f39 100644 --- a/packages/cli/src/m6-update-client.test.ts +++ b/packages/cli/src/m6-update-client.test.ts @@ -3,6 +3,7 @@ import { m6ServiceUpdateRequestSchema } from "@castloop/shared"; import { M6UpdateClient } from "./m6-update-client"; import { createM6UpdateJournal, reconcileM6UpdateCompletion, runM6Update } from "./m6-service-update"; import { workerPayloadHash } from "./worker-upload-hash"; +import { dropSetupCompletion } from "./test-support/drop-setup-completion"; import { consumeM6SetupProbe } from "../../../src/m6-setup-queue"; import { readServiceAdmission } from "../../../src/service-admission"; import { m6SetupFixture } from "../../../src/test-support/m6-setup"; @@ -60,11 +61,7 @@ test("a lost update completion is reconciled from its permanent request/runtime const root = mkdtempSync("/tmp/opencode/castloop-update-http-reconcile-"); try { const journal = await createM6UpdateJournal(root, setup.config, setup.update); - const client = new M6UpdateClient(setup.config, "private-secret", { collectM6DeploymentSnapshot: async () => setup.updatedSnapshot }, async (url, init) => { - const response = await setup.transport(url, init); - if (url.pathname.endsWith("/complete")) { await response.body?.cancel(); throw new Error("Completion response lost"); } - return response; - }); + const client = new M6UpdateClient(setup.config, "private-secret", { collectM6DeploymentSnapshot: async () => setup.updatedSnapshot }, dropSetupCompletion(setup.transport)); await expect(runM6Update(journal, { begin: (request) => client.begin(request), deploy: async () => { setup.deployed(); return setup.target; }, complete: (request, target) => client.complete(request, target, setup.wait) }, setup.source, setup.metadata)).rejects.toThrow("outcome is unknown"); expect(journal.load().phase).toBe("completion_requested"); diff --git a/packages/cli/src/test-support/drop-setup-completion.ts b/packages/cli/src/test-support/drop-setup-completion.ts new file mode 100644 index 0000000..8d6f08d --- /dev/null +++ b/packages/cli/src/test-support/drop-setup-completion.ts @@ -0,0 +1,12 @@ +import type { M6AdminTransport } from "../m6-admin-json"; + +export function dropSetupCompletion(transport: M6AdminTransport = fetch): M6AdminTransport { + return async (url, init) => { + const response = await transport(url, init); + if (url.pathname === "/admin/setup/complete" && init.method === "POST" && response.status === 200) { + await response.body?.cancel(); + throw new Error("Isolated test deliberately discarded the successful setup completion response"); + } + return response; + }; +} diff --git a/packages/cli/src/test-support/verify-operation-status-binary.ts b/packages/cli/src/test-support/verify-operation-status-binary.ts index abc6fe9..c41006f 100644 --- a/packages/cli/src/test-support/verify-operation-status-binary.ts +++ b/packages/cli/src/test-support/verify-operation-status-binary.ts @@ -90,7 +90,9 @@ try { } if (m6TestBinary) { for (const args of [["service-status"], ["init", crypto.randomUUID()], ["update-service", crypto.randomUUID()], ["update-service-verify", crypto.randomUUID()], - ["init-reconcile", crypto.randomUUID()], ["update-service-reconcile", crypto.randomUUID()], + ["init-reconcile", crypto.randomUUID()], ["update-service-reconcile", crypto.randomUUID()], + ["update-service-drop-completion", crypto.randomUUID()], + ["publication-retry", crypto.randomUUID()], ["preview-show-lifecycle", "daily", "delete"], ["preview-episode-lifecycle", "daily", "first", "delete"], ["lifecycle-execute", "plan.json", "a".repeat(64), "confirm-delete-retain-records"], ["lifecycle-retry", crypto.randomUUID(), "a".repeat(64), "confirm-delete-retain-records"], ["operation-status", "lifecycle", crypto.randomUUID()], diff --git a/src/m6-setup-worker.ts b/src/m6-setup-worker.ts index 3928df6..aaced09 100644 --- a/src/m6-setup-worker.ts +++ b/src/m6-setup-worker.ts @@ -1,16 +1,30 @@ import { fetchM6ManagementIntegration, queueM6Candidate } from "./m6-routes"; import type { M6CandidateEnv } from "./m6-routes"; import { consumeM6SetupProbe } from "./m6-setup-queue"; +import { CachedPublicAssets as PublicAssetsEntrypoint } from "./lifecycle-cached-entrypoint"; +import { parsePublicAssetPath } from "./public-assets"; -export { CachedPublicAssets } from "./lifecycle-cached-entrypoint"; +export class CachedPublicAssets extends PublicAssetsEntrypoint { + async fetch(request: Request): Promise { + const response = await super.fetch(request); + const headers = new Headers(response.headers); + headers.set("X-Castloop-Test-Cache-Invocation", crypto.randomUUID()); + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); + } +} export default { async fetch(request, env, ctx): Promise { - return fetchM6ManagementIntegration(request, env, ctx.exports.CachedPublicAssets, { setupRuntime: { + const response = await fetchM6ManagementIntegration(request, env, ctx.exports.CachedPublicAssets, { setupRuntime: { defaultFetch: (input) => ctx.exports.default.fetch(input), cachedRuntime: () => ctx.exports.CachedPublicAssets.describeRuntime(), invalidate: (showId) => ctx.exports.CachedPublicAssets.invalidate({ showId }), } }); + if (!parsePublicAssetPath(new URL(request.url).pathname)) return response; + const headers = new Headers(response.headers); + headers.set("X-Castloop-Test-Gateway-Invocation", crypto.randomUUID()); + headers.set("X-Castloop-Test-Inner-Cache", response.headers.get("Cf-Cache-Status") ?? "absent"); + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); }, async queue(batch, env, ctx): Promise { if (await consumeM6SetupProbe(batch, env)) return; From f6afc27ee04d5b6f8d666571a3ecc57ceb0b6806 Mon Sep 17 00:00:00 2001 From: Akira Shimosako Date: Sun, 4 Oct 2026 09:50:05 +0900 Subject: [PATCH 08/16] test(m6): record cache hits and isolate 300MB CPU acceptance blocker --- design/m6_implementation_log.md | 7 +++++++ design/m6_review_queue.md | 4 +++- design/m6_standalone_acceptance.md | 22 +++++++++++++++++--- experiments/m6/README.md | 5 ++++- experiments/m6/verify-large-media.ts | 30 ++++++++++++++++++++-------- 5 files changed, 55 insertions(+), 13 deletions(-) diff --git a/design/m6_implementation_log.md b/design/m6_implementation_log.md index b3c2ccf..5a5e58b 100644 --- a/design/m6_implementation_log.md +++ b/design/m6_implementation_log.md @@ -1,5 +1,12 @@ # M6: 公開停止・削除 実装ログ +## 2026-10-04: 実cache HIT合格と300MBのCPU制限を確認 + +- NRTでfeed/cover/小さい音源の内部HIT・同じ内部nonceと毎回異なるgateway nonceを確認し、公開GET-onlyの再照合結果もprivate acceptanceへ保存した。 +- 正確な300MBのlocal解析/REST PUT/全量GETと過大入力のlocal拒否は成功したが、Worker staging検証の応答は未確認になった。local finish_requested/server verifying/token保持を観測し、明示pausedで新規配信/書込だけを閉じた。owner/token/payloadは回復可能性のため保持し、elapsed timeやHEADから解放しない。 +- 別名readonly診断Workerで同じR2 objectのnative digest直結と現行経路を各一回試し、両方のexceededCpuをsanitized tailで確認した。直結でもCPU 2068msを要し、現経路はCPU 10ms時点で終了した。契約照会403、課金/CPU limit変更なし。Paid利用に必要な判断と具体的な対象/記録は`m6_standalone_acceptance.md`へ記録した。 +- 300MB harnessへ初期化済みacknowledged workspaceからの独立開始と、途中で既に確認したcache/過大拒否/fixtureの記録を追加した。完了済み更新/六lifecycle試験を新環境で毎回重複させず、未知のfailed workspaceを採用しない。 + ## 2026-10-04: 六lifecycle操作のCloudflare/standalone受け入れ - 通常更新済みの専用`castloop-m6-test-ff3bfd8c`を再利用し、Show/Episode各unpublish/restore/deleteを試験binary→実HTTP→通知/Queue→consumerまで通した。GUID/revision/checksum保持、GET/HEAD/Range/304/416と停止404/削除410、payload物理不在と永久管理記録保持が合格した。 diff --git a/design/m6_review_queue.md b/design/m6_review_queue.md index e717990..a22900f 100644 --- a/design/m6_review_queue.md +++ b/design/m6_review_queue.md @@ -50,7 +50,9 @@ 下記の各module詳細には実装当時の未接続項目も残る。最新の到達点は新規初期化・通常更新の試験HTTP/standalone接続と実Cloudflare合格、Show/Episode六lifecycle操作の実機合格まで進んでいる。後者は配信404/410・復元時GUID/revision保持・物理削除・永久記録保持・owner/token通常返却を含む。通常公開CLI/entryはまだ切り替えていない。 -通常更新の完了応答喪失→別standaloneの非書込照合も実機合格した。残件は実cache HIT、300MBと二種改訂の受け入れ、新規初期化fault、安全なunknown IO/残存lockの外部終了確認・復旧手順、公開CLI/利用案内・release gateである。専用試験harnessを追加して継続し、未実行のharnessを合格扱いしない。詳細は[standalone受け入れ](./m6_standalone_acceptance.md)。旧形式変換は必要時だけとする。 +通常更新の完了応答喪失→別standaloneの非書込照合と、feed/cover/小さい音源の実cache HIT・gateway毎回実行も実機合格した。300MBはREST PUT/全量GETまで成功したが、WorkerのCPU制限で未合格となった。独立readonly診断でもnative digestと現行経路が`exceededCpu`で終了し、元serviceはpaused/owner/token保持とした。契約照会は403で、Paid契約/適用profileの確認と必要な利用承認は管理者の判断が必要。課金変更はしていない。費用測定計画を通常開発のgateへ戻すものではない。 + +残件は300MBと二種改訂の受け入れ、新規初期化fault、安全なunknown IO/残存lockの外部終了確認・復旧手順、公開CLI/利用案内・release gateである。未実行/未完了のharnessを合格扱いしない。詳細は[standalone受け入れ](./m6_standalone_acceptance.md)。旧形式変換は必要時だけとする。 2026-10-03に管理者が「共通journalリファクタリングをmainへmerge → 別branchで新規M6初期化/稼働判定の分離 → 別branchで形式を変えない通常更新 → 旧形式変換は必要時だけ」を承認した。旧形式変換を新規M6サービスの必須経路にしない。既存環境の即時削除や公開gate解除の承認とは扱わない。 diff --git a/design/m6_standalone_acceptance.md b/design/m6_standalone_acceptance.md index 35b12ec..2881616 100644 --- a/design/m6_standalone_acceptance.md +++ b/design/m6_standalone_acceptance.md @@ -37,8 +37,24 @@ 全884テスト/13409 assertions、TypeScript/M6試験tsconfig、通常/試験Linux build、local HTTPS四family statusとproduction資源拒否も合格した。 -## 後続の受け入れ +## 実cache HIT: 合格 -- `verify-large-media.ts`: 正確な300,000,000-byte MP3、過大入力のlocal拒否、audio-only/metadata-only改訂、全量stream照合、GUID/date/history/旧媒体保持、配信・明示deleteを検証する。試験Workerだけのnonce/headerでfeed/cover/小さい音源の実cache HITとdefault gatewayの毎回実行を確認する。 +version `756ab58d-9289-4fbd-a0df-cdc9377b71f4`で`large` Showと小さい`boundary` Episodeを公開し、feed/cover/音源の内部HITを確認した。NRTで同じ内部nonceが返り、gateway nonceは変わった。300MB試験失敗後にも公開GETだけで再照合し、`cache-hit-acceptance.json`へ記録した。実測を複数coloや300MB cache HITまで一般化しない。 -これらのscriptの実装・型検査は実機合格を意味しない。実行結果は別途追記する。unknown live PUT/consumerや残存lockを安全に解放する外部終了確認、復旧手順、通常CLI/README/help/version/releaseの完成は引き続き残る。elapsed time・HEAD不在・traceの終了通知だけを旧IO収束の保証として扱わない。 +## 300MB: WorkerのCPU制限で未合格 + +正確な300,000,000-byte MP3のlocal stream解析とREST単一PUT/全量GET照合は成功した。300,000,001 bytesはlocal size limitで拒否され、remote targetが非変更であることも確認した。続くWorker検証の応答は未確認となった。 + +- staging operation: `4156d1e5-3e22-4b19-b842-b0135fc56cdb`、draft: `d05d71fc-0c58-463d-904c-20704be807ae`。 +- local `finish_requested`/`put_outcome=staged`/acknowledged PUT 1件、server `verifying`/`verification_active=true`/service invocation保持。成功扱い・再送・token奪取はしていない。 +- 明示pause `cd7922c5-3103-4000-9e7d-198613b3dfd9`で新規配信/書込を閉じた。registryには未収束tokenを保持し、pausedをdrainedと表示しない。300MB staging payloadも回復可能性のあるdraftとして保持する。 +- 独立した`castloop-m6-test-digest-e975e1b4`は同じ既知objectを条件付きGETするだけの診断Worker。R2/control/Queueには書き込まない。native DigestStream直結と現行byte-count付き経路を各一回試し、両方の503と`exceededCpu`をsanitized realtime tailで観測した。直結はCPU 2068ms/wall 3215ms、現行経路はCPU 10ms/wall 50msで終了した。直結だけに変えても現CPU制限を回避できない。 +- Worker settingsの`usage_model=standard`はPaid契約の証拠ではない。契約照会は403/code10000であり、billing権限の追加・契約変更・CPU limit変更をしていない。 + +Cloudflareの[limits](https://developers.cloudflare.com/workers/platform/limits/)ではFree HTTP CPUは10ms、Paidは既定30s。[pricing](https://developers.cloudflare.com/workers/platform/pricing/)ではPaidの最低料金はaccountあたり月$5 USD、超過は従量課金。**現方式の300MB受け入れをPaid環境で続ける場合、管理者の契約/利用承認が必要**。これは後回しにした費用測定計画の承認待ちではなく、実際の契約変更の判断である。すでにPaidなら現在の適用profileを確認する。 + +診断結果は`/tmp/opencode/castloop-m6-test-digest-e975e1b4/results-2.json`。tailの終了通知を旧IOがすべて収束した保証として扱わず、元のserviceのtoken/ownerは保持する。既存資源・診断資源の削除もしていない。 + +## 後続 + +300MB/audio-only/metadata-only/配信/明示deleteの未完了受け入れ、安全なunknown IO/残存lockの外部終了確認、復旧手順、通常CLI/README/help/version/releaseの完成は残る。harnessは新規初期化のacknowledged journal/paused checkpointからも開始可能にし、既に確認した通常更新と六lifecycleを300MB試験のたびに再実行しない。failed workspaceの再送/採用/強制解放を許可するmodeではない。 diff --git a/experiments/m6/README.md b/experiments/m6/README.md index 78ccc1a..773e545 100644 --- a/experiments/m6/README.md +++ b/experiments/m6/README.md @@ -63,13 +63,16 @@ bun scripts/build-cli.ts linux-x64 --m6-test bun experiments/m6/verify-lifecycle.ts /tmp/opencode/ACKNOWLEDGED_TEST_WORKSPACE bun experiments/m6/verify-completion-recovery.ts /tmp/opencode/ACKNOWLEDGED_TEST_WORKSPACE bun experiments/m6/verify-large-media.ts /tmp/opencode/ACKNOWLEDGED_TEST_WORKSPACE +bun experiments/m6/verify-large-media.ts --fresh-acknowledged-workspace /tmp/opencode/ACKNOWLEDGED_FRESH_WORKSPACE ``` - lifecycle: compatible-update受け入れ済みの小さい`fresh/first` fixtureで六操作を実行し、GET/HEAD/Range/304/416、404/410、GUID/revision保持、payload物理削除と永久記録保持を確認する。二回GETしただけでcache HIT合格とはしない。 - completion recovery: lifecycle合格後の同じpaused環境を通常更新し、serverの成功completion応答だけを試験transportで破棄する。local requested/server pausedを確認し、別のbinary commandで非書込照合する。強制終了したIOの収束証明とは別である。 -- large media: recovery合格後に別Show `large`を作る。719424個の417-byte MPEG framesと192-byte ID3 prefixで正確な300,000,000-byte MP3をchunk書込・stream解析する。300,000,001 bytes拒否、audio-only/metadata-only改訂、全量stream checksum、GUID/date/history/旧音源保持、HEAD/suffix Range/304、最後に明示Show deleteを検証する。音声内容の聴取品質を検証するfixtureではない。 +- large media: recovery合格後、または明示fresh modeで初期化/公開のacknowledged journalと現在のpaused checkpointを確認した後に、別Show `large`を作る。未確認資源・別version・残存lock/tokenは採用しない。719424個の417-byte MPEG framesと192-byte ID3 prefixで正確な300,000,000-byte MP3をchunk書込・stream解析する。300,000,001 bytes拒否、audio-only/metadata-only改訂、全量stream checksum、GUID/date/history/旧音源保持、HEAD/suffix Range/304、最後に明示Show deleteを検証する。音声内容の聴取品質を検証するfixtureではない。途中失敗でも確認済みcache HIT/過大拒否/fixture checksumを記録する。 - cache: 試験Workerだけが内部/外部invocation nonceとinner cache statusをresponse headerへ追加する。feed/cover/小さい音源で、内部HITの同じnonceと毎要求異なるgateway nonceを照合する。通常Worker/公開binaryへ計測headerを追加しない。 mutationの自動再送、未知lock/tokenの解放、Worker/bucket/Queueの削除はしない。readonlyな観測競合だけ有限回待ち、失敗数を記録する。途中失敗は固定phase/codeだけ保存し、任意exception本文・secret・metadata本文は保存しない。before/incompleteを消して再実行してはいけない。既知の完了checkpointからの続行は個別に証拠を照合する。 各試験は成功時もserviceを明示pausedで残す。これらのscriptの実装・local型検査だけをCloudflare合格として扱わず、実行結果を設計logへ別途記録する。複数colo・独自domain・請求額・無停止移行・unknown live IOの解放・正式releaseは対象外。 + +2026-10-04、六lifecycle・completion recovery・実cache HITは合格したが、300MB stagingのWorker検証はCPU制限で未完了となった。元serviceはpaused/未収束owner/token/payload保持。native digest直結でもexceededCpuを実機観測したため、現方式をPaidで検証する場合は契約/利用承認が必要である。`usage_model=standard`をPaid証拠としない。具体的な証拠・残件は`design/m6_standalone_acceptance.md`。このharnessを既存failed workspaceで再実行してはいけない。 diff --git a/experiments/m6/verify-large-media.ts b/experiments/m6/verify-large-media.ts index 1f2220c..18dab35 100644 --- a/experiments/m6/verify-large-media.ts +++ b/experiments/m6/verify-large-media.ts @@ -1,22 +1,30 @@ import { m6ServiceAdminResponseSchema, parseEpisodeDraft, parseServiceConfig, parseShowMetadata, stringifyToml, targetInspectionResponseSchema } from "../../packages/shared/src/index"; import type { EpisodeRevision } from "../../packages/shared/src/index"; import { validateM6LifecyclePlan } from "../../packages/cli/src/m6-local-lifecycle"; +import { readLocalFreshM6Initialization } from "../../packages/cli/src/m6-service-initialization"; +import { readLocalM6Update } from "../../packages/cli/src/m6-service-update"; import { createAcceptanceMp3 } from "./acceptance-audio"; import { digestAcceptanceResponse, expectStandaloneRejection, readAcceptanceBytes, standaloneCommand, standaloneOutput } from "./standalone-harness"; import assert from "node:assert/strict"; import { open, readFile, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; -if (process.argv.length !== 3) throw new Error("Provide the explicitly acknowledged completion-recovery workspace"); -const root = resolve(process.argv[2]!); +const freshWorkspace = process.argv[2] === "--fresh-acknowledged-workspace"; +if (process.argv.length !== (freshWorkspace ? 4 : 3)) throw new Error("Provide an acknowledged recovery workspace, or --fresh-acknowledged-workspace WORKSPACE"); +const root = resolve(process.argv[freshWorkspace ? 3 : 2]!); const config = parseServiceConfig(await readFile(join(root, "castloop.toml"), "utf8")); -const previous = JSON.parse(await readFile(join(root, "completion-recovery-acceptance.json"), "utf8")) as { - result: string; name: string; pause_id: string; worker_version_id: string; +const previous = JSON.parse(await readFile(join(root, freshWorkspace ? "acceptance.json" : "completion-recovery-acceptance.json"), "utf8")) as { + result: string; name: string; pause_id: string; worker_version_id?: string; operation_id: string; }; -assert.ok(previous.result === "completion_recovery_binary_passed" && previous.name === config.worker_name && +assert.ok(previous.result === (freshWorkspace ? "fresh_binary_publication_passed" : "completion_recovery_binary_passed") && previous.name === config.worker_name && config.account_id === process.env.CLOUDFLARE_ACCOUNT_ID && config.service_id.startsWith("m6-test-") && [config.worker_name, config.bucket_name, config.queue_name, config.dlq_name].every((name) => name.startsWith("castloop-m6-test-")), "Only the acknowledged isolated service is permitted"); +const retained = freshWorkspace ? readLocalFreshM6Initialization(root, config) : readLocalM6Update(root, config, previous.operation_id); +assert.ok(retained.state?.phase === (freshWorkspace ? "initialized" : "completed") && !retained.lockPresent && + retained.state.request.operation_id === previous.operation_id && retained.state.runtime_readiness); +const workerVersionId = retained.state.runtime_readiness.worker_version_id; +if (!freshWorkspace) assert.equal(workerVersionId, previous.worker_version_id); const command = (...args: string[]) => standaloneCommand(root, ...args); const status = async () => m6ServiceAdminResponseSchema.parse(await command("service-status")); const target = async () => targetInspectionResponseSchema.parse(await command("target-episode", "large", "boundary")); @@ -24,6 +32,8 @@ const jobs: string[] = []; const cacheHits: string[] = []; let phase = "preflight"; let readonlyFailures = 0; +let oversizedRejected = false; +let boundaryFixture: { bytes: number; sha256: string } | undefined; async function waitPublished(job: string, episode: boolean): Promise { for (let read = 0; read < 240; read += 1) { try { @@ -47,7 +57,7 @@ const get = (path: string, method = "GET", headers: Record = {}) async function checkMedia(revision: EpisodeRevision, expected: { bytes: number; sha256: string }): Promise { const response = await get(new URL(revision.enclosure_url).pathname); assert.equal(response.headers.get("Cache-Control"), "public, max-age=0, must-revalidate"); - assert.equal(response.headers.get("X-Castloop-Worker-Version"), previous.worker_version_id); + assert.equal(response.headers.get("X-Castloop-Worker-Version"), workerVersionId); assert.deepEqual(await digestAcceptanceResponse(response, expected.bytes), expected); } async function warmCache(path: string): Promise { @@ -73,7 +83,8 @@ try { const before = await status(); assert.equal(before.admission.state, "paused"); assert.equal(before.admission.pause_id, previous.pause_id); - assert.equal(before.worker_version_id, previous.worker_version_id); + assert.equal(before.worker_version_id, workerVersionId); + assert.deepEqual(before.admission.runtime_readiness, retained.state.runtime_readiness); assert.equal(before.admission.invocations.length, 0); const missing = targetInspectionResponseSchema.parse(await command("target-show", "large")); assert.equal(missing.show, null); @@ -115,10 +126,12 @@ try { assert.ok(rejection.includes("300000000") || rejection.includes("300,000,000"), "Rejection must identify the local size limit"); assert.deepEqual(await target(), targetBefore); assert.equal((await status()).admission.invocations.length, 0); + oversizedRejected = true; phase = "boundary-fixture"; const largeFile = join(directory, "boundary.mp3"); const large = await createAcceptanceMp3(largeFile, 719424, 182); assert.equal(large.bytes, 300_000_000); + boundaryFixture = large; phase = "boundary-audio-staging"; console.log(JSON.stringify({ phase, bytes: large.bytes, result: "starting" })); await command("update-episode-audio", "large", "boundary", largeFile); @@ -192,13 +205,14 @@ try { await command("service-pause", pauseId); assert.equal((await status()).admission.invocations.length, 0); await writeFile(join(root, "large-media-acceptance.json"), JSON.stringify({ result: "large_media_binary_acceptance_passed", name: config.worker_name, - worker_version_id: previous.worker_version_id, maximum_audio: large, oversized_rejected: true, guid_date_history_preserved: true, + worker_version_id: workerVersionId, maximum_audio: large, oversized_rejected: true, guid_date_history_preserved: true, audio_only_and_metadata_only_verified: true, get_head_suffix_range_conditional_verified: true, cache_hit_paths: cacheHits, publication_jobs: jobs, deletion_job: plan.request.job_id, pause_id: pauseId, payloads_deleted: true, resources_retained_paused: true, readonly_observation_failures: readonlyFailures, authorizes_release: false }, null, 2), { mode: 0o600, flag: "wx" }); console.log(JSON.stringify({ result: "large_media_binary_acceptance_passed", workspace: root, resources_retained_paused: true })); } catch { await writeFile(join(root, "large-media-incomplete.json"), JSON.stringify({ result: "large_media_acceptance_incomplete", phase, publication_jobs: jobs, + cache_hit_paths: cacheHits, oversized_rejected: oversizedRejected, boundary_fixture: boundaryFixture, readonly_observation_failures: readonlyFailures, resources_not_deleted: true, authorizes_recovery: false }, null, 2), { mode: 0o600, flag: "wx" }); console.error(`Large media acceptance did not complete (${phase}); preserve journals, streams and owners without replay or automatic release.`); process.exitCode = 1; From 3abeabe67bec9e53c5638dd11626076fd4051c2e Mon Sep 17 00:00:00 2001 From: Akira Shimosako Date: Sun, 4 Oct 2026 09:54:36 +0900 Subject: [PATCH 09/16] docs(m6): require free-tier verification without Workers Paid --- design/m6_review_queue.md | 6 +++++- design/m6_standalone_acceptance.md | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/design/m6_review_queue.md b/design/m6_review_queue.md index a22900f..a42b098 100644 --- a/design/m6_review_queue.md +++ b/design/m6_review_queue.md @@ -46,11 +46,15 @@ ## 管理者の判断待ちではない技術残件 +### Workers Paidは利用しない(2026-10-04) + +管理者がWorkers Paidの利用不可を明示した。Paidへの変更を再要求せず、無料枠を前提に検証処理の責務・実行場所を見直す。既存の300MB上限を勝手に下げず、全量contents照合・object identity/owner/generation照合を単純に省略しない。現行Worker内全量SHA-256のCPU超過は、この実装方式の問題であり、R2へ300MBを保存できないという実証ではない。 + ### 現在の到達点(2026-10-04) 下記の各module詳細には実装当時の未接続項目も残る。最新の到達点は新規初期化・通常更新の試験HTTP/standalone接続と実Cloudflare合格、Show/Episode六lifecycle操作の実機合格まで進んでいる。後者は配信404/410・復元時GUID/revision保持・物理削除・永久記録保持・owner/token通常返却を含む。通常公開CLI/entryはまだ切り替えていない。 -通常更新の完了応答喪失→別standaloneの非書込照合と、feed/cover/小さい音源の実cache HIT・gateway毎回実行も実機合格した。300MBはREST PUT/全量GETまで成功したが、WorkerのCPU制限で未合格となった。独立readonly診断でもnative digestと現行経路が`exceededCpu`で終了し、元serviceはpaused/owner/token保持とした。契約照会は403で、Paid契約/適用profileの確認と必要な利用承認は管理者の判断が必要。課金変更はしていない。費用測定計画を通常開発のgateへ戻すものではない。 +通常更新の完了応答喪失→別standaloneの非書込照合と、feed/cover/小さい音源の実cache HIT・gateway毎回実行も実機合格した。300MBはREST PUT/全量GETまで成功したが、WorkerのCPU制限で未合格となった。独立readonly診断でもnative digestと現行経路が`exceededCpu`で終了し、元serviceはpaused/owner/token保持とした。契約照会は403で課金変更はしていない。管理者のPaid利用不可の決定に従い、無料枠に適合する検証方式の見直しを技術残件として進める。費用測定計画を通常開発のgateへ戻すものではない。 残件は300MBと二種改訂の受け入れ、新規初期化fault、安全なunknown IO/残存lockの外部終了確認・復旧手順、公開CLI/利用案内・release gateである。未実行/未完了のharnessを合格扱いしない。詳細は[standalone受け入れ](./m6_standalone_acceptance.md)。旧形式変換は必要時だけとする。 diff --git a/design/m6_standalone_acceptance.md b/design/m6_standalone_acceptance.md index 2881616..22c897e 100644 --- a/design/m6_standalone_acceptance.md +++ b/design/m6_standalone_acceptance.md @@ -51,7 +51,7 @@ version `756ab58d-9289-4fbd-a0df-cdc9377b71f4`で`large` Showと小さい`bounda - 独立した`castloop-m6-test-digest-e975e1b4`は同じ既知objectを条件付きGETするだけの診断Worker。R2/control/Queueには書き込まない。native DigestStream直結と現行byte-count付き経路を各一回試し、両方の503と`exceededCpu`をsanitized realtime tailで観測した。直結はCPU 2068ms/wall 3215ms、現行経路はCPU 10ms/wall 50msで終了した。直結だけに変えても現CPU制限を回避できない。 - Worker settingsの`usage_model=standard`はPaid契約の証拠ではない。契約照会は403/code10000であり、billing権限の追加・契約変更・CPU limit変更をしていない。 -Cloudflareの[limits](https://developers.cloudflare.com/workers/platform/limits/)ではFree HTTP CPUは10ms、Paidは既定30s。[pricing](https://developers.cloudflare.com/workers/platform/pricing/)ではPaidの最低料金はaccountあたり月$5 USD、超過は従量課金。**現方式の300MB受け入れをPaid環境で続ける場合、管理者の契約/利用承認が必要**。これは後回しにした費用測定計画の承認待ちではなく、実際の契約変更の判断である。すでにPaidなら現在の適用profileを確認する。 +Cloudflareの[limits](https://developers.cloudflare.com/workers/platform/limits/)ではFree HTTP CPUは10ms。2026-10-04、管理者が**Workers Paidは利用不可**と決定した。Paid環境への変更ではなく、CLIの既存全量照合とR2の検証能力、Workerで保持するidentity/size/owner検査の責務を無料枠前提で見直す。代替方式はまだ実証済みではなく、単にchecksum検査を削除する承認でもない。300MB保存/CLI全量照合は成功しており、今回の失敗を無料枠での300MB運用そのものが不可能という証明にしない。 診断結果は`/tmp/opencode/castloop-m6-test-digest-e975e1b4/results-2.json`。tailの終了通知を旧IOがすべて収束した保証として扱わず、元のserviceのtoken/ownerは保持する。既存資源・診断資源の削除もしていない。 From 53a432cbe6050c523e8bd566f95514ce1f95e2f6 Mon Sep 17 00:00:00 2001 From: Akira Shimosako Date: Sun, 4 Oct 2026 10:15:16 +0900 Subject: [PATCH 10/16] docs(m6): validate R2 checksum alternatives to Worker rehashing --- design/m6_review_queue.md | 2 + design/m6_staging_rest.md | 2 + design/m6_standalone_acceptance.md | 2 + design/m6_upload_integrity_review.md | 79 +++++++++++++++++++ experiments/m6/README.md | 6 +- experiments/m6/checksum-results-20261004.json | 45 +++++++++++ 6 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 design/m6_upload_integrity_review.md create mode 100644 experiments/m6/checksum-results-20261004.json diff --git a/design/m6_review_queue.md b/design/m6_review_queue.md index a42b098..f47b775 100644 --- a/design/m6_review_queue.md +++ b/design/m6_review_queue.md @@ -50,6 +50,8 @@ 管理者がWorkers Paidの利用不可を明示した。Paidへの変更を再要求せず、無料枠を前提に検証処理の責務・実行場所を見直す。既存の300MB上限を勝手に下げず、全量contents照合・object identity/owner/generation照合を単純に省略しない。現行Worker内全量SHA-256のCPU超過は、この実装方式の問題であり、R2へ300MBを保存できないという実証ではない。 +R2/CLIの[完全性検証調査](./m6_upload_integrity_review.md)で、管理REST PUTは誤ったchecksumヘッダーでも保存する一方、binding PUTは誤ったSHA-256を拒否し、300MBの検証付きstream保存/HEAD native checksumがCPU 2msで成功することを確認した。推奨は既存CLIの全量SHA-256読み戻し結果をexact objectへ結び付け、公開保存時のSHA-256をR2へ任せる方式。Worker内の二箇所の全量再ハッシュを省く案であり、証拠伝達はまだ未実装。全M6の300MB受け入れや旧IO収束の合格とは区別する。 + ### 現在の到達点(2026-10-04) 下記の各module詳細には実装当時の未接続項目も残る。最新の到達点は新規初期化・通常更新の試験HTTP/standalone接続と実Cloudflare合格、Show/Episode六lifecycle操作の実機合格まで進んでいる。後者は配信404/410・復元時GUID/revision保持・物理削除・永久記録保持・owner/token通常返却を含む。通常公開CLI/entryはまだ切り替えていない。 diff --git a/design/m6_staging_rest.md b/design/m6_staging_rest.md index 7f20a6b..eb833c3 100644 --- a/design/m6_staging_rest.md +++ b/design/m6_staging_rest.md @@ -21,6 +21,8 @@ - runnerはIO終了後も明示settlement/finishを別途要求する。GET成功はserver側の検証tokenやpublicationを代替しない。HTTP handlerの独立検証・owner/generation照合を維持する。 - 接続終了をCloudflare側の遅延確定不在の証明にはしない。承認済みの未検証仮定U1を維持する。native runtimeの一般的なretry/切断保証を新たに主張しない。 +2026-10-04の[完全性検証調査](./m6_upload_integrity_review.md)では、上記の現行契約を、CLIの全量照合証拠+Worker HEADのidentity照合とR2の公開保存時checksum検証へ集約する案を推奨した。まだ証拠伝達を実装しておらず、現在のHTTP独立hashを削除済みとは扱わない。 + ## 検証と残件 mock RESTと実Bun/loopback HTTPでexact path/header、3種stagingの明示完了、8MiB snapshot転送、転送中の元ファイル変更、checksum/size/過大応答/早期応答/Range/応答喪失、取消await、live IO中dispose拒否、symlink/foreign account/再送拒否を確認した。Cloudflare RESTの実経路・300MB資源使用量・CLI公開・最新draft/publication照合・unknown outcomeの安全な外部復旧は別の残件である。 diff --git a/design/m6_standalone_acceptance.md b/design/m6_standalone_acceptance.md index 22c897e..aa22547 100644 --- a/design/m6_standalone_acceptance.md +++ b/design/m6_standalone_acceptance.md @@ -55,6 +55,8 @@ Cloudflareの[limits](https://developers.cloudflare.com/workers/platform/limits/ 診断結果は`/tmp/opencode/castloop-m6-test-digest-e975e1b4/results-2.json`。tailの終了通知を旧IOがすべて収束した保証として扱わず、元のserviceのtoken/ownerは保持する。既存資源・診断資源の削除もしていない。 +後続の独立試験では、R2 bindingにSHA-256を渡す300MB stream保存とHEAD native checksum照合がCPU 2msで成功し、CLI全量読み戻しとも一致した。管理REST PUTへのchecksumヘッダー追加は誤ったhashを拒否しなかった。[完全性検証調査](./m6_upload_integrity_review.md)参照。これは代替機能の実証であり、現行M6 staging/publicationの実装変更や全受け入れ合格ではない。元serviceの未知owner/tokenは保持する。 + ## 後続 300MB/audio-only/metadata-only/配信/明示deleteの未完了受け入れ、安全なunknown IO/残存lockの外部終了確認、復旧手順、通常CLI/README/help/version/releaseの完成は残る。harnessは新規初期化のacknowledged journal/paused checkpointからも開始可能にし、既に確認した通常更新と六lifecycleを300MB試験のたびに再実行しない。failed workspaceの再送/採用/強制解放を許可するmodeではない。 diff --git a/design/m6_upload_integrity_review.md b/design/m6_upload_integrity_review.md new file mode 100644 index 0000000..4bab435 --- /dev/null +++ b/design/m6_upload_integrity_review.md @@ -0,0 +1,79 @@ +# M6: 音源の完全性検証をR2/CLIへ集約する調査 + +更新日: 2026-10-04 + +状態: **公式仕様と専用Cloudflare環境で代替手段を確認。方式の提案であり、現行runtimeの変更・M6受け入れ完了ではない。Workers Paidは使用しない。** + +## 結論 + +Workerで300MBのSHA-256を再計算する必要はない。目的は「凍結した入力と保存済み音源が同じ全長・内容であること」であり、検証する場所をWorkerのCPUに限定する理由はない。 + +推奨は、**stagingは既存CLIの全量SHA-256読み戻しを保存objectのidentityへ結び付け、公開時はR2 bindingのSHA-256検証へ任せる**方式である。これならSHA-256による照合を維持し、REST単一PUT・既存の読み戻し・公開時のstream保存を使える。Worker内のstaging/publication二箇所の全量再ハッシュをなくし、新しいupload方式・credential・製品・状態機械を追加しない。 + +さらにCLIの読み戻しもなくす候補として、R2が自動保存するMD5とlocal MD5の比較も成立する。ただしこれはstaging時点のSHA-256照合と同じ保証ではないため、最小改修の第一案とは分ける。 + +## 現行の重複 + +| 区間 | 現行処理 | 見直し対象 | +| --- | --- | --- | +| local入力→staging | CLIが入力SHA-256を凍結し、REST PUT後に全量GET/size/SHA-256を照合 | 読み戻し結果を返さず破棄している。PUT receiptもsuccess/sizeしか検査しない | +| staging完了 | Workerが同じ音源をGETし、全量SHA-256を再計算 | CLIの照合結果をexact objectへ結び付ければ、音源bodyの再読取は不要 | +| staging→公開immutable音源 | Workerの`bucket.put(..., { sha256 })`でR2へ検証を要求 | 既に利用している機能。保存済みのnative checksumを確認すればよい | +| 公開保存後 | Workerが公開音源をGETし、さらに全量SHA-256を再計算 | R2の検証済みchecksum/sizeをHEADで確認する処理に置き換える | + +対象コードは`packages/cli/src/staging-rest.ts`、`src/staging-verification.ts`、`src/publication-episode-runner.ts`。publication admissionは既に検証記録とHEADのETag/sizeを照合しており、ここへ別の全量検証を追加しない。 + +## R2機能: アクセス経路を区別する + +### 現行のCloudflare REST object PUT + +`PUT /accounts/{account_id}/r2/buckets/{bucket_name}/objects/{object_key}`の[公式仕様](https://developers.cloudflare.com/api/resources/r2/subresources/buckets/subresources/objects/methods/upload/)にはchecksum入力が記載されていない。 + +専用bucketの新規`probe/checksum//`へ28-byte bodyを送り、ヘッダーなし・正しい/誤った`Content-MD5`・正しい/誤った`x-amz-checksum-sha256`を比較した。**五件ともHTTP 200で保存され、GETのbodyは送信内容と一致した。誤ったchecksumでも拒否されない。** この経路にS3用ヘッダーを追加するだけでは検証を任せられない。 + +これらのobjectにはR2 native MD5が保存されていたが、SHA-256は保存されていなかった。`customMetadata.sha256`に文字列を書いても、それ自体は検証済みchecksumにならない。 + +### Workers R2 binding + +[Workers API仕様](https://developers.cloudflare.com/r2/api/workers/workers-api-reference/#r2putoptions)は`put`の`sha256`等を受信内容の完全性検証に使うと定める。指定したchecksumは`R2Object.checksums`に保存され、HEADでbodyを読まず取得できる。非multipart objectのMD5は既定で含まれる。 + +実機では正しいSHA-256付き28-byte PUTが成功し、PUT結果とHEADのnative SHA-256が一致した。誤ったSHA-256は`BadDigest`相当のcode `10037`で拒否され、対象keyのHEADはnullだった。 + +続いて既知の300,000,000-byte staging音源を条件付きGETし、診断用の別keyへ`put(object.body, { sha256, onlyIf })`した。**HTTP 200、CPU 2ms、wall 16,449ms、HEADのsize/native SHA-256一致**を確認した。Workerの全量buffer・JavaScript hash・DigestStreamは使っていない。CLIによるコピー先の全量GETでもsize/SHA-256が一致した。 + +CPU limitやPaid契約は変更していない。2msはこの単独保存試験の実測であり、M6の全処理・全colo・常時のCPU上限保証ではない。bindingによるchecksum検証を、300MBのWorker内再ハッシュと同じCPU費用として扱う必要はない。 + +### S3互換API + +[S3互換性仕様](https://developers.cloudflare.com/r2/api/s3/api/)には`Content-MD5`等の対応があるが、現行の管理REST APIとは別経路である。multipart checksumは全量hashと合成hashを区別する必要もある。別credential/署名処理やupload方式の変更なしに解決できるため、今回S3への移行は推奨しない。 + +## 選択肢と保証の違い + +| 案 | stagingの確認 | 公開音源の確認 | 評価 | +| --- | --- | --- | --- | +| A: R2 native checksum中心 | local size/MD5を凍結し、Worker HEADのsize/native MD5と比較 | R2 PUTのSHA-256検証+HEADのnative SHA-256 | 全量読み戻しも不要。偶発的破損・欠落の検出には有効だが、MD5は意図的衝突への耐性が弱い。stagingのSHA-256照合を同等に維持する案ではない | +| **B: CLI SHA-256読み戻し+R2公開検証** | **現在のCLI GETで全量size/SHA-256を照合し、Workerがその結果と保存objectのidentityを確認** | **R2 PUTのSHA-256検証+HEADのnative SHA-256** | **推奨。既存の全量SHA-256照合を残し、Workerでの二重計算をなくす。新しいMD5 manifestも不要** | +| C: stagingをR2 SHA-256付きで再保存 | R2 bindingで既存stagingを読み、SHA-256検証付きで再保存 | 同上 | server独立のSHA-256検証は可能だが、検証だけのために300MBを再書込し、書込副作用も増やす。MVPでは不要 | + +Aでも公開時のR2 SHA-256検証は維持できるため、MD5を全量SHA-256の代用品として最終公開証拠へ流用する必要はない。Bの全量読み戻しは転送時間を増やすが、既に実機300MBで成功している。R2の保存耐久性やHTTP 200だけをlocal入力との照合の代わりにはしない。 + +## 推奨Bの最小実装契約 + +1. `createStagingRestPut`は成功申告だけでなく、固定asset/key、PUT receiptのsize/ETag/versionと、全量GETの実測size/SHA-256/ETagを返す。PUT結果のidentityとGETのidentityを照合する。REST receiptのversion提供・binding HEADとの対応は接続時の実機検証事項とし、欠落/不一致を成功として補完しない。 +2. その小さい証拠だけを既存local journalと認証付きsettlement要求へ保存する。操作ID・draft ID・凍結manifestへ厳密に結び付け、 arbitrary keyや`verified: true`だけの要求を受け付けない。本文・source path・secretは保持記録へ追加しない。 +3. Workerはexact owner/generation・client終了確認と証拠を照合し、音源のHEADでsize/ETag/versionを確認する。音源のGET/全量hashはしない。metadataのschema/identityとcoverの形式検証は別責務として維持する。 +4. verified assetsへidentityと照合済みSHA-256を残し、publication admissionとsource取得時にも同じobjectであることを確認する。サイズだけ・ETagだけ・任意のcustom metadataだけを内容証明にしない。 +5. `persistAudio`は既存のSHA-256付きR2 PUTを維持する。成功後と再実行時はHEADのsize/**native** `checksums.sha256`を凍結commitに照合する。native checksumがない既存objectをcustom metadataだけで成功扱いせず、安全な互換/回復の扱いを別途決める。 +6. 受付/token/既存phaseは使い続ける。未返却PUT/consumerをHEADや時間だけで解放しない。新規の巨大な汎用状態機械や分割upload sessionは作らない。 + +BのCLI証拠は、認証された準拠管理CLIの観測結果であり、悪意ある管理者から独立した暗号学的証明ではない。現行管理者はCloudflare tokenでbucketへ直接書け、現在もPUT終了を自己申告する信頼モデルである。この境界を明示し、Workerが実際にSHA-256を再計算したように表示しない。一方、公開先のSHA-256はR2が実内容を検証するため、異なる内容は公開保存に成功しない。 + +checksumは原本との同一性検査であり、原本自体のMP3破損・再生品質を保証するものではない。既存のCLIによるMP3解析、300MB上限、全量size照合は維持する。 + +## 証拠と残る検証 + +- [sanitized結果](../experiments/m6/checksum-results-20261004.json)。raw観測とprivate実行記録は`/tmp/opencode/castloop-m6-checksum-8057dd2f/`。secretを含む`binding-before.json`を外部報告へ添付しない。 +- 新規診断Workerは`castloop-m6-test-checksum-913cb489`。既存service Worker/control/Queueには書き込まず、既知staging音源は読取だけ。今回作った成功済みprobe payload七件のみexact keyで削除し、GET 404を確認した。資源自体は削除していない。 +- 元serviceのpaused状態・未返却owner/token・回復可能な300MB staging payloadは維持する。この試験は旧IO収束/強制解放の証拠ではない。[U1](./m6_upload_recovery_options.md)の扱いも変更しない。 +- Bの証拠伝達はまだ未実装。接続後はreceipt/GET/HEADのidentity一致・途中差替え・証拠欠落/不一致・manifest/owner/generation不一致を適切な層で検証する。native checksumの実動作は今回の実機証拠を使い、各層へ同じ大容量検証testを重複させない。 +- 300MBのM6 publication全体、二種改訂、配信、明示deleteまでの受け入れは別に残る。今回の成功はその合格の代わりではない。 diff --git a/experiments/m6/README.md b/experiments/m6/README.md index 773e545..2bc1e5a 100644 --- a/experiments/m6/README.md +++ b/experiments/m6/README.md @@ -75,4 +75,8 @@ mutationの自動再送、未知lock/tokenの解放、Worker/bucket/Queueの削 各試験は成功時もserviceを明示pausedで残す。これらのscriptの実装・local型検査だけをCloudflare合格として扱わず、実行結果を設計logへ別途記録する。複数colo・独自domain・請求額・無停止移行・unknown live IOの解放・正式releaseは対象外。 -2026-10-04、六lifecycle・completion recovery・実cache HITは合格したが、300MB stagingのWorker検証はCPU制限で未完了となった。元serviceはpaused/未収束owner/token/payload保持。native digest直結でもexceededCpuを実機観測したため、現方式をPaidで検証する場合は契約/利用承認が必要である。`usage_model=standard`をPaid証拠としない。具体的な証拠・残件は`design/m6_standalone_acceptance.md`。このharnessを既存failed workspaceで再実行してはいけない。 +2026-10-04、六lifecycle・completion recovery・実cache HITは合格したが、300MB stagingのWorker検証はCPU制限で未完了となった。元serviceはpaused/未収束owner/token/payload保持。native digest直結でもexceededCpuを実機観測した。管理者がWorkers Paid利用不可を決定したため、Paidへの変更ではなくR2/CLIの検証能力へ責務を集約する。`usage_model=standard`をPaid証拠としない。具体的な証拠・残件は`design/m6_standalone_acceptance.md`。このharnessを既存failed workspaceで再実行してはいけない。 + +## R2 native checksumの調査(2026-10-04) + +[sanitized結果](./checksum-results-20261004.json)と[調査報告](../../design/m6_upload_integrity_review.md)を参照。管理REST PUTは誤ったchecksumヘッダーを拒否せず、binding PUTは誤ったSHA-256を拒否した。300MBのbinding stream保存/HEAD native SHA-256とCLI全量読み戻しが一致し、単独保存のCPUは2msだった。既存service/control/Queueは変更せず、今回作成したprobe payload七件だけをexact keyで削除した。旧token解放・M6全体の300MB受け入れ合格を許可する証拠ではない。private診断script/manifestを消してmutationを再送しない。 diff --git a/experiments/m6/checksum-results-20261004.json b/experiments/m6/checksum-results-20261004.json new file mode 100644 index 0000000..3e5296c --- /dev/null +++ b/experiments/m6/checksum-results-20261004.json @@ -0,0 +1,45 @@ +{ + "observed_at": "2026-10-04", + "bucket": "castloop-m6-test-ff3bfd8c", + "diagnostic_worker": "castloop-m6-test-checksum-913cb489", + "probe_prefix": "probe/checksum/8057dd2f-ff18-466a-8a83-ad2cc53c5db6/", + "rest_put": { + "body_bytes": 28, + "cases": [ + { "header": "none", "status": 200, "body_matches": true }, + { "header": "Content-MD5", "checksum_valid": true, "status": 200, "body_matches": true }, + { "header": "Content-MD5", "checksum_valid": false, "status": 200, "body_matches": true }, + { "header": "x-amz-checksum-sha256", "checksum_valid": true, "status": 200, "body_matches": true }, + { "header": "x-amz-checksum-sha256", "checksum_valid": false, "status": 200, "body_matches": true } + ], + "binding_head_native_md5_matches": true, + "binding_head_native_sha256_present": false + }, + "binding_put": { + "valid_sha256": { "bytes": 28, "succeeded": true, "head_native_sha256_matches": true }, + "invalid_sha256": { "bytes": 28, "rejected": true, "reason_code": 10037, "head_exists": false }, + "large_sha256": { + "source_key": "staging/episodes/large/boundary/d05d71fc-0c58-463d-904c-20704be807ae/audio.mp3", + "bytes": 300000000, + "source_native_md5_matches_local": true, + "source_native_sha256_present": false, + "target_suffix": "binding_large_sha256", + "status": 200, + "outcome": "ok", + "worker_cpu_ms": 2, + "worker_wall_ms": 16449, + "head_native_sha256": "acef6ef0a2fc8e6ee74e7218eaccfc013a0f89e3e56c1723ab99e5ac6457adee", + "cli_full_readback_size_sha256_matches": true + } + }, + "cleanup": { + "own_successful_probe_payloads_deleted": 7, + "all_get_status": 404, + "source_staging_payload_retained": true, + "resources_deleted": false + }, + "paid_or_cpu_limit_changed": false, + "application_records_written": false, + "authorizes_owner_release": false, + "m6_full_300mb_acceptance_complete": false +} From 4fd79dbd83e7c0776ec8de7f3cb101356751165f Mon Sep 17 00:00:00 2001 From: Akira Shimosako Date: Sun, 4 Oct 2026 11:05:25 +0900 Subject: [PATCH 11/16] refactor(m6): delegate audio integrity to CLI readback and R2 --- design/m6_implementation_log.md | 7 + design/m6_management_api.md | 4 +- design/m6_review_queue.md | 2 +- design/m6_staging_client.md | 10 +- design/m6_staging_rest.md | 14 +- design/m6_standalone_acceptance.md | 10 +- design/m6_upload_integrity_review.md | 17 ++- design/m6_upload_recovery_options.md | 2 +- packages/cli/src/local-draft-journal.test.ts | 4 +- .../cli/src/local-draft-operation.test.ts | 9 +- .../src/local-publication-preparation.test.ts | 9 +- .../cli/src/local-staging-preparation.test.ts | 9 +- packages/cli/src/m6-content-flow.test.ts | 11 +- .../cli/src/publication-operation.test.ts | 5 +- packages/cli/src/publication-sources.test.ts | 14 +- packages/cli/src/staging-client.test.ts | 5 +- packages/cli/src/staging-client.ts | 6 +- packages/cli/src/staging-journal.ts | 10 +- packages/cli/src/staging-operation.test.ts | 33 ++++- packages/cli/src/staging-operation.ts | 28 ++-- packages/cli/src/staging-rest.test.ts | 34 ++--- packages/cli/src/staging-rest.ts | 28 ++-- packages/cli/src/staging-upload.test.ts | 12 +- packages/cli/src/staging-upload.ts | 15 ++- packages/shared/src/index.ts | 6 +- packages/shared/src/staging-admin.ts | 5 +- packages/shared/src/staging.test.ts | 20 ++- packages/shared/src/staging.ts | 28 ++++ src/m6-management-routes.test.ts | 9 +- src/m6-routes.test.ts | 4 +- src/m6-routes.ts | 13 +- src/publication-admin.test.ts | 3 +- src/publication-admission.test.ts | 31 +++-- src/publication-admission.ts | 7 +- src/publication-consumer.ts | 5 +- src/publication-episode-runner.test.ts | 64 +++++---- src/publication-episode-runner.ts | 22 +-- src/publication-inputs.ts | 6 + src/staging-admin.test.ts | 30 +++-- src/staging-admin.ts | 6 +- src/staging-inspection.test.ts | 6 +- src/staging-upload.test.ts | 11 +- src/staging-upload.ts | 18 ++- src/staging-verification.test.ts | 125 ++++++++---------- src/staging-verification.ts | 117 ++++++++-------- src/test-support/episode-publication.ts | 22 +-- src/test-support/lifecycle-admin.ts | 3 +- src/test-support/publication-admin.ts | 2 +- src/test-support/publication.ts | 24 +++- src/test-support/staging-admin.ts | 4 +- 50 files changed, 505 insertions(+), 384 deletions(-) diff --git a/design/m6_implementation_log.md b/design/m6_implementation_log.md index 5a5e58b..886c363 100644 --- a/design/m6_implementation_log.md +++ b/design/m6_implementation_log.md @@ -1,5 +1,12 @@ # M6: 公開停止・削除 実装ログ +## 2026-10-04: 音源のSHA-256検証をCLI/R2へ集約 + +- 管理者が承認した案Bを実装した。REST単一PUT後のCLI全量GET/size/SHA-256照合を維持し、固定key・strong ETag・versionを確認した小さいreceiptを既存journal/settlementへ保存する。証拠の順序/manifest一致はshared helperへ集約し、終了後の変更・legacy証拠の捏造・local/server不一致を拒否する。 +- Worker stagingは音源HEAD size/ETag/versionとexact owner/generation/終了申告を照合し、音源GET/全量hashをしない。metadata/coverのbounded内容検査・token・進捗・取消は維持する。公開immutable音源は既存のR2 SHA-256付きstream PUTとHEAD native checksum照合を使い、公開後のGET/再hashを除いた。native checksumなしの旧媒体を自動採用・上書きしない。 +- 既存のfault/live IO/再実行テストを新境界へ更新し、新しいidentity/証拠欠落/差替え/native checksum検査を適切な層へ追加した。全891テスト/13475 assertions、TypeScript/M6試験tsconfig、通常/試験Linux binary、local HTTPS readonly/production拒否に合格した。 +- 新規専用`castloop-m6-test-7c97f1a0`の初期化→Show/小さいEpisode公開→GET/HEAD/Range→明示pauseが合格し、実REST versionとWorker HEADの対応も確認した。300MB全体の結果は後続受け入れへ別記する。Paid/CPU limit/通常release gateは変更せず、`ff3bfd8c`の旧owner/token/payloadも保持する。詳細は`m6_upload_integrity_review.md`。 + ## 2026-10-04: 実cache HIT合格と300MBのCPU制限を確認 - NRTでfeed/cover/小さい音源の内部HIT・同じ内部nonceと毎回異なるgateway nonceを確認し、公開GET-onlyの再照合結果もprivate acceptanceへ保存した。 diff --git a/design/m6_management_api.md b/design/m6_management_api.md index 16db6be..ffdcd89 100644 --- a/design/m6_management_api.md +++ b/design/m6_management_api.md @@ -27,13 +27,15 @@ CLIが期待generation/current/baseを取得するための読み取り専用`PO | `claim` | 凍結済み`upload` manifest | `claimed`とshow/operation ID・取得generation | | `status` | 同じ完全な`upload` manifest | 読み取り専用status/progress/owner/検証稼働/draft commit観測。PUT/復旧許可なし | | `begin` | `operation` | `started`と許可されたkey/length/checksum。開始は一回限り | -| `settle` | `operation`、明示的`put_requests_settled=true`、`no_more_puts=true` | `settled`。PUT permissionを再発行しない | +| `settle` | `operation`、明示的`put_requests_settled=true`、`no_more_puts=true`、`readback_receipts` | `settled`。PUT permissionを再発行せず、保存後の証拠変更も拒否 | | `finish` | `operation`、`outcome=staged/aborted` | 全副作用終了後に同outcomeを返す。settlement前は拒否 | 認証は既存の`X-Castloop-Key`照合を使い、認証/method/schema/対象serviceの検査を副作用より前に行う。bodyを16,384 bytesへ制限し、宣言長・実stream長・UTF-8・JSONを検査する。過大streamのcancelはawaitし、readerを解放する。responseは常にno-storeで、本文や任意exceptionを返さず、失敗にはallowlistの固定診断だけを使う。 操作identityはsnake_caseで、入力manifestのoperation IDとdraft job IDを分離したまま返す。PUT locationsはstagingの許可keyだけに限定し、show ID/UUID/size/重複を検査する。responseを任意prefix書込の許可へ使わない。payload uploadは引き続きCLIの別REST接続で行う構成であり、Worker handlerで媒体を受信/バッファしない。 +2026-10-04、settlementへCLI全量SHA-256読み戻し証拠(asset/size/hash/ETag/version)を追加した。凍結manifestの順序に一致する成功済みprefixだけを受け付け、staged finishは全payloadの証拠を要求する。Workerの音源検証はHEAD identity照合であり、独立した全量hashではない。metadata/coverのbounded内容検証・owner/generation・未収束token保持は維持する。詳細は[完全性検証契約](./m6_upload_integrity_review.md)。 + 未公開staging client/durable journalを追加し、exact PUT key/size/hash/order照合、送信前requested保存、一度限りPUT、全PUT終了保存後の明示settlement/検証を接続した。statusは共通read-only runtime/snapshot boundaryから保持manifest/request/progress/statusを照合し、payloadを読まずtokenを登録しない。unknown response/保存失敗では観測から再送/終了認定しない。詳細と実REST source adapter/公開CLI/外部復旧の残件は[`m6_staging_client.md`](./m6_staging_client.md)参照。 ## 排他・pause・応答喪失 diff --git a/design/m6_review_queue.md b/design/m6_review_queue.md index f47b775..72d1f6c 100644 --- a/design/m6_review_queue.md +++ b/design/m6_review_queue.md @@ -50,7 +50,7 @@ 管理者がWorkers Paidの利用不可を明示した。Paidへの変更を再要求せず、無料枠を前提に検証処理の責務・実行場所を見直す。既存の300MB上限を勝手に下げず、全量contents照合・object identity/owner/generation照合を単純に省略しない。現行Worker内全量SHA-256のCPU超過は、この実装方式の問題であり、R2へ300MBを保存できないという実証ではない。 -R2/CLIの[完全性検証調査](./m6_upload_integrity_review.md)で、管理REST PUTは誤ったchecksumヘッダーでも保存する一方、binding PUTは誤ったSHA-256を拒否し、300MBの検証付きstream保存/HEAD native checksumがCPU 2msで成功することを確認した。推奨は既存CLIの全量SHA-256読み戻し結果をexact objectへ結び付け、公開保存時のSHA-256をR2へ任せる方式。Worker内の二箇所の全量再ハッシュを省く案であり、証拠伝達はまだ未実装。全M6の300MB受け入れや旧IO収束の合格とは区別する。 +R2/CLIの[完全性検証調査](./m6_upload_integrity_review.md)で、管理REST PUTは誤ったchecksumヘッダーでも保存する一方、binding PUTは誤ったSHA-256を拒否し、300MBの検証付きstream保存/HEAD native checksumがCPU 2msで成功することを確認した。管理者が案Bを承認し、既存CLIの全量SHA-256読み戻し結果をexact objectへ結び付け、公開保存時のSHA-256をR2へ任せる方式を実装した。Worker内の二箇所の全量再ハッシュを除き、owner/generation/IO終了確認とnative checksum照合は維持する。接続後の実機受け入れは別記し、旧IO収束の合格とは区別する。 ### 現在の到達点(2026-10-04) diff --git a/design/m6_staging_client.md b/design/m6_staging_client.md index 6d8cff1..110bb5e 100644 --- a/design/m6_staging_client.md +++ b/design/m6_staging_client.md @@ -1,12 +1,12 @@ # M6: Staging内部client・状態照会・durable upload journal -更新日: 2026-10-02 +更新日: 2026-10-04 ## 範囲と公開gate 未公開`StagingAdminClient`を内部管理APIへ接続した。Show metadata+cover、Episode metadata、Episode audioの受付/一度限りbegin/明示settlement/検証・取消と、読み取り専用statusを使う。`.castloop/staging-uploads//.json`のdurable journalから各操作を実行する内部runnerも追加した。 -現行Worker/bridge/candidate fetchや公開CLI書込commandには接続していない。`m6_ready=false`を維持し、Cloudflare書込/deployや既存v0.1.1環境への適用は行っていない。既存の通常upload/publication経路を置き換えてはいない。 +通常Worker/bridge/candidate fetchや公開CLI書込commandのgateは維持し、既存の通常upload/publication経路を置き換えていない。後続開発でM6試験専用Worker/standaloneへ接続し、2026-10-04には小さいShow/EpisodeのCloudflare実機受け入れも通した。既存v0.1.1環境の変更・採用・未知ownerの解放は行わない。最新の実機結果は[受け入れ記録](./m6_standalone_acceptance.md)参照。 ## Strict clientと読み取り専用status @@ -26,7 +26,7 @@ statusはpayload本文を読まず、owner/token/progressを変更せず、常 private directoryは0700、record/temp/lockは0600で作る。recordはexclusive新規作成、更新は一意temp→file fsync→rename→directory fsyncとし、新規directory階層もfsyncする。strict schema/16KB上限/identity/manifest/phase/receiptを毎回検査する。 -保存するのはservice/account/Worker/origin、凍結manifest、最小claim/begin receipt、phase、成功応答を受けたPUT数、予定finish outcome、固定`put_failed`診断とfinish receiptだけである。本文/タイトル/メール/管理key/API token/任意exceptionをコピーせず、service TOMLにjob IDを書かない。operationとdraft IDの変更、foreign identity、phase飛ばし/後退、PUT許可の再開は拒否する。 +保存するのはservice/account/Worker/origin、凍結manifest、最小claim/begin receipt、phase、全量読み戻しが成功したPUT数とasset/size/SHA-256/ETag/versionの証拠、予定finish outcome、固定`put_failed`診断とfinish receiptだけである。本文/タイトル/メール/管理key/API token/任意exceptionをコピーせず、service TOMLにjob IDを書かない。operationとdraft IDの変更、foreign identity、phase飛ばし/後退、PUT許可の再開、終了後の証拠差替えは拒否する。 | phase | 意味 | | --- | --- | @@ -51,7 +51,9 @@ PUTが正常/例外で終了したら、それ以上PUTしない。例外では begin応答が不明、permission保存が不明、PUT稼働中または終了保存が不明なら、再begin/再PUT/自動settlementしない。時間やHEAD不在で終了と認定しない。正常に記録されたputs_settledからだけ、callerの明示的`put_requests_settled=true`/`no_more_puts=true`を要求する`runStagingSettle`が可能になる。 -`runStagingFinish`はsettledからidle verificationと同ownerを照会して、一回だけ予定staged/abortedを送る。payload検証とowner解放はserverが再度検査する。abortはpayloadを削除しない。pause中でも既受付のsettlement/finishは収束できる。 +`runStagingSettle`は保存済みの読み戻し証拠を送る。成功予定なら全payload分を必須とし、改修前のjournalに欠けた証拠を捏造しない。部分成功/失敗なら確認済みprefixだけを送る。serverもmanifestとの一致と終了後の証拠不変を検査する。 + +`runStagingFinish`はsettledからidle verification・同owner・local/server証拠一致を照会して、一回だけ予定staged/abortedを送る。serverは音源HEADのsize/ETag/versionと証拠を照合し、音源を全量再hashしない。metadata/coverの内容検査とowner解放は維持する。abortはpayloadを削除しない。pause中でも既受付のsettlement/finishは収束できる。 この終了申告はserverによるREST接続終了の直接観測ではない。承認済みREST単一PUT方針と未確認のU1仮定を維持し、切断後書込がないことをCloudflare保証として扱わない。U1をM6 release blockerへ追加せず、supportへ連絡しない。 diff --git a/design/m6_staging_rest.md b/design/m6_staging_rest.md index eb833c3..e2a58af 100644 --- a/design/m6_staging_rest.md +++ b/design/m6_staging_rest.md @@ -1,8 +1,8 @@ # M6: 未公開のREST単一PUT・ローカルsource adapter -更新日: 2026-10-02 +更新日: 2026-10-04 -`staging-sources.ts`、`staging-rest.ts`、`staging-rest-operation.ts`をdurable staging runnerへ接続した。公開CLIや候補Workerの書込routeには接続しない。既存Cloudflare環境への適用・実機合格ではない。 +`staging-sources.ts`、`staging-rest.ts`、`staging-rest-operation.ts`をdurable staging runnerへ接続した。M6試験専用CLI/管理routeで使い、通常releaseの書込gateは開けない。既存サービスの変換・未知ownerの解放は行わない。 ## 固定入力とIO所有 @@ -16,13 +16,13 @@ ## REST契約 - 固定`api.cloudflare.com`、serviceのaccount/bucket、manifest由来のexact staging key/length/hashだけを使用する。account一致の環境credentialsを使い、redirectを拒否する。 -- PUTは一回だけ。REST `If-Match`/`If-None-Match` fence、multipart、別R2 credentials、Wrangler依存を追加しない。応答は64KiB以下のJSON success/sizeを確認する。 -- 同じkeyをGETし、Rangeでない200の全量size/SHA-256をstreamで検証する。失敗・timeout・応答喪失・取消失敗で自動再送しない。GET/PUTのapplication retryもない。 -- runnerはIO終了後も明示settlement/finishを別途要求する。GET成功はserver側の検証tokenやpublicationを代替しない。HTTP handlerの独立検証・owner/generation照合を維持する。 +- PUTは一回だけ。REST `If-Match`/`If-None-Match` fence、multipart、別R2 credentials、Wrangler依存を追加しない。応答は64KiB以下のJSON success・固定key/size・ETag/versionを確認する。sizeは安全整数numberまたはdecimal integer stringに限定する。identityの欠落は補完せず失敗にする。 +- 同じkeyをGETし、PUT ETagと一致するstrong quoted ETag、Rangeでない200、全量size/SHA-256をstreamで検証する。全IO終了後にasset/size/SHA-256/ETag/versionの小さい証拠を返す。失敗・timeout・応答喪失・取消失敗で自動再送しない。GET/PUTのapplication retryもない。 +- runnerはIO終了後も明示settlement/finishを別途要求する。証拠をprivate journalと認証付きsettlementへ保存し、Workerはexact owner/generation・終了確認・manifest・HEAD size/ETag/versionを照合する。音源のWorker GET/全量再hashはしない。metadata/coverのbounded schema/identity/形式検査、検証token、明示publicationは維持する。 - 接続終了をCloudflare側の遅延確定不在の証明にはしない。承認済みの未検証仮定U1を維持する。native runtimeの一般的なretry/切断保証を新たに主張しない。 -2026-10-04の[完全性検証調査](./m6_upload_integrity_review.md)では、上記の現行契約を、CLIの全量照合証拠+Worker HEADのidentity照合とR2の公開保存時checksum検証へ集約する案を推奨した。まだ証拠伝達を実装しておらず、現在のHTTP独立hashを削除済みとは扱わない。 +2026-10-04、管理者が承認した[完全性検証案B](./m6_upload_integrity_review.md)を実装した。CLI証拠は認証済み管理者の観測を信頼するものであり、Worker独立hashや別REST接続の終了観測と表示しない。公開音源はR2 binding PUTのSHA-256検証とHEAD native checksum照合で確認する。 ## 検証と残件 -mock RESTと実Bun/loopback HTTPでexact path/header、3種stagingの明示完了、8MiB snapshot転送、転送中の元ファイル変更、checksum/size/過大応答/早期応答/Range/応答喪失、取消await、live IO中dispose拒否、symlink/foreign account/再送拒否を確認した。Cloudflare RESTの実経路・300MB資源使用量・CLI公開・最新draft/publication照合・unknown outcomeの安全な外部復旧は別の残件である。 +mock RESTと実Bun/loopback HTTPでexact path/header、3種stagingの明示完了、8MiB snapshot転送、転送中の元ファイル変更、checksum/size/過大応答/早期応答/Range/応答喪失、取消await、live IO中dispose拒否、symlink/foreign account/再送拒否を確認した。新規専用`castloop-m6-test-7c97f1a0`でもShow/小さいEpisode公開とREST receipt/GET/HEAD identity照合を通した。300MBを含む全受け入れ、通常CLI公開、unknown outcomeの安全な外部復旧は別途[受け入れ記録](./m6_standalone_acceptance.md)へ記録する。 diff --git a/design/m6_standalone_acceptance.md b/design/m6_standalone_acceptance.md index aa22547..ee554ce 100644 --- a/design/m6_standalone_acceptance.md +++ b/design/m6_standalone_acceptance.md @@ -4,7 +4,7 @@ ## 対象と公開制限 -新規初期化・通常更新が確認済みの`castloop-m6-test-ff3bfd8c`を再利用する。workspaceは`/tmp/opencode/castloop-m6-test-ff3bfd8c`。既存v0.1.1環境・独自domain・有料planは変更せず、Worker/R2/Queue/DLQ資源自体も削除しない。通常binary/WorkerのM6公開gateは閉じたままである。 +初期の六lifecycle/更新応答喪失/cache試験は`castloop-m6-test-ff3bfd8c`で実施した。後述の300MB失敗後は未知owner/tokenを保持し、再利用・再配備・強制解放をしない。新しい完全性検証方式は別名`castloop-m6-test-7c97f1a0`で検証する。workspaceはそれぞれ`/tmp/opencode/`。既存v0.1.1環境・独自domain・有料planは変更せず、Worker/R2/Queue/DLQ資源自体も削除しない。通常binary/WorkerのM6公開gateは閉じたままである。 実行scriptは`experiments/m6/README.md`参照。before/plan/acceptance/incompleteはprivate fileであり、secretを含むlocal manifestを報告へ添付しない。mutationは一度だけ、readonly観測は有限回とし、commitの受付とconsumer完了・owner解放・invocation返却を区別する。 @@ -41,7 +41,7 @@ version `756ab58d-9289-4fbd-a0df-cdc9377b71f4`で`large` Showと小さい`boundary` Episodeを公開し、feed/cover/音源の内部HITを確認した。NRTで同じ内部nonceが返り、gateway nonceは変わった。300MB試験失敗後にも公開GETだけで再照合し、`cache-hit-acceptance.json`へ記録した。実測を複数coloや300MB cache HITまで一般化しない。 -## 300MB: WorkerのCPU制限で未合格 +## 改修前の300MB: WorkerのCPU制限で未合格 正確な300,000,000-byte MP3のlocal stream解析とREST単一PUT/全量GET照合は成功した。300,000,001 bytesはlocal size limitで拒否され、remote targetが非変更であることも確認した。続くWorker検証の応答は未確認となった。 @@ -57,6 +57,12 @@ Cloudflareの[limits](https://developers.cloudflare.com/workers/platform/limits/ 後続の独立試験では、R2 bindingにSHA-256を渡す300MB stream保存とHEAD native checksum照合がCPU 2msで成功し、CLI全量読み戻しとも一致した。管理REST PUTへのchecksumヘッダー追加は誤ったhashを拒否しなかった。[完全性検証調査](./m6_upload_integrity_review.md)参照。これは代替機能の実証であり、現行M6 staging/publicationの実装変更や全受け入れ合格ではない。元serviceの未知owner/tokenは保持する。 +## CLI/R2完全性検証への改修: 小さい公開が合格 + +管理者承認の案Bを実装し、試験専用Linux standaloneを新規`castloop-m6-test-7c97f1a0`で実行した。初期化operation `0cbab4d5-7b15-4ee2-a199-630b2c8cad79`からpaused初期化→明示再開→Show job `412ce195-33b3-40f2-a20a-09bb603100d0`/Episode job `d359f27a-7f8c-4a02-9be9-c2898a5da61f`の公開→GET/HEAD/Range→明示pauseまで通した。 + +CLIの全量SHA-256読み戻しとPUT receiptのETag/versionが、保存journal/認証付きsettlement/Worker HEADへ一致した。公開音源のR2 native SHA-256検査も実経路で成功した。音源のWorker staging/publication全量再hashは除き、owner/generation/IO終了確認は維持する。結果は`acceptance.json`。pause `ab089857-d110-429a-8b4f-f16647b00348`はこの小さい試験の終了checkpointであり、後続300MB試験の現在状態を示さない。 + ## 後続 300MB/audio-only/metadata-only/配信/明示deleteの未完了受け入れ、安全なunknown IO/残存lockの外部終了確認、復旧手順、通常CLI/README/help/version/releaseの完成は残る。harnessは新規初期化のacknowledged journal/paused checkpointからも開始可能にし、既に確認した通常更新と六lifecycleを300MB試験のたびに再実行しない。failed workspaceの再送/採用/強制解放を許可するmodeではない。 diff --git a/design/m6_upload_integrity_review.md b/design/m6_upload_integrity_review.md index 4bab435..2048f4e 100644 --- a/design/m6_upload_integrity_review.md +++ b/design/m6_upload_integrity_review.md @@ -2,7 +2,7 @@ 更新日: 2026-10-04 -状態: **公式仕様と専用Cloudflare環境で代替手段を確認。方式の提案であり、現行runtimeの変更・M6受け入れ完了ではない。Workers Paidは使用しない。** +状態: **管理者が案Bを承認し、M6試験用CLI/Workerへ実装した。ローカル全891テスト・型検査に合格。接続後の実機受け入れは下記へ別途記録する。通常release gateは維持し、Workers Paidは使用しない。** ## 結論 @@ -12,7 +12,7 @@ Workerで300MBのSHA-256を再計算する必要はない。目的は「凍結 さらにCLIの読み戻しもなくす候補として、R2が自動保存するMD5とlocal MD5の比較も成立する。ただしこれはstaging時点のSHA-256照合と同じ保証ではないため、最小改修の第一案とは分ける。 -## 現行の重複 +## 改修前の重複 | 区間 | 現行処理 | 見直し対象 | | --- | --- | --- | @@ -21,7 +21,7 @@ Workerで300MBのSHA-256を再計算する必要はない。目的は「凍結 | staging→公開immutable音源 | Workerの`bucket.put(..., { sha256 })`でR2へ検証を要求 | 既に利用している機能。保存済みのnative checksumを確認すればよい | | 公開保存後 | Workerが公開音源をGETし、さらに全量SHA-256を再計算 | R2の検証済みchecksum/sizeをHEADで確認する処理に置き換える | -対象コードは`packages/cli/src/staging-rest.ts`、`src/staging-verification.ts`、`src/publication-episode-runner.ts`。publication admissionは既に検証記録とHEADのETag/sizeを照合しており、ここへ別の全量検証を追加しない。 +対象コードは`packages/cli/src/staging-rest.ts`、`src/staging-verification.ts`、`src/publication-episode-runner.ts`。publication admissionのHEAD照合にはversionを加え、別の全量検証は追加しない。 ## R2機能: アクセス経路を区別する @@ -57,15 +57,19 @@ CPU limitやPaid契約は変更していない。2msはこの単独保存試験 Aでも公開時のR2 SHA-256検証は維持できるため、MD5を全量SHA-256の代用品として最終公開証拠へ流用する必要はない。Bの全量読み戻しは転送時間を増やすが、既に実機300MBで成功している。R2の保存耐久性やHTTP 200だけをlocal入力との照合の代わりにはしない。 -## 推奨Bの最小実装契約 +## 承認済みBの実装契約 -1. `createStagingRestPut`は成功申告だけでなく、固定asset/key、PUT receiptのsize/ETag/versionと、全量GETの実測size/SHA-256/ETagを返す。PUT結果のidentityとGETのidentityを照合する。REST receiptのversion提供・binding HEADとの対応は接続時の実機検証事項とし、欠落/不一致を成功として補完しない。 +1. `createStagingRestPut`は成功申告だけでなく、固定asset/key、PUT receiptのsize/ETag/versionと、全量GETの実測size/SHA-256/ETagを返す。PUT結果のidentityとGETのidentityを照合する。REST receiptのversion提供・binding HEADとの対応は新しい専用環境の小さいShow/Episode公開で確認した。欠落/不一致を成功として補完しない。 2. その小さい証拠だけを既存local journalと認証付きsettlement要求へ保存する。操作ID・draft ID・凍結manifestへ厳密に結び付け、 arbitrary keyや`verified: true`だけの要求を受け付けない。本文・source path・secretは保持記録へ追加しない。 3. Workerはexact owner/generation・client終了確認と証拠を照合し、音源のHEADでsize/ETag/versionを確認する。音源のGET/全量hashはしない。metadataのschema/identityとcoverの形式検証は別責務として維持する。 4. verified assetsへidentityと照合済みSHA-256を残し、publication admissionとsource取得時にも同じobjectであることを確認する。サイズだけ・ETagだけ・任意のcustom metadataだけを内容証明にしない。 5. `persistAudio`は既存のSHA-256付きR2 PUTを維持する。成功後と再実行時はHEADのsize/**native** `checksums.sha256`を凍結commitに照合する。native checksumがない既存objectをcustom metadataだけで成功扱いせず、安全な互換/回復の扱いを別途決める。 6. 受付/token/既存phaseは使い続ける。未返却PUT/consumerをHEADや時間だけで解放しない。新規の巨大な汎用状態機械や分割upload sessionは作らない。 +`readback_receipts`はasset/length/SHA-256/ETag/versionだけを保存する。keyは凍結manifestから導出し、証拠のpayload順序と一致をshared helperで検査する。部分成功の後に失敗した場合は成功済みprefixだけを保持し、明示abortへ進む。全payloadの証拠がないstaged settlement/finishは成功させない。終了申告後の証拠差替えとlocal/serverの証拠不一致を拒否する。 + +改修前のjournal/progressはreadonly inspectionのために読み取れるが、欠けた証拠やversionを自動補完しない。新しいpublication admissionはverified versionを必須とする。公開音源の再利用/再実行はnative SHA-256を必須とし、checksumを持たない旧形式媒体の変換・採用は今回実装しない。既存immutable keyへ検証のための上書きもしない。 + BのCLI証拠は、認証された準拠管理CLIの観測結果であり、悪意ある管理者から独立した暗号学的証明ではない。現行管理者はCloudflare tokenでbucketへ直接書け、現在もPUT終了を自己申告する信頼モデルである。この境界を明示し、Workerが実際にSHA-256を再計算したように表示しない。一方、公開先のSHA-256はR2が実内容を検証するため、異なる内容は公開保存に成功しない。 checksumは原本との同一性検査であり、原本自体のMP3破損・再生品質を保証するものではない。既存のCLIによるMP3解析、300MB上限、全量size照合は維持する。 @@ -75,5 +79,6 @@ checksumは原本との同一性検査であり、原本自体のMP3破損・再 - [sanitized結果](../experiments/m6/checksum-results-20261004.json)。raw観測とprivate実行記録は`/tmp/opencode/castloop-m6-checksum-8057dd2f/`。secretを含む`binding-before.json`を外部報告へ添付しない。 - 新規診断Workerは`castloop-m6-test-checksum-913cb489`。既存service Worker/control/Queueには書き込まず、既知staging音源は読取だけ。今回作った成功済みprobe payload七件のみexact keyで削除し、GET 404を確認した。資源自体は削除していない。 - 元serviceのpaused状態・未返却owner/token・回復可能な300MB staging payloadは維持する。この試験は旧IO収束/強制解放の証拠ではない。[U1](./m6_upload_recovery_options.md)の扱いも変更しない。 -- Bの証拠伝達はまだ未実装。接続後はreceipt/GET/HEADのidentity一致・途中差替え・証拠欠落/不一致・manifest/owner/generation不一致を適切な層で検証する。native checksumの実動作は今回の実機証拠を使い、各層へ同じ大容量検証testを重複させない。 +- Bの証拠伝達を実装し、receipt/GET/HEADのidentity一致・途中差替え・証拠欠落/不一致・manifest/owner/generation不一致を各責務の層で回帰した。音源stagingのbody GETと公開後のbody GETを行わないこと、native checksum欠落/不一致を拒否すること、live IO/token保持も検査する。native checksumの実動作は今回の実機証拠を使い、各層へ同じ大容量検証testを重複させない。 +- 新規`castloop-m6-test-7c97f1a0`で試験standaloneの初期化→明示再開→Show/20,850-byte Episode公開→GET/HEAD/Range→明示pauseまで合格した。REST PUT receipt→CLI journal/GET→管理settlement→Worker HEADのETag/version一致、公開native checksum検査を実経路で通した。証拠は`/tmp/opencode/castloop-m6-test-7c97f1a0/acceptance.json`。旧環境は採用・再配備・解放しない。 - 300MBのM6 publication全体、二種改訂、配信、明示deleteまでの受け入れは別に残る。今回の成功はその合格の代わりではない。 diff --git a/design/m6_upload_recovery_options.md b/design/m6_upload_recovery_options.md index 90a465f..72eca38 100644 --- a/design/m6_upload_recovery_options.md +++ b/design/m6_upload_recovery_options.md @@ -76,4 +76,4 @@ `settleStageUpload`には`put_requests_settled=true`と`no_more_puts=true`の明示的な申告が必要である。単一PUTが継続している間や、旧clientが後でPUTを開始/再試行し得る間は申告してはならない。新client helperは自身の全PUTをawaitして以後PUTしない場合だけ送信し、beginの結果不明では自動送信しない。別端末からの回復も旧clientの終了確認が必要である。この申告は認証された管理者/準拠CLIの契約であって、R2/Workerが別経路のREST接続終了を直接証明するものではない。Cloudflare tokenによる手動REST書込や虚偽の終了申告を防ぐ保証はない。 -終了確認だけではownerを解放しない。`runStageVerification`はuploading ownerの専用verification tokenを取得し、size/全量checksumとmetadata/coverを検証する。検証成功/明示的取消のfinished progressとv2 statusを照合し、owner解放とupload receiptを同じCASで保存する。取消では既存staging payloadを消さず、次の編集で同じdraft jobを再利用できるようにする。検証失敗の通常終了ではverification tokenだけを返し、stage受付を保持する。時間/HEAD不在だけの解放は追加しない。取得応答喪失/強制終了のtoken回復、これらの基礎関数とclient helperの既存CLI/管理APIへの接続は未完了である。 +終了確認だけではownerを解放しない。`runStageVerification`はuploading ownerの専用verification tokenを取得し、size/CLIの全量checksum照合証拠/HEAD ETag・versionとmetadata/coverを検証する。2026-10-04に[案B](./m6_upload_integrity_review.md)を実装し、音源のWorker内全量hashを除いた。公開保存はR2のSHA-256検証とnative checksum照合を使う。検証成功/明示的取消のfinished progressとv2 statusを照合し、owner解放とupload receiptを同じCASで保存する。取消では既存staging payloadを消さず、次の編集で同じdraft jobを再利用できるようにする。検証失敗の通常終了ではverification tokenだけを返し、stage受付を保持する。時間/HEAD不在だけの解放は追加しない。試験用管理API/CLIは接続済みだが、取得応答喪失/強制終了のtoken回復と通常release gateは未完了である。 diff --git a/packages/cli/src/local-draft-journal.test.ts b/packages/cli/src/local-draft-journal.test.ts index 43fc72a..133bf66 100644 --- a/packages/cli/src/local-draft-journal.test.ts +++ b/packages/cli/src/local-draft-journal.test.ts @@ -11,7 +11,6 @@ import { handleM6PublicationAdmin } from "../../../src/publication-admin"; import { readShowControl } from "../../../src/lifecycle-control"; import { handleM6StagingAdmin } from "../../../src/staging-admin"; import { stagingAdminFixture } from "../../../src/test-support/staging-admin"; -import { publicationTestDigest } from "../../../src/test-support/episode-publication"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -31,7 +30,7 @@ async function fixture(episode = false) { const journal = createLocalDraftJournal(root, setup.config, target, id); const file = join(root, ".castloop", "drafts", setup.config.service_id, episode ? "episode-daily--next.json" : "show-daily.json"); const client = new StagingAdminClient(setup.config, "private-secret", async (input, init) => { - const response = await handleM6StagingAdmin(new Request(input, init), setup.env, setup.bindings, { digest: publicationTestDigest }); + const response = await handleM6StagingAdmin(new Request(input, init), setup.env, setup.bindings); if (!response) throw new Error("Unexpected staging route"); return response; }); @@ -42,6 +41,7 @@ async function fixture(episode = false) { asset === "audio" ? { asset, audio_path: "audio.mp3" } : { asset }); const effects = { ...createStagingOperationEffects(setup.config, prepared.journal.load(), "private-secret", async (remote, index) => { await prepared.sources.withPayload(index, async (body, consumed) => { await setup.bucket.put(remote.key, body); consumed(); }); + return setup.readbacks(prepared.journal.load().upload, index + 1)[index]!; }, client), checkLocalInputs: prepared.sources.assertCurrent }; return { ...prepared, effects, finish: async () => { await runStagingClaim(prepared.journal, effects); diff --git a/packages/cli/src/local-draft-operation.test.ts b/packages/cli/src/local-draft-operation.test.ts index c14c4b9..9e3aa12 100644 --- a/packages/cli/src/local-draft-operation.test.ts +++ b/packages/cli/src/local-draft-operation.test.ts @@ -15,7 +15,6 @@ import { handleM6StagingAdmin } from "../../../src/staging-admin"; import { handleM6TargetInspection } from "../../../src/target-inspection-admin"; import { consumeOwnedPublication } from "../../../src/publication-consumer"; import { stagingAdminFixture } from "../../../src/test-support/staging-admin"; -import { publicationTestDigest } from "../../../src/test-support/episode-publication"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -46,7 +45,7 @@ async function fixture(mode: "show" | "initial" | "metadata" | "audio" = "show") const http = new Request(input, init); const action = (await http.clone().json() as { action: string }).action; requests.push(`stage:${action}`); - const response = await handleM6StagingAdmin(http, setup.env, setup.bindings, { digest: publicationTestDigest }); + const response = await handleM6StagingAdmin(http, setup.env, setup.bindings); if (!response) throw new Error("Unexpected staging route"); if (action === "claim") await afterClaim?.(); if (loseAction === `stage:${action}`) throw new Error("Simulated lost acknowledgement"); @@ -76,13 +75,13 @@ async function fixture(mode: "show" | "initial" | "metadata" | "audio" = "show") requests.push(`rest:${method}`); if (method === "PUT") { const body = new Uint8Array(await new Response(init?.body).arrayBuffer()); - await setup.bucket.put(key, body); + const written = (await setup.bucket.put(key, body))!; if (loseAction === "rest:PUT") throw new Error("Simulated lost PUT acknowledgement after local IO settled"); - return Response.json({ success: true, result: { size: body.length } }); + return Response.json({ success: true, result: { key, size: written.size, etag: written.etag, version: written.version } }); } const object = await setup.bucket.get(key); if (!object) return new Response(null, { status: 404 }); - return new Response(new Uint8Array(object.bytes)); + return new Response(new Uint8Array(object.bytes), { headers: { ETag: `"${object.etag}"` } }); } }; const header = async () => ({ schema_version: 1 as const, expected_show_generation: (await readShowControl(setup.env, "daily"))!.value.generation, ...(episode ? { expected_episode_generation: 0 } : {}), created_at: "2026-10-02T12:00:00Z" }); diff --git a/packages/cli/src/local-publication-preparation.test.ts b/packages/cli/src/local-publication-preparation.test.ts index fe1f0d5..65e3750 100644 --- a/packages/cli/src/local-publication-preparation.test.ts +++ b/packages/cli/src/local-publication-preparation.test.ts @@ -12,7 +12,6 @@ import { handleM6PublicationAdmin } from "../../../src/publication-admin"; import { consumeOwnedPublication } from "../../../src/publication-consumer"; import { handleM6StagingAdmin } from "../../../src/staging-admin"; import { stagingAdminFixture } from "../../../src/test-support/staging-admin"; -import { publicationTestDigest } from "../../../src/test-support/episode-publication"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -36,7 +35,7 @@ async function fixture(mode: "show" | "episode" | "metadata" | "audio" = "show", const selections = mode === "show" ? ["show"] : mode === "episode" ? (audioFirst ? ["audio", "episode_metadata"] : ["episode_metadata", "audio"]) : [mode === "metadata" ? "episode_metadata" : "audio"]; const stagingClient = new StagingAdminClient(setup.config, "private-secret", async (input, init) => { - const response = await handleM6StagingAdmin(new Request(input, init), setup.env, setup.bindings, { digest: publicationTestDigest }); + const response = await handleM6StagingAdmin(new Request(input, init), setup.env, setup.bindings); if (!response) throw new Error("Unexpected staging route"); return response; }); @@ -51,6 +50,7 @@ async function fixture(mode: "show" | "episode" | "metadata" | "audio" = "show", try { const effects = { ...createStagingOperationEffects(setup.config, prepared.journal.load(), "private-secret", async (target, index) => { await prepared.sources.withPayload(index, async (body, consumed) => { await setup.bucket.put(target.key, body); consumed(); }); + return setup.readbacks(prepared.journal.load().upload, index + 1)[index]!; }, stagingClient), checkLocalInputs: prepared.sources.assertCurrent }; await runStagingClaim(prepared.journal, effects); await runStagingBeginAndUpload(prepared.journal, effects); @@ -107,8 +107,7 @@ describe("prepare publication from acknowledged local staging journals", () => { const key = publicationCommitKey(state.publication.commit); expect(setup.sent).toEqual([]); expect(await setup.bucket.head(key)).not.toBeNull(); - expect(await consumeOwnedPublication(setup.env, key, { async checkDeliveryGate() {}, async purge() {} }, - { digest: publicationTestDigest })).toEqual({ state: "completed" }); + expect(await consumeOwnedPublication(setup.env, key, { async checkDeliveryGate() {}, async purge() {} })).toEqual({ state: "completed" }); expect(setup.actions).toEqual(["claim", "status", "commit"]); if (mode !== "show") { const metadata = parseEpisodeRevision(setup.text("public/episodes/daily/next/metadata.toml")); @@ -160,7 +159,7 @@ describe("prepare publication from acknowledged local staging journals", () => { writeFileSync(setup.stageFiles[0]!, JSON.stringify(prepared)); await expect(setup.prepare()).rejects.toThrow("finished staged"); writeFileSync(setup.stageFiles[0]!, JSON.stringify({ ...state, finish_receipt: "aborted", put_outcome: "aborted", - acknowledged_puts: 0, reason_code: "put_failed" })); + acknowledged_puts: 0, readback_receipts: [], reason_code: "put_failed" })); await expect(setup.prepare()).rejects.toThrow("finished staged"); rmSync(setup.stageFiles[0]!); await expect(setup.prepare()).rejects.toThrow("finished staged"); diff --git a/packages/cli/src/local-staging-preparation.test.ts b/packages/cli/src/local-staging-preparation.test.ts index b32d012..9c1277b 100644 --- a/packages/cli/src/local-staging-preparation.test.ts +++ b/packages/cli/src/local-staging-preparation.test.ts @@ -8,7 +8,6 @@ import { createStagingRestPut } from "./staging-rest"; import { inspectLocalStagingSource } from "./staging-sources"; import { handleM6StagingAdmin } from "../../../src/staging-admin"; import { stagingAdminFixture } from "../../../src/test-support/staging-admin"; -import { publicationTestDigest } from "../../../src/test-support/episode-publication"; import { createHash } from "node:crypto"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, truncateSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -76,18 +75,18 @@ describe("prepare M6 staging requests from current local drafts", () => { calls.push(init?.method ?? "GET"); if (init?.method === "PUT") { const bytes = new Uint8Array(await new Response(init.body).arrayBuffer()); - await setup.bucket.put(key, bytes); - return Response.json({ success: true, result: { size: bytes.length } }); + const object = (await setup.bucket.put(key, bytes))!; + return Response.json({ success: true, result: { key, size: object.size, etag: object.etag, version: object.version } }); } const object = await setup.bucket.get(key); if (!object) return new Response(null, { status: 404 }); - return new Response(object.bytes); + return new Response(object.bytes, { headers: { ETag: `"${object.etag}"` } }); } }); const actions: string[] = []; const client = new StagingAdminClient(setup.config, "private-secret", async (input, init) => { const request = new Request(input, init); actions.push((await request.clone().json() as { action: string }).action); - const response = await handleM6StagingAdmin(request, setup.env, setup.bindings, { digest: publicationTestDigest }); + const response = await handleM6StagingAdmin(request, setup.env, setup.bindings); if (!response) throw new Error("Unexpected route"); return response; }); diff --git a/packages/cli/src/m6-content-flow.test.ts b/packages/cli/src/m6-content-flow.test.ts index f1cf423..3ffdd11 100644 --- a/packages/cli/src/m6-content-flow.test.ts +++ b/packages/cli/src/m6-content-flow.test.ts @@ -20,7 +20,6 @@ import { completeM6ServiceInitialization, prepareM6ServiceInitialization } from import { readServiceAdmission, resumeServiceAdmission } from "../../../src/service-admission"; import { parseQueueDelivery } from "../../../src/queue-delivery"; import { m6InitializationFixture } from "../../../src/test-support/m6-initialization"; -import { publicationTestDigest } from "../../../src/test-support/episode-publication"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -40,7 +39,7 @@ test("fresh service initialization, Show registration and local drafts flow thro queued.push(delivery.key); } } as never }; const transport = async (input: URL, init: RequestInit) => fetchM6ManagementIntegration( - new Request(new Request(input, init)), env, cachedAssets, { digest: publicationTestDigest }); + new Request(new Request(input, init)), env, cachedAssets); const inspector = new TargetInspectionClient(setup.config, "private-secret", transport); const stagingClient = new StagingAdminClient(setup.config, "private-secret", transport); const publicationClient = new PublicationAdminClient(setup.config, "private-secret", transport); @@ -50,16 +49,16 @@ test("fresh service initialization, Show registration and local drafts flow thro const key = path.slice(path.indexOf("/objects/") + "/objects/".length); if (init?.method === "PUT") { const bytes = new Uint8Array(await new Response(init.body).arrayBuffer()); - await setup.bucket.put(key, bytes); - return Response.json({ success: true, result: { size: bytes.length } }); + const object = (await setup.bucket.put(key, bytes))!; + return Response.json({ success: true, result: { key, size: object.size, etag: object.etag, version: object.version } }); } const object = await setup.bucket.get(key); - return object ? new Response(new Uint8Array(object.bytes)) : new Response(null, { status: 404 }); + return object ? new Response(new Uint8Array(object.bytes), { headers: { ETag: `"${object.etag}"` } }) : new Response(null, { status: 404 }); } }; const consume = async (key: string) => { const deliver = async (next: string) => queueM6Candidate({ queue: setup.config.queue_name, messages: [{ id: crypto.randomUUID(), body: { object: { key: next } } }] } as never, - env, cachedAssets, { digest: publicationTestDigest, maximumObjects: 2 }); + env, cachedAssets, { maximumObjects: 2 }); await deliver(key); let count = 1; while (queued.length) { diff --git a/packages/cli/src/publication-operation.test.ts b/packages/cli/src/publication-operation.test.ts index ba17c88..87e3559 100644 --- a/packages/cli/src/publication-operation.test.ts +++ b/packages/cli/src/publication-operation.test.ts @@ -7,7 +7,6 @@ import { queueM6Candidate } from "../../../src/m6-routes"; import { acquireShowExecution } from "../../../src/lifecycle-control"; import { SERVICE_ADMISSION_KEY } from "../../../src/service-admission"; import { publicationAdminFixture } from "../../../src/test-support/publication-admin"; -import { publicationTestDigest } from "../../../src/test-support/episode-publication"; import { PublicationAdminClient } from "./publication-client"; import { createPublicationJournal, readLocalPublicationJob } from "./publication-journal"; import type { PublicationJournal } from "./publication-journal"; @@ -33,7 +32,7 @@ async function fixture(mode: "show" | "episode" | "metadata" | "audio" = "show") }); const effects = createPublicationOperationEffects(setup.config, journal.load(), "private-secret", client); const consume = (key: string) => queueM6Candidate({ queue: "test-queue", messages: [{ id: "publication", body: { object: { key } } }] } as never, - setup.candidateEnv, setup.cachedAssets, { digest: publicationTestDigest }); + setup.candidateEnv, setup.cachedAssets); return { ...setup, root, journal, file, calls, client, effects, consume }; } @@ -412,7 +411,7 @@ test("historical publication status remains readable after a later Show publicat await setup.success(setup.input("claim", { upload })); await setup.success(setup.input("begin", { operation })); for (const { asset, bytes } of setup.contents) await setup.bucket.put(stagePayloadKey(upload, asset), bytes); - await setup.success(setup.input("settle", { operation, put_requests_settled: true, no_more_puts: true })); + await setup.success(setup.input("settle", { operation, put_requests_settled: true, no_more_puts: true, readback_receipts: setup.readbacks(upload) })); await setup.success(setup.input("finish", { operation, outcome: "staged" })); const next = publicationRequestSchema.parse({ ...setup.frozen, request: { ...setup.frozen.request, job_id: draftId, expected_show_generation: (await readShowControl(setup.env, "daily"))!.value.generation }, diff --git a/packages/cli/src/publication-sources.test.ts b/packages/cli/src/publication-sources.test.ts index 063b190..56aec90 100644 --- a/packages/cli/src/publication-sources.test.ts +++ b/packages/cli/src/publication-sources.test.ts @@ -6,7 +6,6 @@ import { stagingAdminFixture } from "../../../src/test-support/staging-admin"; import { handleM6PublicationAdmin } from "../../../src/publication-admin"; import { queueM6Candidate } from "../../../src/m6-routes"; import { publicationAdminFixture } from "../../../src/test-support/publication-admin"; -import { publicationTestDigest } from "../../../src/test-support/episode-publication"; import { PublicationAdminClient } from "./publication-client"; import { createPublicationJournal } from "./publication-journal"; import { inspectPublicationOperation, runPublicationClaim, runPublicationCommit, runPublicationRetry } from "./publication-operation"; @@ -49,7 +48,7 @@ async function fixture(mode: "show" | "episode" | "metadata" | "audio" = "show") }); const effects = createLocalPublicationEffects(setup.config, journal.load(), "private-secret", local, client); const consume = (key: string) => queueM6Candidate({ queue: setup.config.queue_name, - messages: [{ id: "publication", body: { object: { key } } }] } as never, setup.candidateEnv, setup.cachedAssets, { digest: publicationTestDigest }); + messages: [{ id: "publication", body: { object: { key } } }] } as never, setup.candidateEnv, setup.cachedAssets); return { ...setup, root, journal, stages, local, calls, client, effects, consume }; } @@ -194,15 +193,16 @@ test("real local staging journal and REST/source effects feed guarded publicatio const sources = await freezeStagingSources(root, setup.upload, [metadataPath, coverPath]); const stageJournal = createStagingJournal(root, setup.config, setup.upload); const client = new StagingAdminClient(setup.config, "private-secret", async (input, init) => - (await handleM6StagingAdmin(new Request(input, init), setup.env, setup.bindings, { digest: publicationTestDigest }))!); + (await handleM6StagingAdmin(new Request(input, init), setup.env, setup.bindings))!); const transport: StagingRestTransport = async (input, init) => { const key = new URL(String(input)).pathname.split("/objects/")[1]!; if (init?.method === "PUT") { const bytes = new Uint8Array(await new Request(input, init).arrayBuffer()); - await setup.bucket.put(key, bytes); - return Response.json({ success: true, result: { size: bytes.length } }); + const object = (await setup.bucket.put(key, bytes))!; + return Response.json({ success: true, result: { key, size: object.size, etag: object.etag, version: object.version } }); } - return new Response((await setup.bucket.get(key))!.bytes); + const object = (await setup.bucket.get(key))!; + return new Response(object.bytes, { headers: { ETag: `"${object.etag}"` } }); }; const stageEffects = createStagingRestEffects(setup.config, stageJournal.load(), "private-secret", sources, { accountId: setup.config.account_id, apiToken: "private-token", transport }, client); @@ -230,7 +230,7 @@ test("real local staging journal and REST/source effects feed guarded publicatio await queueM6Candidate({ queue: setup.config.queue_name, messages: [{ id: "show", body: { object: { key } } }] } as never, { ...env, CASTLOOP_BUCKET: setup.bucket as never, CASTLOOP_QUEUE: env.CASTLOOP_QUEUE as never, CASTLOOP_VERSION_METADATA: { id: setup.versionId, tag: "", timestamp: "2026-10-02T12:00:00Z" }, CASTLOOP_DLQ_NAME: setup.config.dlq_name }, - cachedAssets, { digest: publicationTestDigest }); + cachedAssets); expect((await inspectPublicationOperation(journal, effects)).server_status.status?.state).toBe("published"); expect(stageJournal.load().phase).toBe("finished"); expect(await readFile(metadataPath)).toEqual(Buffer.from(setup.contents[0]!.bytes)); diff --git a/packages/cli/src/staging-client.test.ts b/packages/cli/src/staging-client.test.ts index 9660221..f2c32a3 100644 --- a/packages/cli/src/staging-client.test.ts +++ b/packages/cli/src/staging-client.test.ts @@ -3,7 +3,6 @@ import { stageUploadRequestSchema, stagingAdminRequestSchema, stagingAdminRespon import { handleM6StagingAdmin } from "../../../src/staging-admin"; import { readShowControl } from "../../../src/lifecycle-control"; import { stagingAdminFixture } from "../../../src/test-support/staging-admin"; -import { publicationTestDigest } from "../../../src/test-support/episode-publication"; import { StagingAdminClient } from "./staging-client"; import type { M6AdminTransport } from "./m6-admin-json"; @@ -17,7 +16,7 @@ function transport(setup: Setup, calls: string[]): M6AdminTransport { expect(request.headers.get("X-Castloop-Key")).toBe("private-secret"); const body = stagingAdminRequestSchema.parse(await request.clone().json()); calls.push(body.action); - const response = await handleM6StagingAdmin(request, setup.env, setup.bindings, { digest: publicationTestDigest }); + const response = await handleM6StagingAdmin(request, setup.env, setup.bindings); if (!response) throw new Error("Expected internal staging route"); return response; }; @@ -37,7 +36,7 @@ for (const kind of ["show", "audio", "episode_metadata"] as const) { expect(status.authorizes_put).toBe(false); expect(setup.writes.length).toBe(writes); await setup.putPayloads(); - await client.settle(setup.upload, { put_requests_settled: true, no_more_puts: true }); + await client.settle(setup.upload, { put_requests_settled: true, no_more_puts: true, readback_receipts: setup.readbacks() }); expect((await client.finish(setup.upload, "staged")).result).toBe("staged"); expect((await client.status(setup.upload)).ownership).toBe("released"); expect((await readShowControl(setup.env, "daily"))!.value.owner).toBeUndefined(); diff --git a/packages/cli/src/staging-client.ts b/packages/cli/src/staging-client.ts index bb4bba0..1e04b3a 100644 --- a/packages/cli/src/staging-client.ts +++ b/packages/cli/src/staging-client.ts @@ -1,6 +1,6 @@ import { stageControlRequest, stagePayloadKey, stageUploadRequestSchema, stagingAdminRequestSchema, stagingAdminResponseSchema, stagingOperationSchema } from "@castloop/shared"; -import type { ServiceConfig, StageUploadRequest, StagingAdminRequest, StagingAdminResponse, StagingOperation } from "@castloop/shared"; +import type { ServiceConfig, StageReadbackReceipt, StageSettlement, StageUploadRequest, StagingAdminRequest, StagingAdminResponse, StagingOperation } from "@castloop/shared"; import { M6AdminJsonClient } from "./m6-admin-json"; import type { M6AdminTransport } from "./m6-admin-json"; import { createHash } from "node:crypto"; @@ -26,7 +26,7 @@ export class StagingAdminClient { } private async call(input: StageUploadRequest, action: StagingAdminRequest["action"], - fields: { outcome?: "staged" | "aborted"; put_requests_settled?: true; no_more_puts?: true } = {}): Promise { + fields: { outcome?: "staged" | "aborted"; put_requests_settled?: true; no_more_puts?: true; readback_receipts?: StageReadbackReceipt[] } = {}): Promise { const upload = stageUploadRequestSchema.parse(input); const operation = stagingClientOperation(upload); const identity = { schema_version: 1, service_id: this.http.config.service_id, action }; @@ -65,7 +65,7 @@ export class StagingAdminClient { return value; } - async settle(upload: StageUploadRequest, evidence: { put_requests_settled: true; no_more_puts: true }): Promise> { + async settle(upload: StageUploadRequest, evidence: StageSettlement): Promise> { const value = await this.call(upload, "settle", evidence); if (value.result !== "settled") throw new Error("Invalid staging settlement result"); return value; diff --git a/packages/cli/src/staging-journal.ts b/packages/cli/src/staging-journal.ts index 760a5ff..5fed0cc 100644 --- a/packages/cli/src/staging-journal.ts +++ b/packages/cli/src/staging-journal.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { serviceConfigSchema, stageUploadRequestSchema, stagingOperationSchema } from "@castloop/shared"; +import { parseStageReadbackReceipts, serviceConfigSchema, stageReadbackReceiptSchema, stageUploadRequestSchema, stagingOperationSchema } from "@castloop/shared"; import type { ServiceConfig, StageUploadRequest } from "@castloop/shared"; import { stagingClientOperation, stagingClientTargets } from "./staging-client"; import { readBoundedLocalJournal } from "./local-journal-read"; @@ -15,6 +15,7 @@ const stateSchema = z.object({ schema_version: z.literal(1), identity: identityS begin_receipt: z.array(z.object({ key: z.string().min(1).max(256), length: z.number().int().positive().max(300000000), sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict()).min(1).max(2).optional(), put_outcome: z.enum(["staged", "aborted"]).optional(), acknowledged_puts: z.number().int().min(0).max(2).optional(), + readback_receipts: z.array(stageReadbackReceiptSchema).max(2).optional(), reason_code: z.literal("put_failed").optional(), finish_receipt: z.enum(["staged", "aborted"]).optional(), }).strict(); @@ -27,6 +28,10 @@ export function validateStagingClientState(input: unknown): StagingClientState { const origin = new URL(state.identity.public_base_url); const phase = stateSchema.shape.phase.options.indexOf(state.phase); const operation = stagingClientOperation(state.upload); + if (state.readback_receipts !== undefined && (phase < 6 || + parseStageReadbackReceipts(state.upload, state.readback_receipts).length !== state.acknowledged_puts)) { + throw new Error("Staging journal readback receipts do not match its acknowledged PUTs"); + } if (origin.protocol !== "https:" || origin.username || origin.password || origin.search || origin.hash || origin.pathname !== "/" || (phase >= 2) !== (state.claim_receipt !== undefined) || (phase >= 4) !== (state.begin_receipt !== undefined) || (phase >= 6) !== (state.put_outcome !== undefined) || (phase >= 6) !== (state.acknowledged_puts !== undefined) || @@ -88,7 +93,8 @@ export function createStagingJournal(root: string, configInput: ServiceConfig, i previous.claim_receipt && JSON.stringify(next.claim_receipt) !== JSON.stringify(previous.claim_receipt) || previous.begin_receipt && JSON.stringify(next.begin_receipt) !== JSON.stringify(previous.begin_receipt) || previous.put_outcome && (next.put_outcome !== previous.put_outcome || next.acknowledged_puts !== previous.acknowledged_puts || - next.reason_code !== previous.reason_code) || previous.finish_receipt && next.finish_receipt !== previous.finish_receipt) { + next.reason_code !== previous.reason_code || JSON.stringify(next.readback_receipts) !== JSON.stringify(previous.readback_receipts)) || + previous.finish_receipt && next.finish_receipt !== previous.finish_receipt) { throw new Error("Frozen staging journal cannot change, skip phases or reopen PUT permission"); } storage.replace(next); diff --git a/packages/cli/src/staging-operation.test.ts b/packages/cli/src/staging-operation.test.ts index 43581dc..e05f9ab 100644 --- a/packages/cli/src/staging-operation.test.ts +++ b/packages/cli/src/staging-operation.test.ts @@ -5,7 +5,6 @@ import { SERVICE_ADMISSION_KEY } from "../../../src/service-admission"; import { handleM6StagingAdmin } from "../../../src/staging-admin"; import { acquireStageVerification } from "../../../src/staging-verification"; import { stagingAdminFixture } from "../../../src/test-support/staging-admin"; -import { publicationTestDigest } from "../../../src/test-support/episode-publication"; import { StagingAdminClient } from "./staging-client"; import { createStagingJournal, readLocalStagingOperation } from "./staging-journal"; import type { StagingJournal } from "./staging-journal"; @@ -25,13 +24,14 @@ async function fixture(kind: "show" | "audio" | "episode_metadata" = "show") { const client = new StagingAdminClient(setup.config, "private-secret", async (input, init) => { const request = new Request(input, init); calls.push(stagingAdminRequestSchema.parse(await request.clone().json()).action); - const response = await handleM6StagingAdmin(request, setup.env, setup.bindings, { digest: publicationTestDigest }); + const response = await handleM6StagingAdmin(request, setup.env, setup.bindings); if (!response) throw new Error("Expected staging internal API"); return response; }); const effects = createStagingOperationEffects(setup.config, journal.load(), "private-secret", async (target, index) => { puts.push(target.key); await setup.bucket.put(target.key, setup.contents[index]!.bytes); + return setup.readbacks(setup.upload, index + 1)[index]!; }, client); return { ...setup, root, journal, file, client, calls, puts, effects }; } @@ -64,6 +64,7 @@ for (const kind of ["show", "audio", "episode_metadata"] as const) { expect(await runStagingBeginAndUpload(setup.journal, setup.effects)).toBe("staged"); expect(setup.journal.load().phase).toBe("puts_settled"); expect(setup.journal.load().acknowledged_puts).toBe(setup.upload.payloads.length); + expect(setup.journal.load().readback_receipts).toEqual(setup.readbacks()); expect((await readShowControl(setup.env, "daily"))!.value.owner?.state).toBe("uploading"); await runStagingSettle(setup.journal, setup.effects, settlement); await runStagingFinish(setup.journal, setup.effects); @@ -106,6 +107,29 @@ test("known PUT exception stops remaining writes, retains payload and permits ex expect((await readShowControl(setup.env, "daily"))!.value.owner).toBeUndefined(); }); +test("legacy missing readbacks and divergent server settlement never authorize finish or fabricated evidence", async () => { + const setup = await fixture("audio"); + try { + await runStagingClaim(setup.journal, setup.effects); + await runStagingBeginAndUpload(setup.journal, setup.effects); + const saved = readFileSync(setup.file, "utf8"); + const { readback_receipts: _receipts, ...legacy } = setup.journal.load(); + writeFileSync(setup.file, JSON.stringify(legacy)); + expect(setup.journal.load().phase).toBe("puts_settled"); + await expect(runStagingSettle(setup.journal, setup.effects, settlement)).rejects.toThrow("never fabricate legacy evidence"); + expect(setup.calls).not.toContain("settle"); + writeFileSync(setup.file, saved); + await runStagingSettle(setup.journal, setup.effects, settlement); + const effects = { ...setup.effects, status: async () => { + const value = await setup.effects.status(); + return { ...value, progress: { ...value.progress!, readback_receipts: [] } }; + } }; + await expect(runStagingFinish(setup.journal, effects)).rejects.toThrow("differ from its local journal"); + expect(setup.journal.load().phase).toBe("settled"); + expect(setup.calls).not.toContain("finish"); + } finally { rmSync(setup.root, { recursive: true, force: true }); } +}); + test("claim response loss is held even after owner observation", async () => { const setup = await fixture(); const effects = { ...setup.effects, claim: async () => { await setup.effects.claim(); throw new Error("Lost claim response"); } }; @@ -163,7 +187,7 @@ test("live PUT holds local and Show owners and prevents settlement until its pro const entered = new Promise((resolve) => { enter = resolve; }); const released = new Promise((resolve) => { release = resolve; }); const running = runStagingBeginAndUpload(setup.journal, { ...setup.effects, put: async (target, index) => { - enter(); await released; await setup.effects.put(target, index); + enter(); await released; return setup.effects.put(target, index); } }); await entered; expect(setup.journal.load().phase).toBe("puts_running"); @@ -304,6 +328,7 @@ test("journal refuses foreign config, changed manifest, phase skipping and inval await runStagingBeginAndUpload(setup.journal, setup.effects); await setup.journal.exclusively(async () => { expect(() => setup.journal.save({ ...setup.journal.load(), phase: "claimed" })).toThrow(); - expect(() => setup.journal.save({ ...setup.journal.load(), put_outcome: "aborted", acknowledged_puts: 0, reason_code: "put_failed" })).toThrow("cannot change"); + expect(() => setup.journal.save({ ...setup.journal.load(), put_outcome: "aborted", acknowledged_puts: 0, + readback_receipts: [], reason_code: "put_failed" })).toThrow("cannot change"); }); }); diff --git a/packages/cli/src/staging-operation.ts b/packages/cli/src/staging-operation.ts index f9cb84c..62f9a10 100644 --- a/packages/cli/src/staging-operation.ts +++ b/packages/cli/src/staging-operation.ts @@ -1,5 +1,5 @@ -import { serviceConfigSchema, stageControlRequest, stageUploadRequestSchema, stagingAdminRequestSchema, stagingAdminResponseSchema } from "@castloop/shared"; -import type { ServiceConfig, StageUploadRequest, StagingAdminResponse } from "@castloop/shared"; +import { parseStageReadbackReceipts, serviceConfigSchema, stageControlRequest, stageUploadRequestSchema, stagingAdminRequestSchema, stagingAdminResponseSchema } from "@castloop/shared"; +import type { ServiceConfig, StageReadbackReceipt, StageSettlement, StageUploadRequest, StagingAdminResponse } from "@castloop/shared"; import { StagingAdminClient, stagingClientOperation, stagingClientTargets } from "./staging-client"; import type { StagePutTarget } from "./staging-client"; import { validateStagingClientState } from "./staging-journal"; @@ -8,8 +8,8 @@ import { createHash } from "node:crypto"; type Receipt = Extract; export type StagingOperationEffects = { upload: StageUploadRequest; claim: () => Promise>; - begin: () => Promise>; put: (target: StagePutTarget, index: number) => Promise; - settle: (evidence: { put_requests_settled: true; no_more_puts: true }) => Promise>; + begin: () => Promise>; put: (target: StagePutTarget, index: number) => Promise; + settle: (evidence: StageSettlement) => Promise>; finish: (outcome: "staged" | "aborted") => Promise>; status: () => Promise>; checkLocalInputs?: () => Promise }; @@ -40,6 +40,10 @@ async function requireOwner(state: StagingClientState, effects: StagingOperation status.progress.client_settled !== (phase === "settled") || status.status?.state === "completed") { throw new Error("Staging operation requires its current unfinished owner, phase and idle verification"); } + if (phase === "settled" && (!state.readback_receipts || + JSON.stringify(status.progress.readback_receipts) !== JSON.stringify(state.readback_receipts))) { + throw new Error("Staging settlement readback receipts differ from its local journal"); + } } export async function runStagingClaim(journal: StagingJournal, effects: StagingOperationEffects): Promise { @@ -67,15 +71,18 @@ export async function runStagingBeginAndUpload(journal: StagingJournal, effects: journal.save(acknowledged); journal.save({ ...acknowledged, phase: "puts_running" }); let acknowledgedPuts = 0; + const readbacks: StageReadbackReceipt[] = []; let failed = false; try { for (const target of value.payloads) { - await effects.put(target, acknowledgedPuts); + const proof = await effects.put(target, acknowledgedPuts); + parseStageReadbackReceipts(state.upload, [...readbacks, proof]); + readbacks.push(proof); acknowledgedPuts += 1; } } catch { failed = true; } const outcome = failed ? "aborted" : "staged"; - journal.save({ ...acknowledged, phase: "puts_settled", put_outcome: outcome, acknowledged_puts: acknowledgedPuts, + journal.save({ ...acknowledged, phase: "puts_settled", put_outcome: outcome, acknowledged_puts: acknowledgedPuts, readback_receipts: readbacks, ...(failed ? { reason_code: "put_failed" } : {}) }); return outcome; }); @@ -86,11 +93,16 @@ export async function runStagingSettle(journal: StagingJournal, effects: Staging await journal.exclusively(async () => { const state = load(journal, effects); if (state.phase !== "puts_settled") throw new Error("Staging settlement requires acknowledged local PUT termination; never replay an unknown POST"); + const readbacks = parseStageReadbackReceipts(state.upload, state.readback_receipts ?? []); + if (state.put_outcome === "staged" && readbacks.length !== state.upload.payloads.length) { + throw new Error("Staging settlement requires retained full readback receipts; never fabricate legacy evidence"); + } + const settlement = { ...evidence, readback_receipts: readbacks }; stagingAdminRequestSchema.parse({ schema_version: 1, service_id: state.identity.service_id, action: "settle", - operation: stagingClientOperation(state.upload), ...evidence }); + operation: stagingClientOperation(state.upload), ...settlement }); await requireOwner(state, effects, "uploading"); journal.save({ ...state, phase: "settlement_requested" }); - receipt(state, await effects.settle(evidence), "settled"); + receipt(state, await effects.settle(settlement), "settled"); journal.save({ ...state, phase: "settled" }); }); } diff --git a/packages/cli/src/staging-rest.test.ts b/packages/cli/src/staging-rest.test.ts index 7f37c4b..4f3bf38 100644 --- a/packages/cli/src/staging-rest.test.ts +++ b/packages/cli/src/staging-rest.test.ts @@ -4,7 +4,6 @@ import { stagingAdminFixture } from "../../../src/test-support/staging-admin"; import { handleM6StagingAdmin } from "../../../src/staging-admin"; import { readShowControl } from "../../../src/lifecycle-control"; import { fetchM6ManagementIntegration } from "../../../src/m6-routes"; -import { publicationTestDigest } from "../../../src/test-support/episode-publication"; import { StagingAdminClient, stagingClientTargets } from "./staging-client"; import { createStagingJournal } from "./staging-journal"; import { runStagingBeginAndUpload, runStagingClaim, runStagingFinish, runStagingSettle } from "./staging-operation"; @@ -33,11 +32,12 @@ async function fixture(kind: "show" | "audio" | "episode_metadata" = "audio") { const key = decodeURI(new URL(request.url).pathname.split("/objects/")[1]!); if (request.method === "PUT") { const bytes = new Uint8Array(await request.arrayBuffer()); - await setup.bucket.put(key, bytes); - return Response.json({ success: true, result: { size: kind === "show" ? String(bytes.length) : bytes.length } }); + const object = (await setup.bucket.put(key, bytes))!; + return Response.json({ success: true, result: { key, etag: object.etag, version: object.version, + size: kind === "show" ? String(bytes.length) : bytes.length } }); } const object = await setup.bucket.get(key); - return new Response(object!.bytes); + return new Response(object!.bytes, { headers: { ETag: `"${object!.etag}"` } }); }; const options = { accountId: setup.config.account_id, apiToken: "private-token", transport }; return { ...setup, root, paths, sources, calls, options }; @@ -48,7 +48,7 @@ for (const kind of ["show", "audio", "episode_metadata"] as const) { const setup = await fixture(kind); const journal = createStagingJournal(setup.root, setup.config, setup.upload); const client = new StagingAdminClient(setup.config, "private-secret", async (input, init) => { - const response = await handleM6StagingAdmin(new Request(input, init), setup.env, setup.bindings, { digest: publicationTestDigest }); + const response = await handleM6StagingAdmin(new Request(input, init), setup.env, setup.bindings); return response!; }); const effects = createStagingRestEffects(setup.config, journal.load(), "private-secret", setup.sources, setup.options, client); @@ -122,8 +122,7 @@ test("pre-send input rejection permits explicit same-journal claim and begin aft return fetchM6ManagementIntegration(request, { CASTLOOP_BUCKET: setup.bucket as never, CASTLOOP_ADMIN_KEY: "private-secret", CASTLOOP_DLQ_NAME: setup.config.dlq_name, CASTLOOP_VERSION_METADATA: { id: setup.versionId, tag: "", timestamp: "2026-10-02T12:00:00Z" }, CASTLOOP_QUEUE: {} as never, - }, Object.assign(() => ({ fetch: async () => new Response() }), { invalidate: async () => {}, ...setup.bindings.cachedAssets }), - { digest: publicationTestDigest }); + }, Object.assign(() => ({ fetch: async () => new Response() }), { invalidate: async () => {}, ...setup.bindings.cachedAssets })); }); const effects = createStagingRestEffects(setup.config, journal.load(), "private-secret", setup.sources, setup.options, client); try { @@ -188,7 +187,7 @@ test("begin rechecks local sources after the read-only owner check and keeps unk const request = new Request(input, init); const action = (await request.clone().json<{ action: string }>()).action; actions.push(action); - const response = await handleM6StagingAdmin(request, setup.env, setup.bindings, { digest: publicationTestDigest }); + const response = await handleM6StagingAdmin(request, setup.env, setup.bindings); if (action === "status" && changeAfterStatus) await writeFile(setup.paths[0]!, new Uint8Array(setup.upload.payloads[0]!.length_bytes)); return response!; }); @@ -241,7 +240,8 @@ test("REST credentials cannot target another account and session must match dura await setup.sources.dispose(); }); -for (const failure of ["early", "lost", "http", "oversized", "receipt", "receipt_exponent", "hash", "range"] as const) { +for (const failure of ["early", "lost", "http", "oversized", "receipt", "receipt_exponent", "hash", "range", + "receipt_key", "receipt_etag", "receipt_version", "readback_etag", "readback_weak_etag"] as const) { test(`REST ${failure} failure never retries PUT or returns arbitrary API diagnostics`, async () => { const setup = await fixture(); const calls: string[] = []; @@ -254,10 +254,14 @@ for (const failure of ["early", "lost", "http", "oversized", "receipt", "receipt if (failure === "http") return new Response("private-token", { status: 403 }); if (failure === "oversized") return new Response("x".repeat(65537)); const length = setup.upload.payloads[0]!.length_bytes; - return Response.json({ success: true, result: { size: failure === "receipt" ? 1 : failure === "receipt_exponent" ? `${length}e0` : length } }); + return Response.json({ success: true, result: { key: failure === "receipt_key" ? "system/service.toml" : stagingClientTargets(setup.upload)[0]!.key, + etag: failure === "receipt_etag" ? undefined : "object-etag", version: failure === "receipt_version" ? undefined : "upload-version", + size: failure === "receipt" ? 1 : failure === "receipt_exponent" ? `${length}e0` : length } }); } - return new Response(new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array(setup.upload.payloads[0]!.length_bytes)); if (failure !== "range") controller.close(); }, cancel() { cancelled = true; } }), - failure === "range" ? { status: 206, headers: { "content-range": "bytes 0-1/2" } } : undefined); + const bytes = failure === "hash" ? new Uint8Array(setup.upload.payloads[0]!.length_bytes) : setup.contents[0]!.bytes; + return new Response(new ReadableStream({ start(controller) { controller.enqueue(bytes); if (failure !== "range") controller.close(); }, cancel() { cancelled = true; } }), + failure === "range" ? { status: 206, headers: { "content-range": "bytes 0-1/2" } } : { headers: { + ETag: failure === "readback_etag" ? '"replacement-etag"' : failure === "readback_weak_etag" ? 'W/"object-etag"' : '"object-etag"' } }); }; const put = createStagingRestPut(setup.config, setup.sources, { ...setup.options, transport, timeoutMs: 20 }); await expect(put(stagingClientTargets(setup.upload)[0]!, 0)).rejects.toThrow("no automatic retry"); @@ -277,7 +281,7 @@ test("owned network/GET cancellation remains awaited and snapshots cannot be dis const transport: StagingRestTransport = async (input, init) => { if (init!.method === "PUT") return setup.options.transport(input, init); return new Response(new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array(setup.upload.payloads[0]!.length_bytes + 1)); }, - async cancel() { enter(); await released; } })); + async cancel() { enter(); await released; } }), { headers: { ETag: `"${(await setup.bucket.head(stagingClientTargets(setup.upload)[0]!.key))!.etag}"` } }); }; const put = createStagingRestPut(setup.config, setup.sources, { ...setup.options, transport }); const running = put(stagingClientTargets(setup.upload)[0]!, 0); @@ -310,10 +314,10 @@ test("native Bun streaming HTTP uses a pinned snapshot even when the editable or expect(request.headers.get("content-length")).toBe(String(bytes.length)); await writeFile(setup.paths[0]!, new Uint8Array(bytes.length)); received = new Uint8Array(await request.arrayBuffer()); - return Response.json({ success: true, result: { size: received.length } }); + return Response.json({ success: true, result: { key: stagingClientTargets(upload)[0]!.key, size: received.length, etag: "native-etag", version: "native-version" } }); } reads += 1; - return new Response(received); + return new Response(received, { headers: { ETag: '"native-etag"' } }); } }); try { const transport: StagingRestTransport = (input, init) => fetch(new URL(new URL(String(input)).pathname, server.url), init); diff --git a/packages/cli/src/staging-rest.ts b/packages/cli/src/staging-rest.ts index c1984c3..6e201e3 100644 --- a/packages/cli/src/staging-rest.ts +++ b/packages/cli/src/staging-rest.ts @@ -1,5 +1,5 @@ -import { serviceConfigSchema, stageUploadRequestSchema } from "@castloop/shared"; -import type { ServiceConfig } from "@castloop/shared"; +import { serviceConfigSchema, stageReadbackReceiptSchema, stageUploadRequestSchema } from "@castloop/shared"; +import type { ServiceConfig, StageReadbackReceipt } from "@castloop/shared"; import { stagingClientTargets } from "./staging-client"; import type { StagePutTarget } from "./staging-client"; import type { FrozenStagingSources } from "./staging-sources"; @@ -13,7 +13,7 @@ export type StagingRestOptions = { timeoutMs?: number; }; -async function readReceipt(response: Response, length: number): Promise { +async function readReceipt(response: Response, target: StagePutTarget): Promise> { if (!response.body) throw new Error("Staging PUT receipt is missing"); const reader = response.body.getReader(); try { @@ -32,16 +32,20 @@ async function readReceipt(response: Response, length: number): Promise { throw new Error("Staging PUT receipt size/success was not verified"); } const received = typeof value.result.size === "string" && /^\d+$/.test(value.result.size) ? Number(value.result.size) : value.result.size; - if (typeof received !== "number" || !Number.isSafeInteger(received) || received !== length) { + if (typeof received !== "number" || !Number.isSafeInteger(received) || received !== target.length || + !("key" in value.result) || value.result.key !== target.key || !("etag" in value.result) || !("version" in value.result)) { throw new Error("Staging PUT receipt size/success was not verified"); } + return { etag: stageReadbackReceiptSchema.shape.etag.parse(value.result.etag), + version: stageReadbackReceiptSchema.shape.version.parse(value.result.version) }; } finally { await reader.cancel(); reader.releaseLock(); } } -async function verifyResponse(response: Response, target: StagePutTarget): Promise { +async function verifyResponse(response: Response, target: StagePutTarget, etag: string): Promise { if (!response.body || response.status !== 200 || response.headers.has("content-range")) throw new Error("Staging verification requires a complete object response"); const reader = response.body.getReader(); try { + if (response.headers.get("ETag") !== `"${etag}"`) throw new Error("Staging readback differs from its PUT object identity"); const hash = createHash("sha256"); let size = 0; for (;;) { @@ -51,13 +55,15 @@ async function verifyResponse(response: Response, target: StagePutTarget): Promi if (size > target.length) throw new Error("Staging verification size exceeds its frozen manifest"); hash.update(chunk.value); } - if (size !== target.length || hash.digest("hex") !== target.sha256) throw new Error("Staging verification checksum/size differs from its frozen manifest"); + const sha256 = hash.digest("hex"); + if (size !== target.length || sha256 !== target.sha256) throw new Error("Staging verification checksum/size differs from its frozen manifest"); + return sha256; } finally { await reader.cancel(); reader.releaseLock(); } } export function createStagingRestPut(configInput: ServiceConfig, sources: FrozenStagingSources, options: StagingRestOptions = { accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "", apiToken: process.env.CLOUDFLARE_API_TOKEN ?? "" }): - (target: StagePutTarget, index: number) => Promise { + (target: StagePutTarget, index: number) => Promise { const config = serviceConfigSchema.parse(configInput); const upload = stageUploadRequestSchema.parse(sources.upload); if (options.accountId !== config.account_id || !options.apiToken || /[\r\n]/.test(options.apiToken)) throw new Error("Staging REST credentials do not match the service account"); @@ -73,6 +79,7 @@ export function createStagingRestPut(configInput: ServiceConfig, sources: Frozen throw new Error("Staging REST PUT requires its exact one-time key, size and checksum"); } used.add(index); + let readback: StageReadbackReceipt | undefined; await sources.withPayload(index, async (body, requireConsumed) => { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); @@ -82,11 +89,12 @@ export function createStagingRestPut(configInput: ServiceConfig, sources: Frozen response = await transport(url, { method: "PUT", body, redirect: "error", signal: controller.signal, headers: { Authorization: `Bearer ${apiToken}`, "Content-Type": types[upload.payloads[index]!.asset], "Content-Length": String(target.length) } }); if (response.status !== 200) throw new Error("Staging REST PUT failed or has an unknown outcome"); - await readReceipt(response, target.length); + const receipt = await readReceipt(response, target); requireConsumed(); response = await transport(url, { method: "GET", redirect: "error", cache: "no-store", signal: controller.signal, headers: { Authorization: `Bearer ${apiToken}`, "Accept-Encoding": "identity" } }); - await verifyResponse(response, target); + const sha256 = await verifyResponse(response, target, receipt.etag); + readback = stageReadbackReceiptSchema.parse({ ...upload.payloads[index], sha256, ...receipt }); } catch { throw new Error("Staging REST PUT/verification failed; no automatic retry or remote termination proof"); } finally { @@ -97,5 +105,7 @@ export function createStagingRestPut(configInput: ServiceConfig, sources: Frozen } } }); + if (!readback) throw new Error("Staging readback receipt is missing"); + return readback; }; } diff --git a/packages/cli/src/staging-upload.test.ts b/packages/cli/src/staging-upload.test.ts index d2aaf54..5993856 100644 --- a/packages/cli/src/staging-upload.test.ts +++ b/packages/cli/src/staging-upload.test.ts @@ -9,20 +9,24 @@ function fixture() { created_at: "2026-10-01T12:00:00Z", payloads: [{ asset: "audio", length_bytes: 1, sha256: "a".repeat(64) }] }); const calls: string[] = []; const operation = { showId: "daily", operationId: request.operation_id, generation: 1 }; + const proof = { ...request.payloads[0]!, etag: "saved-etag", version: "saved-version" }; const effects: StageClientEffects = { async claim() { calls.push("claim"); return operation; }, async begin() { calls.push("begin"); return [{ key: `staging/episodes/daily/first/${request.draft_job_id}/audio.mp3`, length: 1, sha256: "a".repeat(64) }]; }, - async put() { calls.push("put"); }, - async settle(_operation, evidence) { calls.push("settle"); expect(evidence).toEqual({ put_requests_settled: true, no_more_puts: true }); }, + async put() { calls.push("put"); return proof; }, + async settle(_operation, evidence) { calls.push("settle"); expect(evidence.put_requests_settled).toBe(true); expect(evidence.no_more_puts).toBe(true); }, async finish(_operation, outcome) { calls.push(outcome); }, }; - return { request, calls, effects }; + return { request, calls, effects, proof }; } describe("M6 standalone CLI staging sequencing", () => { test("successful single PUT settles before verification and admission completion", async () => { const setup = fixture(); - await runStageClientUpload(setup.request, setup.effects); + await runStageClientUpload(setup.request, { ...setup.effects, async settle(operation, evidence) { + expect(evidence.readback_receipts).toEqual([setup.proof]); + await setup.effects.settle(operation, evidence); + } }); expect(setup.calls).toEqual(["claim", "begin", "put", "settle", "staged"]); }); diff --git a/packages/cli/src/staging-upload.ts b/packages/cli/src/staging-upload.ts index cff778a..071b8eb 100644 --- a/packages/cli/src/staging-upload.ts +++ b/packages/cli/src/staging-upload.ts @@ -1,12 +1,12 @@ -import { stagePayloadKey, stageUploadRequestSchema } from "../../shared/src/index"; -import type { StageUploadRequest } from "../../shared/src/index"; +import { parseStageReadbackReceipts, stagePayloadKey, stageUploadRequestSchema } from "../../shared/src/index"; +import type { StageReadbackReceipt, StageSettlement, StageUploadRequest } from "../../shared/src/index"; export type StageClientOperation = { showId: string; operationId: string; generation: number }; export type StageClientEffects = { claim: (request: StageUploadRequest) => Promise; begin: (operation: StageClientOperation) => Promise>; - put: (target: { key: string; length: number; sha256: string }, index: number) => Promise; - settle: (operation: StageClientOperation, evidence: { put_requests_settled: true; no_more_puts: true }) => Promise; + put: (target: { key: string; length: number; sha256: string }, index: number) => Promise; + settle: (operation: StageClientOperation, evidence: StageSettlement) => Promise; finish: (operation: StageClientOperation, outcome: "staged" | "aborted") => Promise; }; @@ -18,6 +18,7 @@ export async function runStageClientUpload(input: StageUploadRequest, effects: S const targets = await effects.begin(operation); let failure: unknown; let failed = false; + const readbacks: StageReadbackReceipt[] = []; try { if (targets.length !== request.payloads.length) throw new Error("Staging upload permission does not match its frozen payloads"); for (let index = 0; index < targets.length; index += 1) { @@ -26,10 +27,12 @@ export async function runStageClientUpload(input: StageUploadRequest, effects: S if (target.key !== stagePayloadKey(request, payload.asset) || target.length !== payload.length_bytes || target.sha256 !== payload.sha256) { throw new Error("Staging upload permission does not match its payload key, checksum and size"); } - await effects.put(target, index); + const proof = await effects.put(target, index); + parseStageReadbackReceipts(request, [...readbacks, proof]); + readbacks.push(proof); } } catch (error) { failed = true; failure = error; } - await effects.settle(operation, { put_requests_settled: true, no_more_puts: true }); + await effects.settle(operation, { put_requests_settled: true, no_more_puts: true, readback_receipts: readbacks }); await effects.finish(operation, failed ? "aborted" : "staged"); if (failed) throw failure; } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f73b758..9d8c6b3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -15,9 +15,9 @@ export { lifecycleCommitKey, lifecycleCommitSchema, parseLifecycleCommitKey } fr export type { LifecycleCommit, LifecycleCommitTarget } from "./lifecycle-commit"; export { lifecycleAdminRequestSchema, lifecycleAdminResponseSchema, lifecycleOperationRequestSchema } from "./lifecycle-admin"; export type { LifecycleAdminRequest, LifecycleAdminResponse, LifecycleOperationRequest } from "./lifecycle-admin"; -export { stageAssetSchema, stageControlRequest, stageDraftPrefix, stagePayloadKey, stagePayloadSchema, - stageUploadProgressSchema, stageUploadRequestSchema } from "./staging"; -export type { StageAsset, StagePayload, StageUploadProgress, StageUploadRequest } from "./staging"; +export { parseStageReadbackReceipts, stageAssetSchema, stageControlRequest, stageDraftPrefix, stagePayloadKey, stagePayloadSchema, + stageReadbackReceiptSchema, stageSettlementSchema, stageUploadProgressSchema, stageUploadRequestSchema } from "./staging"; +export type { StageAsset, StagePayload, StageReadbackReceipt, StageSettlement, StageUploadProgress, StageUploadRequest } from "./staging"; export { stagingAdminRequestSchema, stagingAdminResponseSchema, stagingOperationSchema } from "./staging-admin"; export type { StagingAdminRequest, StagingAdminResponse, StagingOperation } from "./staging-admin"; export { showRegistrationRequestSchema, showRegistrationResponseSchema, showReservationSchema } from "./show-registration"; diff --git a/packages/shared/src/staging-admin.ts b/packages/shared/src/staging-admin.ts index 187628a..d4c315f 100644 --- a/packages/shared/src/staging-admin.ts +++ b/packages/shared/src/staging-admin.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { serviceAdmissionSchema } from "./service-admission"; import { lifecycleJobStatusSchema } from "./lifecycle-job"; -import { stagePayloadSchema, stageUploadProgressSchema, stageUploadRequestSchema } from "./staging"; +import { stagePayloadSchema, stageSettlementSchema, stageUploadProgressSchema, stageUploadRequestSchema } from "./staging"; export const stagingOperationSchema = z.object({ show_id: stageUploadRequestSchema.shape.show_id, @@ -18,8 +18,7 @@ export const stagingAdminRequestSchema = z.discriminatedUnion("action", [ z.object({ ...identity, action: z.literal("claim"), upload: stageUploadRequestSchema }).strict(), z.object({ ...identity, action: z.literal("status"), upload: stageUploadRequestSchema }).strict(), z.object({ ...identity, action: z.literal("begin"), operation: stagingOperationSchema }).strict(), - z.object({ ...identity, action: z.literal("settle"), operation: stagingOperationSchema, - put_requests_settled: z.literal(true), no_more_puts: z.literal(true) }).strict(), + stageSettlementSchema.extend({ ...identity, action: z.literal("settle"), operation: stagingOperationSchema }), z.object({ ...identity, action: z.literal("finish"), operation: stagingOperationSchema, outcome: z.enum(["staged", "aborted"]) }).strict(), ]); diff --git a/packages/shared/src/staging.test.ts b/packages/shared/src/staging.test.ts index a7893a2..eac9cfc 100644 --- a/packages/shared/src/staging.test.ts +++ b/packages/shared/src/staging.test.ts @@ -1,11 +1,29 @@ import { describe, expect, test } from "bun:test"; -import { parseShowControl, stageControlRequest, stageDraftPrefix, stagePayloadKey, stageUploadProgressSchema, stageUploadRequestSchema } from "./index"; +import { parseShowControl, parseStageReadbackReceipts, stageControlRequest, stageDraftPrefix, stagePayloadKey, stageUploadProgressSchema, stageUploadRequestSchema } from "./index"; const base = { schema_version: 1, operation_id: crypto.randomUUID(), draft_job_id: crypto.randomUUID(), kind: "episode", show_id: "daily", episode_id: "first", expected_show_generation: 0, expected_episode_generation: 0, created_at: "2026-10-01T12:00:00Z", payloads: [{ asset: "audio", length_bytes: 300_000_000, sha256: "a".repeat(64) }] }; describe("M6 staging manifest and settlement", () => { + test("readback receipts are strict, ordered evidence for the frozen payloads", () => { + const request = stageUploadRequestSchema.parse(base); + const receipt = { ...request.payloads[0]!, etag: "saved-etag", version: "saved-version" }; + expect(parseStageReadbackReceipts(request, [])).toEqual([]); + expect(parseStageReadbackReceipts(request, [receipt])).toEqual([receipt]); + for (const change of [{ asset: "episode_metadata" }, { length_bytes: 1 }, { sha256: "b".repeat(64) }, + { etag: "" }, { version: "" }, { secret: "private" }]) { + expect(() => parseStageReadbackReceipts(request, [{ ...receipt, ...change }])).toThrow(); + } + expect(() => parseStageReadbackReceipts(request, [receipt, receipt])).toThrow(); + const show = stageUploadRequestSchema.parse({ ...base, kind: "show", episode_id: undefined, expected_episode_generation: undefined, + payloads: [{ asset: "show_metadata", length_bytes: 100, sha256: "a".repeat(64) }, + { asset: "cover_png", length_bytes: 10, sha256: "b".repeat(64) }] }); + const receipts = show.payloads.map((payload) => ({ ...payload, etag: "e1", version: "v1" })); + expect(parseStageReadbackReceipts(show, receipts.slice(0, 1))).toEqual(receipts.slice(0, 1)); + expect(() => parseStageReadbackReceipts(show, receipts.toReversed())).toThrow(); + }); + test("operation and draft IDs are distinct and keys are derived from frozen targets", () => { const request = stageUploadRequestSchema.parse(base); expect(stageControlRequest(request).job_id).toBe(base.operation_id); diff --git a/packages/shared/src/staging.ts b/packages/shared/src/staging.ts index e9a741b..cacdca6 100644 --- a/packages/shared/src/staging.ts +++ b/packages/shared/src/staging.ts @@ -42,6 +42,29 @@ export type StageUploadRequest = z.infer; export type StagePayload = z.infer; export type StageAsset = z.infer; +export const stageReadbackReceiptSchema = stagePayloadSchema.safeExtend({ + etag: z.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/), + version: z.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/), +}); +export type StageReadbackReceipt = z.infer; +export const stageSettlementSchema = z.object({ + put_requests_settled: z.literal(true), no_more_puts: z.literal(true), + readback_receipts: z.array(stageReadbackReceiptSchema).max(2), +}).strict(); +export type StageSettlement = z.infer; + +export function parseStageReadbackReceipts(input: StageUploadRequest, receipts: unknown): StageReadbackReceipt[] { + const request = stageUploadRequestSchema.parse(input); + const values = z.array(stageReadbackReceiptSchema).max(2).parse(receipts); + for (const [index, receipt] of values.entries()) { + const payload = request.payloads[index]; + if (!payload || payload.asset !== receipt.asset || payload.length_bytes !== receipt.length_bytes || payload.sha256 !== receipt.sha256) { + throw new Error("Staging readback receipt differs from its frozen payload"); + } + } + return values; +} + export function stageControlRequest(input: StageUploadRequest): ControlRequest { const request = stageUploadRequestSchema.parse(input); return controlRequestSchema.parse({ schema_version: 1, job_id: request.operation_id, show_id: request.show_id, kind: request.kind, @@ -70,16 +93,21 @@ export const stageUploadProgressSchema = z.object({ manifest_sha256: z.string().regex(/^[a-f0-9]{64}$/), phase: z.enum(["ready", "uploading", "settled", "verifying", "verified", "finished"]), client_settled: z.boolean(), + readback_receipts: z.array(stageReadbackReceiptSchema).max(2).optional(), outcome: z.enum(["staged", "aborted"]).optional(), verified_assets: z.array(z.object({ asset: stageAssetSchema, etag: z.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/), + version: stageReadbackReceiptSchema.shape.version.optional(), length_bytes: z.number().int().positive().max(300_000_000), sha256: z.string().regex(/^[a-f0-9]{64}$/), }).strict()).max(2), reason_code: z.literal("validation_failed").optional(), }).strict().superRefine((value, context) => { const settled = !["ready", "uploading"].includes(value.phase); + if (!settled && value.readback_receipts !== undefined) { + context.addIssue({ code: "custom", message: "Staging readback receipts require client settlement" }); + } if (settled !== value.client_settled) context.addIssue({ code: "custom", message: "Staging progress requires explicit client settlement" }); if ((value.phase === "finished") !== (value.outcome !== undefined)) { context.addIssue({ code: "custom", message: "Only finished staging progress has an outcome" }); diff --git a/src/m6-management-routes.test.ts b/src/m6-management-routes.test.ts index d077275..2f3cf48 100644 --- a/src/m6-management-routes.test.ts +++ b/src/m6-management-routes.test.ts @@ -6,7 +6,6 @@ import type { M6CandidateEnv, M6CachedLoopback } from "./m6-routes"; import { readServiceAdmission, SERVICE_ADMISSION_KEY } from "./service-admission"; import { stagingAdminFixture } from "./test-support/staging-admin"; import { publicationAdminFixture } from "./test-support/publication-admin"; -import { publicationTestDigest } from "./test-support/episode-publication"; import { createHash } from "node:crypto"; type StagingSetup = Awaited>; @@ -30,7 +29,7 @@ const post = (path: string, body: unknown, key = "private-secret") => }); const run = async (env: M6CandidateEnv, assets: M6CachedLoopback, path: string, body: unknown) => { - const response = await fetchM6ManagementIntegration(post(path, body), env, assets, { digest: publicationTestDigest }); + const response = await fetchM6ManagementIntegration(post(path, body), env, assets); return { response, body: await response.json() }; }; @@ -65,7 +64,7 @@ describe("unreleased M6 management fetch integration", () => { const early = await run(env, assets, "/admin/staging", setup.input("finish", { operation: setup.operation, outcome: "staged" })); expect(early.response.status).toBe(409); expect((await run(env, assets, "/admin/staging", setup.input("settle", { operation: setup.operation, - put_requests_settled: true, no_more_puts: true }))).response.status).toBe(200); + put_requests_settled: true, no_more_puts: true, readback_receipts: setup.readbacks() }))).response.status).toBe(200); expect((await run(env, assets, "/admin/staging", setup.input("finish", { operation: setup.operation, outcome: "staged" }))).response.status).toBe(200); expect((await readShowControl(setup.env, "daily"))!.value.owner).toBeUndefined(); expect((await readServiceAdmission(setup.env, "service"))!.value.invocations).toEqual([]); @@ -97,7 +96,7 @@ describe("unreleased M6 management fetch integration", () => { expect((await readShowControl(setup.env, "daily"))!.value.owner?.state).toBe("reserved"); expect((await run(env, assets, "/admin/publication", { ...setup.body("commit"), action: "unknown" })).response.status).toBe(400); await queueM6Candidate({ queue: setup.config.queue_name, messages: [{ id: "message-1", body: { object: { key: setup.markerKey } } }] } as never, - env, assets, { digest: publicationTestDigest }); + env, assets); expect((await readShowControl(setup.env, "daily"))!.value.owner).toBeUndefined(); expect(setup.text(setup.statusKey)).toContain("published"); expect((await run(env, assets, "/admin/publication", setup.body("commit"))).response.status).toBe(409); @@ -112,7 +111,7 @@ describe("unreleased M6 management fetch integration", () => { expect((await run(env, assets, "/admin/publication", setup.body("claim"))).response.status).toBe(200); expect((await run(env, assets, "/admin/publication", setup.body("commit"))).response.status).toBe(200); await queueM6Candidate({ queue: setup.config.queue_name, messages: [{ id: "message-1", body: { object: { key: setup.markerKey } } }] } as never, - env, assets, { digest: publicationTestDigest }); + env, assets); const request = { schema_version: 1 as const, job_id: crypto.randomUUID(), show_id: "daily", kind: "episode" as const, episode_id: "next", action: "unpublish" as const, expected_show_generation: (await readShowControl(setup.env, "daily"))!.value.generation, expected_episode_generation: (await readEpisodeLifecycle(setup.env, "daily", "next"))!.generation, created_at: "2026-10-02T12:00:00Z" }; diff --git a/src/m6-routes.test.ts b/src/m6-routes.test.ts index 1cd4c9c..b80ce50 100644 --- a/src/m6-routes.test.ts +++ b/src/m6-routes.test.ts @@ -9,7 +9,7 @@ import type { M6CachedLoopback, M6CandidateEnv } from "./m6-routes"; import { readServiceAdmission, SERVICE_ADMISSION_KEY } from "./service-admission"; import { migrationFixture } from "./test-support/migration"; import { publicationFixture } from "./test-support/publication"; -import { episodePublicationFixture, publicationTestDigest } from "./test-support/episode-publication"; +import { episodePublicationFixture } from "./test-support/episode-publication"; const feedPath = "/podcasts/daily/feed.xml"; const request = (path = feedPath, method = "GET", headers: HeadersInit = {}) => @@ -217,7 +217,7 @@ describe("M6 candidate Queue routing and common service fence", () => { const publication = await episodePublicationFixture(update); await publication.bucket.put(SERVICE_ADMISSION_KEY, setup.entries.get(SERVICE_ADMISSION_KEY)!.data); const env: M6CandidateEnv = { ...setup.env, CASTLOOP_BUCKET: publication.bucket } as never; - await queueM6Candidate(batch(publication.key), env, setup.loopback, { digest: publicationTestDigest }); + await queueM6Candidate(batch(publication.key), env, setup.loopback); expect(parseJobStatus(publication.text(publication.statusKey)).state).toBe("published"); expect(parseEpisodeLifecycle(publication.text(publication.lifecycleKey)).lifecycle).toBe("active"); const revision = parseEpisodeRevision(publication.text(publication.metadataKey)); diff --git a/src/m6-routes.ts b/src/m6-routes.ts index 5a250e9..6259c97 100644 --- a/src/m6-routes.ts +++ b/src/m6-routes.ts @@ -15,7 +15,6 @@ import { parseQueueDelivery, recordDeadLetterDelivery } from "./queue-delivery"; import { readServiceAdmission, requireM6ServiceRuntime, withServiceInvocation } from "./service-admission"; import { readServiceCapabilities } from "./service-capabilities"; import { handleM6StagingAdmin } from "./staging-admin"; -import type { StageStreamDigest } from "./staging-verification"; import { readBootstrapDeliveryWindow } from "./migration-bootstrap"; import { handleMigrationAdmin } from "./migration-admin"; import type { BootstrapRuntime } from "./migration-bootstrap"; @@ -65,7 +64,7 @@ async function deliveryMigration(env: M6CandidateEnv, config: ServiceConfig): Pr } async function m6ManagementRoute(request: Request, env: M6CandidateEnv, cachedAssets: M6CachedLoopback, - options: { digest?: StageStreamDigest }): Promise { + options: { setupRuntime?: M6SetupRuntime }): Promise { const pathname = new URL(request.url).pathname; if (pathname !== "/admin/staging" && pathname !== "/admin/publication" && pathname !== "/admin/lifecycle" && pathname !== "/admin/shows" && pathname !== "/admin/target" && pathname !== "/admin/service") return null; @@ -74,7 +73,7 @@ async function m6ManagementRoute(request: Request, env: M6CandidateEnv, cachedAs if (pathname === "/admin/service") return handleM6ServiceAdmin(request, env); if (pathname === "/admin/target") return handleM6TargetInspection(request, env, bindings); if (pathname === "/admin/shows") return handleM6ShowRegistrationAdmin(request, env, bindings); - if (pathname === "/admin/staging") return handleM6StagingAdmin(request, env, bindings, options); + if (pathname === "/admin/staging") return handleM6StagingAdmin(request, env, bindings); if (pathname === "/admin/publication") return handleM6PublicationAdmin(request, env, bindings); return handleM6LifecycleAdmin(request, env, bindings); } @@ -85,13 +84,13 @@ export async function fetchM6Candidate(request: Request, env: M6CandidateEnv, } export async function fetchM6ManagementIntegration(request: Request, env: M6CandidateEnv, - cachedAssets: M6CachedLoopback, options: { digest?: StageStreamDigest; setupRuntime?: M6SetupRuntime } = {}): Promise { + cachedAssets: M6CachedLoopback, options: { setupRuntime?: M6SetupRuntime } = {}): Promise { return fetchM6Routes(request, env, cachedAssets, true, undefined, options); } async function fetchM6Routes(request: Request, env: M6CandidateEnv, cachedAssets: M6CachedLoopback, managementIntegration: boolean, bootstrapRuntime?: BootstrapRuntime, - options: { digest?: StageStreamDigest; setupRuntime?: M6SetupRuntime } = {}): Promise { + options: { setupRuntime?: M6SetupRuntime } = {}): Promise { const pathname = new URL(request.url).pathname; const asset = parsePublicAssetPath(pathname); if (asset) { @@ -143,7 +142,7 @@ async function fetchM6Routes(request: Request, env: M6CandidateEnv, } export async function queueM6Candidate(batch: MessageBatch, env: M6CandidateEnv, cachedAssets: M6CachedLoopback, - options: { digest?: StageStreamDigest; maximumObjects?: number } = {}): Promise { + options: { maximumObjects?: number } = {}): Promise { if (batch.messages.length > 1) throw new Error("M6 candidate requires one-message Queue batches"); if (!batch.messages.length) return; const config = await serviceConfig(env); @@ -160,7 +159,7 @@ export async function queueM6Candidate(batch: MessageBatch, env: M6Cand await recordDeadLetterDelivery(env, message); } else if (delivery?.family === "publication") { await consumeOwnedPublication(env, delivery.key, (execution) => createPublicationWorkerEffects(env, execution, - { cachedAssets, checkDeliveryGate: gate }), options); + { cachedAssets, checkDeliveryGate: gate })); } else if (delivery) { await consumeLifecycleCommit(env, delivery.key, (execution) => createLifecycleWorkerEffects(env, execution, { cachedAssets, checkDeliveryGate: gate, queue: env.CASTLOOP_QUEUE }), options); diff --git a/src/publication-admin.test.ts b/src/publication-admin.test.ts index 5a4c075..1af37fa 100644 --- a/src/publication-admin.test.ts +++ b/src/publication-admin.test.ts @@ -5,7 +5,6 @@ import { readShowControl } from "./lifecycle-control"; import { fetchM6Candidate, queueM6Candidate } from "./m6-routes"; import { handleM6PublicationAdmin } from "./publication-admin"; import { pauseServiceAdmission, readServiceAdmission, SERVICE_ADMISSION_KEY } from "./service-admission"; -import { publicationTestDigest } from "./test-support/episode-publication"; import { publicationAdminFixture } from "./test-support/publication-admin"; describe("unreleased M6 publication admission and commit boundary", () => { @@ -27,7 +26,7 @@ describe("unreleased M6 publication admission and commit boundary", () => { expect([...setup.entries].filter(([key]) => key.startsWith("public/") || key.startsWith("system/shows/"))).toEqual(publicBefore); expect((await readServiceAdmission(setup.env, "service"))?.value.invocations).toEqual([]); await queueM6Candidate({ queue: "test-queue", messages: [{ id: "publication", body: { object: { key: setup.markerKey } } }] } as never, - setup.candidateEnv, setup.cachedAssets, { digest: publicationTestDigest }); + setup.candidateEnv, setup.cachedAssets); expect(parseJobStatus(setup.text(`system/jobs/${setup.publicationOperation.job_id}/status.toml`)).state).toBe("published"); expect((await readShowControl(setup.env, "daily"))?.value.owner).toBeUndefined(); expect((await readServiceAdmission(setup.env, "service"))?.value.invocations).toEqual([]); diff --git a/src/publication-admission.test.ts b/src/publication-admission.test.ts index 47e80ce..9111f3f 100644 --- a/src/publication-admission.test.ts +++ b/src/publication-admission.test.ts @@ -18,7 +18,8 @@ async function fixture(kind: "show" | "episode" = "show", update?: "metadata" | lifecycle: kind === "show" ? "draft" : "active", generation: 0, feed_generation: 0 })); if (kind === "episode") await setup.bucket.put("system/episode-lifecycle/daily/first.toml", stringifyLifecycleToml({ schema_version: 1, show_id: "daily", episode_id: "first", lifecycle: update ? "active" : "draft", generation: 0 })); - const payloads = new Map }>(); + const payloads = new Map; checksums?: { sha256: ArrayBuffer } }>(); let version = 0; const bucket = { ...setup.bucket, async head(key: string) { const payload = payloads.get(key); return payload ? { key, ...payload } : setup.bucket.head(key); }, @@ -41,17 +42,18 @@ async function fixture(kind: "show" | "episode" = "show", update?: "metadata" | const current = episodeRevisionSchema.parse({ schema_version: 1, episode_id: "first", guid, title: "Current title", description: "Current description", published_at: "2026-10-01T12:00:00Z", revision_id: baseRevisionId, enclosure_url: `https://example.com/podcasts/daily/episodes/first/${crypto.randomUUID()}.mp3`, - content_type: "audio/mpeg", length_bytes: 1, duration_seconds: 1, sha256: "a".repeat(64), updated_at: "2026-10-01T12:00:00Z" }); + content_type: "audio/mpeg", length_bytes: 1, duration_seconds: 1, sha256: checksum(Uint8Array.from([1])), updated_at: "2026-10-01T12:00:00Z" }); await bucket.put("public/episodes/daily/first/metadata.toml", stringifyToml(current)); await bucket.put(`public/episodes/daily/first/revisions/${baseRevisionId}.toml`, stringifyToml(current)); - payloads.set(`public${new URL(current.enclosure_url).pathname}`, { bytes: Uint8Array.from([1]), size: 1, etag: "base-audio", customMetadata: { sha256: current.sha256 } }); + payloads.set(`public${new URL(current.enclosure_url).pathname}`, { bytes: Uint8Array.from([1]), size: 1, etag: "base-audio", version: "base-upload", + customMetadata: { sha256: current.sha256 }, checksums: { sha256: Uint8Array.from(createHash("sha256").update(Uint8Array.from([1])).digest()).buffer } }); } const inputs: Array> = kind === "show" ? [[ { asset: "show_metadata", bytes: metadata }, { asset: "cover_jpg", bytes: media }, ]] : update === "metadata" ? [[{ asset: "episode_metadata", bytes: metadata }]] : update === "audio" ? [[{ asset: "audio", bytes: media }]] : [[{ asset: "episode_metadata", bytes: metadata }], [{ asset: "audio", bytes: media }]]; const staged: StageUploadRequest[] = []; - function putPayload(key: string, bytes: Uint8Array) { payloads.set(key, { bytes, size: bytes.byteLength, etag: `payload-${++version}` }); } + function putPayload(key: string, bytes: Uint8Array) { payloads.set(key, { bytes, size: bytes.byteLength, etag: `payload-${++version}`, version: `upload-${version}` }); } for (const input of inputs) { const generation = (await readShowControl(env, "daily"))!.value.generation; const request = stageUploadRequestSchema.parse({ schema_version: 1, operation_id: crypto.randomUUID(), draft_job_id: draftJobId, @@ -62,8 +64,12 @@ async function fixture(kind: "show" | "episode" = "show", update?: "metadata" | const operation = await claimStageUpload(env, request); await beginStageUpload(env, operation); for (const { asset, bytes } of input) putPayload(stagePayloadKey(request, asset), bytes); - await settleStageUpload(env, operation, { put_requests_settled: true, no_more_puts: true }); - await runStageVerification(env, operation, "staged", { async digest(body) { return checksum(new Uint8Array(await new Response(body).arrayBuffer())); } }); + await settleStageUpload(env, operation, { put_requests_settled: true, no_more_puts: true, + readback_receipts: request.payloads.map((payload) => { + const object = payloads.get(stagePayloadKey(request, payload.asset))!; + return { asset: payload.asset, length_bytes: object.size, sha256: checksum(object.bytes), etag: object.etag, version: object.version }; + }) }); + await runStageVerification(env, operation, "staged"); } const generation = (await readShowControl(env, "daily"))!.value.generation; const frozen = publicationRequestSchema.parse({ schema_version: 1, @@ -132,8 +138,8 @@ describe("M6 publication admission from verified staging", () => { } }); - test("missing, aborted, foreign or checksum/ETag-mismatched staging proofs cannot create a marker", async () => { - for (const fault of ["missing-status", "aborted", "foreign-draft", "hash", "etag", "size"] as const) { + test("missing, aborted, foreign or checksum/object-identity-mismatched staging proofs cannot create a marker", async () => { + for (const fault of ["missing-status", "aborted", "foreign-draft", "hash", "etag", "version", "missing-version", "size"] as const) { const setup = await fixture(); const request = setup.staged[0]!; const operation = await claimPublicationOperation(setup.env, setup.frozen); @@ -154,6 +160,15 @@ describe("M6 publication admission from verified staging", () => { const key = stagePayloadKey(request, "show_metadata"); setup.putPayload(key, fault === "etag" ? setup.payloads.get(key)!.bytes : new Uint8Array(1)); } + if (fault === "version") setup.payloads.get(stagePayloadKey(request, "show_metadata"))!.version = "replacement-upload"; + if (fault === "missing-version") { + const key = `system/jobs/${request.operation_id}/upload-progress.json`; + const progress = JSON.parse(setup.entries.get(key)!.data); + await setup.bucket.put(key, JSON.stringify({ ...progress, verified_assets: progress.verified_assets.map((asset: { version: string }) => { + const { version: _version, ...legacy } = asset; + return legacy; + }) })); + } await expect(commitOwnedPublication(setup.env, operation)).rejects.toThrow(); expect(setup.entries.has(setup.markerKey)).toBe(false); expect((await readShowControl(setup.env, "daily"))?.value.owner?.job_id).toBe(setup.draftJobId); diff --git a/src/publication-admission.ts b/src/publication-admission.ts index 294e84d..927ebbf 100644 --- a/src/publication-admission.ts +++ b/src/publication-admission.ts @@ -7,6 +7,7 @@ import { claimShowOperation, controlRequestHash, readEpisodeLifecycle, requireOw import type { LifecycleControlEnv, OwnedShowControlSnapshot } from "./lifecycle-control"; import { canonicalEnclosureUrl } from "./media-url"; import { parsePublicAssetPath } from "./public-assets"; +import { matchesPublishedAudio } from "./publication-inputs"; import { stageManifestHash } from "./staging-upload"; export { publicationCommitKey, publicationRequestSchema } from "../packages/shared/src/index"; @@ -150,7 +151,7 @@ export async function verifyPublicationStaging(env: LifecycleControlEnv, operati throw new Error("Publication base revision has an invalid immutable audio reference"); } const audio = await env.CASTLOOP_BUCKET.head(asset.key); - if (!audio || audio.size !== current.length_bytes || audio.customMetadata?.sha256 !== current.sha256) { + if (!matchesPublishedAudio(audio, current.length_bytes, current.sha256)) { throw new Error("Publication base audio is missing or inconsistent"); } } @@ -167,7 +168,9 @@ export async function verifyPublicationStaging(env: LifecycleControlEnv, operati throw new Error("Publication payload does not match its staging verification evidence"); } const head = await env.CASTLOOP_BUCKET.head(stagePayloadKey(proof.request, payload.asset)); - if (!head || head.etag !== verified.etag || head.size !== verified.length_bytes) throw new Error("Staged payload changed after verification"); + if (!head || head.etag !== verified.etag || !verified.version || head.version !== verified.version || head.size !== verified.length_bytes) { + throw new Error("Staged payload changed after verification"); + } checked.add(payload.asset); } } diff --git a/src/publication-consumer.ts b/src/publication-consumer.ts index b95f9d3..a00355a 100644 --- a/src/publication-consumer.ts +++ b/src/publication-consumer.ts @@ -8,7 +8,6 @@ import type { LifecycleControlEnv } from "./lifecycle-control"; import { runOwnedShowPublication } from "./publication-show-runner"; import { runOwnedEpisodePublication } from "./publication-episode-runner"; import type { PublicationEffects } from "./publication-inputs"; -import type { StageStreamDigest } from "./staging-verification"; export type PublicationConsumerResult = { state: "completed" } | { state: "ignored"; reason: "unmatched_path" | "missing_marker" | "stale_operation" } | @@ -30,7 +29,7 @@ export async function requeuePublicationOperation(env: LifecycleControlEnv, oper } export async function consumeOwnedPublication(env: LifecycleFeedEnv, key: string, - source: PublicationEffects | PublicationEffectFactory, options: { digest?: StageStreamDigest } = {}): Promise { + source: PublicationEffects | PublicationEffectFactory): Promise { const target = parsePublicationCommitKey(key); if (!target) return { state: "ignored", reason: "unmatched_path" }; let frozen; @@ -51,7 +50,7 @@ export async function consumeOwnedPublication(env: LifecycleFeedEnv, key: string try { const effects = typeof source === "function" ? await source(execution) : source; if (request.kind === "show") await runOwnedShowPublication(env, execution, effects); - else await runOwnedEpisodePublication(env, execution, effects, options); + else await runOwnedEpisodePublication(env, execution, effects); } catch (error) { if (await releaseSettledExecution(env, execution) === "completed") return { state: "completed" }; throw error; diff --git a/src/publication-episode-runner.test.ts b/src/publication-episode-runner.test.ts index 268cf7c..420fbde 100644 --- a/src/publication-episode-runner.test.ts +++ b/src/publication-episode-runner.test.ts @@ -7,7 +7,7 @@ import { requireOwnedPublication } from "./publication-admission"; import { consumeOwnedPublication } from "./publication-consumer"; import { runOwnedEpisodePublication } from "./publication-episode-runner"; import type { PublicationEffects } from "./publication-inputs"; -import { episodePublicationFixture, publicationTestDigest } from "./test-support/episode-publication"; +import { episodePublicationFixture } from "./test-support/episode-publication"; function effects(setup: Awaited>) { const events: string[] = []; @@ -30,7 +30,9 @@ describe("M6 owned Episode publication runner", () => { const bound = effects(setup); const before = new Map([...setup.entries].filter(([key]) => key.endsWith(".mp3") || key.includes("/revisions/") || key.includes("/stopped/"))); const feedGeneration = (await readShowControl(setup.env, "daily"))!.value.feed_generation; - expect(await consumeOwnedPublication(setup.env, setup.key, bound.effects, { digest: publicationTestDigest })).toEqual({ state: "completed" }); + const reads = setup.bodyReads.length; + expect(await consumeOwnedPublication(setup.env, setup.key, bound.effects)).toEqual({ state: "completed" }); + expect(setup.bodyReads.slice(reads)).not.toContain(setup.mediaKey); const revision = parseEpisodeRevision(setup.text(setup.metadataKey)); expect(revision.guid).toBe(setup.base?.guid ?? setup.draft.guid); expect(revision.published_at).toBe(setup.base?.published_at ?? setup.draft.published_at); @@ -65,12 +67,11 @@ describe("M6 owned Episode publication runner", () => { test("new Episode stays private on purge failure and resumes without duplicating audio or history", async () => { const setup = await episodePublicationFixture(); const bound = effects(setup); - await expect(consumeOwnedPublication(setup.env, setup.key, { ...bound.effects, async purge() { throw new Error("Purge unavailable"); } }, - { digest: publicationTestDigest })).rejects.toThrow("Purge unavailable"); + await expect(consumeOwnedPublication(setup.env, setup.key, { ...bound.effects, async purge() { throw new Error("Purge unavailable"); } })).rejects.toThrow("Purge unavailable"); expect(await readPublicVisibility(setup.env, "daily", setup.episodeId)).toBe("not_found"); expect((await readShowControl(setup.env, "daily"))?.value.owner?.execution_id).toBeUndefined(); const before = new Map(setup.entries); - await consumeOwnedPublication(setup.env, setup.key, bound.effects, { digest: publicationTestDigest }); + await consumeOwnedPublication(setup.env, setup.key, bound.effects); for (const key of [setup.mediaKey, setup.historyKey, setup.metadataKey, setup.feedKey]) expect(setup.entries.get(key)).toEqual(before.get(key)); expect(await readPublicVisibility(setup.env, "daily", setup.episodeId)).toBe("public"); }); @@ -94,9 +95,9 @@ describe("M6 owned Episode publication runner", () => { if (!lost && written && matches) { lost = true; throw new Error("Episode response lost"); } return written; } } } as never; - if (fault === "release") expect(await consumeOwnedPublication(env, setup.key, bound.effects, { digest: publicationTestDigest })).toEqual({ state: "completed" }); - else await expect(consumeOwnedPublication(env, setup.key, bound.effects, { digest: publicationTestDigest })).rejects.toThrow("response lost"); - await consumeOwnedPublication(env, setup.key, bound.effects, { digest: publicationTestDigest }); + if (fault === "release") expect(await consumeOwnedPublication(env, setup.key, bound.effects)).toEqual({ state: "completed" }); + else await expect(consumeOwnedPublication(env, setup.key, bound.effects)).rejects.toThrow("response lost"); + await consumeOwnedPublication(env, setup.key, bound.effects); expect((await readShowControl(setup.env, "daily"))?.value.owner).toBeUndefined(); expect((await readShowControl(setup.env, "daily"))?.value.feed_generation).toBe(generation + 1); expect(parseEpisodeLifecycle(setup.text(setup.lifecycleKey)).generation).toBe(1); @@ -106,18 +107,20 @@ describe("M6 owned Episode publication runner", () => { } }); - test("a live streamed digest holds its execution token and blocks deletion or duplicate publication", async () => { + test("a live R2 stream PUT holds its execution token and blocks deletion or duplicate publication", async () => { const setup = await episodePublicationFixture(); const bound = effects(setup); const started = Promise.withResolvers(); const ended = Promise.withResolvers(); + const env = { CASTLOOP_BUCKET: { ...setup.bucket, async put(...args: Parameters) { + if (args[0] === setup.mediaKey) { started.resolve(); await ended.promise; } + return setup.bucket.put(...args); + } } } as never; const outcome = (async () => { - try { await consumeOwnedPublication(setup.env, setup.key, bound.effects, { digest: async (body, length) => { - started.resolve(); await ended.promise; return publicationTestDigest(body, length); - } }); return null; } catch (error) { return error; } + try { await consumeOwnedPublication(env, setup.key, bound.effects); return null; } catch (error) { return error; } })(); await started.promise; - await expect(consumeOwnedPublication(setup.env, setup.key, bound.effects, { digest: publicationTestDigest })).rejects.toThrow("still owns"); + await expect(consumeOwnedPublication(setup.env, setup.key, bound.effects)).rejects.toThrow("still owns"); await expect(claimShowOperation(setup.env, { schema_version: 1, job_id: crypto.randomUUID(), show_id: "daily", kind: "show", action: "delete", expected_show_generation: setup.operation.generation, created_at: "2026-10-01T12:00:00Z" })).rejects.toThrow("unfinished"); expect(setup.entries.has(setup.historyKey)).toBe(false); @@ -126,20 +129,25 @@ describe("M6 owned Episode publication runner", () => { }); test("conflicting immutable media/history are retained and never overwritten", async () => { - for (const fault of ["media", "history", "checksum"] as const) { + for (const fault of ["media", "history", "checksum", "missing-native", "wrong-native"] as const) { const setup = await episodePublicationFixture(); const bound = effects(setup); if (fault === "media") await setup.bucket.put(setup.mediaKey, "foreign audio", { customMetadata: { sha256: "a".repeat(64) } }); if (fault === "checksum") await setup.bucket.put(setup.mediaKey, new Uint8Array(setup.audio.length), { customMetadata: { sha256: setup.frozen.commit.kind === "episode" ? setup.frozen.commit.audio_sha256! : "" }, }); + if (fault === "missing-native" || fault === "wrong-native") { + const sha256 = setup.frozen.commit.kind === "episode" ? setup.frozen.commit.audio_sha256! : ""; + await setup.bucket.put(setup.mediaKey, setup.audio, { customMetadata: { sha256 } }); + if (fault === "wrong-native") setup.entries.get(setup.mediaKey)!.checksums.sha256 = new Uint8Array(32).buffer; + } if (fault === "history") { const other = parseEpisodeRevision(setup.text("public/episodes/daily/second/metadata.toml")); await setup.bucket.put(setup.historyKey, stringifyToml({ ...other, episode_id: setup.episodeId, revision_id: setup.operation.jobId })); } const key = fault === "history" ? setup.historyKey : setup.mediaKey; const before = setup.entries.get(key); - await expect(consumeOwnedPublication(setup.env, setup.key, bound.effects, { digest: publicationTestDigest })).rejects.toThrow(); + await expect(consumeOwnedPublication(setup.env, setup.key, bound.effects)).rejects.toThrow(); expect(setup.entries.get(key)).toEqual(before); expect(parseJobStatus(setup.text(setup.statusKey)).state).toBe("retrying"); expect(parseEpisodeLifecycle(setup.text(setup.lifecycleKey)).lifecycle).toBe("draft"); @@ -152,7 +160,7 @@ describe("M6 owned Episode publication runner", () => { const stoppedKey = "public/episodes/daily/stopped/metadata.toml"; const stopped = parseEpisodeRevision(setup.text(stoppedKey)); await setup.bucket.put(stoppedKey, stringifyToml({ ...stopped, guid: setup.draft.guid })); - await expect(consumeOwnedPublication(setup.env, setup.key, effects(setup).effects, { digest: publicationTestDigest })).rejects.toThrow("GUID is already used"); + await expect(consumeOwnedPublication(setup.env, setup.key, effects(setup).effects)).rejects.toThrow("GUID is already used"); expect(setup.entries.has(setup.mediaKey)).toBe(false); expect(setup.entries.has(setup.historyKey)).toBe(false); }); @@ -160,12 +168,12 @@ describe("M6 owned Episode publication runner", () => { test("Episode activation replay requires durable purge progress and cannot free a later owner", async () => { const setup = await episodePublicationFixture(); const execution = await acquireShowExecution(setup.env, "daily", setup.operation.jobId, setup.operation.generation); - await runOwnedEpisodePublication(setup.env, execution, effects(setup).effects, { digest: publicationTestDigest }); + await runOwnedEpisodePublication(setup.env, execution, effects(setup).effects); const jobId = crypto.randomUUID(); await claimShowOperation(setup.env, { schema_version: 1, job_id: jobId, show_id: "daily", kind: "show", action: "unpublish", expected_show_generation: setup.operation.generation, created_at: "2026-10-01T12:00:00Z" }); const before = new Map(setup.entries); - await runOwnedEpisodePublication(setup.env, execution, effects(setup).effects, { digest: publicationTestDigest }); + await runOwnedEpisodePublication(setup.env, execution, effects(setup).effects); expect(setup.entries).toEqual(before); const other = await episodePublicationFixture(); const state = parseEpisodeLifecycle(other.text(other.lifecycleKey)); @@ -179,24 +187,26 @@ describe("M6 owned Episode publication runner", () => { expect(await consumeOwnedPublication(setup.env, setup.key, (execution) => createPublicationWorkerEffects(setup.env, execution, { async checkDeliveryGate(target) { expect(target).toEqual({ showId: "daily", episodeId: setup.episodeId }); }, cachedAssets: { async invalidate(target) { purged.push(target); } }, - }), { digest: publicationTestDigest })).toEqual({ state: "completed" }); + }))).toEqual({ state: "completed" }); expect(purged).toEqual([{ showId: "daily", episodeId: setup.episodeId }]); }); - test("stream verification failure retains the job with fixed diagnostics and retry reuses the same media", async () => { + test("native checksum HEAD failure retains the job with fixed diagnostics and retry reuses the same media", async () => { const setup = await episodePublicationFixture(); const bound = effects(setup); const originalFeed = setup.entries.get(setup.feedKey); - await expect(consumeOwnedPublication(setup.env, setup.key, bound.effects, { async digest() { - throw new Error("Private Episode description owner@example.com Bearer secret"); - } })).rejects.toThrow("Bearer secret"); + const env = { CASTLOOP_BUCKET: { ...setup.bucket, async head(key: string) { + if (key === setup.mediaKey && setup.entries.has(key)) throw new Error("Private Episode description owner@example.com Bearer secret"); + return setup.bucket.head(key); + } } } as never; + await expect(consumeOwnedPublication(env, setup.key, bound.effects)).rejects.toThrow("Bearer secret"); expect(setup.entries.get(setup.feedKey)).toEqual(originalFeed); expect(setup.entries.has(setup.historyKey)).toBe(false); expect(setup.entries.has(setup.metadataKey)).toBe(false); expect(setup.text(setup.statusKey)).not.toContain("Bearer secret"); expect(setup.text(setup.statusKey)).not.toContain("owner@example.com"); expect((await readShowControl(setup.env, "daily"))?.value.owner?.execution_id).toBeUndefined(); - await consumeOwnedPublication(setup.env, setup.key, bound.effects, { digest: publicationTestDigest }); + await consumeOwnedPublication(setup.env, setup.key, bound.effects); expect(setup.writes.filter((key) => key === setup.mediaKey)).toHaveLength(1); expect(await readPublicVisibility(setup.env, "daily", setup.episodeId)).toBe("public"); }); @@ -210,10 +220,10 @@ describe("M6 owned Episode publication runner", () => { if (!lost && written && args[0] === setup.metadataKey) { lost = true; throw new Error("Current metadata response lost"); } return written; } } } as never; - await expect(consumeOwnedPublication(env, setup.key, bound.effects, { digest: publicationTestDigest })).rejects.toThrow("response lost"); + await expect(consumeOwnedPublication(env, setup.key, bound.effects)).rejects.toThrow("response lost"); const history = setup.entries.get(setup.historyKey); const media = setup.entries.get(setup.mediaKey); - await consumeOwnedPublication(env, setup.key, bound.effects, { digest: publicationTestDigest }); + await consumeOwnedPublication(env, setup.key, bound.effects); expect(setup.entries.get(setup.historyKey)).toEqual(history); expect(setup.entries.get(setup.mediaKey)).toEqual(media); const current = parseEpisodeRevision(setup.text(setup.metadataKey)); @@ -233,7 +243,7 @@ describe("M6 owned Episode publication runner", () => { const episode = parseEpisodeLifecycle(setup.text(setup.lifecycleKey)); await setup.bucket.put(setup.lifecycleKey, stringifyLifecycleToml({ ...episode, lifecycle: "unpublished" })); } - await expect(consumeOwnedPublication(setup.env, setup.key, effects(setup).effects, { digest: publicationTestDigest })).rejects.toThrow(); + await expect(consumeOwnedPublication(setup.env, setup.key, effects(setup).effects)).rejects.toThrow(); for (const [key, entry] of before) expect(setup.entries.get(key)).toEqual(entry); expect((await readShowControl(setup.env, "daily"))?.value.owner?.job_id).toBe(setup.operation.jobId); expect((await readShowControl(setup.env, "daily"))?.value.owner?.execution_id).toBeUndefined(); diff --git a/src/publication-episode-runner.ts b/src/publication-episode-runner.ts index 0c0f34c..e24a69f 100644 --- a/src/publication-episode-runner.ts +++ b/src/publication-episode-runner.ts @@ -10,10 +10,8 @@ import { readLifecycleJobJournal, writeLifecycleJobStatus, writeLifecycleProgres import { advanceLifecycleFeedGeneration } from "./lifecycle-mutations"; import { canonicalEnclosureUrl } from "./media-url"; import { publicationCommitKey, readFrozenPublicationCommit, requireOwnedPublication, verifyPublicationStaging } from "./publication-admission"; -import { publicationChecksum, readPublicationBytes, renderPublicationFeed } from "./publication-inputs"; +import { matchesPublishedAudio, publicationChecksum, readPublicationBytes, renderPublicationFeed } from "./publication-inputs"; import type { PublicationEffects } from "./publication-inputs"; -import { digestStageStream } from "./staging-verification"; -import type { StageStreamDigest } from "./staging-verification"; function text(bytes: Uint8Array): string { return new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes); @@ -52,8 +50,7 @@ async function uniqueGuid(env: LifecycleFeedEnv, execution: ShowExecution, revis } while (true); } -async function persistAudio(env: LifecycleFeedEnv, execution: ShowExecution, commit: EpisodeCommit, - digest: StageStreamDigest): Promise { +async function persistAudio(env: LifecycleFeedEnv, execution: ShowExecution, commit: EpisodeCommit): Promise { if (!commit.audio_sha256 || !commit.audio_length_bytes) return; const key = `public/podcasts/${execution.showId}/episodes/${commit.episode_id}/${execution.jobId}.mp3`; await requireShowExecution(env, execution); @@ -62,7 +59,7 @@ async function persistAudio(env: LifecycleFeedEnv, execution: ShowExecution, com const source = await env.CASTLOOP_BUCKET.head(sourceKey); if (!source || source.size !== commit.audio_length_bytes) throw new Error("Episode staging audio has an unexpected size"); const object = await env.CASTLOOP_BUCKET.get(sourceKey, { onlyIf: { etagMatches: source.etag } }); - if (!object || !("body" in object) || !object.body || object.etag !== source.etag || object.size !== source.size) { + if (!object || !("body" in object) || !object.body || object.etag !== source.etag || object.version !== source.version || object.size !== source.size) { throw new Error("Episode staging audio changed before streaming"); } await requireShowExecution(env, execution); @@ -70,21 +67,14 @@ async function persistAudio(env: LifecycleFeedEnv, execution: ShowExecution, com httpMetadata: { contentType: "audio/mpeg" }, customMetadata: { sha256: commit.audio_sha256 } }); } const head = await env.CASTLOOP_BUCKET.head(key); - if (!head || head.size !== commit.audio_length_bytes || head.customMetadata?.sha256 !== commit.audio_sha256) { + if (!matchesPublishedAudio(head, commit.audio_length_bytes, commit.audio_sha256)) { throw new Error("Immutable published audio is missing or inconsistent"); } - const object = await env.CASTLOOP_BUCKET.get(key, { onlyIf: { etagMatches: head.etag } }); - if (!object || !("body" in object) || !object.body || object.etag !== head.etag || object.size !== head.size) { - throw new Error("Immutable published audio changed before verification"); - } - if (await digest(object.body, commit.audio_length_bytes) !== commit.audio_sha256) throw new Error("Immutable published audio checksum does not match its commit"); - const latest = await env.CASTLOOP_BUCKET.head(key); - if (!latest || latest.etag !== head.etag || latest.size !== head.size) throw new Error("Immutable published audio changed during verification"); await requireShowExecution(env, execution); } export async function runOwnedEpisodePublication(env: LifecycleFeedEnv, execution: ShowExecution, - effects: PublicationEffects, options: { digest?: StageStreamDigest } = {}): Promise { + effects: PublicationEffects): Promise { const existing = await readShowControl(env, execution.showId); const receipt = existing?.value.last_finished_operation; if (receipt?.job_id === execution.jobId && receipt.generation === execution.generation && receipt.execution_id === execution.executionId) { @@ -157,7 +147,7 @@ export async function runOwnedEpisodePublication(env: LifecycleFeedEnv, executio failurePhase = "feed"; await recordProgress("feed"); await guard(); - await persistAudio(env, execution, commit, options.digest ?? digestStageStream); + await persistAudio(env, execution, commit); const inputs = await readLifecycleFeedInputs(env, execution, { candidate: revision }); if (!inputs.writeFeed) throw new Error("Episode lifecycle does not permit publication feed writing"); const feed = renderPublicationFeed(show, inputs.episodes, service.public_base_url, extension); diff --git a/src/publication-inputs.ts b/src/publication-inputs.ts index 65b416b..1f3326e 100644 --- a/src/publication-inputs.ts +++ b/src/publication-inputs.ts @@ -24,6 +24,12 @@ export async function publicationChecksum(bytes: Uint8Array): Promise { return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); } +export function matchesPublishedAudio(object: R2Object | null, length: number, sha256: string): boolean { + if (!object || object.size !== length || object.customMetadata?.sha256 !== sha256 || !object.checksums?.sha256) return false; + const checksum = [...new Uint8Array(object.checksums.sha256)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); + return checksum === sha256; +} + export function renderPublicationFeed(show: ShowMetadata, episodes: EpisodeRevision[], baseUrl: string, extension: "jpg" | "png"): string { const sourceBytes = new TextEncoder().encode(JSON.stringify({ show, episodes, baseUrl })).byteLength; if (sourceBytes * 6 + episodes.length * 1024 + 4096 > 32_000_000) throw new Error("Publication feed exceeds its conservative rendering budget"); diff --git a/src/staging-admin.test.ts b/src/staging-admin.test.ts index 0cbca0c..97c3423 100644 --- a/src/staging-admin.test.ts +++ b/src/staging-admin.test.ts @@ -4,7 +4,6 @@ import { readShowControl } from "./lifecycle-control"; import { fetchM6Candidate } from "./m6-routes"; import { acquireServiceInvocation, pauseServiceAdmission, readServiceAdmission, releaseServiceInvocation, SERVICE_ADMISSION_KEY } from "./service-admission"; import { handleM6StagingAdmin } from "./staging-admin"; -import { publicationTestDigest } from "./test-support/episode-publication"; import { stagingAdminFixture } from "./test-support/staging-admin"; describe("unreleased M6 staging management boundary", () => { @@ -26,7 +25,7 @@ describe("unreleased M6 staging management boundary", () => { await setup.putPayloads(); expect((await setup.call(setup.input("finish", { operation: setup.operation, outcome: "staged" }))).status).toBe(409); expect((await readShowControl(setup.env, "daily"))?.value.owner).toBeDefined(); - const settle = await setup.success(setup.input("settle", { operation: setup.operation, put_requests_settled: true, no_more_puts: true })); + const settle = await setup.success(setup.input("settle", { operation: setup.operation, put_requests_settled: true, no_more_puts: true, readback_receipts: setup.readbacks() })); expect(settle.result).toBe("settled"); expect((await setup.success(setup.input("finish", { operation: setup.operation, outcome: "staged" }))).result).toBe("staged"); expect((await readShowControl(setup.env, "daily"))?.value.owner).toBeUndefined(); @@ -51,7 +50,7 @@ describe("unreleased M6 staging management boundary", () => { for (const input of [{ ...setup.input("claim", { upload: setup.upload }), title: "private" }, { ...setup.input("claim", { upload: setup.upload }), service_id: "foreign" }, { ...setup.input("claim", { upload: setup.upload }), upload: { ...setup.upload, secret: "private" } }, - { ...setup.input("settle", { operation: setup.operation, put_requests_settled: true, no_more_puts: true }), no_more_puts: false }, + { ...setup.input("settle", { operation: setup.operation, put_requests_settled: true, no_more_puts: true, readback_receipts: [] }), no_more_puts: false }, setup.input("begin", { operation: { ...setup.operation, show_id: "../daily" } })]) { const result = await setup.call(input); expect(result.status).toBe(400); @@ -102,7 +101,7 @@ describe("unreleased M6 staging management boundary", () => { await pauseServiceAdmission(setup.env, "service", pauseId); expect((await setup.call(setup.input("claim", { upload: { ...setup.upload, operation_id: crypto.randomUUID() } }))).status).toBe(409); expect((await setup.call(setup.input("begin", { operation: setup.operation }))).status).toBe(409); - await setup.success(setup.input("settle", { operation: setup.operation, put_requests_settled: true, no_more_puts: true })); + await setup.success(setup.input("settle", { operation: setup.operation, put_requests_settled: true, no_more_puts: true, readback_receipts: setup.readbacks() })); await setup.success(setup.input("finish", { operation: setup.operation, outcome: "staged" })); const service = (await readServiceAdmission(setup.env, "service"))!.value; expect(service.state).toBe("paused"); @@ -117,7 +116,7 @@ describe("unreleased M6 staging management boundary", () => { await setup.putPayloads(); expect((await setup.call(setup.input("finish", { operation: setup.operation, outcome: "aborted" }))).status).toBe(409); expect((await readShowControl(setup.env, "daily"))?.value.owner?.state).toBe("uploading"); - await setup.success(setup.input("settle", { operation: setup.operation, put_requests_settled: true, no_more_puts: true })); + await setup.success(setup.input("settle", { operation: setup.operation, put_requests_settled: true, no_more_puts: true, readback_receipts: setup.readbacks() })); expect((await setup.success(setup.input("finish", { operation: setup.operation, outcome: "aborted" }))).result).toBe("aborted"); expect(setup.entries.has(stagePayloadKey(setup.upload, "audio"))).toBe(true); const progress = setup.text(`system/jobs/${setup.upload.operation_id}/upload-progress.json`); @@ -130,22 +129,24 @@ describe("unreleased M6 staging management boundary", () => { test("settling a ready upload permits explicit abort without ever granting PUT permission", async () => { const setup = await stagingAdminFixture(); await setup.success(setup.input("claim", { upload: setup.upload })); - await setup.success(setup.input("settle", { operation: setup.operation, put_requests_settled: true, no_more_puts: true })); + await setup.success(setup.input("settle", { operation: setup.operation, put_requests_settled: true, no_more_puts: true, readback_receipts: [] })); expect((await setup.call(setup.input("begin", { operation: setup.operation }))).status).toBe(409); expect((await setup.success(setup.input("finish", { operation: setup.operation, outcome: "aborted" }))).result).toBe("aborted"); }); - test("while verification streams are live, service and Show tokens remain held despite pause", async () => { + test("while verification HEAD is live, service and Show tokens remain held despite pause", async () => { const setup = await stagingAdminFixture("audio"); await setup.success(setup.input("claim", { upload: setup.upload })); await setup.success(setup.input("begin", { operation: setup.operation })); await setup.putPayloads(); - await setup.success(setup.input("settle", { operation: setup.operation, put_requests_settled: true, no_more_puts: true })); + await setup.success(setup.input("settle", { operation: setup.operation, put_requests_settled: true, no_more_puts: true, readback_receipts: setup.readbacks() })); const started = Promise.withResolvers(); const ended = Promise.withResolvers(); - const pending = handleM6StagingAdmin(setup.http(setup.input("finish", { operation: setup.operation, outcome: "staged" })), setup.env, setup.bindings, { - digest: async (body, length) => { started.resolve(); await ended.promise; return publicationTestDigest(body, length); }, - }); + const env = { ...setup.env, CASTLOOP_BUCKET: { ...setup.bucket, async head(key: string) { + if (key === stagePayloadKey(setup.upload, "audio")) { started.resolve(); await ended.promise; } + return setup.bucket.head(key); + } } } as never; + const pending = handleM6StagingAdmin(setup.http(setup.input("finish", { operation: setup.operation, outcome: "staged" })), env, setup.bindings); await started.promise; const held = (await readServiceAdmission(setup.env, "service"))!.value.invocations; expect(held).toHaveLength(1); @@ -165,8 +166,9 @@ describe("unreleased M6 staging management boundary", () => { const setup = await stagingAdminFixture("audio"); await setup.success(setup.input("claim", { upload: setup.upload })); await setup.success(setup.input("begin", { operation: setup.operation })); + await setup.putPayloads(); + await setup.success(setup.input("settle", { operation: setup.operation, put_requests_settled: true, no_more_puts: true, readback_receipts: setup.readbacks() })); await setup.bucket.put(stagePayloadKey(setup.upload, "audio"), "bad"); - await setup.success(setup.input("settle", { operation: setup.operation, put_requests_settled: true, no_more_puts: true })); const result = await setup.call(setup.input("finish", { operation: setup.operation, outcome: "staged" })); expect(result.status).toBe(409); expect(await result.text()).not.toContain("size"); @@ -211,7 +213,7 @@ describe("unreleased M6 staging management boundary", () => { await setup.success(setup.input("claim", { upload: setup.upload })); await setup.success(setup.input("begin", { operation: setup.operation })); await setup.putPayloads(); - await setup.success(setup.input("settle", { operation: setup.operation, put_requests_settled: true, no_more_puts: true })); + await setup.success(setup.input("settle", { operation: setup.operation, put_requests_settled: true, no_more_puts: true, readback_receipts: setup.readbacks() })); const original = setup.env.CASTLOOP_BUCKET.put.bind(setup.env.CASTLOOP_BUCKET); setup.env.CASTLOOP_BUCKET.put = (async (key: string, value: string, options?: R2PutOptions) => { const written = await original(key, value, options); @@ -279,7 +281,7 @@ describe("unreleased M6 staging management boundary", () => { test("shared response schemas reject arbitrary/foreign/traversal/oversized/duplicated PUT locations", async () => { const setup = await stagingAdminFixture(); - const request = setup.input("settle", { operation: setup.operation, put_requests_settled: true, no_more_puts: true }); + const request = setup.input("settle", { operation: setup.operation, put_requests_settled: true, no_more_puts: true, readback_receipts: [] }); expect(stagingAdminRequestSchema.safeParse({ ...request, put_requests_settled: false }).success).toBe(false); const payload = { key: stagePayloadKey(setup.upload, "show_metadata"), length: 1, sha256: "a".repeat(64) }; const response = { schema_version: 1, service_id: "service", result: "started", operation: setup.operation, payloads: [payload] }; diff --git a/src/staging-admin.ts b/src/staging-admin.ts index 0e0c6ab..93c1567 100644 --- a/src/staging-admin.ts +++ b/src/staging-admin.ts @@ -9,7 +9,6 @@ import { beginStageUpload, claimStageUpload, settleStageUpload } from "./staging import type { StageOperation } from "./staging-upload"; import type { StageEpisodeDraftEnv } from "./staging-episode-draft"; import { runStageVerification } from "./staging-verification"; -import type { StageStreamDigest } from "./staging-verification"; export type StagingAdminEnv = StageEpisodeDraftEnv & { CASTLOOP_ADMIN_KEY: string }; @@ -21,8 +20,7 @@ function wireOperation(operation: StageOperation): StagingOperation { return { show_id: operation.showId, operation_id: operation.operationId, show_generation: operation.generation }; } -export async function handleM6StagingAdmin(request: Request, env: StagingAdminEnv, bindings: M6DeliveryGateBindings, - options: { digest?: StageStreamDigest } = {}): Promise { +export async function handleM6StagingAdmin(request: Request, env: StagingAdminEnv, bindings: M6DeliveryGateBindings): Promise { if (new URL(request.url).pathname !== "/admin/staging") return null; if (!authenticated(request, env.CASTLOOP_ADMIN_KEY)) return reply({ error: "unauthorized" }, 401); if (request.method !== "POST") return reply({ error: "method not allowed" }, 405); @@ -48,7 +46,7 @@ export async function handleM6StagingAdmin(request: Request, env: StagingAdminEn await settleStageUpload(env, operation, input); response = { ...identity, result: "settled", operation: input.operation }; } else { - await runStageVerification(env, operation, input.outcome, options); + await runStageVerification(env, operation, input.outcome); response = { ...identity, result: input.outcome, operation: input.operation }; } } diff --git a/src/staging-inspection.test.ts b/src/staging-inspection.test.ts index aadd2d3..accd0b2 100644 --- a/src/staging-inspection.test.ts +++ b/src/staging-inspection.test.ts @@ -13,7 +13,7 @@ test("staging status observes ready/uploading/settled/finished without writes or if (phase === "uploading") await setup.success(setup.input("begin", { operation: setup.operation })); if (phase === "settled") { await setup.putPayloads(); - await setup.success(setup.input("settle", { operation: setup.operation, put_requests_settled: true, no_more_puts: true })); + await setup.success(setup.input("settle", { operation: setup.operation, put_requests_settled: true, no_more_puts: true, readback_receipts: setup.readbacks() })); } if (phase === "finished") await setup.success(setup.input("finish", { operation: setup.operation, outcome: "staged" })); const writes = setup.writes.length; @@ -45,7 +45,7 @@ test("partial claim manifest can be inspected but cannot authorize a PUT or adop test("paused service and retained verification tokens can be observed without releasing them", async () => { const setup = await stagingAdminFixture(); await setup.success(setup.input("claim", { upload: setup.upload })); - await setup.success(setup.input("settle", { operation: setup.operation, put_requests_settled: true, no_more_puts: true })); + await setup.success(setup.input("settle", { operation: setup.operation, put_requests_settled: true, no_more_puts: true, readback_receipts: [] })); const token = await acquireStageVerification(setup.env, { showId: "daily", operationId: setup.upload.operation_id, generation: setup.operation.show_generation }); await setup.bucket.put(SERVICE_ADMISSION_KEY, JSON.stringify({ ...setup.service, state: "paused", pause_id: crypto.randomUUID(), invocations: [{ token: crypto.randomUUID(), kind: "m6_recovery" }] })); @@ -66,7 +66,7 @@ test("archived staging status remains available after newer uploads replace its const operation = { show_id: upload.show_id, operation_id: upload.operation_id, show_generation: upload.expected_show_generation + 1 }; await setup.success(setup.input("claim", { upload })); await setup.success(setup.input("begin", { operation })); - await setup.success(setup.input("settle", { operation, put_requests_settled: true, no_more_puts: true })); + await setup.success(setup.input("settle", { operation, put_requests_settled: true, no_more_puts: true, readback_receipts: [] })); await setup.success(setup.input("finish", { operation, outcome: "aborted" })); }; await stage(setup.upload); diff --git a/src/staging-upload.test.ts b/src/staging-upload.test.ts index 26b4a04..96004e1 100644 --- a/src/staging-upload.test.ts +++ b/src/staging-upload.test.ts @@ -65,12 +65,15 @@ describe("M6 staging admission and one-time PUT permission", () => { const operation = await claimStageUpload(setup.env, setup.request); await beginStageUpload(setup.env, operation); await expect(settleStageUpload(setup.env, operation, { put_requests_settled: false, no_more_puts: true } as never)).rejects.toThrow("explicitly confirmed"); - await settleStageUpload(setup.env, operation, { put_requests_settled: true, no_more_puts: true }); + await settleStageUpload(setup.env, operation, { put_requests_settled: true, no_more_puts: true, readback_receipts: [] }); const snapshot = await requireStageUpload(setup.env, operation); const progress = (await readStageUploadProgress(setup.env, operation, snapshot))!.value; expect(progress.phase).toBe("settled"); expect(snapshot.control.value.owner.state).toBe("uploading"); - await expect(writeStageUploadProgress(setup.env, operation, { ...progress, phase: "ready", client_settled: false })).rejects.toThrow("reopen"); + await expect(settleStageUpload(setup.env, operation, { put_requests_settled: true, no_more_puts: true, + readback_receipts: setup.request.payloads.map((payload) => ({ ...payload, etag: "e1", version: "v1" })) })).rejects.toThrow("cannot change"); + await expect(writeStageUploadProgress(setup.env, operation, { ...progress, phase: "ready", client_settled: false, + readback_receipts: undefined })).rejects.toThrow("reopen"); await expect(beginStageUpload(setup.env, operation)).rejects.toThrow("already started"); }); @@ -92,8 +95,8 @@ describe("M6 staging admission and one-time PUT permission", () => { await expect(beginStageUpload(env, operation)).rejects.toThrow("already started"); } else { await beginStageUpload(env, operation); - await expect(settleStageUpload(env, operation, { put_requests_settled: true, no_more_puts: true })).rejects.toThrow("response lost"); - await settleStageUpload(env, operation, { put_requests_settled: true, no_more_puts: true }); + await expect(settleStageUpload(env, operation, { put_requests_settled: true, no_more_puts: true, readback_receipts: [] })).rejects.toThrow("response lost"); + await settleStageUpload(env, operation, { put_requests_settled: true, no_more_puts: true, readback_receipts: [] }); } expect((await readShowControl(setup.env, "daily"))?.value.owner?.job_id).toBe(operation.operationId); } diff --git a/src/staging-upload.ts b/src/staging-upload.ts index 6c97207..1215fc3 100644 --- a/src/staging-upload.ts +++ b/src/staging-upload.ts @@ -1,5 +1,5 @@ -import { stageControlRequest, stageDraftPrefix, stagePayloadKey, stageUploadProgressSchema, stageUploadRequestSchema } from "../packages/shared/src/index"; -import type { StageUploadProgress, StageUploadRequest } from "../packages/shared/src/index"; +import { parseStageReadbackReceipts, stageControlRequest, stageDraftPrefix, stagePayloadKey, stageSettlementSchema, stageUploadProgressSchema, stageUploadRequestSchema } from "../packages/shared/src/index"; +import type { StageSettlement, StageUploadProgress, StageUploadRequest } from "../packages/shared/src/index"; import { claimShowOperation, controlRequestHash, readEpisodeLifecycle, requireOwnedOperation } from "./lifecycle-control"; import type { LifecycleControlEnv, OwnedShowControlSnapshot } from "./lifecycle-control"; import { initializeOwnedEpisodeDraft, prepareNewEpisodeDraft } from "./staging-episode-draft"; @@ -75,6 +75,9 @@ export async function writeStageUploadProgress(env: LifecycleControlEnv, operati if (!transitions[existing.value.phase].includes(value.phase) || existing.value.client_settled && !value.client_settled) { throw new Error("Staging progress cannot reopen PUT permission"); } + if (existing.value.client_settled && JSON.stringify(existing.value.readback_receipts) !== JSON.stringify(value.readback_receipts)) { + throw new Error("Settled staging readback receipts cannot change"); + } } const latest = await requireStageUpload(env, operation); if (latest.control.value.owner.verification_id !== verificationId) throw new Error("Staging verification token changed before progress was written"); @@ -120,12 +123,17 @@ export async function beginStageUpload(env: LifecycleControlEnv, operation: Stag } export async function settleStageUpload(env: LifecycleControlEnv, operation: StageOperation, - evidence: { put_requests_settled: true; no_more_puts: true }): Promise { + evidence: StageSettlement): Promise { if (evidence.put_requests_settled !== true || evidence.no_more_puts !== true) throw new Error("Staging PUT completion and no further writes must be explicitly confirmed"); const snapshot = await requireStageUpload(env, operation); + const readbacks = parseStageReadbackReceipts(snapshot.request, stageSettlementSchema.parse({ + put_requests_settled: evidence.put_requests_settled, no_more_puts: evidence.no_more_puts, readback_receipts: evidence.readback_receipts }).readback_receipts); const progress = await readStageUploadProgress(env, operation, snapshot); if (!progress) throw new Error("Staging upload has no durable progress"); - if (progress.value.client_settled) return; + if (progress.value.client_settled) { + if (JSON.stringify(progress.value.readback_receipts) !== JSON.stringify(readbacks)) throw new Error("Settled staging readback receipts cannot change"); + return; + } if (progress.value.phase !== "ready" && progress.value.phase !== "uploading") throw new Error("Staging upload cannot be settled from this phase"); - await writeStageUploadProgress(env, operation, { ...progress.value, phase: "settled", client_settled: true }); + await writeStageUploadProgress(env, operation, { ...progress.value, phase: "settled", client_settled: true, readback_receipts: readbacks }); } diff --git a/src/staging-verification.test.ts b/src/staging-verification.test.ts index 41b7a7f..4101ea0 100644 --- a/src/staging-verification.test.ts +++ b/src/staging-verification.test.ts @@ -1,8 +1,7 @@ import { describe, expect, test } from "bun:test"; import { episodeRevisionSchema, parseEpisodeDraft, parseJobStatus, parseShowMetadata, stagePayloadKey, stageUploadRequestSchema, stringifyLifecycleToml, stringifyToml } from "../packages/shared/src/index"; -import { acquireStageVerification, digestStageStream, releaseStageVerification, runStageVerification } from "./staging-verification"; -import type { StageStreamDigest } from "./staging-verification"; +import { acquireStageVerification, releaseStageVerification, runStageVerification } from "./staging-verification"; import { beginStageUpload, claimStageUpload, readStageUploadProgress, requireStageUpload, settleStageUpload } from "./staging-upload"; import { claimShowOperation, readShowControl } from "./lifecycle-control"; import { lifecycleFixture } from "./test-support/lifecycle"; @@ -10,23 +9,6 @@ import { createHash } from "node:crypto"; const SHOW_TEXT = "schema_version = 1\nshow_id = 'daily'\ntitle = 'Private Show title'\ndescription = 'Private description'\nlanguage = 'en'\nauthor = 'Author'\nowner_name = 'Owner'\nowner_email = 'owner@example.com'\ncategories = ['Arts']\nexplicit = false\nsite_url = 'https://example.com'\nimage_path = 'cover.jpg'\n"; -const digest: StageStreamDigest = async (body, length) => { - const hash = createHash("sha256"); - let received = 0; - const reader = body.getReader(); - try { - for (;;) { - const chunk = await reader.read(); - if (chunk.done) break; - received += chunk.value.byteLength; - if (received > length) throw new Error("Stream size mismatch"); - hash.update(chunk.value); - } - } finally { reader.releaseLock(); } - if (received !== length) throw new Error("Stream size mismatch"); - return hash.digest("hex"); -}; - async function fixture(asset: "show" | "audio" | "episode_metadata" = "show", invalid?: "image" | "metadata") { const kind = asset === "show" ? "show" : "episode"; const base = await lifecycleFixture({ kind }); @@ -35,7 +17,7 @@ async function fixture(asset: "show" | "audio" | "episode_metadata" = "show", in if (kind === "episode") await base.bucket.put("system/episode-lifecycle/daily/first.toml", stringifyLifecycleToml({ schema_version: 1, show_id: "daily", episode_id: "first", lifecycle: "draft", generation: 0, })); - const payloads = new Map(); + const payloads = new Map(); const bodyReads: string[] = []; let revision = 0; const bucket = { @@ -66,20 +48,26 @@ async function fixture(asset: "show" | "audio" | "episode_metadata" = "show", in payloads: contents.map((content) => ({ asset: content.asset, length_bytes: content.bytes.length, sha256: createHash("sha256").update(content.bytes).digest("hex") })) }); const operation = await claimStageUpload(env, request); await beginStageUpload(env, operation); - function putPayload(key: string, bytes: Uint8Array) { payloads.set(key, { bytes, size: bytes.length, etag: `payload-${++revision}` }); } + function putPayload(key: string, bytes: Uint8Array) { payloads.set(key, { bytes, size: bytes.length, etag: `payload-${++revision}`, version: `upload-${revision}` }); } for (const content of contents) putPayload(stagePayloadKey(request, content.asset as "show_metadata" | "cover_jpg" | "audio" | "episode_metadata"), content.bytes); return { ...base, env, bucket, payloads, bodyReads, putPayload, request, operation, - settle: () => settleStageUpload(env, operation, { put_requests_settled: true, no_more_puts: true }), + settle: () => settleStageUpload(env, operation, { put_requests_settled: true, no_more_puts: true, + readback_receipts: request.payloads.map((payload) => { + const object = payloads.get(stagePayloadKey(request, payload.asset))!; + return { asset: payload.asset, length_bytes: object.size, sha256: createHash("sha256").update(object.bytes).digest("hex"), + etag: object.etag, version: object.version }; + }) }), statusKey: `system/jobs/${operation.operationId}/status.toml`, progressKey: `system/jobs/${operation.operationId}/upload-progress.json` }; } describe("M6 staging verification and admission completion", () => { - test("Show, Episode metadata and streamed audio finish without publishing or copying private content", async () => { + test("Show and Episode finish from exact readback receipts without reading audio bodies or publishing private content", async () => { for (const asset of ["show", "audio", "episode_metadata"] as const) { const setup = await fixture(asset); await setup.settle(); const before = new Map(setup.payloads); - await runStageVerification(setup.env, setup.operation, "staged", { digest }); + await runStageVerification(setup.env, setup.operation, "staged"); + if (asset === "audio") expect(setup.bodyReads).toEqual([]); const control = (await readShowControl(setup.env, "daily"))!.value; expect(control.owner).toBeUndefined(); expect(control.lifecycle).toBe(asset === "show" ? "draft" : "active"); @@ -95,15 +83,15 @@ describe("M6 staging verification and admission completion", () => { expect(entry.data).not.toContain("owner@example.com"); } const count = setup.writes.length; - await runStageVerification(setup.env, setup.operation, "staged", { digest }); + await runStageVerification(setup.env, setup.operation, "staged"); expect(setup.writes).toHaveLength(count); } }); test("verification and abort are forbidden while the client may still PUT", async () => { const setup = await fixture("audio"); - await expect(runStageVerification(setup.env, setup.operation, "staged", { digest })).rejects.toThrow("must settle"); - await expect(runStageVerification(setup.env, setup.operation, "aborted", { digest })).rejects.toThrow("must settle"); + await expect(runStageVerification(setup.env, setup.operation, "staged")).rejects.toThrow("must settle"); + await expect(runStageVerification(setup.env, setup.operation, "aborted")).rejects.toThrow("must settle"); expect(setup.bodyReads).toEqual([]); expect((await readShowControl(setup.env, "daily"))?.value.owner?.state).toBe("uploading"); }); @@ -117,18 +105,21 @@ describe("M6 staging verification and admission completion", () => { expect(setup.payloads).toEqual(before); expect((await readShowControl(setup.env, "daily"))?.value.owner).toBeUndefined(); expect((await readShowControl(setup.env, "daily"))?.value.last_finished_upload?.outcome).toBe("aborted"); - await expect(runStageVerification(setup.env, setup.operation, "staged", { digest })).rejects.toThrow("different outcome"); + await expect(runStageVerification(setup.env, setup.operation, "staged")).rejects.toThrow("different outcome"); }); test("size/hash/image/schema failures keep admission held and retain only fixed diagnostics", async () => { - for (const fault of ["size", "hash", "digest-error", "image", "metadata"] as const) { + for (const fault of ["size", "hash", "head-error", "image", "metadata"] as const) { const setup = await fixture(fault === "image" || fault === "metadata" ? "show" : "audio", fault === "image" || fault === "metadata" ? fault : undefined); await setup.settle(); if (fault === "size") setup.putPayload(stagePayloadKey(setup.request, "audio"), new Uint8Array(1)); if (fault === "hash") setup.putPayload(stagePayloadKey(setup.request, "audio"), new Uint8Array(setup.request.payloads[0]!.length_bytes)); const secret = "Private title owner@example.com Bearer secret"; - const checksum = fault === "digest-error" ? async () => { throw new Error(secret); } : digest; - await expect(runStageVerification(setup.env, setup.operation, "staged", { digest: checksum })).rejects.toThrow(); + const env = fault === "head-error" ? { CASTLOOP_BUCKET: { ...setup.bucket, async head(key: string) { + if (key === stagePayloadKey(setup.request, "audio")) throw new Error(secret); + return setup.bucket.head(key); + } } } as never : setup.env; + await expect(runStageVerification(env, setup.operation, "staged")).rejects.toThrow(); const control = (await readShowControl(setup.env, "daily"))!.value; expect(control.owner?.job_id).toBe(setup.operation.operationId); expect(control.owner?.verification_id).toBeUndefined(); @@ -140,15 +131,17 @@ describe("M6 staging verification and admission completion", () => { } }); - test("live verification is exclusive until its streamed digest has settled", async () => { + test("live verification is exclusive until its HEAD has settled", async () => { const setup = await fixture("audio"); await setup.settle(); const started = Promise.withResolvers(); const ended = Promise.withResolvers(); + const env = { CASTLOOP_BUCKET: { ...setup.bucket, async head(key: string) { + if (key === stagePayloadKey(setup.request, "audio")) { started.resolve(); await ended.promise; } + return setup.bucket.head(key); + } } } as never; const outcome = (async () => { - try { await runStageVerification(setup.env, setup.operation, "staged", { digest: async (body, length) => { - started.resolve(); await ended.promise; return digest(body, length); - } }); return null; } catch (error) { return error; } + try { await runStageVerification(env, setup.operation, "staged"); return null; } catch (error) { return error; } })(); await started.promise; expect((await readShowControl(setup.env, "daily"))?.value.owner?.verification_id).toBeString(); @@ -171,7 +164,7 @@ describe("M6 staging verification and admission completion", () => { } return written; } } } as never; - await expect(runStageVerification(env, setup.operation, "staged", { digest })).rejects.toThrow("acquisition response lost"); + await expect(runStageVerification(env, setup.operation, "staged")).rejects.toThrow("acquisition response lost"); const before = (await readShowControl(setup.env, "daily"))!.value; expect(before.owner?.verification_id).toBeString(); await expect(runStageVerification(setup.env, setup.operation, "aborted")).rejects.toThrow("still running"); @@ -192,9 +185,9 @@ describe("M6 staging verification and admission completion", () => { if (lose && written && match) { lose = false; throw new Error("Verification response lost"); } return written; } } } as never; - if (fault === "release") await runStageVerification(env, setup.operation, "staged", { digest }); - else await expect(runStageVerification(env, setup.operation, "staged", { digest })).rejects.toThrow("response lost"); - await runStageVerification(env, setup.operation, "staged", { digest }); + if (fault === "release") await runStageVerification(env, setup.operation, "staged"); + else await expect(runStageVerification(env, setup.operation, "staged")).rejects.toThrow("response lost"); + await runStageVerification(env, setup.operation, "staged"); expect((await readShowControl(setup.env, "daily"))?.value.owner).toBeUndefined(); expect((await readShowControl(setup.env, "daily"))?.value.last_finished_upload?.outcome).toBe("staged"); expect(parseJobStatus(setup.entries.get(setup.statusKey)!.data).state).toBe("completed"); @@ -205,12 +198,12 @@ describe("M6 staging verification and admission completion", () => { test("completion replay never releases a later lifecycle owner", async () => { const setup = await fixture("audio"); await setup.settle(); - await runStageVerification(setup.env, setup.operation, "staged", { digest }); + await runStageVerification(setup.env, setup.operation, "staged"); const nextJob = crypto.randomUUID(); await claimShowOperation(setup.env, { schema_version: 1, job_id: nextJob, show_id: "daily", kind: "show", action: "delete", expected_show_generation: 1, created_at: "2026-10-01T12:00:00Z" }); const before = new Map(setup.entries); - await runStageVerification(setup.env, setup.operation, "staged", { digest }); + await runStageVerification(setup.env, setup.operation, "staged"); expect(setup.entries).toEqual(before); expect((await readShowControl(setup.env, "daily"))?.value.owner?.job_id).toBe(nextJob); }); @@ -257,11 +250,12 @@ describe("M6 staging verification and admission completion", () => { const setup = await fixture("audio"); await setup.settle(); const key = stagePayloadKey(setup.request, "audio"); - await expect(runStageVerification(setup.env, setup.operation, "staged", { digest: async (body, length) => { - const result = await digest(body, length); - setup.putPayload(key, setup.payloads.get(key)!.bytes); - return result; - } })).rejects.toThrow("changed before completion"); + const env = { CASTLOOP_BUCKET: { ...setup.bucket, async put(...args: Parameters) { + const written = await setup.bucket.put(...args); + if (args[0] === setup.progressKey && JSON.parse(args[1]).phase === "verified") setup.putPayload(key, setup.payloads.get(key)!.bytes); + return written; + } } } as never; + await expect(runStageVerification(env, setup.operation, "staged")).rejects.toThrow("changed before completion"); expect((await readShowControl(setup.env, "daily"))?.value.owner?.job_id).toBe(setup.operation.operationId); expect((await readShowControl(setup.env, "daily"))?.value.last_finished_upload).toBeUndefined(); }); @@ -274,36 +268,29 @@ describe("M6 staging verification and admission completion", () => { throw new Error("Verification token return unavailable"); } return setup.bucket.put(...args); + }, async head(key: string) { + if (key === stagePayloadKey(setup.request, "audio")) throw new Error("Read unavailable"); + return setup.bucket.head(key); } } } as never; - await expect(runStageVerification(env, setup.operation, "staged", { digest: async () => { throw new Error("Read unavailable"); } })) + await expect(runStageVerification(env, setup.operation, "staged")) .rejects.toThrow("token return unavailable"); expect((await readShowControl(setup.env, "daily"))?.value.owner?.verification_id).toBeString(); await expect(runStageVerification(setup.env, setup.operation, "aborted")).rejects.toThrow("still running"); }); - test("the Workers streaming digest path counts bytes and awaits pipe and digest failures", async () => { - const previous = Object.getOwnPropertyDescriptor(crypto, "DigestStream"); - class TestDigestStream extends WritableStream { - digest: Promise; - constructor() { - const hash = createHash("sha256"); - const result = Promise.withResolvers(); - super({ write(chunk) { hash.update(chunk); }, close() { result.resolve(Uint8Array.from(hash.digest()).buffer); }, - abort(reason) { result.reject(reason); } }); - this.digest = result.promise; + test("missing receipts and same-ETag object replacements never substitute for the CLI full readback", async () => { + for (const fault of ["missing", "version"] as const) { + const setup = await fixture("audio"); + await setup.settle(); + if (fault === "version") setup.payloads.get(stagePayloadKey(setup.request, "audio"))!.version = "replacement-upload"; + else { + const progress = JSON.parse(setup.entries.get(setup.progressKey)!.data); + delete progress.readback_receipts; + await setup.bucket.put(setup.progressKey, JSON.stringify(progress)); } - } - Object.defineProperty(crypto, "DigestStream", { configurable: true, value: TestDigestStream }); - const stream = () => new ReadableStream({ start(controller) { - controller.enqueue(Uint8Array.from([1])); controller.enqueue(Uint8Array.from([2, 3])); controller.close(); - } }); - try { - expect(await digestStageStream(stream(), 3)).toBe(createHash("sha256").update(Uint8Array.from([1, 2, 3])).digest("hex")); - await expect(digestStageStream(stream(), 2)).rejects.toThrow("exceeds"); - await expect(digestStageStream(stream(), 4)).rejects.toThrow("ended before"); - } finally { - if (previous) Object.defineProperty(crypto, "DigestStream", previous); - else Reflect.deleteProperty(crypto, "DigestStream"); + await expect(runStageVerification(setup.env, setup.operation, "staged")).rejects.toThrow("readback receipt"); + expect(setup.bodyReads).toEqual([]); + expect((await readShowControl(setup.env, "daily"))?.value.owner?.job_id).toBe(setup.operation.operationId); } }); }); diff --git a/src/staging-verification.ts b/src/staging-verification.ts index 19aa8d4..f0d63e1 100644 --- a/src/staging-verification.ts +++ b/src/staging-verification.ts @@ -8,32 +8,12 @@ import { readStageUploadProgress, requireStageUpload, stageManifestHash, writeSt import type { StageOperation, StageUploadSnapshot } from "./staging-upload"; export type StageVerification = StageOperation & { verificationId: string }; -export type StageStreamDigest = (body: ReadableStream, length: number) => Promise; async function hashBytes(bytes: ArrayBuffer): Promise { const digest = await crypto.subtle.digest("SHA-256", bytes); return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); } -export async function digestStageStream(body: ReadableStream, length: number): Promise { - if (!Number.isSafeInteger(length) || length < 1 || length > 300_000_000) throw new Error("Invalid staging digest size"); - if (typeof crypto.DigestStream !== "function") throw new Error("Streaming checksum verification is unavailable"); - const digest = new crypto.DigestStream("SHA-256"); - let received = 0; - const measured = new TransformStream({ - transform(chunk, controller) { - received += chunk.byteLength; - if (received > length) throw new Error("Staging stream exceeds the expected size"); - controller.enqueue(chunk); - }, - flush() { if (received !== length) throw new Error("Staging stream ended before the expected size"); }, - }); - const results = await Promise.allSettled([body.pipeThrough(measured).pipeTo(digest), digest.digest]); - if (results[0].status === "rejected") throw results[0].reason; - if (results[1].status === "rejected") throw results[1].reason; - return [...new Uint8Array(results[1].value)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); -} - export async function acquireStageVerification(env: LifecycleControlEnv, operation: StageOperation): Promise { const snapshot = await requireStageUpload(env, operation); const progress = await readStageUploadProgress(env, operation, snapshot); @@ -69,59 +49,65 @@ export async function releaseStageVerification(env: LifecycleControlEnv, verific } async function verifyPayload(env: LifecycleControlEnv, verification: StageVerification, snapshot: StageUploadSnapshot, - payload: StagePayload, digest: StageStreamDigest): Promise { + payload: StagePayload): Promise { await requireStageVerification(env, verification); const key = stagePayloadKey(snapshot.request, payload.asset); const head = await env.CASTLOOP_BUCKET.head(key); if (!head || head.size !== payload.length_bytes) throw new Error("Uploaded staging size does not match its manifest"); + const progress = (await readStageUploadProgress(env, verification, snapshot))!.value; + const receipt = progress.readback_receipts?.find((item) => item.asset === payload.asset); + if (!receipt || receipt.sha256 !== payload.sha256 || receipt.length_bytes !== payload.length_bytes || + head.etag !== receipt.etag || head.version !== receipt.version) { + throw new Error("Uploaded staging object does not match its retained full readback receipt"); + } + if (payload.asset === "audio") { + await requireStageVerification(env, verification); + return receipt; + } const object = await env.CASTLOOP_BUCKET.get(key, { onlyIf: { etagMatches: head.etag } }); - if (!object || !("body" in object) || !object.body || object.etag !== head.etag || object.size !== payload.length_bytes) { + if (!object || !("body" in object) || !object.body || object.etag !== head.etag || object.version !== head.version || object.size !== payload.length_bytes) { + if (object && "body" in object && object.body) await object.body.cancel(); throw new Error("Uploaded staging object changed before verification"); } - let checksum: string; - if (payload.asset === "audio") checksum = await digest(object.body, payload.length_bytes); - else { - const bytes = await object.arrayBuffer(); - if (bytes.byteLength !== payload.length_bytes) throw new Error("Uploaded staging contents have a different size"); - checksum = await hashBytes(bytes); - if (checksum !== payload.sha256) throw new Error("Uploaded staging checksum does not match its manifest"); - if (payload.asset === "show_metadata") { - const show = parseShowMetadata(new TextDecoder().decode(bytes)); - if (show.show_id !== snapshot.request.show_id) throw new Error("Staged Show metadata targets another Show"); - const extension = show.image_path.toLowerCase().endsWith(".png") ? "png" : "jpg"; - if (!snapshot.request.payloads.some((item) => item.asset === `cover_${extension}`)) throw new Error("Staged Show metadata and cover extension differ"); - const published = await env.CASTLOOP_BUCKET.get(`system/shows/${snapshot.request.show_id}/show.toml`); - if (!published && snapshot.control.value.lifecycle === "active") throw new Error("Active Show has no published snapshot"); - if (published) { - if (published.size > 1_000_000) throw new Error("Published Show metadata is oversized"); - const previous = parseShowMetadata(await published.text()); - if (previous.show_id !== show.show_id || (previous.image_path.toLowerCase().endsWith(".png") ? "png" : "jpg") !== extension) { - throw new Error("Changing a published Show cover extension is not supported"); - } + const bytes = await object.arrayBuffer(); + if (bytes.byteLength !== payload.length_bytes) throw new Error("Uploaded staging contents have a different size"); + const checksum = await hashBytes(bytes); + if (checksum !== payload.sha256) throw new Error("Uploaded staging checksum does not match its manifest"); + if (payload.asset === "show_metadata") { + const show = parseShowMetadata(new TextDecoder().decode(bytes)); + if (show.show_id !== snapshot.request.show_id) throw new Error("Staged Show metadata targets another Show"); + const extension = show.image_path.toLowerCase().endsWith(".png") ? "png" : "jpg"; + if (!snapshot.request.payloads.some((item) => item.asset === `cover_${extension}`)) throw new Error("Staged Show metadata and cover extension differ"); + const published = await env.CASTLOOP_BUCKET.get(`system/shows/${snapshot.request.show_id}/show.toml`); + if (!published && snapshot.control.value.lifecycle === "active") throw new Error("Active Show has no published snapshot"); + if (published) { + if (published.size > 1_000_000) throw new Error("Published Show metadata is oversized"); + const previous = parseShowMetadata(await published.text()); + if (previous.show_id !== show.show_id || (previous.image_path.toLowerCase().endsWith(".png") ? "png" : "jpg") !== extension) { + throw new Error("Changing a published Show cover extension is not supported"); } - } else if (payload.asset === "episode_metadata") { - const episode = parseEpisodeDraft(new TextDecoder().decode(bytes)); - if (episode.episode_id !== snapshot.request.episode_id) throw new Error("Staged Episode metadata targets another Episode"); - const published = await env.CASTLOOP_BUCKET.get(`public/episodes/${snapshot.request.show_id}/${snapshot.request.episode_id}/metadata.toml`); - const lifecycle = await readEpisodeLifecycle(env, snapshot.request.show_id, snapshot.request.episode_id!); - if (!published && lifecycle?.lifecycle === "active") throw new Error("Active Episode has no published snapshot"); - if (published) { - if (published.size > 1_000_000) throw new Error("Published Episode metadata is oversized"); - const previous = parseEpisodeRevision(await published.text()); - if (previous.episode_id !== episode.episode_id || previous.guid !== episode.guid || previous.published_at !== episode.published_at) { - throw new Error("Episode identity and published_at must remain unchanged"); - } + } + } else if (payload.asset === "episode_metadata") { + const episode = parseEpisodeDraft(new TextDecoder().decode(bytes)); + if (episode.episode_id !== snapshot.request.episode_id) throw new Error("Staged Episode metadata targets another Episode"); + const published = await env.CASTLOOP_BUCKET.get(`public/episodes/${snapshot.request.show_id}/${snapshot.request.episode_id}/metadata.toml`); + const lifecycle = await readEpisodeLifecycle(env, snapshot.request.show_id, snapshot.request.episode_id!); + if (!published && lifecycle?.lifecycle === "active") throw new Error("Active Episode has no published snapshot"); + if (published) { + if (published.size > 1_000_000) throw new Error("Published Episode metadata is oversized"); + const previous = parseEpisodeRevision(await published.text()); + if (previous.episode_id !== episode.episode_id || previous.guid !== episode.guid || previous.published_at !== episode.published_at) { + throw new Error("Episode identity and published_at must remain unchanged"); } - } else { - const cover = new Uint8Array(bytes); - const valid = payload.asset === "cover_jpg" ? cover.length >= 3 && cover[0] === 255 && cover[1] === 216 && cover[2] === 255 : - cover.length >= 8 && [137, 80, 78, 71, 13, 10, 26, 10].every((byte, index) => cover[index] === byte); - if (!valid) throw new Error("Staged cover contents do not match their image type"); } + } else { + const cover = new Uint8Array(bytes); + const valid = payload.asset === "cover_jpg" ? cover.length >= 3 && cover[0] === 255 && cover[1] === 216 && cover[2] === 255 : + cover.length >= 8 && [137, 80, 78, 71, 13, 10, 26, 10].every((byte, index) => cover[index] === byte); + if (!valid) throw new Error("Staged cover contents do not match their image type"); } - if (checksum !== payload.sha256) throw new Error("Uploaded staging checksum does not match its manifest"); await requireStageVerification(env, verification); - return { asset: payload.asset, etag: object.etag, length_bytes: payload.length_bytes, sha256: checksum }; + return { asset: payload.asset, etag: object.etag, version: object.version, length_bytes: payload.length_bytes, sha256: checksum }; } function matchesStatus(status: LifecycleJobStatus, verification: StageVerification, snapshot: StageUploadSnapshot): boolean { @@ -175,7 +161,9 @@ async function finishVerification(env: LifecycleControlEnv, verification: StageV const verified = progress.verified_assets.find((item) => item.asset === payload.asset); const head = await env.CASTLOOP_BUCKET.head(stagePayloadKey(snapshot.request, payload.asset)); if (!verified || verified.sha256 !== payload.sha256 || verified.length_bytes !== payload.length_bytes || - !head || head.size !== payload.length_bytes || head.etag !== verified.etag) throw new Error("Verified staging payload changed before completion"); + !head || head.size !== payload.length_bytes || head.etag !== verified.etag || !verified.version || head.version !== verified.version) { + throw new Error("Verified staging payload changed before completion"); + } } } const latest = await requireStageVerification(env, verification); @@ -202,8 +190,7 @@ async function finishedOutcome(env: LifecycleControlEnv, operation: StageOperati return receipt.outcome; } -export async function runStageVerification(env: LifecycleControlEnv, operation: StageOperation, outcome: "staged" | "aborted", - options: { digest?: StageStreamDigest } = {}): Promise { +export async function runStageVerification(env: LifecycleControlEnv, operation: StageOperation, outcome: "staged" | "aborted"): Promise { if (outcome !== "staged" && outcome !== "aborted") throw new Error("Invalid staging outcome"); const finished = await finishedOutcome(env, operation); if (finished) { @@ -221,7 +208,7 @@ export async function runStageVerification(env: LifecycleControlEnv, operation: if (outcome === "staged" && progress.phase !== "verified") { await writeStageUploadProgress(env, verification, { ...progress, phase: "verifying", verified_assets: [] }, verification.verificationId); const verified: StageUploadProgress["verified_assets"] = []; - for (const payload of snapshot.request.payloads) verified.push(await verifyPayload(env, verification, snapshot, payload, options.digest ?? digestStageStream)); + for (const payload of snapshot.request.payloads) verified.push(await verifyPayload(env, verification, snapshot, payload)); progress = stageUploadProgressSchema.parse({ ...progress, phase: "verified", verified_assets: verified }); await writeStageUploadProgress(env, verification, progress, verification.verificationId); } diff --git a/src/test-support/episode-publication.ts b/src/test-support/episode-publication.ts index 087643a..1b5b9f9 100644 --- a/src/test-support/episode-publication.ts +++ b/src/test-support/episode-publication.ts @@ -5,27 +5,9 @@ import { claimPublicationOperation, commitOwnedPublication, publicationRequestSc import { consumeOwnedPublication } from "../publication-consumer"; import { beginStageUpload, claimStageUpload, settleStageUpload } from "../staging-upload"; import { runStageVerification } from "../staging-verification"; -import type { StageStreamDigest } from "../staging-verification"; import { publicationFixture } from "./publication"; import { createHash } from "node:crypto"; -export const publicationTestDigest: StageStreamDigest = async (body, length) => { - const hash = createHash("sha256"); - const reader = body.getReader(); - let bytes = 0; - try { - for (;;) { - const chunk = await reader.read(); - if (chunk.done) break; - bytes += chunk.value.byteLength; - if (bytes > length) throw new Error("Audio stream exceeds its expected size"); - hash.update(chunk.value); - } - } finally { reader.releaseLock(); } - if (bytes !== length) throw new Error("Audio stream has an unexpected size"); - return hash.digest("hex"); -}; - export async function episodePublicationFixture(update?: "metadata" | "audio") { const setup = await publicationFixture({ active: true, episodes: true }); await consumeOwnedPublication(setup.env, setup.key, { async checkDeliveryGate() {}, async purge() {} }); @@ -51,8 +33,8 @@ export async function episodePublicationFixture(update?: "metadata" | "audio") { const operation = await claimStageUpload(setup.env, request); await beginStageUpload(setup.env, operation); await setup.bucket.put(stagePayloadKey(request, content.asset), content.bytes); - await settleStageUpload(setup.env, operation, { put_requests_settled: true, no_more_puts: true }); - await runStageVerification(setup.env, operation, "staged", { digest: publicationTestDigest }); + await settleStageUpload(setup.env, operation, { put_requests_settled: true, no_more_puts: true, readback_receipts: setup.readbacks(request) }); + await runStageVerification(setup.env, operation, "staged"); stages.push(request); } const frozen = publicationRequestSchema.parse({ schema_version: 1, diff --git a/src/test-support/lifecycle-admin.ts b/src/test-support/lifecycle-admin.ts index af39615..a00f6db 100644 --- a/src/test-support/lifecycle-admin.ts +++ b/src/test-support/lifecycle-admin.ts @@ -5,7 +5,6 @@ import { handleM6LifecycleAdmin } from "../lifecycle-admin"; import type { LifecycleAdminEnv } from "../lifecycle-admin"; import { queueM6Candidate } from "../m6-routes"; import { parseQueueDelivery } from "../queue-delivery"; -import { publicationTestDigest } from "./episode-publication"; import { publicationAdminFixture } from "./publication-admin"; export async function lifecycleAdminFixture() { @@ -13,7 +12,7 @@ export async function lifecycleAdminFixture() { await setup.publicationSuccess(setup.body("claim")); await setup.publicationSuccess(setup.body("commit")); await queueM6Candidate({ queue: "test-queue", messages: [{ id: "publication", body: { object: { key: setup.markerKey } } }] } as never, - setup.candidateEnv, setup.cachedAssets, { digest: publicationTestDigest }); + setup.candidateEnv, setup.cachedAssets); await setup.addEpisode("untouched", "active"); const continuations: string[] = []; const candidateEnv = { ...setup.candidateEnv, CASTLOOP_QUEUE: { send: async (body: unknown) => { diff --git a/src/test-support/publication-admin.ts b/src/test-support/publication-admin.ts index 143eb86..76f7db4 100644 --- a/src/test-support/publication-admin.ts +++ b/src/test-support/publication-admin.ts @@ -25,7 +25,7 @@ export async function publicationAdminFixture(mode: "show" | "episode" | "metada await setup.success(setup.input("claim", { upload })); await setup.success(setup.input("begin", { operation })); for (const { asset, bytes } of contents) await setup.bucket.put(stagePayloadKey(upload, asset), bytes); - await setup.success(setup.input("settle", { operation, put_requests_settled: true, no_more_puts: true })); + await setup.success(setup.input("settle", { operation, put_requests_settled: true, no_more_puts: true, readback_receipts: setup.readbacks(upload) })); await setup.success(setup.input("finish", { operation, outcome: "staged" })); stages.push(upload); }; diff --git a/src/test-support/publication.ts b/src/test-support/publication.ts index 2944162..19c56ef 100644 --- a/src/test-support/publication.ts +++ b/src/test-support/publication.ts @@ -1,5 +1,5 @@ -import { episodeRevisionSchema, stagePayloadKey, stageUploadRequestSchema, stringifyLifecycleToml, stringifyToml } from "../../packages/shared/src/index"; -import type { LifecycleState } from "../../packages/shared/src/index"; +import { episodeRevisionSchema, stagePayloadKey, stageReadbackReceiptSchema, stageUploadRequestSchema, stringifyLifecycleToml, stringifyToml } from "../../packages/shared/src/index"; +import type { LifecycleState, StageReadbackReceipt, StageUploadRequest } from "../../packages/shared/src/index"; import { readShowControl } from "../lifecycle-control"; import { claimPublicationOperation, commitOwnedPublication, publicationRequestSchema } from "../publication-admission"; import { beginStageUpload, claimStageUpload, settleStageUpload } from "../staging-upload"; @@ -10,7 +10,8 @@ import { createHash } from "node:crypto"; export const PUBLICATION_SHOW_TEXT = "schema_version = 1\nshow_id = 'daily'\ntitle = 'New Show title'\ndescription = 'Private description'\nlanguage = 'en'\nauthor = 'Author'\nowner_name = 'Owner'\nowner_email = 'owner@example.com'\ncategories = ['Arts']\nexplicit = false\nsite_url = 'https://example.com'\nimage_path = 'cover.jpg'\n"; export const PUBLICATION_SERVICE_TEXT = "schema_version = 1\nservice_id = 'service'\naccount_id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'\nbucket_name = 'test-bucket'\nworker_name = 'test-worker'\nqueue_name = 'test-queue'\ndlq_name = 'test-dlq'\npublic_base_url = 'https://current.example'\n"; -type Entry = { bytes: Uint8Array; size: number; etag: string; customMetadata?: Record; httpMetadata?: R2HTTPMetadata }; +type Entry = { bytes: Uint8Array; size: number; etag: string; version: string; checksums: { sha256?: ArrayBuffer }; + customMetadata?: Record; httpMetadata?: R2HTTPMetadata }; type PutOptions = Pick; export async function publicationFixture(options: { active?: boolean; episodes?: boolean } = {}) { @@ -39,7 +40,8 @@ export async function publicationFixture(options: { active?: boolean; episodes?: if (options?.onlyIf instanceof Headers && previous) return null; if (options?.onlyIf && !(options.onlyIf instanceof Headers) && options.onlyIf.etagMatches !== previous?.etag) return null; if (typeof options?.sha256 === "string" && createHash("sha256").update(bytes).digest("hex") !== options.sha256) throw new Error("Checksum mismatch"); - const entry: Entry = { bytes, size: bytes.length, etag: String(++version), + const entry: Entry = { bytes, size: bytes.length, etag: String(++version), version: `upload-${version}`, + checksums: typeof options?.sha256 === "string" ? { sha256: Uint8Array.from(createHash("sha256").update(bytes).digest()).buffer } : {}, ...(options?.customMetadata ? { customMetadata: options.customMetadata } : {}), ...(options?.httpMetadata && !(options.httpMetadata instanceof Headers) ? { httpMetadata: options.httpMetadata } : {}) }; entries.set(key, entry); @@ -75,7 +77,7 @@ export async function publicationFixture(options: { active?: boolean; episodes?: content_type: "audio/mpeg", length_bytes: 1, duration_seconds: 1, sha256: createHash("sha256").update("x").digest("hex"), updated_at: "2026-09-02T00:00:00Z" }); await bucket.put(`public/episodes/daily/${episodeId}/metadata.toml`, stringifyToml(revision)); await bucket.put(`public/episodes/daily/${episodeId}/revisions/${revision.revision_id}.toml`, stringifyToml(revision)); - await bucket.put(`public${new URL(revision.enclosure_url).pathname}`, "x", { customMetadata: { sha256: revision.sha256 } }); + await bucket.put(`public${new URL(revision.enclosure_url).pathname}`, "x", { sha256: revision.sha256, customMetadata: { sha256: revision.sha256 } }); return revision; } if (options.episodes) for (const [episodeId, lifecycle] of [["first", "active"], ["second", "active"], ["stopped", "unpublished"], @@ -91,7 +93,15 @@ export async function publicationFixture(options: { active?: boolean; episodes?: await beginStageUpload(env, upload); await bucket.put(stagePayloadKey(stage, "show_metadata"), metadata); await bucket.put(stagePayloadKey(stage, "cover_jpg"), cover); - await settleStageUpload(env, upload, { put_requests_settled: true, no_more_puts: true }); + function readbacks(request: StageUploadRequest = stage, count = request.payloads.length): StageReadbackReceipt[] { + return request.payloads.slice(0, count).map((payload) => { + const object = entries.get(stagePayloadKey(request, payload.asset)); + if (!object) throw new Error("Test readback object is missing"); + return stageReadbackReceiptSchema.parse({ asset: payload.asset, length_bytes: object.size, + sha256: sha256(object.bytes), etag: object.etag, version: object.version }); + }); + } + await settleStageUpload(env, upload, { put_requests_settled: true, no_more_puts: true, readback_receipts: readbacks(stage) }); await runStageVerification(env, upload, "staged"); const frozen = publicationRequestSchema.parse({ schema_version: 1, request: { schema_version: 1, job_id: stage.draft_job_id, show_id: "daily", kind: "show", action: "publish", @@ -100,7 +110,7 @@ export async function publicationFixture(options: { active?: boolean; episodes?: cover_sha256: sha256(cover), cover_extension: "jpg" }, staged_uploads: [stage.operation_id] }); const operation = await claimPublicationOperation(env, frozen); const { key } = await commitOwnedPublication(env, operation); - return { bucket, env, entries, writes, bodyReads, addEpisode, operation, key, frozen, stage, + return { bucket, env, entries, writes, bodyReads, addEpisode, readbacks, operation, key, frozen, stage, text: (key: string) => new TextDecoder().decode(entries.get(key)?.bytes), feedKey: "public/podcasts/daily/feed.xml", statusKey: `system/jobs/${operation.jobId}/status.toml`, progressKey: `system/jobs/${operation.jobId}/progress.toml` }; } diff --git a/src/test-support/staging-admin.ts b/src/test-support/staging-admin.ts index 389b3a4..83a3e59 100644 --- a/src/test-support/staging-admin.ts +++ b/src/test-support/staging-admin.ts @@ -7,7 +7,6 @@ import { consumeOwnedPublication } from "../publication-consumer"; import { SERVICE_ADMISSION_KEY } from "../service-admission"; import { handleM6StagingAdmin } from "../staging-admin"; import type { StagingAdminEnv } from "../staging-admin"; -import { publicationTestDigest } from "./episode-publication"; import { publicationFixture, PUBLICATION_SHOW_TEXT } from "./publication"; import { createHash } from "node:crypto"; @@ -42,7 +41,7 @@ export async function stagingAdminFixture(kind: "show" | "audio" | "episode_meta method, headers: { "X-Castloop-Key": secret }, ...(method === "POST" ? { body: JSON.stringify(body) } : {}), }); const call = async (body: unknown) => { - const response = await handleM6StagingAdmin(http(body), env, bindings, { digest: publicationTestDigest }); + const response = await handleM6StagingAdmin(http(body), env, bindings); if (!response) throw new Error("Staging route was not handled"); return response; }; @@ -52,5 +51,6 @@ export async function stagingAdminFixture(kind: "show" | "audio" | "episode_meta return stagingAdminResponseSchema.parse(await response.json()); }; return { ...setup, config, versionId, service, env, bindings, contents, upload, operation, input, http, call, success, + readbacks: (request = upload, count = request.payloads.length) => setup.readbacks(request, count), putPayloads: async () => { for (const { asset, bytes } of contents) await setup.bucket.put(stagePayloadKey(upload, asset), bytes); } }; } From cb72a2343fa1969c33c9726794112dca4437c99f Mon Sep 17 00:00:00 2001 From: Akira Shimosako Date: Sun, 4 Oct 2026 11:21:49 +0900 Subject: [PATCH 12/16] fix(m6): complete 300MB and metadata-only standalone acceptance --- design/m6_implementation_log.md | 7 + design/m6_review_queue.md | 4 +- design/m6_standalone_acceptance.md | 25 +- design/m6_upload_integrity_review.md | 4 +- experiments/m6/README.md | 4 + .../m6/large-media-results-20261004.json | 48 ++++ experiments/m6/verify-large-media.ts | 217 ++++++++++++------ packages/cli/src/m6-test-cli.ts | 5 +- .../verify-operation-status-binary.ts | 4 + 9 files changed, 238 insertions(+), 80 deletions(-) create mode 100644 experiments/m6/large-media-results-20261004.json diff --git a/design/m6_implementation_log.md b/design/m6_implementation_log.md index 886c363..cd83f5a 100644 --- a/design/m6_implementation_log.md +++ b/design/m6_implementation_log.md @@ -1,5 +1,12 @@ # M6: 公開停止・削除 実装ログ +## 2026-10-04: 300MB公開・二種改訂・明示削除の実機合格 + +- 新規専用`castloop-m6-test-7c97f1a0`でCLI全量SHA-256/ETag/version証拠とWorker HEAD照合、R2 SHA-256 stream PUT/native checksumを使い、正確な300MB公開・全量配信に合格した。300,000,001 bytesのlocal拒否、audio-only/metadata-only、GUID/date/history/旧音源保持、HEAD/suffix Range/304、実cache HIT、最後の明示Show delete/媒体404・公開410も合格した。 +- metadata-only試験で判明した試験CLIの不要なMP3 path必須制約を修正した。既存source guardは維持し、正常finished/staged upload・remote released証拠・同じbase/version・空registry・publication journal未作成を照合する限定checkpointだけから続行した。元incompleteを保持し、再PUT/再staging/未知POSTの再送・owner/token強制解放はしない。 +- 最終pause `d89332da-8564-42c9-b605-e37a5af0596d`、service generation 149、空registry。資源はpausedで保持し、Paid/CPU limit/通常release gateは変更しない。旧`ff3bfd8c`の未収束owner/token/payloadにも手を加えない。全891テスト/13475 assertions、TypeScript/M6試験tsconfig、Linux standaloneのlocal HTTPS readonly/production拒否に合格した。 +- 詳細は`m6_standalone_acceptance.md`、sanitized結果は`experiments/m6/large-media-results-20261004.json`。残件は新規初期化fault、unknown IO/残存lockの安全な外部終了確認と復旧案内、通常CLI/README/help/version/releaseであり、旧形式変換は必要時だけとする。 + ## 2026-10-04: 音源のSHA-256検証をCLI/R2へ集約 - 管理者が承認した案Bを実装した。REST単一PUT後のCLI全量GET/size/SHA-256照合を維持し、固定key・strong ETag・versionを確認した小さいreceiptを既存journal/settlementへ保存する。証拠の順序/manifest一致はshared helperへ集約し、終了後の変更・legacy証拠の捏造・local/server不一致を拒否する。 diff --git a/design/m6_review_queue.md b/design/m6_review_queue.md index 72d1f6c..bcff60a 100644 --- a/design/m6_review_queue.md +++ b/design/m6_review_queue.md @@ -56,9 +56,9 @@ R2/CLIの[完全性検証調査](./m6_upload_integrity_review.md)で、管理RES 下記の各module詳細には実装当時の未接続項目も残る。最新の到達点は新規初期化・通常更新の試験HTTP/standalone接続と実Cloudflare合格、Show/Episode六lifecycle操作の実機合格まで進んでいる。後者は配信404/410・復元時GUID/revision保持・物理削除・永久記録保持・owner/token通常返却を含む。通常公開CLI/entryはまだ切り替えていない。 -通常更新の完了応答喪失→別standaloneの非書込照合と、feed/cover/小さい音源の実cache HIT・gateway毎回実行も実機合格した。300MBはREST PUT/全量GETまで成功したが、WorkerのCPU制限で未合格となった。独立readonly診断でもnative digestと現行経路が`exceededCpu`で終了し、元serviceはpaused/owner/token保持とした。契約照会は403で課金変更はしていない。管理者のPaid利用不可の決定に従い、無料枠に適合する検証方式の見直しを技術残件として進める。費用測定計画を通常開発のgateへ戻すものではない。 +通常更新の完了応答喪失→別standaloneの非書込照合と、feed/cover/小さい音源の実cache HIT・gateway毎回実行も実機合格した。改修前の300MB Worker hashはCPU超過となり、元serviceはpaused/owner/token/payloadを保持する。管理者承認のCLI全量SHA-256証拠+Worker HEAD照合+R2公開時検証へ集約した後、新規`castloop-m6-test-7c97f1a0`で300MB公開・audio-only/metadata-only改訂・全量配信/HEAD/Range/304・明示削除まで合格した。試験CLIのmetadata-only引数制約は修正し、publication未開始の確認済みcheckpointだけを続行した。課金/CPU limitは変更しておらず、費用測定計画を通常開発のgateへ戻すものではない。 -残件は300MBと二種改訂の受け入れ、新規初期化fault、安全なunknown IO/残存lockの外部終了確認・復旧手順、公開CLI/利用案内・release gateである。未実行/未完了のharnessを合格扱いしない。詳細は[standalone受け入れ](./m6_standalone_acceptance.md)。旧形式変換は必要時だけとする。 +残件は新規初期化fault、安全なunknown IO/残存lockの外部終了確認・復旧手順、公開CLI/利用案内・release gateである。300MB合格を旧未知owner/tokenの解放へ使わず、未実行/未完了のharnessを合格扱いしない。詳細は[standalone受け入れ](./m6_standalone_acceptance.md)。旧形式変換は必要時だけとする。 2026-10-03に管理者が「共通journalリファクタリングをmainへmerge → 別branchで新規M6初期化/稼働判定の分離 → 別branchで形式を変えない通常更新 → 旧形式変換は必要時だけ」を承認した。旧形式変換を新規M6サービスの必須経路にしない。既存環境の即時削除や公開gate解除の承認とは扱わない。 diff --git a/design/m6_standalone_acceptance.md b/design/m6_standalone_acceptance.md index ee554ce..d8b8155 100644 --- a/design/m6_standalone_acceptance.md +++ b/design/m6_standalone_acceptance.md @@ -65,4 +65,27 @@ CLIの全量SHA-256読み戻しとPUT receiptのETag/versionが、保存journal/ ## 後続 -300MB/audio-only/metadata-only/配信/明示deleteの未完了受け入れ、安全なunknown IO/残存lockの外部終了確認、復旧手順、通常CLI/README/help/version/releaseの完成は残る。harnessは新規初期化のacknowledged journal/paused checkpointからも開始可能にし、既に確認した通常更新と六lifecycleを300MB試験のたびに再実行しない。failed workspaceの再送/採用/強制解放を許可するmodeではない。 +新規初期化のfault受け入れ、安全なunknown IO/残存lockの外部終了確認・復旧手順、通常CLI/README/help/version/releaseの完成は残る。費用/停止時間測定と無停止移行は承認済みのMVP後計画とし、旧形式変換は必要時だけ行う。下記の300MB合格を旧環境の未返却owner/token解放や、通常release gateの解除へ流用しない。 + +## CLI/R2方式の300MB・二種改訂・明示削除: 合格 + +`castloop-m6-test-7c97f1a0`、version `77775577-cf6f-47e1-be97-a271f9d618e5`で`verify-large-media.ts`を実行し、以下が合格した。Paid契約/CPU limitは変更していない。 + +- 正確な300,000,000-byte MP3の解析・REST単一PUT・CLI全量SHA-256読み戻し・ETag/version証拠伝達・Worker HEAD照合と、R2 SHA-256付きstream PUT/native checksum照合による公開。 +- 300,000,001-byte入力のlocal拒否とremote target非変更、音源だけの改訂、metadataだけの改訂、GUID/公開日時/immutable履歴と旧音源の保持。 +- 公開音源の全量GET/size/SHA-256、HEAD、末尾Range、304。feed/cover/小さい旧音源の実cache HITとgateway毎要求実行。300MB音源自体のcache HIT・複数colo・聴取品質の合格とはしない。 +- 明示Show delete後の既知音源R2 GET 404と公開410、owner/token通常終了、明示pause。最終generationは149、registryは空。Worker/R2/Queue/DLQ資源は保持する。 + +| 操作 | job ID | +| --- | --- | +| Show公開 | `1a091043-e620-4e97-9cc5-adcf1070ebf6` | +| 小さいEpisode公開 | `ee7a258e-8a20-4cdf-a811-2ed39749a4c9` | +| 300MB audio-only改訂 | `5f4a2cb8-4b3f-4c8b-b191-a3487c1e0a56` | +| metadata-only改訂 | `d0811f94-52ac-43ed-9d27-e667f726d750` | +| Show明示削除 | `28b77bf2-cf57-4f8a-8a53-8f2182d2701c` | + +初回は300MB公開/全量配信後、試験CLIがmetadata-onlyにもMP3 pathを必須にしていたため送信前guardで停止した。metadata upload `542eb77e-2099-45ed-b805-c0d64a957dad`は正常finished/staged、publication journalは未作成、service registryとShow ownerも空であった。CLIをoptional pathへ修正し、既存の「changed audioだけsource path必須」検査は緩めていない。 + +続行は`--metadata-staged-workspace`でexact version/readiness・同じbase・finished local/remote released staging・読戻し証拠一致・publication未作成・lock/token不在を照合したcheckpointに限る。別の`wx` before fileを作り、元incompleteを保持する。再PUT/再staging/未知POSTの再送・ownerの強制解放はしていない。 + +最終pauseは`d89332da-8564-42c9-b605-e37a5af0596d`。private `large-media-acceptance.json`と[sanitized結果](../experiments/m6/large-media-results-20261004.json)を保存した。readonly snapshot競合は9件を有限観測で扱い、mutationは再送していない。全891テスト/13475 assertions、TypeScript/M6試験tsconfig、Linux binaryとlocal HTTPS readonly/production拒否も合格した。 diff --git a/design/m6_upload_integrity_review.md b/design/m6_upload_integrity_review.md index 2048f4e..9b59964 100644 --- a/design/m6_upload_integrity_review.md +++ b/design/m6_upload_integrity_review.md @@ -2,7 +2,7 @@ 更新日: 2026-10-04 -状態: **管理者が案Bを承認し、M6試験用CLI/Workerへ実装した。ローカル全891テスト・型検査に合格。接続後の実機受け入れは下記へ別途記録する。通常release gateは維持し、Workers Paidは使用しない。** +状態: **管理者が案Bを承認し、M6試験用CLI/Workerへ実装した。ローカル全891テスト・型検査と、専用Cloudflareでの300MB公開・二種改訂・配信・明示削除が合格。通常release gateは維持し、Paid契約/CPU limitは変更していない。** ## 結論 @@ -81,4 +81,4 @@ checksumは原本との同一性検査であり、原本自体のMP3破損・再 - 元serviceのpaused状態・未返却owner/token・回復可能な300MB staging payloadは維持する。この試験は旧IO収束/強制解放の証拠ではない。[U1](./m6_upload_recovery_options.md)の扱いも変更しない。 - Bの証拠伝達を実装し、receipt/GET/HEADのidentity一致・途中差替え・証拠欠落/不一致・manifest/owner/generation不一致を各責務の層で回帰した。音源stagingのbody GETと公開後のbody GETを行わないこと、native checksum欠落/不一致を拒否すること、live IO/token保持も検査する。native checksumの実動作は今回の実機証拠を使い、各層へ同じ大容量検証testを重複させない。 - 新規`castloop-m6-test-7c97f1a0`で試験standaloneの初期化→明示再開→Show/20,850-byte Episode公開→GET/HEAD/Range→明示pauseまで合格した。REST PUT receipt→CLI journal/GET→管理settlement→Worker HEADのETag/version一致、公開native checksum検査を実経路で通した。証拠は`/tmp/opencode/castloop-m6-test-7c97f1a0/acceptance.json`。旧環境は採用・再配備・解放しない。 -- 300MBのM6 publication全体、二種改訂、配信、明示deleteまでの受け入れは別に残る。今回の成功はその合格の代わりではない。 +- 同じ新規専用環境で300MBのM6 publication全体、二種改訂、全量配信/HEAD/Range/304、明示deleteが合格した。[sanitized結果](../experiments/m6/large-media-results-20261004.json)と[checkpoint/残件](./m6_standalone_acceptance.md)参照。旧IOの収束、全colo、聴取品質、通常release全体の合格とは区別する。 diff --git a/experiments/m6/README.md b/experiments/m6/README.md index 2bc1e5a..c2d8749 100644 --- a/experiments/m6/README.md +++ b/experiments/m6/README.md @@ -64,11 +64,13 @@ bun experiments/m6/verify-lifecycle.ts /tmp/opencode/ACKNOWLEDGED_TEST_WORKSPACE bun experiments/m6/verify-completion-recovery.ts /tmp/opencode/ACKNOWLEDGED_TEST_WORKSPACE bun experiments/m6/verify-large-media.ts /tmp/opencode/ACKNOWLEDGED_TEST_WORKSPACE bun experiments/m6/verify-large-media.ts --fresh-acknowledged-workspace /tmp/opencode/ACKNOWLEDGED_FRESH_WORKSPACE +bun experiments/m6/verify-large-media.ts --metadata-staged-workspace /tmp/opencode/ACKNOWLEDGED_METADATA_CHECKPOINT ``` - lifecycle: compatible-update受け入れ済みの小さい`fresh/first` fixtureで六操作を実行し、GET/HEAD/Range/304/416、404/410、GUID/revision保持、payload物理削除と永久記録保持を確認する。二回GETしただけでcache HIT合格とはしない。 - completion recovery: lifecycle合格後の同じpaused環境を通常更新し、serverの成功completion応答だけを試験transportで破棄する。local requested/server pausedを確認し、別のbinary commandで非書込照合する。強制終了したIOの収束証明とは別である。 - large media: recovery合格後、または明示fresh modeで初期化/公開のacknowledged journalと現在のpaused checkpointを確認した後に、別Show `large`を作る。未確認資源・別version・残存lock/tokenは採用しない。719424個の417-byte MPEG framesと192-byte ID3 prefixで正確な300,000,000-byte MP3をchunk書込・stream解析する。300,000,001 bytes拒否、audio-only/metadata-only改訂、全量stream checksum、GUID/date/history/旧音源保持、HEAD/suffix Range/304、最後に明示Show deleteを検証する。音声内容の聴取品質を検証するfixtureではない。途中失敗でも確認済みcache HIT/過大拒否/fixture checksumを記録する。 +- metadata checkpoint: fresh modeの300MB公開後、metadata stagingだけが正常完了し、publication journalがまだ存在しない場合の限定続行。実行version/readiness・空registry・同じbase revision・editable draft・finished/staged receipt・remote released status・local/server読戻し証拠を照合し、別の`wx` before fileを作る。元のincompleteを保持し、unknown publication/lock/tokenを採用・再送しない。試験CLIの`publish-episode`は変更音源をstagingした場合だけMP3 pathを必要とする。 - cache: 試験Workerだけが内部/外部invocation nonceとinner cache statusをresponse headerへ追加する。feed/cover/小さい音源で、内部HITの同じnonceと毎要求異なるgateway nonceを照合する。通常Worker/公開binaryへ計測headerを追加しない。 mutationの自動再送、未知lock/tokenの解放、Worker/bucket/Queueの削除はしない。readonlyな観測競合だけ有限回待ち、失敗数を記録する。途中失敗は固定phase/codeだけ保存し、任意exception本文・secret・metadata本文は保存しない。before/incompleteを消して再実行してはいけない。既知の完了checkpointからの続行は個別に証拠を照合する。 @@ -80,3 +82,5 @@ mutationの自動再送、未知lock/tokenの解放、Worker/bucket/Queueの削 ## R2 native checksumの調査(2026-10-04) [sanitized結果](./checksum-results-20261004.json)と[調査報告](../../design/m6_upload_integrity_review.md)を参照。管理REST PUTは誤ったchecksumヘッダーを拒否せず、binding PUTは誤ったSHA-256を拒否した。300MBのbinding stream保存/HEAD native SHA-256とCLI全量読み戻しが一致し、単独保存のCPUは2msだった。既存service/control/Queueは変更せず、今回作成したprobe payload七件だけをexact keyで削除した。旧token解放・M6全体の300MB受け入れ合格を許可する証拠ではない。private診断script/manifestを消してmutationを再送しない。 + +管理者承認の案Bを実装し、CLI全量SHA-256読戻しのETag/version証拠とWorker HEAD照合、公開時のR2 SHA-256検証/native checksum照合へ集約した。Workerの音源全量再hashは行わない。新規`castloop-m6-test-7c97f1a0`で初期化・小さい公開・300MB公開/全量配信・二種改訂・明示削除まで合格した。途中の試験CLI引数制約を修正し、正常metadata staging/publication未開始のcheckpointだけから続行した。最終pause/空registryを確認し、旧未知owner/tokenと資源は解放・削除しない。[sanitized結果](./large-media-results-20261004.json)と[受け入れ記録](../../design/m6_standalone_acceptance.md)参照。 diff --git a/experiments/m6/large-media-results-20261004.json b/experiments/m6/large-media-results-20261004.json new file mode 100644 index 0000000..913f67a --- /dev/null +++ b/experiments/m6/large-media-results-20261004.json @@ -0,0 +1,48 @@ +{ + "date": "2026-10-04", + "result": "large_media_binary_acceptance_passed", + "worker_name": "castloop-m6-test-7c97f1a0", + "worker_version_id": "77775577-cf6f-47e1-be97-a271f9d618e5", + "maximum_audio": { + "bytes": 300000000, + "sha256": "acef6ef0a2fc8e6ee74e7218eaccfc013a0f89e3e56c1723ab99e5ac6457adee" + }, + "staging_integrity": "CLI full SHA-256 readback and Worker HEAD size/ETag/version", + "publication_integrity": "R2 SHA-256 stream PUT and HEAD native checksum", + "oversized_rejected": true, + "guid_date_history_preserved": true, + "audio_only_and_metadata_only_verified": true, + "get_head_suffix_range_conditional_verified": true, + "cache_hit_paths": [ + "/podcasts/large/feed.xml", + "/podcasts/large/cover.png", + "/podcasts/large/episodes/boundary/ee7a258e-8a20-4cdf-a811-2ed39749a4c9.mp3" + ], + "publication_jobs": [ + "1a091043-e620-4e97-9cc5-adcf1070ebf6", + "ee7a258e-8a20-4cdf-a811-2ed39749a4c9", + "5f4a2cb8-4b3f-4c8b-b191-a3487c1e0a56", + "d0811f94-52ac-43ed-9d27-e667f726d750" + ], + "continuation": { + "checkpoint": "finished-metadata-staging", + "cause": "Test CLI required an audio path even for metadata-only publication", + "staging_operation_id": "542eb77e-2099-45ed-b805-c0d64a957dad", + "publication_journal_absent_before_continuation": true, + "finished_local_and_released_remote_staging_verified": true, + "current_base_and_empty_registry_verified": true, + "original_incomplete_record_retained": true, + "unknown_request_replayed": false + }, + "deletion_job": "28b77bf2-cf57-4f8a-8a53-8f2182d2701c", + "payloads_deleted": true, + "pause_id": "d89332da-8564-42c9-b605-e37a5af0596d", + "final_service_generation": 149, + "final_invocations": 0, + "resources_retained_paused": true, + "readonly_observation_failures": 9, + "paid_plan_or_cpu_limit_changed": false, + "multi_colo_verified": false, + "audio_listening_quality_verified": false, + "authorizes_release": false +} diff --git a/experiments/m6/verify-large-media.ts b/experiments/m6/verify-large-media.ts index 18dab35..4f95b60 100644 --- a/experiments/m6/verify-large-media.ts +++ b/experiments/m6/verify-large-media.ts @@ -1,16 +1,20 @@ -import { m6ServiceAdminResponseSchema, parseEpisodeDraft, parseServiceConfig, parseShowMetadata, stringifyToml, targetInspectionResponseSchema } from "../../packages/shared/src/index"; +import { m6ServiceAdminResponseSchema, parseEpisodeDraft, parseEpisodeRevision, parseServiceConfig, parseShowMetadata, stagingAdminResponseSchema, stringifyToml, targetInspectionResponseSchema } from "../../packages/shared/src/index"; import type { EpisodeRevision } from "../../packages/shared/src/index"; +import { readLocalDraft } from "../../packages/cli/src/local-draft-journal"; import { validateM6LifecyclePlan } from "../../packages/cli/src/m6-local-lifecycle"; import { readLocalFreshM6Initialization } from "../../packages/cli/src/m6-service-initialization"; import { readLocalM6Update } from "../../packages/cli/src/m6-service-update"; +import { readLocalPublicationJob } from "../../packages/cli/src/publication-journal"; +import { readLocalStagingOperation } from "../../packages/cli/src/staging-journal"; import { createAcceptanceMp3 } from "./acceptance-audio"; import { digestAcceptanceResponse, expectStandaloneRejection, readAcceptanceBytes, standaloneCommand, standaloneOutput } from "./standalone-harness"; import assert from "node:assert/strict"; import { open, readFile, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; -const freshWorkspace = process.argv[2] === "--fresh-acknowledged-workspace"; -if (process.argv.length !== (freshWorkspace ? 4 : 3)) throw new Error("Provide an acknowledged recovery workspace, or --fresh-acknowledged-workspace WORKSPACE"); +const metadataStaged = process.argv[2] === "--metadata-staged-workspace"; +const freshWorkspace = metadataStaged || process.argv[2] === "--fresh-acknowledged-workspace"; +if (process.argv.length !== (freshWorkspace ? 4 : 3)) throw new Error("Provide an acknowledged recovery workspace, --fresh-acknowledged-workspace WORKSPACE, or --metadata-staged-workspace WORKSPACE"); const root = resolve(process.argv[freshWorkspace ? 3 : 2]!); const config = parseServiceConfig(await readFile(join(root, "castloop.toml"), "utf8")); const previous = JSON.parse(await readFile(join(root, freshWorkspace ? "acceptance.json" : "completion-recovery-acceptance.json"), "utf8")) as { @@ -47,13 +51,19 @@ async function waitPublished(job: string, episode: boolean): Promise { - const receipt = await command(...(episode ? ["publish-episode", "large", "boundary", audio!] : ["publish-show", "large"])) as { result: string; job_id: string }; + const receipt = await command(...(episode ? ["publish-episode", "large", "boundary", ...(audio ? [audio] : [])] : ["publish-show", "large"])) as { result: string; job_id: string }; assert.equal(receipt.result, "publication-committed"); jobs.push(receipt.job_id); return waitPublished(receipt.job_id, episode); } const get = (path: string, method = "GET", headers: Record = {}) => fetch(new URL(path, config.public_base_url), { method, headers, redirect: "error", signal: AbortSignal.timeout(120000) }); +const token = process.env.CLOUDFLARE_API_TOKEN; +assert.ok(token); +const object = (key: string) => fetch(`https://api.cloudflare.com/client/v4/accounts/${config.account_id}/r2/buckets/${config.bucket_name}/objects/${key}`, + { headers: { Authorization: `Bearer ${token}` }, redirect: "error", signal: AbortSignal.timeout(30000) }); +const revision = async (id: string) => parseEpisodeRevision(new TextDecoder().decode(await readAcceptanceBytes(await object( + `public/episodes/large/boundary/revisions/${id}.toml`)))); async function checkMedia(revision: EpisodeRevision, expected: { bytes: number; sha256: string }): Promise { const response = await get(new URL(revision.enclosure_url).pathname); assert.equal(response.headers.get("Cache-Control"), "public, max-age=0, must-revalidate"); @@ -81,85 +91,145 @@ async function warmCache(path: string): Promise { try { const before = await status(); - assert.equal(before.admission.state, "paused"); - assert.equal(before.admission.pause_id, previous.pause_id); assert.equal(before.worker_version_id, workerVersionId); assert.deepEqual(before.admission.runtime_readiness, retained.state.runtime_readiness); assert.equal(before.admission.invocations.length, 0); - const missing = targetInspectionResponseSchema.parse(await command("target-show", "large")); - assert.equal(missing.show, null); - await writeFile(join(root, "large-media-before.json"), JSON.stringify({ name: config.worker_name, maximum_bytes: 300_000_000, - planned_show: "large", planned_episode: "boundary" }), { mode: 0o600, flag: "wx" }); - phase = "explicit-resume"; - await command("service-resume", previous.pause_id); - phase = "new-show"; - await standaloneOutput(root, "create-show", "large", "https://example.org/castloop-m6-large-media-test"); const directory = join(root, "large"); - const showFile = join(directory, "show.toml"); - const show = parseShowMetadata(await readFile(showFile, "utf8")); - const cover = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6nxsAAAAASUVORK5CYII=", "base64"); - await writeFile(join(directory, "cover.png"), cover, { mode: 0o600, flag: "wx" }); - await writeFile(showFile, stringifyToml({ ...show, image_path: "cover.png" })); - await command("update-show", "large"); - await publish(false); - phase = "small-original-publication"; - await standaloneOutput(root, "create-episode", "large", "boundary"); const episodeFile = join(directory, "episode-boundary.toml"); - const episode = parseEpisodeDraft(await readFile(episodeFile, "utf8")); - const smallFile = join(directory, "small.mp3"); - const small = await createAcceptanceMp3(smallFile, 50); - await command("update-episode", "large", "boundary"); - await command("update-episode-audio", "large", "boundary", smallFile); - const original = await publish(true, smallFile); - assert.ok(original); - await checkMedia(original, small); - const retryRejection = await expectStandaloneRejection(root, "publication-retry", original.revision_id); - assert.ok(retryRejection.includes("unfinished held owner"), "A completed publication cannot be requeued"); - phase = "actual-cache-hits"; - for (const path of ["/podcasts/large/feed.xml", "/podcasts/large/cover.png", new URL(original.enclosure_url).pathname]) await warmCache(path); - phase = "oversized-local-rejection"; - const oversizedFile = join(directory, "oversized.mp3"); - const oversized = await open(oversizedFile, "wx", 0o600); - try { await oversized.truncate(300_000_001); await oversized.sync(); } finally { await oversized.close(); } - const targetBefore = await target(); - const rejection = await expectStandaloneRejection(root, "update-episode-audio", "large", "boundary", oversizedFile); - assert.ok(rejection.includes("300000000") || rejection.includes("300,000,000"), "Rejection must identify the local size limit"); - assert.deepEqual(await target(), targetBefore); - assert.equal((await status()).admission.invocations.length, 0); - oversizedRejected = true; - phase = "boundary-fixture"; - const largeFile = join(directory, "boundary.mp3"); - const large = await createAcceptanceMp3(largeFile, 719424, 182); - assert.equal(large.bytes, 300_000_000); - boundaryFixture = large; - phase = "boundary-audio-staging"; - console.log(JSON.stringify({ phase, bytes: large.bytes, result: "starting" })); - await command("update-episode-audio", "large", "boundary", largeFile); - phase = "boundary-audio-publication"; - const updated = await publish(true, largeFile); - assert.ok(updated); - assert.equal(updated.length_bytes, large.bytes); - assert.equal(updated.sha256, large.sha256); - assert.equal(updated.guid, episode.guid); - assert.equal(updated.published_at, episode.published_at); - assert.notEqual(updated.enclosure_url, original.enclosure_url); - await checkMedia(updated, large); - await checkMedia(original, small); - console.log(JSON.stringify({ phase, bytes: large.bytes, result: "published" })); + let original: EpisodeRevision; + let updated: EpisodeRevision; + let large: { bytes: number; sha256: string }; + if (metadataStaged) { + assert.equal(before.admission.state, "open"); + const checkpoint = JSON.parse(await readFile(join(root, "large-media-incomplete.json"), "utf8")) as { + result: string; phase: string; publication_jobs: string[]; cache_hit_paths: string[]; + oversized_rejected: boolean; boundary_fixture: { bytes: number; sha256: string }; readonly_observation_failures: number; + }; + assert.equal(checkpoint.result, "large_media_acceptance_incomplete"); + assert.equal(checkpoint.phase, "metadata-only-publication"); + assert.equal(checkpoint.publication_jobs.length, 3); + assert.equal(new Set(checkpoint.publication_jobs).size, 3); + assert.equal(checkpoint.oversized_rejected, true); + for (const id of checkpoint.publication_jobs) { + const job = readLocalPublicationJob(root, config, id); + assert.ok(job.client_state?.phase === "committed" && !job.lock_present); + } + original = await revision(checkpoint.publication_jobs[1]!); + updated = await revision(checkpoint.publication_jobs[2]!); + assert.equal(original.revision_id, checkpoint.publication_jobs[1]); + assert.equal(updated.revision_id, checkpoint.publication_jobs[2]); + large = checkpoint.boundary_fixture; + assert.equal(large.bytes, 300_000_000); + assert.equal(large.sha256, updated.sha256); + assert.equal(updated.length_bytes, large.bytes); + assert.equal(updated.guid, original.guid); + assert.equal(updated.published_at, original.published_at); + const current = await target(); + assert.ok(!current.unfinished_show_operation && current.show?.lifecycle === "active" && current.episode?.lifecycle === "active"); + assert.deepEqual(current.current_revision, updated); + const draft = readLocalDraft(root, config, { kind: "episode", show_id: "large", episode_id: "boundary" }); + assert.ok(draft.client_state?.phase === "editable" && !draft.lock_present && draft.client_state.base_revision_id === updated.revision_id && + draft.client_state.uploads.length === 1 && draft.client_state.uploads[0]!.slot === "episode_metadata"); + const publication = readLocalPublicationJob(root, config, draft.client_state.draft_job_id); + assert.ok(!publication.client_state && !publication.lock_present, "An unknown publication must never be replayed"); + const stageId = draft.client_state.uploads[0]!.operation_id; + const stage = readLocalStagingOperation(root, config, stageId); + assert.ok(stage.client_state?.phase === "finished" && stage.client_state.finish_receipt === "staged" && !stage.lock_present && + stage.client_state.upload.draft_job_id === draft.client_state.draft_job_id && stage.client_state.upload.payloads.length === 1 && + stage.client_state.upload.payloads[0]!.asset === "episode_metadata"); + assert.equal(stage.client_state.upload.expected_show_generation + 1, current.show.generation); + assert.equal(stage.client_state.upload.expected_episode_generation, current.episode.generation); + const inspection = await command("operation-status", "staging", stageId) as { server_status: unknown }; + const remote = stagingAdminResponseSchema.parse(inspection.server_status); + assert.ok(remote.result === "status" && remote.ownership === "released" && !remote.verification_active && !remote.draft_committed && + remote.progress?.outcome === "staged"); + assert.deepEqual(remote.upload, stage.client_state.upload); + assert.deepEqual(remote.progress.readback_receipts, stage.client_state.readback_receipts); + assert.deepEqual(checkpoint.cache_hit_paths, ["/podcasts/large/feed.xml", "/podcasts/large/cover.png", new URL(original.enclosure_url).pathname]); + jobs.push(...checkpoint.publication_jobs); + cacheHits.push(...checkpoint.cache_hit_paths); + oversizedRejected = true; + boundaryFixture = large; + readonlyFailures = checkpoint.readonly_observation_failures; + await writeFile(join(root, "large-media-continuation-before.json"), JSON.stringify({ name: config.worker_name, + worker_version_id: workerVersionId, draft_job_id: draft.client_state.draft_job_id, staged_operation_id: stageId, + checkpoint: "finished-metadata-staging" }), { mode: 0o600, flag: "wx" }); + } else { + assert.equal(before.admission.state, "paused"); + assert.equal(before.admission.pause_id, previous.pause_id); + const missing = targetInspectionResponseSchema.parse(await command("target-show", "large")); + assert.equal(missing.show, null); + await writeFile(join(root, "large-media-before.json"), JSON.stringify({ name: config.worker_name, maximum_bytes: 300_000_000, + planned_show: "large", planned_episode: "boundary" }), { mode: 0o600, flag: "wx" }); + phase = "explicit-resume"; + await command("service-resume", previous.pause_id); + phase = "new-show"; + await standaloneOutput(root, "create-show", "large", "https://example.org/castloop-m6-large-media-test"); + const showFile = join(directory, "show.toml"); + const show = parseShowMetadata(await readFile(showFile, "utf8")); + const cover = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6nxsAAAAASUVORK5CYII=", "base64"); + await writeFile(join(directory, "cover.png"), cover, { mode: 0o600, flag: "wx" }); + await writeFile(showFile, stringifyToml({ ...show, image_path: "cover.png" })); + await command("update-show", "large"); + await publish(false); + phase = "small-original-publication"; + await standaloneOutput(root, "create-episode", "large", "boundary"); + const episode = parseEpisodeDraft(await readFile(episodeFile, "utf8")); + const smallFile = join(directory, "small.mp3"); + const small = await createAcceptanceMp3(smallFile, 50); + await command("update-episode", "large", "boundary"); + await command("update-episode-audio", "large", "boundary", smallFile); + const first = await publish(true, smallFile); + assert.ok(first); + original = first; + await checkMedia(original, small); + const retryRejection = await expectStandaloneRejection(root, "publication-retry", original.revision_id); + assert.ok(retryRejection.includes("unfinished held owner"), "A completed publication cannot be requeued"); + phase = "actual-cache-hits"; + for (const path of ["/podcasts/large/feed.xml", "/podcasts/large/cover.png", new URL(original.enclosure_url).pathname]) await warmCache(path); + phase = "oversized-local-rejection"; + const oversizedFile = join(directory, "oversized.mp3"); + const oversized = await open(oversizedFile, "wx", 0o600); + try { await oversized.truncate(300_000_001); await oversized.sync(); } finally { await oversized.close(); } + const targetBefore = await target(); + const rejection = await expectStandaloneRejection(root, "update-episode-audio", "large", "boundary", oversizedFile); + assert.ok(rejection.includes("300000000") || rejection.includes("300,000,000"), "Rejection must identify the local size limit"); + assert.deepEqual(await target(), targetBefore); + assert.equal((await status()).admission.invocations.length, 0); + oversizedRejected = true; + phase = "boundary-fixture"; + const largeFile = join(directory, "boundary.mp3"); + large = await createAcceptanceMp3(largeFile, 719424, 182); + assert.equal(large.bytes, 300_000_000); + boundaryFixture = large; + phase = "boundary-audio-staging"; + console.log(JSON.stringify({ phase, bytes: large.bytes, result: "starting" })); + await command("update-episode-audio", "large", "boundary", largeFile); + phase = "boundary-audio-publication"; + const boundary = await publish(true, largeFile); + assert.ok(boundary); + updated = boundary; + assert.equal(updated.length_bytes, large.bytes); + assert.equal(updated.sha256, large.sha256); + assert.equal(updated.guid, episode.guid); + assert.equal(updated.published_at, episode.published_at); + assert.notEqual(updated.enclosure_url, original.enclosure_url); + await checkMedia(updated, large); + await checkMedia(original, small); + console.log(JSON.stringify({ phase, bytes: large.bytes, result: "published" })); + phase = "metadata-only-publication"; + await writeFile(episodeFile, stringifyToml({ ...episode, title: "M6 exact 300 MB metadata revision" })); + await command("update-episode", "large", "boundary"); + } phase = "metadata-only-publication"; - await writeFile(episodeFile, stringifyToml({ ...episode, title: "M6 exact 300 MB metadata revision" })); - await command("update-episode", "large", "boundary"); - const metadata = await publish(true, largeFile); + const episode = parseEpisodeDraft(await readFile(episodeFile, "utf8")); + const metadata = await publish(true); assert.ok(metadata); assert.equal(metadata.guid, episode.guid); assert.equal(metadata.published_at, episode.published_at); assert.equal(metadata.enclosure_url, updated.enclosure_url); assert.equal(metadata.sha256, large.sha256); assert.equal(metadata.length_bytes, large.bytes); - const token = process.env.CLOUDFLARE_API_TOKEN; - assert.ok(token); - const object = (key: string) => fetch(`https://api.cloudflare.com/client/v4/accounts/${config.account_id}/r2/buckets/${config.bucket_name}/objects/${key}`, - { headers: { Authorization: `Bearer ${token}` }, redirect: "error", signal: AbortSignal.timeout(30000) }); for (const revision of [original, updated, metadata]) assert.ok((await readAcceptanceBytes(await object( `public/episodes/large/boundary/revisions/${revision.revision_id}.toml`))).length > 0); const path = new URL(updated.enclosure_url).pathname; @@ -208,10 +278,11 @@ try { worker_version_id: workerVersionId, maximum_audio: large, oversized_rejected: true, guid_date_history_preserved: true, audio_only_and_metadata_only_verified: true, get_head_suffix_range_conditional_verified: true, cache_hit_paths: cacheHits, publication_jobs: jobs, deletion_job: plan.request.job_id, pause_id: pauseId, payloads_deleted: true, resources_retained_paused: true, - readonly_observation_failures: readonlyFailures, authorizes_release: false }, null, 2), { mode: 0o600, flag: "wx" }); + readonly_observation_failures: readonlyFailures, continuation_checkpoint: metadataStaged ? "finished-metadata-staging" : "none", + authorizes_release: false }, null, 2), { mode: 0o600, flag: "wx" }); console.log(JSON.stringify({ result: "large_media_binary_acceptance_passed", workspace: root, resources_retained_paused: true })); } catch { - await writeFile(join(root, "large-media-incomplete.json"), JSON.stringify({ result: "large_media_acceptance_incomplete", phase, publication_jobs: jobs, + await writeFile(join(root, metadataStaged ? "large-media-continuation-incomplete.json" : "large-media-incomplete.json"), JSON.stringify({ result: "large_media_acceptance_incomplete", phase, publication_jobs: jobs, cache_hit_paths: cacheHits, oversized_rejected: oversizedRejected, boundary_fixture: boundaryFixture, readonly_observation_failures: readonlyFailures, resources_not_deleted: true, authorizes_recovery: false }, null, 2), { mode: 0o600, flag: "wx" }); console.error(`Large media acceptance did not complete (${phase}); preserve journals, streams and owners without replay or automatic release.`); diff --git a/packages/cli/src/m6-test-cli.ts b/packages/cli/src/m6-test-cli.ts index 123818a..3453919 100644 --- a/packages/cli/src/m6-test-cli.ts +++ b/packages/cli/src/m6-test-cli.ts @@ -38,7 +38,8 @@ target-show SHOW_ID | target-episode SHOW_ID EPISODE_ID create-show SHOW_ID SITE_URL | create-episode SHOW_ID EPISODE_ID update-show SHOW_ID | update-episode SHOW_ID EPISODE_ID update-episode-audio SHOW_ID EPISODE_ID MP3_PATH -publish-show SHOW_ID | publish-episode SHOW_ID EPISODE_ID MP3_PATH +publish-show SHOW_ID | publish-episode SHOW_ID EPISODE_ID [MP3_PATH] +MP3_PATH is required only when audio has been staged for this draft. publication-retry JOB_UUID (same acknowledged commit; refuses active or unknown execution) preview-show-lifecycle SHOW_ID unpublish|restore|delete preview-episode-lifecycle SHOW_ID EPISODE_ID unpublish|restore|delete @@ -61,7 +62,7 @@ async function main(): Promise { "preview-show-lifecycle": 2, "preview-episode-lifecycle": 3, "lifecycle-execute": 3, "lifecycle-retry": 3, "publication-retry": 1, "operation-status": 2, "create-show": 2, "create-episode": 2, "update-show": 1, "update-episode": 2, "update-episode-audio": 3, "publish-show": 1, "publish-episode": 3 }; if (!Object.hasOwn(counts, command)) throw new Error("Unknown test command; use --help"); - argumentsFor(args, counts[command]!); + argumentsFor(args, command === "publish-episode" && args.length === 2 ? 2 : counts[command]!); const root = process.cwd(); if (!lstatSync(root).isDirectory()) throw new Error("Test workspace must be a real directory"); const config = parseServiceConfig(readFileSync(join(root, "castloop.toml"), "utf8")); diff --git a/packages/cli/src/test-support/verify-operation-status-binary.ts b/packages/cli/src/test-support/verify-operation-status-binary.ts index c41006f..be6c6e1 100644 --- a/packages/cli/src/test-support/verify-operation-status-binary.ts +++ b/packages/cli/src/test-support/verify-operation-status-binary.ts @@ -101,6 +101,10 @@ try { requireCheck(child.exitCode === (args[0] === "--help" ? 0 : 1), "Test binary did not reject a production resource or unsupported command"); requireCheck(!Buffer.concat([child.stdout, child.stderr]).toString().includes("private-secret"), "Test binary printed a secret"); } + const metadataOnly = Bun.spawnSync([m6TestBinary, "publish-episode", "daily", "first"], { cwd: root, + env: { PATH: process.env.PATH ?? "" }, stdout: "pipe", stderr: "pipe" }); + requireCheck(metadataOnly.exitCode === 1 && metadataOnly.stderr.toString().includes("Only explicitly named isolated M6 test resources"), + "Metadata-only publication arguments must pass parsing without bypassing production rejection"); requireCheck(JSON.stringify(snapshot(root)) === before, "Test binary changed a rejected workspace"); } requireCheck(calls.length === 4 && calls.every((call) => call.action === "status"), "Standalone inspection sent an unexpected request"); From 8bc79f5687f7c536f1b9ea4ea7379429a4b2796e Mon Sep 17 00:00:00 2001 From: Akira Shimosako Date: Sun, 4 Oct 2026 13:11:21 +0900 Subject: [PATCH 13/16] feat(m6): connect formal CLI and Worker with shared runners --- AGENTS.md | 9 +- README.md | 169 ++---- design/m6_implementation_log.md | 7 + design/m6_review_queue.md | 8 +- design/m6_standalone_acceptance.md | 4 +- docs/linux_smoke_test.md | 2 + docs/release-next.md | 22 + package-lock.json | 4 +- package.json | 2 +- packages/cli/src/administrator-key.ts | 26 + packages/cli/src/help.test.ts | 32 +- packages/cli/src/help.ts | 89 +++- packages/cli/src/index.ts | 734 ++++---------------------- packages/cli/src/m6-commands.ts | 153 ++++++ packages/cli/src/m6-test-cli.ts | 224 +------- packages/cli/src/worker-payload.ts | 4 +- scripts/build-cli.ts | 5 +- src/m6-setup-worker.ts | 12 +- src/m6-worker.ts | 6 - src/worker.ts | 25 + 20 files changed, 527 insertions(+), 1010 deletions(-) create mode 100644 docs/release-next.md create mode 100644 packages/cli/src/administrator-key.ts mode change 100755 => 100644 packages/cli/src/index.ts create mode 100644 packages/cli/src/m6-commands.ts create mode 100644 src/worker.ts diff --git a/AGENTS.md b/AGENTS.md index fcc806f..e5ffcbd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ Follow the commands and conventions below. - Store non-secret service settings in `castloop.toml` at the working-directory root. Keep unpublished job IDs in a git-ignored local state file, not in service TOML. - The public URL is a Worker URL; a custom domain is optional. Store `public_base_url` in the service config, never an R2 public URL in show metadata. - Local TOML files are the editable source; R2 stores published snapshots and job status. -- M6 new Show registration claims a permanent `reservation_id` in the Show control record before completing its matching reservation record; block mutations while registration is incomplete, never recreate an advanced or deleted Show, and retain the identity after deletion. New Episode controls are initialized only under the exact Show staging owner after checking for orphan data. These internal paths do not authorize exposing M6 write commands before the release gates pass. +- M6 new Show registration claims a permanent `reservation_id` in the Show control record before completing its matching reservation record; block mutations while registration is incomplete, never recreate an advanced or deleted Show, and retain the identity after deletion. New Episode controls are initialized only under the exact Show staging owner after checking for orphan data. Formal M6 development commands reuse these runners and must preserve runtime readiness and admission checks. - `create-show` requires a real site URL supplied by the administrator and writes it into the local `show.toml`; publication requires `site_url`. `update-show` stages Show TOML and cover art in R2 without publishing; only `publish-show` creates its commit marker and starts publication. - `create-episode` fills `published_at` with the current timestamp as a quoted RFC 3339 string including seconds and an offset. Render RSS pubDate as RFC 2822. - Service and show metadata: `system/service.toml` and `system/shows//show.toml`. @@ -34,7 +34,7 @@ Follow the commands and conventions below. - `create-episode` creates a local TOML draft. `update-episode` stages only metadata, and `update-episode-audio` stages only MP3 audio in R2. Neither update command publishes. Only `publish-episode` validates the staged inputs and writes `commit.json` last; initial and subsequent publications both require an explicit publish command. - Keep one job ID for an unpublished draft. After writing `commit.json`, freeze that draft; use a new job ID for later edits. Reject stale local metadata rather than silently publishing an older staged copy. - Do not assume R2 binding CAS applies to the Cloudflare R2 REST object PUT endpoint. Dedicated M6 tests observed overwrites despite a mismatched If-Match header; this does not prove that aborted PUTs continue writing. The administrator decided on 2026-10-01 to retain simple REST single-object PUTs for M6 and assume that a PUT does not create or update an object later after client disconnection. Record this as an unverified assumption and unresolved concern U1 in design/m6_upload_recovery_options.md, not a Cloudflare guarantee. Do not require multipart uploads or resolution of U1 as an M6 release gate. Preserve upload admission, size/content verification and owner/generation-checked recovery; never release a still-running PUT or publication consumer merely because time elapsed or HEAD found no object. Do not contact Cloudflare support about U1. -- Keep the immutable published audio and revision history during normal publication. M6 explicit deletion is the approved exception, but do not expose it before admission, delivery, recovery and deletion gates pass. Decide staging audio retention separately from published media; do not apply a blanket expiration rule to active or recoverable drafts. +- Keep the immutable published audio and revision history during normal publication. M6 explicit deletion is the approved exception and uses confirmed, admission-checked lifecycle runners. Decide staging audio retention separately from published media; do not apply a blanket expiration rule to active or recoverable drafts. - The administrator approved M6 retention on 2026-10-01: explicitly delete payloads, but retain necessary small reservations, lifecycle/tombstone records, frozen requests, commit markers and job status/progress without automatic expiration. Do not copy titles, descriptions, email addresses or secrets into retained operational records. Persist only allowlisted diagnostic codes/messages, not arbitrary exception messages. Keep unfinished/recoverable job records; time-based audit cleanup is deferred. See design/m6_review_queue.md. - Published feeds and media: `public/podcasts//feed.xml`, `cover.`, and `episodes//.mp3`. - Current episode metadata: `public/episodes///metadata.toml`. @@ -50,14 +50,15 @@ Follow the commands and conventions below. - Overwrite the Show cover art at the approved `public/podcasts//cover.` key on image updates and purge its cache tag. Do not mark a publication job as complete until feed and applicable cover-art Workers Cache purges succeed. Retry purge failures without duplicating published media. ## Milestones +- On 2026-10-04, the administrator approved completing the MVP by connecting existing runners to the formal CLI/Worker, testing changed entry points and one formal-binary walkthrough, and writing concise usage/recovery guidance. Reuse completed 300 MB, six lifecycle operation and compatible-update acceptance. Do not add a dedicated fresh-init fault harness, exhaustive failure matrix, legacy conversion, zero-downtime migration or generic future-version framework. Unknown IO and residual locks remain blocked; universal external recovery is not an MVP release gate. Preserve owner/token checks and never release on elapsed time or absent HEAD alone. This scope supersedes older broader gate descriptions; see design/m6_review_queue.md. - M0 validates Cloudflare authentication, resource creation, R2 media delivery, Workers Caching, MP3 upload/analysis, and the R2 event-to-Queue path. - M1 delivers service initialization, local metadata models, ID validation, and show reservation. - M2 publishes a show through `update-show`/`publish-show` and one episode end-to-end using separate metadata/audio staging, explicit publish, job status, feed generation, and media delivery. - M3 supports multiple episodes, metadata-only and audio-only revisions, concurrency/retry handling, and cleanup. - M4 delivers the Bun executable and usage documentation. - M5 removes Wrangler from the build and administrator workflows and verifies binary-only publication and recovery. See design/initial_design.md and design/m5_implementation_log.md. -- M6 is the next milestone after v0.1.2: Episode and Show unpublishing/deletion, with explicit restoration proposed as the reversible counterpart. See design/m6_content_lifecycle_plan.md and design/m6_implementation_log.md. The R2 control-record approach is decided; strict schemas and atomic-admission/public-visibility primitives are implemented but not connected to live routes. Complete the cache/REST/upload/migration gates before exposing lifecycle commands. -- On 2026-10-03, the administrator approved separating fresh M6 initialization, compatible M6 Worker updates, and legacy data conversion needed only on demand. Fresh initialization and compatible updates have internal CAS/journal/REST implementations, not released CLI or HTTP routes. Share runtime verification without a generic operation state machine; keep legacy migration audit evidence separate from current-version runtime readiness. Finish paused and require explicit resume. Never infer runtime readiness from REST settings alone, adopt unknown resources, replay unknown deploys, or bypass unfinished upload/publication owners. See design/m6_fresh_initialization.md and design/m6_compatible_updates.md. Existing Cloudflare resources are not authorized for immediate deletion. +- M6 is the next milestone after v0.1.2: Episode and Show unpublishing/deletion with explicit restoration. See design/m6_content_lifecycle_plan.md and design/m6_implementation_log.md. Formal development CLI/Worker entry points connect the existing R2 control-record, atomic-admission and lifecycle-aware delivery runners. Respect the approved MVP scope below; do not infer legacy migration permission or release readiness from source availability alone. +- On 2026-10-03, the administrator approved separating fresh M6 initialization, compatible M6 Worker updates, and legacy data conversion needed only on demand. Fresh initialization and compatible updates use CAS/journal/REST implementations connected to the formal development CLI/Worker; a packaged release remains separate. Share runtime verification without a generic operation state machine; keep legacy migration audit evidence separate from current-version runtime readiness. Finish paused and require explicit resume. Never infer runtime readiness from REST settings alone, adopt unknown resources, replay unknown deploys, or bypass unfinished upload/publication owners. See design/m6_fresh_initialization.md and design/m6_compatible_updates.md. Existing Cloudflare resources are not authorized for immediate deletion. - The administrator decided on 2026-10-02 to use an isolated M6 test environment in the same Cloudflare account as existing services and to build migration around a planned maintenance window with temporary public-delivery and mutation downtime. Complete the full M6 feature set and release it as the next version; a limited pilot release and zero-downtime migration are not prerequisites. Defer cost and downtime measurement planning, and consideration of zero-downtime migration, until after MVP construction. Do not block ordinary implementation or feature completion on measurement approval. Preserve functional and safety acceptance, including correct 300 MB handling within runtime limits, old-IO quiescence, cache-safe delivery and owner/token-checked recovery. This does not authorize an immediate production migration, unverified destructive commands, unlimited spending or paid-plan changes. See design/m6_review_queue.md. - Custom-domain work retains its approved plan and existing groundwork but follows M6. Do not expose domain add/remove before their existing migration and recovery gates pass. Lifecycle state must also be respected by future domain migration. diff --git a/README.md b/README.md index 5d9916f..24879e1 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,28 @@ # castloop -castloop is a serverless podcast hosting program. Once deployed, it runs with minimal ops. +Serverless podcast hosting on Cloudflare: one private R2 bucket and one public Worker per service, multiple Shows, managed through a standalone CLI. -## Overview -- Hosts public podcasts on CloudFlare. -- One deployment can host multiple shows. -- A CLI (`castloop`) manages shows and episodes. +**This checkout is the unreleased M6 development version.** It supports new M6 services and compatible updates of initialized M6 services. It does not convert or adopt v0.1.x services. The published [v0.1.2 binary](https://github.com/simosako/castloop-v2/releases/tag/v0.1.2) has different commands and no lifecycle operations; its historical instructions are in [the Linux smoke guide](docs/linux_smoke_test.md). -## Install the CLI (Linux x86-64) +## Build and install -Download `castloop-linux-x64`, `SHA256SUMS`, `THIRD_PARTY_NOTICES.md`, and `LICENSE` from the [v0.1.2 release](https://github.com/simosako/castloop-v2/releases/tag/v0.1.2). Verify the checksum in the download directory and install the binary: +The build machine needs Bun 1.4.2 and Node.js/npm: ```sh -sha256sum --check SHA256SUMS -mkdir -p "$HOME/.local/bin" -install -m 0755 castloop-linux-x64 "$HOME/.local/bin/castloop" +npm ci +npm run check +bun test +npm run build:cli -- linux-x64 +sha256sum dist/castloop-linux-x64 +install -m 0755 dist/castloop-linux-x64 "$HOME/.local/bin/castloop" castloop --version ``` -The binary includes the CLI and deployable Worker. **v0.1.2 is distributed for Linux x86-64 only.** The target machine needs `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` in its environment; it needs no Node.js/npm, Wrangler, Bun, `ffprobe`, R2 S3 credentials, or source tree. Enable R2 and create an account-scoped API token in the Cloudflare Dashboard before running `init`. The tested management operations require Workers Scripts, Workers R2 Storage, and Queues permissions. Do not put the token in `castloop.toml` or Git. For an optional check on a separate Linux x86-64 machine, see the [smoke test guide](docs/linux_smoke_test.md). - -### Building from source +The target Linux x86-64 machine needs no Bun, Node.js, Wrangler, source tree, `ffprobe`, or R2 S3 credentials. Enable R2 and Queues and supply an account-scoped Cloudflare API token with Workers Scripts, Workers R2 Storage, and Queues permissions. Never store it in TOML or Git. Experimental macOS/Windows cross-builds are not distributed or runtime-verified. -The build machine needs Bun 1.4.2 and Node.js/npm for dependency installation and checks. Build from this checkout with `npm ci`, `npm run check`, `bun test`, and `npm run build:cli -- linux-x64`. The Worker bundle and CLI are built without Wrangler. The build script also supports experimental `macos-x64`, `macos-arm64`, and `windows-x64` cross-compilation, but those targets are not distributed. +## Initialize and publish -The [release workflow](.github/workflows/build-binaries.yml) checks the code, builds a Linux x86-64 binary, runs an on-runner smoke check, and attaches the executable, checksum, [LICENSE](LICENSE), and [third-party notices](THIRD_PARTY_NOTICES.md) to a GitHub Release on a `v*` tag. Include both license files with redistributed binaries. - -castloop is distributed under the [MIT License](LICENSE). Third-party dependency licenses are listed separately in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). - -### Initialize and publish +Use unused resource names and a new workspace. Initialization verifies the deployed runtime and completes **paused**. It does not adopt existing resources or automatically resume. ```sh export CLOUDFLARE_ACCOUNT_ID=... CLOUDFLARE_API_TOKEN=... @@ -36,127 +30,78 @@ castloop init /path/to/workspace \ --service-id my-service --bucket-name my-private-bucket \ --workers-subdomain my-account-subdomain cd /path/to/workspace +castloop service-status +castloop service-resume PAUSE_UUID castloop create-show my-show --site-url https://your-real-site.example/podcast # Edit my-show/show.toml and provide the JPEG/PNG named in image_path. castloop update-show my-show castloop publish-show my-show -castloop job-status SHOW_JOB_ID --show my-show +castloop job-status SHOW_JOB_UUID cd my-show -castloop create-episode first-episode -# Edit episode-first-episode.toml and provide an MP3. -castloop update-episode first-episode -castloop update-episode-audio first-episode audio.mp3 -castloop publish-episode first-episode +castloop create-episode first +# Edit episode-first.toml and provide an MP3. +castloop update-episode first +castloop update-episode-audio first audio.mp3 +castloop publish-episode first audio.mp3 cd .. -castloop job-status EPISODE_JOB_ID --show my-show --episode first-episode +castloop job-status EPISODE_JOB_UUID ``` -The publish commands print their job IDs. A publication is finished when `job-status` reports `status.state: "published"` **and** `owner.state: "free"`. The public feed is at `/podcasts/my-show/feed.xml`; `public_base_url` is in `castloop.toml`. Staging with `update-show`, `update-episode`, or `update-episode-audio` does not publish anything. Replace the example site URL above with a real URL supplied by the administrator. See [`examples/show.toml`](examples/show.toml) and [`examples/episode.toml`](examples/episode.toml) for metadata shapes; `create-show` and `create-episode` generate working local drafts with unique IDs and timestamps. +Use the `pause_id` from `service-status`, not a newly generated ID, to resume. Replace the example website with the administrator's real site URL. A publish receipt means submitted, not finished: wait for `server_status.status.state: "published"` and `server_status.ownership: "released"` before another operation on that Show. The feed is `/podcasts/my-show/feed.xml`; settings are in `castloop.toml`. -For a metadata-only Episode revision, edit the local Episode TOML and run `update-episode ID`, then `publish-episode ID`. For an audio-only revision, run `update-episode-audio ID file.mp3`, then `publish-episode ID`. Keep the Episode GUID and original `published_at` unchanged. Old public MP3s and revision history are retained. After a successful publication, `cleanup-job JOB_ID --show my-show --episode ID` removes only its redundant staged MP3. +Updates stage inputs without publishing. For metadata-only Episode revisions, run `update-episode ID`, then `publish-episode ID` **without an MP3 argument**. For audio-only revisions, run `update-episode-audio ID file.mp3`, then `publish-episode ID file.mp3`. Whenever audio is staged, publication requires the unchanged original MP3. Keep GUID and `published_at` unchanged. Normal revisions retain immutable published media and history. -### Update the executable and Worker +MP3s are limited to 300,000,000 bytes. The CLI validates duration, uploads and reads back the complete object to verify its size/SHA-256 and identity. The Worker checks that evidence against R2 HEAD; R2 verifies SHA-256 when saving immutable published audio. Workers Paid is not required by this implementation. Staging audio has no automatic expiration or general cleanup command in this MVP. -Download the new release binary and its checksum to a temporary directory, verify it, then replace the old binary and deploy the embedded Worker **for each service workspace**: +## Unpublish, restore, or delete -```sh -mkdir -p /tmp/castloop-update -cd /tmp/castloop-update -curl -fLO https://github.com/simosako/castloop-v2/releases/download/v0.1.2/castloop-linux-x64 -curl -fLO https://github.com/simosako/castloop-v2/releases/download/v0.1.2/SHA256SUMS -sha256sum --check SHA256SUMS -install -m 0755 castloop-linux-x64 "$HOME/.local/bin/castloop.next" -mv "$HOME/.local/bin/castloop.next" "$HOME/.local/bin/castloop" -cd /path/to/workspace -castloop deploy -``` - -For source builds, use the commands in [Building from source](#building-from-source). Back up `castloop.toml` and the private `.castloop/` directory before moving a workspace. Do not replace `.castloop/secrets.json` or `.castloop/state.json` when updating. `castloop deploy` deploys the Worker bundled with the current binary through Cloudflare's API, preserving existing secret bindings. Re-running `init` in an existing workspace resumes unfinished initialization without recreating resources when the resource-creation steps have been recorded in `.castloop/state.json`. - -### Troubleshooting - -| Symptom | Action | -| --- | --- | -| Cloudflare API returns HTTP 403 | Check the API token's account scope and Workers Scripts, Workers R2 Storage, and Queues permissions. | -| `init` stopped partway | After deployment, `init` waits up to two minutes for Worker health, retrying temporary failures every five seconds and printing progress. Bad credentials fail immediately. If it still fails, keep the workspace and run `castloop init /path/to/workspace` again without flags. Completed initialization steps are retained; resources are not automatically deleted. Inspect `.castloop/state.json` and Cloudflare resources if it still fails. The v0.1.1 Release binary uses the older six-attempt wait. | -| Show ID already reserved | Use a new Show ID or inspect the existing reservation. A reservation alone does not publish a Show. | -| Local TOML or MP3 changed after staging | Re-run the corresponding `update-*` command before `publish-*`. A committed job is frozen; later edits need a new job. | -| `retrying` or `processing` with `dlq: true` | Inspect `job-status` and the Show admission; after resolving the transient issue, use `castloop retry-job JOB_ID --show my-show [--episode ID]` to resume the **same** job. A DLQ record remains as history even after recovery. | -| `failed` or admission still held | `retry-job` only requeues a job whose status is `retrying` or `processing` and whose DLQ record exists. A permanent `failed` job cannot currently be repaired with that command. Preserve the workspace and inspect status, frozen commit, and published keys; do not start another job for the Show or delete its reservation while a partial publication may exist. Recovery may require manual R2 repair. | - -If a resource-creation request in `init` reaches Cloudflare but its response is lost before `.castloop/state.json` records completion, retrying may report that the bucket or Queue already exists. Keep the same workspace and inspect Cloudflare resources before attempting manual recovery; automatic reconciliation of this ambiguous case is not yet implemented. - -The CLI reports Cloudflare failures without printing API credentials. See [`design/m2_implementation_log.md`](design/m2_implementation_log.md) and [`design/m3_implementation_log.md`](design/m3_implementation_log.md) for publication and recovery behavior. - -## Development - -The build machine needs Bun 1.4.2 and Node.js/npm. Wrangler is not a project dependency. From a checkout: +Run from the service workspace. Preview is read-only; save and review the exact target, action and request hash: ```sh -mise install -npm ci -npm run check -bun test -npm run build:cli -- linux-x64 +castloop preview-episode-lifecycle my-show first unpublish > plan.json +# Read plan.json and copy preview.request_sha256. +castloop lifecycle-execute plan.json REQUEST_SHA256 confirm +castloop operation-status lifecycle JOB_UUID ``` -Do not commit credentials, `.castloop/`, or unpublished media. +Replace `unpublish` with `restore` or `delete`. For a whole Show, use `preview-show-lifecycle my-show ACTION`. Delete requires **`confirm-delete-retain-records`** instead of `confirm`. Never substitute a new preview after confirmation; stale generations are rejected. -## Source CLI (development) +- Unpublish hides content with HTTP 404; restore preserves GUID, revisions and media. +- Delete physically removes payloads and is irreversible. Deleted IDs cannot be reused. Necessary small reservations, tombstones, frozen requests, commit markers and status/progress records are retained indefinitely without copying content text or secrets. +- Deleting/deleted content returns 410. Public responses require cache revalidation; `system/` and `staging/` are never public. +- Operations do not interrupt unfinished uploads/publications. Check lifecycle status for completed/released ownership, not just a commit receipt. -The source CLI runs with Bun. From this repository, use `bun packages/cli/src/index.ts` (or `npm run cli --`). Source mode bundles `src/index.ts` from this checkout; the compiled executable deploys its embedded Worker instead. +## Compatible Worker updates -```sh -export CLOUDFLARE_ACCOUNT_ID=... CLOUDFLARE_API_TOKEN=... -bun packages/cli/src/index.ts init /path/to/workspace \ - --service-id my-service --bucket-name my-private-bucket \ - --workers-subdomain my-account-subdomain -cd /path/to/workspace -bun /path/to/castloop-v2/packages/cli/src/index.ts create-show my-show \ - --site-url https://example.com/my-show -cd my-show -bun /path/to/castloop-v2/packages/cli/src/index.ts create-episode first-episode -``` - -Missing arguments are prompted for on an interactive terminal. Initialization creates a private R2 bucket, Queue, DLQ, Worker and commit-marker notification. It stores non-secret service settings in `castloop.toml`; `.castloop/` contains the local admin key and retry state and must remain private. An incomplete `init` or `create-show` can be rerun with the same workspace and ID. For details and verification, see [`design/m1_implementation_log.md`](design/m1_implementation_log.md). - -## Publication commands from the source checkout - -Run `deploy` from the workspace root after updating the Worker source. Edit the local Show TOML and cover first. The update commands only stage inputs; publication always requires an explicit publish command. +Back up `castloop.toml` and the private `.castloop/` directory, replace the executable with the verified new build, then run from each **already initialized M6** workspace: ```sh -cd /path/to/workspace -bun /path/to/castloop-v2/packages/cli/src/index.ts deploy -bun /path/to/castloop-v2/packages/cli/src/index.ts update-show my-show -bun /path/to/castloop-v2/packages/cli/src/index.ts publish-show my-show -cd my-show -bun /path/to/castloop-v2/packages/cli/src/index.ts create-episode first-episode -# Edit episode-first-episode.toml and prepare an MP3. -bun /path/to/castloop-v2/packages/cli/src/index.ts update-episode first-episode -bun /path/to/castloop-v2/packages/cli/src/index.ts update-episode-audio first-episode audio.mp3 -bun /path/to/castloop-v2/packages/cli/src/index.ts publish-episode first-episode -cd .. -bun /path/to/castloop-v2/packages/cli/src/index.ts job-status JOB_ID --show my-show --episode first-episode +castloop service-pause NEW_PAUSE_UUID +castloop service-status +# Wait for invocations to settle and unfinished Show owners to complete. +castloop deploy +castloop service-status +castloop service-resume NEW_PAUSE_UUID ``` -MP3 duration is analyzed by the CLI before upload; unreadable or unsupported input is rejected. A job showing `retrying` and `dlq: true` can be explicitly requeued with `retry-job JOB_ID --show my-show --episode first-episode`. Show jobs omit `--episode`. See [`design/m2_implementation_log.md`](design/m2_implementation_log.md) for the verified scope and recovery details. - -For subsequent Episode revisions, run `update-episode ID` only when TOML changed, or `update-episode-audio ID file.mp3` only when audio changed, then `publish-episode ID`. Unchanged published inputs are reused; GUID and the original `published_at` must stay the same. A stale base revision is rejected. To remove only the redundant staged MP3 after a job is published, use `cleanup-job JOB_ID --show my-show --episode ID` from the workspace root. Published media, revision metadata, and commit markers remain available. See [`design/m3_implementation_log.md`](design/m3_implementation_log.md). - -## Next milestone: M6 content lifecycle +Pause temporarily stops delivery and new mutations. Paused does not necessarily mean drained. Deployment preserves data, media URLs and secrets, verifies the new runtime, and remains paused until explicit resume. There is no legacy conversion, resource adoption, automatic rollback or zero-downtime update in this MVP. -**v0.1.2 does not support Episode or Show deletion, unpublishing, or restoration.** Removing local files does not stop public delivery. `cleanup-job` only removes redundant staged audio, not published content. +## Errors and recovery limits -The next milestone is [M6: Episode and Show unpublishing and deletion](design/m6_content_lifecycle_plan.md). The R2 control-record approach is decided. Foundations include strict schemas, atomic admission, per-invocation execution tokens, reserved-only abandonment, versioned job status/progress with allowlisted diagnostics, guarded completion, read-only migration/deletion inventories, owner-gated payload deletion batches and verification passes, lifecycle-aware feed inputs, and an uncached public gateway. The Show/Episode unpublish state machine has automated failure/recovery tests with injected feed and purge effects. Dedicated Cloudflare tests previously verified the uncached gateway/cached inner entrypoint and R2 admission races. These modules are not connected to live routes yet. M6 deletion will remove payloads while retaining necessary small control/audit records without automatic expiration; it will not copy content text or secrets into those records. M6 will retain simple Cloudflare REST single-object PUT uploads, assuming that a PUT does not create or update an object later after client disconnection. This is an explicit design assumption, not a verified Cloudflare guarantee; the [unresolved concern U1](design/m6_upload_recovery_options.md) is retained without blocking M6 or requiring multipart uploads. Upload admission, verification and interruption recovery still need integration. Lifecycle commands require migration, recovery and production regression gates before becoming available. Progress is recorded in the [M6 implementation log](design/m6_implementation_log.md); [approved policies and remaining gates](design/m6_review_queue.md) distinguish policy decisions from technical work. +Preserve the workspace, journals, locks and remote owners after any error. Do not rerun unknown requests, delete locks, replace administrator secrets or release tokens because time elapsed or HEAD found no object. An unknown/forcibly interrupted invocation can remain blocked; universal recovery is outside this MVP. -Custom-domain work retains its approved plan and groundwork but follows M6. `domain add/list/remove` remain unavailable. +- `local-operation-status FAMILY ID` inspects local records without credentials or network access. `operation-status FAMILY ID` compares them with authenticated server status. Families: `staging`, `publication`, `lifecycle`, `show-registration`. +- `retry-job JOB_UUID` and `lifecycle-retry JOB_UUID REQUEST_SHA256 ACK` explicitly retry the same acknowledged commit only when execution has safely settled; they refuse active/unknown execution. +- `init-reconcile OPERATION_UUID` and `update-service-reconcile OPERATION_UUID` reconcile already completed, paused server operations with retained local requests. They do not repeat deployments. `update-service-verify OPERATION_UUID` continues only an acknowledged deployment's verification. +- Initialization journals are `.castloop/service-initializations/SERVICE_ID.json`; update journals are `.castloop/service-updates/SERVICE_ID/OPERATION_UUID.json`. Retain these and their locks. Re-running `init` is not a recovery method for a started operation. +- HTTP 403: check account/token scope and permissions. Local inputs changed after staging: restage only an editable, unclaimed draft; committed/unknown operations stay frozen. -The approved M6 delivery plan is to complete the full feature set and release it as the next version, using isolated test resources in the same Cloudflare account. Migration may temporarily stop public delivery and administrative writes during a planned maintenance window; zero-downtime migration is deferred. Cost and downtime measurement planning follows MVP construction and does not block implementation. Functional and safety acceptance still apply before destructive commands become available. This is not an immediate migration of an existing service or a claim of measured costs or downtime. +The single REST PUT recovery model retains [unverified assumption U1](design/m6_upload_recovery_options.md): a PUT is assumed not to write later after client disconnection. This is not a Cloudflare guarantee. Owner/generation checks and explicit IO settlement remain required. -Current source builds also include read-only `castloop migration-status` for a separately deployed migration bridge/candidate Worker. It does not deploy, migrate, resume writes or certify M6 readiness; legacy Workers and the released v0.1.2 binary do not provide this new diagnostic path. The [bootstrap contract](design/m6_migration_bootstrap.md) and [client journal contract](design/m6_migration_client.md) describe the isolated implementation and remaining release gates. Do not deploy these candidate modules to an existing service until the migration/cutover procedure and recovery gates are complete. +## Development and release scope -Source builds additionally provide `castloop local-operation-status FAMILY ID` from the service workspace. Use `staging` with an upload operation ID, `publication`/`lifecycle` with a job ID, or `show-registration` with a Show ID. This reads only the local M6 journal and observes its lock, without credentials, network access, writes or lock removal. Missing records and local phases do not prove remote absence/completion or authorize recovery. The released v0.1.2 binary does not include this command. See the [offline diagnostic contract](design/m6_local_operation_status.md). +Use `castloop help COMMAND` for syntax. Source mode is `bun packages/cli/src/index.ts`; it bundles `src/worker.ts`, while compiled binaries contain the Worker. Test-only fault injection and cache nonce headers are excluded from the formal entry points. Do not commit `.castloop/`, credentials or unpublished media. -Source builds also provide `castloop operation-status FAMILY ID` with the same families and IDs. It compares a frozen local journal with one authenticated read-only M6 server status request using the local administrator key, without Cloudflare API credentials or local changes. It never promotes phases, retries mutations or removes locks. Legacy Workers and unreleased candidate management routes reject this request; it does not enable those routes or certify readiness. The released v0.1.2 binary does not include it. See the [remote diagnostic contract](design/m6_remote_operation_status.md). +[M6 acceptance](design/m6_standalone_acceptance.md) and [approved scope](design/m6_review_queue.md) distinguish verified behavior from remaining release preparation. Custom domains, old-format conversion, zero-downtime migration and cost/downtime measurement follow the MVP. Existing Cloudflare resources are not automatically deleted. -Current source builds and binaries built from this checkout also provide `castloop migration-preflight LEGACY_VERSION_ID`. Run it from an existing service workspace with `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN`; it needs only `castloop.toml`, not the local admin secret or Wrangler. It checks the expected legacy Worker version at 100% traffic through Cloudflare GET requests, including bounded, non-executing script inspection when legacy API fields are omitted. It rejects deployment/settings/script drift and unsupported Custom Domains, returns only a read-only snapshot, and does not deploy, create journals, pause delivery, certify old I/O termination or authorize migration completion/recovery. The released v0.1.2 binary does not include this command. See the [preflight contract](design/m6_migration_bridge_preparation.md). +The [release workflow](.github/workflows/build-binaries.yml) builds/checks Linux x86-64 artifacts and publishes on a matching `v*` tag. Include [LICENSE](LICENSE) and [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) with redistributed binaries. castloop uses the MIT License. diff --git a/design/m6_implementation_log.md b/design/m6_implementation_log.md index cd83f5a..e5864d8 100644 --- a/design/m6_implementation_log.md +++ b/design/m6_implementation_log.md @@ -1,5 +1,12 @@ # M6: 公開停止・削除 実装ログ +## 2026-10-04: 承認済みMVP範囲で正式CLI/Workerへ接続 + +- 既存runnerの呼出を`m6-commands.ts`へ共有し、正式CLIの旧state.json/直接PUT/公開処理を置き換えた。試験CLIは専用資源制限とfault injectionだけの薄いwrapperとし、状態機械・receipt検査は増やしていない。管理keyのprivate/fsync付き保存も一か所へ集約した。 +- 正式Worker entryを`src/worker.ts`へ接続し、試験entryはこれにnonce headerだけを加える。通常buildも同じruntime/admission/配信/consumerを使用し、初期化と通常更新はpaused完了・明示再開を維持する。正式入口にfault commandは含めない。 +- README/helpを現行CLIへ整理し、v0.1.2手順との混用、旧形式採用、未知IO/残存lockの強制復旧を禁止した。未確定Releaseは`0.2.0-dev`で区別し、草案を`docs/release-next.md`へ置く。新規fault harnessや網羅的matrixは追加しない。 +- 全893テスト/13526 assertions、TypeScript/M6試験tsconfig、正式/試験Linux buildが合格。追加は正式引数境界とlegacy workspace非変換の2テストだけ。正式バイナリの実機確認は専用の新規資源で進め、既存の300MB・六操作・通常更新の証拠は再利用する。 + ## 2026-10-04: 300MB公開・二種改訂・明示削除の実機合格 - 新規専用`castloop-m6-test-7c97f1a0`でCLI全量SHA-256/ETag/version証拠とWorker HEAD照合、R2 SHA-256 stream PUT/native checksumを使い、正確な300MB公開・全量配信に合格した。300,000,001 bytesのlocal拒否、audio-only/metadata-only、GUID/date/history/旧音源保持、HEAD/suffix Range/304、実cache HIT、最後の明示Show delete/媒体404・公開410も合格した。 diff --git a/design/m6_review_queue.md b/design/m6_review_queue.md index bcff60a..09788ab 100644 --- a/design/m6_review_queue.md +++ b/design/m6_review_queue.md @@ -46,6 +46,12 @@ ## 管理者の判断待ちではない技術残件 +### 承認済みのMVP完成範囲(2026-10-04) + +管理者が開発量を抑える方針を承認した。既存runnerを正式CLI/Workerへ接続し、接続で変わる入口の回帰と正式バイナリでの一通りの実機確認、短い利用案内を完成する。既存の300MB・六lifecycle操作・通常更新の受け入れを再利用し、新規初期化専用fault harnessや網羅的な異常系matrixは追加しない。 + +終了が不明なIO/残存lockは停止・ブロックを保持することをMVPのサポート境界とする。既存の完了照合・安全な同job retryを利用し、万能な外部復旧は完成条件にしない。時間経過やHEAD不在でのowner/token解放は引き続き禁止する。旧形式変換・無停止移行・将来versionの汎用基盤・大規模な再リファクタリングは追加しない。以下の過去の残件記述より、この完成範囲を優先する。 + ### Workers Paidは利用しない(2026-10-04) 管理者がWorkers Paidの利用不可を明示した。Paidへの変更を再要求せず、無料枠を前提に検証処理の責務・実行場所を見直す。既存の300MB上限を勝手に下げず、全量contents照合・object identity/owner/generation照合を単純に省略しない。現行Worker内全量SHA-256のCPU超過は、この実装方式の問題であり、R2へ300MBを保存できないという実証ではない。 @@ -58,7 +64,7 @@ R2/CLIの[完全性検証調査](./m6_upload_integrity_review.md)で、管理RES 通常更新の完了応答喪失→別standaloneの非書込照合と、feed/cover/小さい音源の実cache HIT・gateway毎回実行も実機合格した。改修前の300MB Worker hashはCPU超過となり、元serviceはpaused/owner/token/payloadを保持する。管理者承認のCLI全量SHA-256証拠+Worker HEAD照合+R2公開時検証へ集約した後、新規`castloop-m6-test-7c97f1a0`で300MB公開・audio-only/metadata-only改訂・全量配信/HEAD/Range/304・明示削除まで合格した。試験CLIのmetadata-only引数制約は修正し、publication未開始の確認済みcheckpointだけを続行した。課金/CPU limitは変更しておらず、費用測定計画を通常開発のgateへ戻すものではない。 -残件は新規初期化fault、安全なunknown IO/残存lockの外部終了確認・復旧手順、公開CLI/利用案内・release gateである。300MB合格を旧未知owner/tokenの解放へ使わず、未実行/未完了のharnessを合格扱いしない。詳細は[standalone受け入れ](./m6_standalone_acceptance.md)。旧形式変換は必要時だけとする。 +残件は正式CLI/Workerへの接続、変更入口の検証と正式バイナリの実機確認、利用案内・release準備である。300MB合格を旧未知owner/tokenの解放へ使わず、未実行/未完了のharnessを合格扱いしない。詳細は[standalone受け入れ](./m6_standalone_acceptance.md)。旧形式変換は必要時だけとする。 2026-10-03に管理者が「共通journalリファクタリングをmainへmerge → 別branchで新規M6初期化/稼働判定の分離 → 別branchで形式を変えない通常更新 → 旧形式変換は必要時だけ」を承認した。旧形式変換を新規M6サービスの必須経路にしない。既存環境の即時削除や公開gate解除の承認とは扱わない。 diff --git a/design/m6_standalone_acceptance.md b/design/m6_standalone_acceptance.md index d8b8155..9e53389 100644 --- a/design/m6_standalone_acceptance.md +++ b/design/m6_standalone_acceptance.md @@ -4,7 +4,7 @@ ## 対象と公開制限 -初期の六lifecycle/更新応答喪失/cache試験は`castloop-m6-test-ff3bfd8c`で実施した。後述の300MB失敗後は未知owner/tokenを保持し、再利用・再配備・強制解放をしない。新しい完全性検証方式は別名`castloop-m6-test-7c97f1a0`で検証する。workspaceはそれぞれ`/tmp/opencode/`。既存v0.1.1環境・独自domain・有料planは変更せず、Worker/R2/Queue/DLQ資源自体も削除しない。通常binary/WorkerのM6公開gateは閉じたままである。 +初期の六lifecycle/更新応答喪失/cache試験は`castloop-m6-test-ff3bfd8c`で実施した。後述の300MB失敗後は未知owner/tokenを保持し、再利用・再配備・強制解放をしない。新しい完全性検証方式は別名`castloop-m6-test-7c97f1a0`で検証した。workspaceはそれぞれ`/tmp/opencode/`。既存v0.1.1環境・独自domain・有料planは変更せず、Worker/R2/Queue/DLQ資源自体も削除しない。承認済みMVP範囲で通常binary/Workerを既存runnerへ接続し、正式入口の実機確認を進める。配布済みv0.1.2や既存serviceを切り替えるものではない。 実行scriptは`experiments/m6/README.md`参照。before/plan/acceptance/incompleteはprivate fileであり、secretを含むlocal manifestを報告へ添付しない。mutationは一度だけ、readonly観測は有限回とし、commitの受付とconsumer完了・owner解放・invocation返却を区別する。 @@ -65,7 +65,7 @@ CLIの全量SHA-256読み戻しとPUT receiptのETag/versionが、保存journal/ ## 後続 -新規初期化のfault受け入れ、安全なunknown IO/残存lockの外部終了確認・復旧手順、通常CLI/README/help/version/releaseの完成は残る。費用/停止時間測定と無停止移行は承認済みのMVP後計画とし、旧形式変換は必要時だけ行う。下記の300MB合格を旧環境の未返却owner/token解放や、通常release gateの解除へ流用しない。 +管理者承認により、残件を正式CLI/Workerの接続、変更入口と正式バイナリの一通りの検証、短い利用案内・release準備へ絞る。新規初期化fault harnessや網羅的異常系は追加しない。unknown IO/残存lockは停止・ブロックを保持し、万能な外部復旧をMVP完成条件にしない。費用/停止時間測定・無停止移行・必要時の旧形式変換は後続とする。下記の300MB合格を旧環境の未返却owner/token解放へ流用しない。 ## CLI/R2方式の300MB・二種改訂・明示削除: 合格 diff --git a/docs/linux_smoke_test.md b/docs/linux_smoke_test.md index d1f8297..0afa106 100644 --- a/docs/linux_smoke_test.md +++ b/docs/linux_smoke_test.md @@ -1,5 +1,7 @@ # 別環境での簡易動作確認(Linux x86-64) +**この文書のコマンドは配布済みv0.1.2向けの過去の手順です。現在のcheckoutからビルドするM6版には、そのまま適用しないでください。** M6の初期paused・明示再開、MP3引数付きpublish、停止/再開/削除、通常更新と復旧上限は[README](../README.md)を参照してください。以下のsource buildで得られるものも現在はM6版であり、v0.1.2の再現には対応tagのcheckoutが必要です。 + この手順では、ソースコードのない別のLinuxマシンで配布バイナリを実行します。**第1段階**で`workers.dev`を使って専用の検証サービス・Show・Episodeを公開し、**第2段階**で同じworkspaceを独自ドメインへ移行して戻します。Cloudflare上にWorker、private R2 bucket、Queue、DLQと公開コンテンツが作成されます。通常運用中のworkspaceや既存のShow IDは使わないでください。 **現在の状態:** v0.1.2で実施できるのは第1段階までです。独自ドメインの`domain add/list/remove`はまだCLIに公開されていません。第2段階は両コマンドと安全な復帰が実装・受け入れ済みの**将来の配布版**を入手してから実施してください。現時点で手動でWorkerにCustom Domainを付けたり、`castloop.toml`の`public_base_url`だけを書き換えたりしても、feedの移行試験にはなりません。 diff --git a/docs/release-next.md b/docs/release-next.md new file mode 100644 index 0000000..03dc3f6 --- /dev/null +++ b/docs/release-next.md @@ -0,0 +1,22 @@ +# 次期M6版 Release notes(草案) + +正式version番号・tag・GitHub Releaseは未確定です。checkoutは`0.2.0-dev`として、配布済みv0.1.2と区別します。 + +## 変更 + +- 新規M6サービスの初期化、認証付き稼働検証、paused完了と明示再開。 +- Show/Episodeの公開停止・復元・不可逆削除。削除はpayloadを物理削除し、必要な小さい操作記録と削除済みIDを永久保持します。 +- 公開配信の状態検査・cache再検証、owner/generation/token付き受付とQueue処理。 +- データ形式を変えないM6 Worker更新。停止・処理終了確認・配備/検証後もpausedで完了します。 +- 300,000,000-byte MP3対応。完全性検証はCLIの全量読み戻しとR2のSHA-256検証へ集約し、Worker内の全量再ハッシュを除きました。Paid契約は変更していません。 +- 正式CLIと試験CLIが既存runnerを共有し、旧CLIの重複処理を削減しました。 + +## 利用上の変更と上限 + +- v0.1.x workspaceの自動変換・既存資源の採用はありません。新規M6 workspaceを作成してください。既存資源の削除は自動実行しません。 +- 音源をstageしたEpisodeの公開は`publish-episode ID MP3`、metadata-onlyは`publish-episode ID`です。`job-status JOB`/`retry-job JOB`にはShow/Episode flagsを付けません。 +- lifecycleはプレビューJSONとrequest hashを確認し、`lifecycle-execute`で明示承認します。削除には`confirm-delete-retain-records`が必要です。 +- 不明なIO/残存lockは停止・ブロックを保持します。万能な復旧、強制unlock、時間経過によるtoken解放はありません。 +- staging音源の一般cleanup、旧形式変換、独自domain、無停止更新、費用/停止時間測定は今回の範囲外です。 + +操作例・復旧可能な範囲は[README](../README.md)、検証の証拠と限定条件は[M6受け入れ記録](../design/m6_standalone_acceptance.md)を参照してください。Linux x86-64のみを配布対象とし、MIT Licenseと第三者ライセンス通知を同梱します。 diff --git a/package-lock.json b/package-lock.json index cef3c93..3cb392f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "castloop-v2", - "version": "0.1.2", + "version": "0.2.0-dev", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "castloop-v2", - "version": "0.1.2", + "version": "0.2.0-dev", "license": "MIT", "workspaces": [ "packages/*" diff --git a/package.json b/package.json index b030c4e..486c353 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "castloop-v2", - "version": "0.1.2", + "version": "0.2.0-dev", "description": "castloop is a serverless podcast hosting program. Once deployed, it runs with minimal ops.", "private": true, "workspaces": [ diff --git a/packages/cli/src/administrator-key.ts b/packages/cli/src/administrator-key.ts new file mode 100644 index 0000000..898d415 --- /dev/null +++ b/packages/cli/src/administrator-key.ts @@ -0,0 +1,26 @@ +import { readBoundedLocalJournal } from "./local-journal-read"; +import { randomBytes } from "node:crypto"; +import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +export function administratorKey(root: string, create = false): string { + const directory = join(root, ".castloop"); + const file = join(directory, "secrets.json"); + if (create && !existsSync(directory)) mkdirSync(directory, { mode: 0o700 }); + if (!lstatSync(directory).isDirectory()) throw new Error("Local state parent must be a real directory"); + if (create && !existsSync(file)) { + const fd = openSync(file, "wx", 0o600); + try { + writeFileSync(fd, JSON.stringify({ CASTLOOP_ADMIN_KEY: randomBytes(32).toString("hex") })); + fsyncSync(fd); + } finally { closeSync(fd); } + const parent = openSync(directory, "r"); + try { fsyncSync(parent); } finally { closeSync(parent); } + } + const secret = readBoundedLocalJournal(file); + if (!secret || typeof secret !== "object" || !("CASTLOOP_ADMIN_KEY" in secret) || + typeof secret.CASTLOOP_ADMIN_KEY !== "string" || !secret.CASTLOOP_ADMIN_KEY) { + throw new Error("Missing local administrator key"); + } + return secret.CASTLOOP_ADMIN_KEY; +} diff --git a/packages/cli/src/help.test.ts b/packages/cli/src/help.test.ts index 6f2f678..78a87f7 100644 --- a/packages/cli/src/help.test.ts +++ b/packages/cli/src/help.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { mkdtempSync, readdirSync, rmdirSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { COMMAND_HELP } from "./help"; @@ -40,3 +40,33 @@ test("unknown commands and missing ordinary option values still fail", () => { expect(result.stderr.toString()).toContain(args.includes("unknown") ? "Unknown command" : "Invalid or missing value"); } }); + +test("formal commands reject test-only faults and malformed mutations without creating local state", () => { + const directory = mkdtempSync(join(tmpdir(), "castloop-entrypoint-")); + try { + for (const args of [["update-service-drop-completion", "id"], ["deploy", "--force", "true"], + ["lifecycle-execute", "plan.json", "hash"], ["service-resume"], + ["publish-episode", "first", "one.mp3", "two.mp3"], ["init", "--__proto__", "value"]]) { + const result = Bun.spawnSync([process.execPath, entrypoint, ...args], { cwd: directory }); + expect(result.exitCode).toBe(1); + expect(result.stdout.toString()).toBe(""); + } + expect(readdirSync(directory)).toEqual([]); + } finally { rmdirSync(directory); } +}); + +test("formal initialization does not convert a legacy workspace or create administrator credentials", () => { + const directory = mkdtempSync(join(tmpdir(), "castloop-legacy-entrypoint-")); + const stateDirectory = join(directory, ".castloop"); + const state = join(stateDirectory, "state.json"); + mkdirSync(stateDirectory); + writeFileSync(state, "retained legacy state"); + try { + const result = Bun.spawnSync([process.execPath, entrypoint, "init"], { cwd: directory }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("Legacy workspaces are not converted"); + expect(readFileSync(state, "utf8")).toBe("retained legacy state"); + expect(readdirSync(stateDirectory)).toEqual(["state.json"]); + expect(readdirSync(directory)).toEqual([".castloop"]); + } finally { unlinkSync(state); rmdirSync(stateDirectory); rmdirSync(directory); } +}); diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts index 2735b4f..1b6140e 100644 --- a/packages/cli/src/help.ts +++ b/packages/cli/src/help.ts @@ -2,9 +2,10 @@ type CommandHelp = { usage: string; description: string }; export const COMMAND_HELP: Record = { init: { - usage: "init [dir] [--service-id ID] [--account-id ID] [--bucket-name NAME] [--workers-subdomain NAME]", + usage: "init [dir] [--service-id ID] [--account-id ID] [--bucket-name NAME] [--workers-subdomain NAME] [--operation-id UUID]", description: "Create a service workspace and Cloudflare resources. Missing values are prompted on a terminal.\n" + - "Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN. Resume an existing workspace with init [dir] without flags.", + "Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN. Creates only new resources and completes paused.\n" + + "Use service-status, then service-resume with its pause ID. Never rerun an initialization with an unknown outcome.", }, "create-show": { usage: "create-show ID [--site-url URL]", @@ -23,7 +24,7 @@ export const COMMAND_HELP: Record = { "publish-show": { usage: "publish-show ID", description: "Run from the service workspace after update-show. Submit the staged Show for publication.\n" + - "Check the returned job ID with job-status until published and owner free.", + "Check the returned job ID with job-status until published and ownership released.", }, "update-episode": { usage: "update-episode ID", @@ -35,29 +36,77 @@ export const COMMAND_HELP: Record = { "This does not publish. Initial publication also requires update-episode.", }, "publish-episode": { - usage: "publish-episode ID", + usage: "publish-episode ID [MP3]", description: "Run from the Show directory. Submit staged Episode changes for publication.\n" + - "Initial publication requires metadata and audio. Later revisions can reuse unchanged published inputs.", + "Initial publication requires metadata and audio. Supply the original MP3 path whenever audio was staged.\n" + + "For metadata-only revisions omit MP3; unchanged published audio is reused.", }, "job-status": { - usage: "job-status JOB --show ID [--episode ID]", - description: "Run from the service workspace. Print publication status, admission owner, commit marker and DLQ state.\n" + - "Supply --episode for Episode jobs. Complete means status published and owner free.", + usage: "job-status JOB", + description: "Run from the service workspace. Compare the retained publication journal with server status.\n" + + "Complete means published status and released ownership; a commit receipt only means queued.", }, "retry-job": { - usage: "retry-job JOB --show ID [--episode ID]", - description: "Run from the service workspace. Requeue the same processing/retrying job only when its DLQ record exists.\n" + - "Supply --episode for Episode jobs. Permanently failed jobs cannot be retried with this command.", - }, - "cleanup-job": { - usage: "cleanup-job JOB --show ID --episode ID", - description: "Run from the service workspace. Remove staged audio only after successful Episode publication.\n" + - "Published audio, revision history and commit markers are retained.", + usage: "retry-job JOB", + description: "Run from the service workspace. Explicitly retry the same acknowledged publication commit.\n" + + "Requires its retained journal and settled execution; active or unknown owners/locks are not released.", }, deploy: { - usage: "deploy", - description: "Run from the service workspace. Deploy this executable's embedded Worker and preserve existing secrets.\n" + - "Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN.", + usage: "deploy [--operation-id UUID]", + description: "Run from the service workspace. Update an already initialized M6 service without converting data.\n" + + "Explicitly pause and drain first. Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN.\n" + + "Completes paused; resume explicitly. Does not adopt legacy services or replay unknown deployments.", + }, + "init-reconcile": { + usage: "init-reconcile OPERATION_UUID", + description: "Reconcile a retained initialization only when the server already completed it paused. Does not redeploy or replay initialization.", + }, + "update-service-verify": { + usage: "update-service-verify OPERATION_UUID", + description: "Continue verification of an acknowledged deployment using its retained journal. Never re-upload an unknown deployment.", + }, + "update-service-reconcile": { + usage: "update-service-reconcile OPERATION_UUID", + description: "Reconcile an already completed paused update without repeating remote mutations.", + }, + "service-status": { + usage: "service-status", + description: "Read service admission, pause ID, worker version and unsettled invocations. Paused does not necessarily mean drained.", + }, + "service-pause": { + usage: "service-pause PAUSE_UUID", + description: "Explicitly pause delivery and new mutations. Existing owners must finish; no elapsed-time release is performed.", + }, + "service-resume": { + usage: "service-resume PAUSE_UUID", + description: "Resume only the exact paused service after runtime verification and settlement of existing owners.", + }, + "target-show": { + usage: "target-show SHOW_ID", + description: "Read a Show's current lifecycle, generations and unfinished ownership without mutations.", + }, + "target-episode": { + usage: "target-episode SHOW_ID EPISODE_ID", + description: "Read an Episode's current lifecycle, revision and unfinished ownership without mutations.", + }, + "preview-show-lifecycle": { + usage: "preview-show-lifecycle SHOW_ID unpublish|restore|delete", + description: "Print a non-mutating lifecycle plan. Save JSON, review target/action/request hash, then use lifecycle-execute.\n" + + "Unpublish is reversible; deletion removes payloads permanently while retaining operational records and IDs.", + }, + "preview-episode-lifecycle": { + usage: "preview-episode-lifecycle SHOW_ID EPISODE_ID unpublish|restore|delete", + description: "Print a non-mutating Episode lifecycle plan. Save JSON and review it before lifecycle-execute.\n" + + "Deletion is irreversible and retains operational records and IDs.", + }, + "lifecycle-execute": { + usage: "lifecycle-execute PLAN_JSON REQUEST_SHA256 confirm|confirm-delete-retain-records", + description: "Execute the exact reviewed plan with explicit acknowledgement. Delete requires confirm-delete-retain-records.\n" + + "The returned job ID is queued, not completed; inspect operation-status lifecycle JOB until settled.", + }, + "lifecycle-retry": { + usage: "lifecycle-retry JOB_UUID REQUEST_SHA256 confirm|confirm-delete-retain-records", + description: "Retry the same confirmed lifecycle job only with its retained journal and settled execution. No force unlock or owner release.", }, "migration-status": { usage: "migration-status [--local]", @@ -85,7 +134,7 @@ export const COMMAND_HELP: Record = { "FAMILY must be staging, publication, lifecycle or show-registration; IDs match local-operation-status.\n" + "Requires the local administrator key and an M6 management status route. No Cloudflare API token is required.\n" + "This does not send mutations, update local phases, remove locks, release tokens, authorize recovery or certify M6 readiness.\n" + - "Legacy Workers and unreleased candidate management routes reject this inspection.", + "Legacy Workers and migration-only candidate routes reject this inspection.", }, }; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts old mode 100755 new mode 100644 index 3b97997..fb05d33 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,711 +1,155 @@ #!/usr/bin/env bun -import { - episodeCommitSchema, episodeDraftFromRevision, episodeRevisionSchema, jobStatusSchema, - parseEpisodeDraft, parseServiceConfig, parseShowMetadata, - serviceConfigSchema, showCommitSchema, showMetadataSchema, stringifyToml, validateId, -} from "@castloop/shared"; -import type { EpisodeCommit, EpisodeRevision, ServiceConfig, ShowCommit } from "@castloop/shared"; -import { analyzeAudio } from "./audio"; -import { CloudflareApi, hashFile } from "./cloudflare-api"; -import { waitForWorkerHealth } from "./health"; -import { commandHelp } from "./help"; +import { parseServiceConfig, serviceConfigSchema, stringifyToml, validateId } from "@castloop/shared"; +import type { ServiceConfig } from "@castloop/shared"; +import { version } from "../../../package.json"; +import { administratorKey } from "./administrator-key"; +import { CloudflareApi } from "./cloudflare-api"; +import { COMMAND_HELP, commandHelp } from "./help"; import { readLocalOperationStatus } from "./local-operation-status"; -import { readRemoteOperationStatus } from "./remote-operation-status"; +import { M6_COMMAND_ARGUMENTS, runM6Command } from "./m6-commands"; +import { readLocalFreshM6Initialization } from "./m6-service-initialization"; import { MigrationAdminClient } from "./migration-client"; import { readLocalMigrationSetup } from "./migration-setup-journal"; -import { embeddedWorkerSource, WORKER_COMPATIBILITY_DATE } from "./worker-payload"; -import { createHash, randomBytes, randomUUID } from "node:crypto"; -import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, - statSync, writeFileSync } from "node:fs"; +import { embeddedWorkerSource } from "./worker-payload"; +import { randomBytes, randomUUID } from "node:crypto"; +import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { createInterface } from "node:readline/promises"; import { fileURLToPath } from "node:url"; -type ShowDraft = { - job_id: string; - metadata_sha256: string; - cover_sha256: string; - cover_extension: "jpg" | "png"; - staged: boolean; - committed: boolean; -}; -type EpisodeStage = { - job_id: string; - base_revision_id?: string; - metadata_sha256?: string; - audio_sha256?: string; - audio_length_bytes?: number; - duration_seconds?: number; - audio_path?: string; - committed_at?: string; - committed: boolean; -}; -type LocalState = { - shows: Record; - init_steps: string[]; - drafts: Record; - episodes: Record; -}; const SOURCE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); -const CLI_VERSION = "0.1.2"; -const USAGE = "Usage: castloop init [dir] --service-id ID --bucket-name NAME --workers-subdomain NAME | " + - "create-show ID --site-url URL | create-episode ID | update-show ID | publish-show ID | " + - "update-episode ID | update-episode-audio ID MP3 | publish-episode ID | " + - "job-status JOB --show ID [--episode ID] | retry-job JOB --show ID [--episode ID] | " + - "cleanup-job JOB --show ID --episode ID | migration-preflight LEGACY_VERSION_ID | migration-status [--local] | " + - "local-operation-status FAMILY ID | operation-status FAMILY ID | deploy"; +const CLI_VERSION = version; +const USAGE = `Usage: castloop COMMAND [arguments]\n\nCommands:\n ${Object.keys(COMMAND_HELP).join("\n ")}\n\nUse castloop help COMMAND for details.`; function argsOf(values: string[], switches: string[] = []): { positional: string[]; flags: Record } { const positional: string[] = []; const flags: Record = {}; for (let index = 0; index < values.length; index += 1) { - const item = values[index]; - if (!item.startsWith("--")) { - positional.push(item); - continue; - } + const item = values[index]!; + if (!item.startsWith("--")) { positional.push(item); continue; } const name = item.slice(2); - if (!name || flags[name]) throw new Error(`Invalid or missing value for ${item}`); - if (switches.includes(name)) { - flags[name] = "true"; - continue; - } - if (!values[index + 1] || values[index + 1].startsWith("--")) { - throw new Error(`Invalid or missing value for ${item}`); - } - flags[name] = values[++index]; + if (!name || name === "__proto__" || Object.hasOwn(flags, name)) throw new Error(`Invalid or missing value for ${item}`); + if (switches.includes(name)) { flags[name] = "true"; continue; } + if (!values[index + 1] || values[index + 1]!.startsWith("--")) throw new Error(`Invalid or missing value for ${item}`); + flags[name] = values[++index]!; } return { positional, flags }; } function allowedFlags(flags: Record, allowed: string[]): void { - for (const name of Object.keys(flags)) { - if (!allowed.includes(name)) throw new Error(`Unknown option: --${name}`); - } -} - -function required(value: string | undefined, name: string): string { - if (!value) throw new Error(`Missing --${name}`); - return value; + for (const name of Object.keys(flags)) if (!allowed.includes(name)) throw new Error(`Unknown option: --${name}`); } async function ask(value: string | undefined, name: string, label: string): Promise { if (value) return value; - if (!process.stdin.isTTY) return required(value, name); + if (!process.stdin.isTTY) throw new Error(`Missing --${name}`); const prompt = createInterface({ input: process.stdin, output: process.stdout }); try { - return required((await prompt.question(`${label}: `)).trim(), name); - } finally { - prompt.close(); - } -} - -function statePath(root: string): string { - return join(root, ".castloop", "state.json"); -} - -function loadState(root: string): LocalState { - if (!existsSync(statePath(root))) return { shows: {}, init_steps: [], drafts: {}, episodes: {} }; - const state: unknown = JSON.parse(readFileSync(statePath(root), "utf8")); - if (!state || typeof state !== "object" || !("shows" in state) || !("init_steps" in state) || - !state.shows || typeof state.shows !== "object" || !Array.isArray(state.init_steps)) { - throw new Error("Invalid .castloop/state.json"); - } - return { ...(state as LocalState), drafts: ("drafts" in state && state.drafts && - typeof state.drafts === "object" ? state.drafts : {}) as Record, - episodes: ("episodes" in state && state.episodes && typeof state.episodes === "object" - ? state.episodes : {}) as Record }; -} - -function saveState(root: string, state: LocalState): void { - const file = statePath(root); - mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); - writeFileSync(`${file}.tmp`, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 }); - renameSync(`${file}.tmp`, file); + const answer = (await prompt.question(`${label}: `)).trim(); + if (!answer) throw new Error(`Missing --${name}`); + return answer; + } finally { prompt.close(); } } async function workerSource(): Promise { - if (Bun.isStandaloneExecutable) { - if (!embeddedWorkerSource) throw new Error("The executable does not contain a Worker bundle"); - return embeddedWorkerSource; - } - const result = await Bun.build({ entrypoints: [join(SOURCE_ROOT, "src/index.ts")], - target: "browser", minify: true }); + if (embeddedWorkerSource) return embeddedWorkerSource; + if (Bun.isStandaloneExecutable) throw new Error("The executable does not contain a Worker bundle"); + const result = await Bun.build({ entrypoints: [join(SOURCE_ROOT, "src/worker.ts")], + target: "browser", minify: true, external: ["cloudflare:workers"] }); if (!result.success || result.outputs.length !== 1) throw new Error("Worker bundle build failed"); - return result.outputs[0].text(); + return result.outputs[0]!.text(); } -async function provision(root: string, config: ServiceConfig, state: LocalState): Promise { - mkdirSync(join(root, ".castloop"), { recursive: true, mode: 0o700 }); - const keyFile = join(root, ".castloop", "secrets.json"); - if (!existsSync(keyFile)) { - writeFileSync(keyFile, JSON.stringify({ CASTLOOP_ADMIN_KEY: randomBytes(32).toString("hex") }) + "\n", - { flag: "wx", mode: 0o600 }); - } - const api = new CloudflareApi(config); - const steps: Array<[string, () => Promise]> = [ - ["bucket", () => api.createBucket(config.bucket_name)], - ["queue", () => api.createQueue(config.queue_name)], - ["dlq", () => api.createQueue(config.dlq_name)], - ["worker", async () => api.deployWorker(config, await workerSource(), adminKey(root), WORKER_COMPATIBILITY_DATE)], - ["consumer", async () => api.ensureConsumer(await api.queueId(config.queue_name), config, false)], - ["dlq-consumer", async () => api.ensureConsumer(await api.queueId(config.dlq_name), config, true)], - ]; - for (const [step, run] of steps) { - if (state.init_steps.includes(step)) continue; - await run(); - state.init_steps.push(step); - saveState(root, state); - } - const key: unknown = JSON.parse(readFileSync(keyFile, "utf8")); - if (!key || typeof key !== "object" || !("CASTLOOP_ADMIN_KEY" in key) || - typeof key.CASTLOOP_ADMIN_KEY !== "string") throw new Error("Local administrator key is missing"); - await waitForWorkerHealth(config.public_base_url, key.CASTLOOP_ADMIN_KEY); - if (!state.init_steps.includes("service")) { - const file = join(root, "castloop.toml"); - await api.putObject(config.bucket_name, "system/service.toml", file, "application/toml", - await hashFile(file)); - state.init_steps.push("service"); - saveState(root, state); - } - if (!state.init_steps.includes("notification")) { - await api.createNotification(config, await api.queueId(config.queue_name)); - state.init_steps.push("notification"); - saveState(root, state); - } +function loadConfig(root: string): ServiceConfig { + return parseServiceConfig(readFileSync(join(root, "castloop.toml"), "utf8")); } -async function init(target: string, flags: Record): Promise { - allowedFlags(flags, ["service-id", "account-id", "bucket-name", "workers-subdomain"]); +async function init(target: string, flags: Record): Promise { + allowedFlags(flags, ["service-id", "account-id", "bucket-name", "workers-subdomain", "operation-id"]); const root = resolve(target); mkdirSync(root, { recursive: true }); + if (!lstatSync(root).isDirectory()) throw new Error("Workspace must be a real directory"); + if (existsSync(join(root, ".castloop", "state.json"))) throw new Error("Legacy workspaces are not converted; initialize a new M6 workspace"); const file = join(root, "castloop.toml"); - let config: ServiceConfig; - if (existsSync(file)) { - config = parseServiceConfig(readFileSync(file, "utf8")); - if (Object.keys(flags).length > 0) throw new Error("Service already initialized; rerun init without flags to resume"); - } else { + if (!existsSync(file)) { const serviceId = validateId(await ask(flags["service-id"], "service-id", "Service ID"), "service"); const suffix = randomBytes(4).toString("hex"); - const accountId = await ask(flags["account-id"] ?? process.env.CLOUDFLARE_ACCOUNT_ID, - "account-id", "Cloudflare account ID"); + const accountId = await ask(flags["account-id"] ?? process.env.CLOUDFLARE_ACCOUNT_ID, "account-id", "Cloudflare account ID"); const bucketName = await ask(flags["bucket-name"], "bucket-name", "Private R2 bucket name"); const subdomain = await ask(flags["workers-subdomain"], "workers-subdomain", "workers.dev subdomain"); if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(subdomain)) throw new Error("Invalid workers.dev subdomain"); const workerName = `castloop-${serviceId}-${suffix}`; - config = serviceConfigSchema.parse({ schema_version: 1, service_id: serviceId, - account_id: accountId, bucket_name: bucketName, - worker_name: workerName, queue_name: `castloop-${serviceId}-${suffix}`, - dlq_name: `castloop-${serviceId}-dlq-${suffix}`, - public_base_url: `https://${workerName}.${subdomain}.workers.dev` }); + const config = serviceConfigSchema.parse({ schema_version: 1, service_id: serviceId, account_id: accountId, + bucket_name: bucketName, worker_name: workerName, queue_name: workerName, + dlq_name: `castloop-${serviceId}-dlq-${suffix}`, public_base_url: `https://${workerName}.${subdomain}.workers.dev` }); writeFileSync(file, stringifyToml(config), { flag: "wx" }); + } else if (Object.keys(flags).some((name) => name !== "operation-id")) { + throw new Error("Workspace configuration already exists; do not change its initialization inputs"); } + const config = loadConfig(root); if (process.env.CLOUDFLARE_ACCOUNT_ID !== config.account_id || !process.env.CLOUDFLARE_API_TOKEN) { throw new Error("Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN for this service before provisioning"); } + const retained = readLocalFreshM6Initialization(root, config); + if (retained.lockPresent || retained.state && retained.state.phase !== "prepared") { + throw new Error("Initialization already started; inspect its journal or use init-reconcile only for an acknowledged completed initialization"); + } const ignore = join(root, ".gitignore"); if (!existsSync(ignore)) writeFileSync(ignore, ""); const existing = readFileSync(ignore, "utf8"); const additions = [".castloop/", "*.mp3"].filter((line) => !existing.split(/\r?\n/).includes(line)); if (additions.length) appendFileSync(ignore, `${existing && !existing.endsWith("\n") ? "\n" : ""}${additions.join("\n")}\n`); - await provision(root, config, loadState(root)); - console.log(`Initialized ${config.service_id} in ${root}`); -} - -function loadConfig(root: string): ServiceConfig { - return parseServiceConfig(readFileSync(join(root, "castloop.toml"), "utf8")); -} - -function adminKey(root: string): string { - const key: unknown = JSON.parse(readFileSync(join(root, ".castloop", "secrets.json"), "utf8")); - if (!key || typeof key !== "object" || !("CASTLOOP_ADMIN_KEY" in key) || - typeof key.CASTLOOP_ADMIN_KEY !== "string") throw new Error("Local administrator key is missing"); - return key.CASTLOOP_ADMIN_KEY; -} - -async function adminCall(root: string, path: string, body?: object): Promise<{ response: Response; data: unknown }> { - const config = loadConfig(root); - const response = await fetch(new URL(path, config.public_base_url), { - method: body ? "POST" : "GET", - headers: { "X-Castloop-Key": adminKey(root), "User-Agent": "castloop-cli/0.1", - ...(body ? { "Content-Type": "application/json" } : {}) }, - ...(body ? { body: JSON.stringify(body) } : {}), - signal: AbortSignal.timeout(30000), - }); - return { response, data: await response.json() as unknown }; -} - -function digest(data: Buffer | string): string { - return createHash("sha256").update(data).digest("hex"); -} - -function showInputs(root: string, showId: string): { - metadata: string; image: string; cover: Buffer; extension: "jpg" | "png"; -} { - const directory = join(root, showId); - const metadata = readFileSync(join(directory, "show.toml"), "utf8"); - const show = parseShowMetadata(metadata); - if (show.show_id !== showId) throw new Error("Show ID in show.toml does not match the directory"); - const image = join(directory, show.image_path); - const size = statSync(image).size; - if (size > 5_000_000 || size === 0) { - throw new Error("Cover image must be at most 5 MB and nonempty"); - } - const cover = readFileSync(image); - const extension = show.image_path.toLowerCase().endsWith(".png") ? "png" : "jpg"; - const valid = extension === "jpg" ? cover.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff])) - : cover.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); - if (!valid) throw new Error("Cover image format does not match image_path"); - return { metadata, image, cover, extension }; -} - -function requireShow(root: string, showId: string): void { - if (!loadState(root).shows[showId]?.confirmed) throw new Error("Reserve the Show ID first"); -} - -async function updateShow(showArg: string): Promise { - const root = process.cwd(); - const config = loadConfig(root); - const showId = validateId(showArg, "show"); - requireShow(root, showId); - const input = showInputs(root, showId); - const state = loadState(root); - let draft = state.drafts[showId]; - if (draft && !draft.committed) { - const remote = await inspectJob(root, showId, draft.job_id); - if (remote.marker) { - draft.committed = true; - saveState(root, state); - } else if (remote.owner?.job_id === draft.job_id && remote.owner.state !== "free") { - throw new Error(`Job ${draft.job_id} already claimed; finish publish-show before editing this draft`); - } - } - if (!draft || draft.committed) { - draft = { job_id: randomUUID(), metadata_sha256: "", cover_sha256: "", - cover_extension: input.extension, staged: false, committed: false }; - state.drafts[showId] = draft; - saveState(root, state); - } - const prefix = `staging/shows/${showId}/${draft.job_id}`; - const api = new CloudflareApi(config); - await api.putObject(config.bucket_name, `${prefix}/show.toml`, join(root, showId, "show.toml"), - "application/toml", digest(input.metadata)); - await api.putObject(config.bucket_name, `${prefix}/cover.${input.extension}`, input.image, - input.extension === "jpg" ? "image/jpeg" : "image/png", digest(input.cover)); - draft.metadata_sha256 = digest(input.metadata); - draft.cover_sha256 = digest(input.cover); - draft.cover_extension = input.extension; - draft.staged = true; - saveState(root, state); - console.log(`Show draft staged: ${draft.job_id}`); -} - -function showCommit(showId: string, draft: ShowDraft): ShowCommit { - return showCommitSchema.parse({ schema_version: 1, kind: "show", show_id: showId, - job_id: draft.job_id, metadata_sha256: draft.metadata_sha256, - cover_sha256: draft.cover_sha256, cover_extension: draft.cover_extension }); -} - -async function inspectJob(root: string, showId: string, jobId: string, episodeId?: string): Promise<{ - owner: { job_id: string; state: string } | null; - marker: unknown; - status: { state: string; reason?: string } | null; - dlq: boolean; -}> { - const query = new URLSearchParams({ show: showId, ...(episodeId ? { episode: episodeId } : {}) }); - const { response, data } = await adminCall(root, `/admin/jobs/${jobId}?${query}`); - if (response.status !== 200 || !data || typeof data !== "object") throw new Error("Cannot read publication job"); - const record = data as Record; - const marker = record.marker ?? null; - const status = record.status ? jobStatusSchema.parse(record.status) : null; - const owner = record.owner as { job_id: string; state: string } | null; - return { owner, marker, status, dlq: record.dlq === true }; -} - -async function publishShow(showArg: string): Promise { - const root = process.cwd(); - const config = loadConfig(root); - const showId = validateId(showArg, "show"); - requireShow(root, showId); - const state = loadState(root); - const draft = state.drafts[showId]; - if (!draft?.staged) throw new Error("Run update-show before publish-show"); - const input = showInputs(root, showId); - if (digest(input.metadata) !== draft.metadata_sha256 || digest(input.cover) !== draft.cover_sha256 || - input.extension !== draft.cover_extension) { - throw new Error("Local Show metadata or cover changed after staging; run update-show again"); - } - const commit = showCommit(showId, draft); - const previous = await inspectJob(root, showId, draft.job_id); - if (previous.marker) { - if (JSON.stringify(showCommitSchema.parse(previous.marker)) !== JSON.stringify(commit)) { - throw new Error("Remote commit differs from local draft"); - } - draft.committed = true; - saveState(root, state); - console.log(`Show publication already submitted: ${draft.job_id} (${previous.status?.state ?? "queued"})`); - return; - } - if (draft.committed) throw new Error("Committed Show marker is missing; inspect job before retrying"); - const { response, data } = await adminCall(root, "/admin/publications/claim", - { show_id: showId, job_id: draft.job_id }); - if (response.status !== 200 && response.status !== 201) { - const owner = data && typeof data === "object" && "job_id" in data ? String(data.job_id) : "unknown"; - throw new Error(`Show publication not admitted (HTTP ${response.status}, current job: ${owner})`); - } - const file = join(root, ".castloop", `commit-${draft.job_id}.json`); - writeFileSync(file, JSON.stringify(commit) + "\n", { mode: 0o600 }); - await new CloudflareApi(config).putObject(config.bucket_name, - `staging/shows/${showId}/${draft.job_id}/commit.json`, file, "application/json", - await hashFile(file)); - draft.committed = true; - saveState(root, state); - console.log(`Show publication queued: ${draft.job_id}`); -} - -async function jobStatus(jobId: string, flags: Record): Promise { - allowedFlags(flags, ["show", "episode"]); - const showId = validateId(required(flags.show, "show"), "show"); - const episodeId = flags.episode ? validateId(flags.episode, "episode") : undefined; - const record = await inspectJob(process.cwd(), showId, jobId, episodeId); - console.log(JSON.stringify(record, null, 2)); -} - -async function retryJob(jobId: string, flags: Record): Promise { - allowedFlags(flags, ["show", "episode"]); - const showId = validateId(required(flags.show, "show"), "show"); - const episodeId = flags.episode ? validateId(flags.episode, "episode") : undefined; - const { response } = await adminCall(process.cwd(), "/admin/jobs/retry", { - show_id: showId, job_id: jobId, kind: episodeId ? "episode" : "show", - ...(episodeId ? { episode_id: episodeId } : {}), - }); - if (response.status !== 202) throw new Error(`Job cannot be requeued (HTTP ${response.status})`); - console.log(`Job requeued: ${jobId}`); -} - -async function cleanupJob(jobId: string, flags: Record): Promise { - allowedFlags(flags, ["show", "episode"]); - const showId = validateId(required(flags.show, "show"), "show"); - const episodeId = validateId(required(flags.episode, "episode"), "episode"); - const { response, data } = await adminCall(process.cwd(), "/admin/jobs/cleanup", { - show_id: showId, episode_id: episodeId, job_id: jobId, - }); - if (response.status !== 200) throw new Error(`Staging cleanup refused (HTTP ${response.status})`); - console.log(JSON.stringify(data)); -} - -async function deployService(): Promise { - const root = process.cwd(); - const config = loadConfig(root); - const api = new CloudflareApi(config); - await api.deployWorker(config, await workerSource(), adminKey(root), WORKER_COMPATIBILITY_DATE); - await api.ensureConsumer(await api.queueId(config.queue_name), config, false); - await api.ensureConsumer(await api.queueId(config.dlq_name), config, true); - console.log("Worker deployed"); -} - -function episodeContext(episodeArg: string): { root: string; showId: string; episodeId: string; - config: ServiceConfig; file: string; key: string } { - const directory = process.cwd(); - const root = resolve(directory, ".."); - const config = loadConfig(root); - const showId = validateId(basename(directory), "show"); - requireShow(root, showId); - const episodeId = validateId(episodeArg, "episode"); - const file = join(directory, `episode-${episodeId}.toml`); - const show = parseShowMetadata(readFileSync(join(directory, "show.toml"), "utf8")); - if (show.show_id !== showId) throw new Error("Show directory does not match show.toml"); - return { root, showId, episodeId, config, file, key: `${showId}/${episodeId}` }; + return runM6Command(root, config, "init", [flags["operation-id"] ?? retained.state?.request.operation_id ?? randomUUID()], { workerSource }); } -async function editableEpisodeStage(root: string, showId: string, episodeId: string, - key: string): Promise<{ state: LocalState; stage: EpisodeStage }> { - const state = loadState(root); - let stage = state.episodes[key]; - const current = await currentEpisode(root, showId, episodeId); - if (stage && !stage.committed) { - const remote = await inspectJob(root, showId, stage.job_id, episodeId); - if (remote.marker) { - stage.committed = true; - saveState(root, state); - } else if (remote.owner?.job_id === stage.job_id && remote.owner.state !== "free") { - throw new Error(`Job ${stage.job_id} is claimed; finish publish-episode before changing this draft`); - } - } - if (!stage || stage.committed) { - stage = { job_id: randomUUID(), committed: false, - ...(current ? { base_revision_id: current.revision_id } : {}) }; - state.episodes[key] = stage; - saveState(root, state); - } else if (stage.base_revision_id !== current?.revision_id) { - throw new Error("Episode changed since this draft was created; inspect the current revision before editing"); - } - return { state, stage }; -} - -async function currentEpisode(root: string, showId: string, episodeId: string): Promise { - const { response, data } = await adminCall(root, `/admin/episodes/${showId}/${episodeId}/current`); - if (response.status !== 200 || !data || typeof data !== "object" || !("revision" in data)) { - throw new Error("Cannot read current Episode revision"); - } - return data.revision === null ? null : episodeRevisionSchema.parse(data.revision); -} - -async function updateEpisode(episodeArg: string): Promise { - const context = episodeContext(episodeArg); - const source = readFileSync(context.file, "utf8"); - const draft = parseEpisodeDraft(source); - if (draft.episode_id !== context.episodeId) throw new Error("Episode ID does not match filename"); - const { state, stage } = await editableEpisodeStage(context.root, context.showId, context.episodeId, context.key); - const current = await currentEpisode(context.root, context.showId, context.episodeId); - if (current && (draft.guid !== current.guid || draft.published_at !== current.published_at)) { - throw new Error("Episode GUID and published_at must remain unchanged"); - } - await new CloudflareApi(context.config).putObject(context.config.bucket_name, - `staging/episodes/${context.showId}/${context.episodeId}/${stage.job_id}/episode.toml`, - context.file, "application/toml", digest(source)); - stage.metadata_sha256 = digest(source); - saveState(context.root, state); - console.log(`Episode metadata staged: ${stage.job_id}`); -} - -async function updateEpisodeAudio(episodeArg: string, audioArg: string): Promise { - const context = episodeContext(episodeArg); - const audio = resolve(process.cwd(), audioArg); - const { length, duration } = await analyzeAudio(audio); - const checksum = await hashFile(audio); - const { state, stage } = await editableEpisodeStage(context.root, context.showId, context.episodeId, context.key); - await new CloudflareApi(context.config).putObject(context.config.bucket_name, - `staging/episodes/${context.showId}/${context.episodeId}/${stage.job_id}/audio.mp3`, - audio, "audio/mpeg", checksum); - stage.audio_path = audio; - stage.audio_sha256 = checksum; - stage.audio_length_bytes = length; - stage.duration_seconds = duration; - saveState(context.root, state); - console.log(`Episode audio staged: ${stage.job_id}`); -} - -async function publishEpisode(episodeArg: string): Promise { - const { root, showId, episodeId, config, file, key } = episodeContext(episodeArg); - const state = loadState(root); - const stage = state.episodes[key]; - if (!stage || (!stage.metadata_sha256 && !stage.audio_sha256) || - (!stage.base_revision_id && (!stage.metadata_sha256 || !stage.audio_sha256))) { - throw new Error("Stage Episode changes (both metadata and audio for an initial publication)"); - } - const current = await currentEpisode(root, showId, episodeId); - if (current?.revision_id !== stage.base_revision_id && current?.revision_id !== stage.job_id) { - throw new Error("Episode revision changed since staging; publish is stale"); - } - if (stage.metadata_sha256) { - const metadata = readFileSync(file, "utf8"); - const draft = parseEpisodeDraft(metadata); - if (draft.episode_id !== episodeId || digest(metadata) !== stage.metadata_sha256) { - throw new Error("Local Episode metadata changed after staging; run update-episode again"); - } - if (current && (draft.guid !== current.guid || draft.published_at !== current.published_at)) { - throw new Error("Episode GUID and published_at must match the published revision"); - } - } else if (current && existsSync(file) && - JSON.stringify(parseEpisodeDraft(readFileSync(file, "utf8"))) !== - JSON.stringify(episodeDraftFromRevision(current))) { - throw new Error("Local Episode metadata differs from published metadata; stage it with update-episode"); - } - if (stage.audio_sha256) { - if (!stage.audio_path || !stage.audio_length_bytes || !stage.duration_seconds) { - throw new Error("Staged Episode audio is incomplete"); - } - const info = await analyzeAudio(stage.audio_path); - if (info.length !== stage.audio_length_bytes || info.duration !== stage.duration_seconds || - await hashFile(stage.audio_path) !== stage.audio_sha256) { - throw new Error("Local MP3 changed after staging; run update-episode-audio again"); - } - } - stage.committed_at ??= new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); - saveState(root, state); - const commit: EpisodeCommit = episodeCommitSchema.parse({ schema_version: 1, kind: "episode", - show_id: showId, episode_id: episodeId, job_id: stage.job_id, - ...(stage.base_revision_id ? { base_revision_id: stage.base_revision_id } : {}), - ...(stage.metadata_sha256 ? { metadata_sha256: stage.metadata_sha256 } : {}), - ...(stage.audio_sha256 ? { audio_sha256: stage.audio_sha256, - audio_length_bytes: stage.audio_length_bytes, duration_seconds: stage.duration_seconds } : {}), - committed_at: stage.committed_at }); - const previous = await inspectJob(root, showId, stage.job_id, episodeId); - if (previous.marker) { - if (JSON.stringify(episodeCommitSchema.parse(previous.marker)) !== JSON.stringify(commit)) { - throw new Error("Remote Episode commit differs from local draft"); - } - stage.committed = true; - saveState(root, state); - console.log(`Episode publication already submitted: ${stage.job_id} (${previous.status?.state ?? "queued"})`); - return; - } - if (stage.committed) throw new Error("Committed Episode marker is missing; inspect job before retrying"); - const { response, data } = await adminCall(root, "/admin/publications/claim", - { show_id: showId, job_id: stage.job_id }); - if (response.status !== 200 && response.status !== 201) { - const owner = data && typeof data === "object" && "job_id" in data ? String(data.job_id) : "unknown"; - throw new Error(`Episode publication not admitted (HTTP ${response.status}, current job: ${owner})`); - } - const path = join(root, ".castloop", `commit-${stage.job_id}.json`); - writeFileSync(path, JSON.stringify(commit) + "\n", { mode: 0o600 }); - await new CloudflareApi(config).putObject(config.bucket_name, - `staging/episodes/${showId}/${episodeId}/${stage.job_id}/commit.json`, path, "application/json", - await hashFile(path)); - stage.committed = true; - saveState(root, state); - console.log(`Episode publication queued: ${stage.job_id}`); -} - -async function createShow(showArg: string, flags: Record): Promise { - allowedFlags(flags, ["site-url"]); - const root = process.cwd(); - const config = loadConfig(root); - const showId = validateId(showArg, "show"); - const directory = join(root, showId); - const file = join(directory, "show.toml"); - const state = loadState(root); - let reservation = state.shows[showId]; - if (existsSync(directory)) { - if (!reservation || !existsSync(file)) throw new Error(`Show directory already exists: ${directory}`); - const metadata = parseShowMetadata(readFileSync(file, "utf8")); - if (metadata.show_id !== showId) throw new Error("Local Show ID does not match the directory"); - if (flags["site-url"] && flags["site-url"] !== metadata.site_url) { - throw new Error("The existing show.toml has a different site_url"); - } - } else { - const siteUrl = await ask(flags["site-url"], "site-url", "Show website URL"); - const draft = showMetadataSchema.parse({ schema_version: 1, show_id: showId, - title: showId, description: "Edit this description before publication", - language: "ja", author: "Edit author", owner_name: "Edit owner", - owner_email: "owner@example.com", categories: ["Technology"], explicit: false, - site_url: siteUrl, image_path: "cover.jpg" }); - reservation = { reservation_id: randomUUID(), confirmed: false }; - mkdirSync(directory); - writeFileSync(file, stringifyToml(draft), { flag: "wx" }); - state.shows[showId] = reservation; - saveState(root, state); - } - const keyFile = join(root, ".castloop", "secrets.json"); - const key: unknown = JSON.parse(readFileSync(keyFile, "utf8")); - if (!key || typeof key !== "object" || !("CASTLOOP_ADMIN_KEY" in key) || - typeof key.CASTLOOP_ADMIN_KEY !== "string") throw new Error("Local administrator key is missing"); - const url = new URL("/admin/shows/reserve", config.public_base_url); - const response = await fetch(url, { method: "POST", headers: { - "Content-Type": "application/json", "X-Castloop-Key": key.CASTLOOP_ADMIN_KEY, - "User-Agent": "castloop-cli/0.1", - }, body: JSON.stringify({ show_id: showId, reservation_id: reservation.reservation_id }) }); - if (response.status !== 201 && response.status !== 200) { - throw new Error(`Show ID reservation failed (HTTP ${response.status}); local draft retained for retry`); - } - reservation.confirmed = true; - saveState(root, state); - console.log(`Show ${showId} reserved; edit ${file} before publication`); -} - -function createEpisode(episodeArg: string): void { - const root = resolve(process.cwd(), ".."); - loadConfig(root); - const showId = validateId(basename(process.cwd()), "show"); - const show = parseShowMetadata(readFileSync(join(process.cwd(), "show.toml"), "utf8")); - if (show.show_id !== showId) throw new Error("Show directory and show.toml disagree"); - if (!loadState(root).shows[showId]?.confirmed) throw new Error("Reserve the Show ID before creating an Episode"); - const episodeId = validateId(episodeArg, "episode"); - const file = join(process.cwd(), `episode-${episodeId}.toml`); - const draft = parseEpisodeDraft(stringifyToml({ schema_version: 1, episode_id: episodeId, - guid: randomUUID(), title: episodeId, description: "Edit this description before publication", - published_at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), - })); - writeFileSync(file, stringifyToml(draft), { flag: "wx" }); - console.log(`Created ${file}`); -} - -async function main(): Promise { - const [command, ...rest] = process.argv.slice(2); - if ((command === "help" || command === "--help" || command === "-h") && rest.length === 0) { - console.log(USAGE); - return; - } - if (command === "help" && rest.length === 1) { - console.log(commandHelp(rest[0])); - return; - } - if (command && (rest.includes("--help") || rest.includes("-h"))) { - console.log(commandHelp(command)); - return; - } - if ((command === "version" || command === "--version") && rest.length === 0) { - console.log(CLI_VERSION); - return; - } +async function main(): Promise { + let [command, ...rest] = process.argv.slice(2); + if (!command || (["help", "--help", "-h"].includes(command) && !rest.length)) return USAGE; + if (command === "help" && rest.length === 1) return commandHelp(rest[0]!); + if (rest.includes("--help") || rest.includes("-h")) return commandHelp(command); + if (["version", "--version"].includes(command) && !rest.length) return CLI_VERSION; + if (!Object.hasOwn(COMMAND_HELP, command)) throw new Error(`Unknown command: ${command}`); const { positional, flags } = argsOf(rest, command === "migration-status" ? ["local"] : []); - if (command === "init" && positional.length <= 1) return init(positional[0] ?? ".", flags); - if (command === "create-show" && positional.length === 1) return createShow(positional[0], flags); - if (command === "create-episode" && positional.length === 1) { - allowedFlags(flags, []); - return createEpisode(positional[0]); - } - if (command === "update-show" && positional.length === 1) { - allowedFlags(flags, []); - return updateShow(positional[0]); - } - if (command === "publish-show" && positional.length === 1) { - allowedFlags(flags, []); - return publishShow(positional[0]); - } - if (command === "job-status" && positional.length === 1) return jobStatus(positional[0], flags); - if (command === "retry-job" && positional.length === 1) return retryJob(positional[0], flags); - if (command === "cleanup-job" && positional.length === 1) return cleanupJob(positional[0], flags); - if (command === "migration-preflight" && positional.length === 1) { - allowedFlags(flags, []); - const config = loadConfig(process.cwd()); - console.log(JSON.stringify(await new CloudflareApi(config).inspectLegacyService(config, positional[0]), null, 2)); - return; - } - if (command === "migration-status" && positional.length === 0) { - allowedFlags(flags, ["local"]); - const root = process.cwd(); - const config = loadConfig(root); - const result = flags.local ? readLocalMigrationSetup(root, config) : await new MigrationAdminClient(config, adminKey(root)).status(); - console.log(JSON.stringify(result, null, 2)); - return; - } - if (command === "local-operation-status" && positional.length === 2) { - allowedFlags(flags, []); - const root = process.cwd(); - console.log(JSON.stringify(readLocalOperationStatus(root, loadConfig(root), positional[0], positional[1]), null, 2)); - return; - } - if (command === "operation-status" && positional.length === 2) { - allowedFlags(flags, []); - const root = process.cwd(); - console.log(JSON.stringify(await readRemoteOperationStatus(root, loadConfig(root), positional[0], positional[1], () => adminKey(root)), null, 2)); - return; - } - if (command === "update-episode" && positional.length === 1) { - allowedFlags(flags, []); - return updateEpisode(positional[0]); - } - if (command === "update-episode-audio" && positional.length === 2) { - allowedFlags(flags, []); - return updateEpisodeAudio(positional[0], positional[1]); + if (command === "init") { + if (positional.length > 1) throw new Error(commandHelp(command)); + return init(positional[0] ?? ".", flags); + } + let root = process.cwd(); + if (command === "create-show") { + allowedFlags(flags, ["site-url"]); + if (positional.length !== 1) throw new Error(commandHelp(command)); + positional.push(await ask(flags["site-url"], "site-url", "Show website URL")); + } else if (command === "deploy") { + allowedFlags(flags, ["operation-id"]); + if (positional.length) throw new Error(commandHelp(command)); + command = "update-service"; + positional.push(flags["operation-id"] ?? randomUUID()); + } else { + allowedFlags(flags, command === "migration-status" ? ["local"] : []); } - if (command === "publish-episode" && positional.length === 1) { - allowedFlags(flags, []); - return publishEpisode(positional[0]); + if (["create-episode", "update-episode", "update-episode-audio", "publish-episode"].includes(command)) { + positional.unshift(validateId(basename(root), "show")); + root = resolve(root, ".."); } - if (command === "deploy" && positional.length === 0) { - allowedFlags(flags, []); - return deployService(); + if (command === "job-status") { command = "operation-status"; positional.unshift("publication"); } + if (command === "retry-job") command = "publication-retry"; + if (Object.hasOwn(M6_COMMAND_ARGUMENTS, command)) { + const count = command === "publish-episode" && positional.length === 2 ? 2 : M6_COMMAND_ARGUMENTS[command]; + if (positional.length !== count) throw new Error("Invalid command arguments; use --help"); + return runM6Command(root, loadConfig(root), command, positional, { workerSource }); } - throw new Error(USAGE); + const config = loadConfig(root); + if (command === "local-operation-status" && positional.length === 2) return readLocalOperationStatus(root, config, positional[0]!, positional[1]!); + if (command === "migration-preflight" && positional.length === 1) return new CloudflareApi(config).inspectLegacyService(config, positional[0]!); + if (command === "migration-status" && !positional.length) return flags.local ? readLocalMigrationSetup(root, config) : new MigrationAdminClient(config, administratorKey(root)).status(); + throw new Error("Invalid command arguments; use --help"); } try { - await main(); + const result = await main(); + console.log(typeof result === "string" ? result : JSON.stringify(result, null, 2)); } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); + console.error(error instanceof Error ? error.message : "Command failed"); process.exitCode = 1; } diff --git a/packages/cli/src/m6-commands.ts b/packages/cli/src/m6-commands.ts new file mode 100644 index 0000000..44978f4 --- /dev/null +++ b/packages/cli/src/m6-commands.ts @@ -0,0 +1,153 @@ +import { + buildM6WorkerUploadMetadata, lifecycleOperationRequestSchema, m6ServiceConfigHash, + m6ServiceUpdateRequestSchema, showMetadataSchema, validateId, +} from "@castloop/shared"; +import type { ServiceConfig } from "@castloop/shared"; +import { administratorKey } from "./administrator-key"; +import { CloudflareApi } from "./cloudflare-api"; +import { readBoundedLocalJournal } from "./local-journal-read"; +import { createLifecycleJournal, readLocalLifecycleJob } from "./lifecycle-journal"; +import { createLifecycleOperationEffects, runLifecycleRetry } from "./lifecycle-operation"; +import { createFreshM6RestEffects } from "./m6-initialization-rest"; +import { createM6LocalEpisodeDraft, createM6LocalShowDraft } from "./m6-local-drafts"; +import { confirmLocalM6Lifecycle, executeLocalM6Lifecycle, previewLocalM6Lifecycle, validateM6LifecyclePlan } from "./m6-local-lifecycle"; +import { publishLocalM6Draft, updateLocalM6Draft } from "./m6-local-update"; +import { M6ServiceClient } from "./m6-service-client"; +import { createFreshM6InitializationJournal, openFreshM6InitializationJournal, reconcileFreshM6Initialization, runFreshM6Initialization } from "./m6-service-initialization"; +import { createM6UpdateJournal, readLocalM6Update, reconcileM6UpdateCompletion, resumeM6UpdateCompletion, resumeM6UpdateDeploymentVerification, runM6Update } from "./m6-service-update"; +import type { M6UpdateJournal } from "./m6-service-update"; +import { M6SetupClient } from "./m6-setup-client"; +import { M6UpdateClient } from "./m6-update-client"; +import { createM6UpdateRestEffects } from "./m6-update-rest"; +import { createPublicationJournal, readLocalPublicationJob } from "./publication-journal"; +import { createPublicationOperationEffects, runPublicationRetry } from "./publication-operation"; +import { readRemoteOperationStatus } from "./remote-operation-status"; +import { createShowRegistrationJournal } from "./show-registration-journal"; +import { createShowRegistrationEffects, runShowRegistration } from "./show-registration-operation"; +import { TargetInspectionClient } from "./target-inspection-client"; +import { WORKER_COMPATIBILITY_DATE } from "./worker-payload"; +import { workerPayloadHash } from "./worker-upload-hash"; +import { existsSync, lstatSync } from "node:fs"; +import { join, resolve } from "node:path"; + +export const M6_COMMAND_ARGUMENTS: Record = { + init: 1, "init-reconcile": 1, "update-service": 1, "update-service-verify": 1, "update-service-reconcile": 1, + "service-status": 0, "service-pause": 1, "service-resume": 1, "target-show": 1, "target-episode": 2, + "preview-show-lifecycle": 2, "preview-episode-lifecycle": 3, "lifecycle-execute": 3, "lifecycle-retry": 3, + "publication-retry": 1, "operation-status": 2, "create-show": 2, "create-episode": 2, + "update-show": 1, "update-episode": 2, "update-episode-audio": 3, "publish-show": 1, "publish-episode": 3, +}; + +export async function runM6Command(root: string, config: ServiceConfig, command: string, args: string[], options: { + workerSource: () => Promise; + updateClient?: (api: CloudflareApi, key: string) => M6UpdateClient; +}): Promise { + const count = command === "publish-episode" && args.length === 2 ? 2 : M6_COMMAND_ARGUMENTS[command]; + if (!Object.hasOwn(M6_COMMAND_ARGUMENTS, command) || args.length !== count || args.some((arg) => !arg || arg.startsWith("--"))) { + throw new Error("Invalid command arguments; use --help"); + } + if (!lstatSync(root).isDirectory()) throw new Error("Workspace must be a real directory"); + const key = administratorKey(root, command === "init"); + if (command === "publication-retry") { + const retained = readLocalPublicationJob(root, config, args[0]!); + if (!retained.client_state || retained.lock_present) throw new Error("Retry requires its retained publication journal without an unknown lock"); + const journal = createPublicationJournal(root, config, retained.client_state.publication); + await runPublicationRetry(journal, createPublicationOperationEffects(config, journal.load(), key)); + return { result: "publication-requeued", job_id: args[0] }; + } + if (command === "operation-status") return readRemoteOperationStatus(root, config, args[0]!, args[1]!, () => key); + if (command === "lifecycle-execute") { + const plan = validateM6LifecyclePlan(config, readBoundedLocalJournal(resolve(args[0]!))); + const confirmation = confirmLocalM6Lifecycle(plan.request.action, args[1]!, args[2]!); + const state = await executeLocalM6Lifecycle(root, config, plan, confirmation, key); + return { result: "lifecycle-committed", job_id: state.claim.request.job_id }; + } + if (command === "lifecycle-retry") { + const retained = readLocalLifecycleJob(root, config, args[0]!); + if (!retained.client_state || retained.lock_present) throw new Error("Retry requires its retained confirmed journal without an unknown lock"); + const claim = retained.client_state.claim; + const journal = createLifecycleJournal(root, config, { ...claim, confirmation: confirmLocalM6Lifecycle(claim.request.action, args[1]!, args[2]!) }); + await runLifecycleRetry(journal, createLifecycleOperationEffects(config, journal.load(), key)); + return { result: "lifecycle-requeued", job_id: claim.request.job_id }; + } + if (command.startsWith("preview-")) { + const showId = validateId(args[0]!, "show"); + const episode = command === "preview-episode-lifecycle"; + const target = episode ? { kind: "episode" as const, show_id: showId, episode_id: validateId(args[1]!, "episode") } : { kind: "show" as const, show_id: showId }; + const action = lifecycleOperationRequestSchema.shape.action.parse(args[episode ? 2 : 1]); + return previewLocalM6Lifecycle(config, target, action, key); + } + if (command === "init") { + const source = await options.workerSource(); + const metadata = buildM6WorkerUploadMetadata(config, null, key, WORKER_COMPATIBILITY_DATE); + const journal = await createFreshM6InitializationJournal(root, config, args[0]!, source, metadata); + await runFreshM6Initialization(journal, createFreshM6RestEffects(new CloudflareApi(config), key), source, metadata); + return { result: "initialized-paused", operation_id: args[0], runtime_readiness: journal.load().runtime_readiness }; + } + if (command === "init-reconcile") { + const journal = await openFreshM6InitializationJournal(root, config); + if (journal.load().request.operation_id !== args[0]) throw new Error("Initialization reconciliation has another operation identity"); + const client = new M6SetupClient(config, key, new CloudflareApi(config)); + await reconcileFreshM6Initialization(journal, (_config, target) => client.observeCompleted({ target })); + return { result: "initialized-paused", operation_id: args[0], runtime_readiness: journal.load().runtime_readiness }; + } + if (command.startsWith("update-service")) { + const operationId = m6ServiceUpdateRequestSchema.shape.operation_id.parse(args[0]); + const api = new CloudflareApi(config); + const client = options.updateClient ? options.updateClient(api, key) : new M6UpdateClient(config, key, api); + const effects = createM6UpdateRestEffects(api, { begin: (input) => client.begin(input), + admission: () => client.admission(), complete: (input, target) => client.complete(input, target) }); + let journal: M6UpdateJournal; + if (command !== "update-service") { + const retained = readLocalM6Update(root, config, operationId); + if (!retained.state || retained.lockPresent) throw new Error("Verification requires its retained journal without an unknown lock"); + journal = await createM6UpdateJournal(root, config, retained.state.request); + if (command === "update-service-reconcile") await reconcileM6UpdateCompletion(journal, (request, target) => client.observeCompleted(request, target)); + else if (retained.state.phase === "deploy_requested") await resumeM6UpdateDeploymentVerification(journal, effects); + else await resumeM6UpdateCompletion(journal, effects); + } else { + const source = await options.workerSource(); + const status = await new M6ServiceClient(config, key).call({ service_id: config.service_id, action: "status" }); + if (status.admission.state !== "paused" || status.admission.invocations.length) throw new Error("Explicitly pause and drain before a compatible update"); + const metadata = await api.prepareCompatibleM6WorkerUpload(config, status.worker_version_id); + const request = m6ServiceUpdateRequestSchema.parse({ operation_id: operationId, service_id: config.service_id, + pause_id: status.admission.pause_id, expected_service_generation: status.admission.generation, + previous_worker_version_id: status.worker_version_id, service_config_sha256: await m6ServiceConfigHash(config), + worker_source_sha256: workerPayloadHash(source), worker_metadata_sha256: workerPayloadHash(metadata) }); + journal = await createM6UpdateJournal(root, config, request); + await runM6Update(journal, effects, source, metadata); + } + return { result: "updated-paused", operation_id: operationId, runtime_readiness: journal.load().runtime_readiness }; + } + if (command.startsWith("service-")) { + const action = command.slice("service-".length) as "status" | "pause" | "resume"; + return new M6ServiceClient(config, key).call(action === "status" ? { service_id: config.service_id, action } : { service_id: config.service_id, action, pause_id: args[0]! }); + } + const showId = validateId(args[0]!, "show"); + if (command.startsWith("target-")) { + const target = command === "target-show" ? { kind: "show" as const, show_id: showId } : + { kind: "episode" as const, show_id: showId, episode_id: validateId(args[1]!, "episode") }; + return new TargetInspectionClient(config, key).inspect({ schema_version: 1, service_id: config.service_id, ...target }); + } + if (command === "create-show") { + showMetadataSchema.shape.site_url.parse(args[1]); + if (existsSync(join(root, showId))) throw new Error("Local Show directory already exists; do not create another registration"); + const journal = createShowRegistrationJournal(root, config, { schema_version: 1, action: "reserve", service_id: config.service_id, + show_id: showId, reservation_id: crypto.randomUUID() }); + await runShowRegistration(journal, createShowRegistrationEffects(config, key)); + return createM6LocalShowDraft(root, config, showId, args[1]!); + } + const episodeId = command.endsWith("show") ? undefined : validateId(args[1]!, "episode"); + if (command === "create-episode") return createM6LocalEpisodeDraft(root, config, showId, episodeId!); + const target = episodeId ? { kind: "episode" as const, show_id: showId, episode_id: episodeId } : { kind: "show" as const, show_id: showId }; + if (command.startsWith("publish-")) { + const state = await publishLocalM6Draft(root, config, target, key, args[2] ? { audioPath: resolve(args[2]) } : {}); + return { result: "publication-committed", job_id: state.publication.request.job_id }; + } + const state = await updateLocalM6Draft(root, config, target, command === "update-show" ? { asset: "show" } : command === "update-episode" ? + { asset: "episode_metadata" } : { asset: "audio", audio_path: resolve(args[2]!) }, key, { + rest: { accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "", apiToken: process.env.CLOUDFLARE_API_TOKEN ?? "" }, + }); + if (state.finish_receipt !== "staged") throw new Error("Staging was explicitly aborted; its receipt was retained and publication was not authorized"); + return { result: "staged", operation_id: state.upload.operation_id }; +} diff --git a/packages/cli/src/m6-test-cli.ts b/packages/cli/src/m6-test-cli.ts index 3453919..d3e5765 100644 --- a/packages/cli/src/m6-test-cli.ts +++ b/packages/cli/src/m6-test-cli.ts @@ -1,215 +1,33 @@ -import { buildM6WorkerUploadMetadata, lifecycleOperationRequestSchema, m6ServiceConfigHash, m6ServiceUpdateRequestSchema, parseServiceConfig, showMetadataSchema, validateId } from "@castloop/shared"; -import { CloudflareApi } from "./cloudflare-api"; -import { readBoundedLocalJournal } from "./local-journal-read"; -import { createLifecycleJournal, readLocalLifecycleJob } from "./lifecycle-journal"; -import { createLifecycleOperationEffects, runLifecycleRetry } from "./lifecycle-operation"; -import { createFreshM6RestEffects } from "./m6-initialization-rest"; -import { createM6LocalEpisodeDraft, createM6LocalShowDraft } from "./m6-local-drafts"; -import { confirmLocalM6Lifecycle, executeLocalM6Lifecycle, previewLocalM6Lifecycle, validateM6LifecyclePlan } from "./m6-local-lifecycle"; -import { publishLocalM6Draft, updateLocalM6Draft } from "./m6-local-update"; -import { M6ServiceClient } from "./m6-service-client"; -import { createFreshM6InitializationJournal, openFreshM6InitializationJournal, reconcileFreshM6Initialization, runFreshM6Initialization } from "./m6-service-initialization"; -import { createM6UpdateJournal, readLocalM6Update, reconcileM6UpdateCompletion, resumeM6UpdateCompletion, resumeM6UpdateDeploymentVerification, runM6Update } from "./m6-service-update"; -import { M6SetupClient } from "./m6-setup-client"; +import { parseServiceConfig } from "@castloop/shared"; +import { M6_COMMAND_ARGUMENTS, runM6Command } from "./m6-commands"; import { M6UpdateClient } from "./m6-update-client"; -import { createPublicationJournal, readLocalPublicationJob } from "./publication-journal"; -import { createPublicationOperationEffects, runPublicationRetry } from "./publication-operation"; -import { readRemoteOperationStatus } from "./remote-operation-status"; -import { createM6UpdateRestEffects } from "./m6-update-rest"; -import { createShowRegistrationJournal } from "./show-registration-journal"; -import { createShowRegistrationEffects, runShowRegistration } from "./show-registration-operation"; -import { TargetInspectionClient } from "./target-inspection-client"; import { dropSetupCompletion } from "./test-support/drop-setup-completion"; -import { embeddedWorkerSource, WORKER_COMPATIBILITY_DATE } from "./worker-payload"; -import { workerPayloadHash } from "./worker-upload-hash"; -import { randomBytes } from "node:crypto"; -import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { embeddedWorkerSource } from "./worker-payload"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; -const HELP = `Unreleased isolated M6 test binary. Requires castloop.toml in the working directory. -init OPERATION_UUID -init-reconcile OPERATION_UUID (only an already-completed paused initialization) -update-service OPERATION_UUID (requires explicit pause and settled owners) -update-service-drop-completion OPERATION_UUID (isolated fault test; deliberately loses the completion response) -update-service-verify OPERATION_UUID (only durable acknowledged deployment; never re-upload) -update-service-reconcile OPERATION_UUID (only an already-completed paused update) -service-status | service-pause PAUSE_UUID | service-resume PAUSE_UUID -target-show SHOW_ID | target-episode SHOW_ID EPISODE_ID -create-show SHOW_ID SITE_URL | create-episode SHOW_ID EPISODE_ID -update-show SHOW_ID | update-episode SHOW_ID EPISODE_ID -update-episode-audio SHOW_ID EPISODE_ID MP3_PATH -publish-show SHOW_ID | publish-episode SHOW_ID EPISODE_ID [MP3_PATH] -MP3_PATH is required only when audio has been staged for this draft. -publication-retry JOB_UUID (same acknowledged commit; refuses active or unknown execution) -preview-show-lifecycle SHOW_ID unpublish|restore|delete -preview-episode-lifecycle SHOW_ID EPISODE_ID unpublish|restore|delete -lifecycle-execute PLAN_JSON REQUEST_SHA256 confirm|confirm-delete-retain-records -lifecycle-retry JOB_UUID REQUEST_SHA256 confirm|confirm-delete-retain-records -operation-status FAMILY ID -Deletion physically removes payloads and permanently retains operational records/IDs. -No production release, resource adoption/deletion, automatic resume or unknown-outcome replay.`; - -function argumentsFor(args: string[], count: number): void { - if (args.length !== count || args.some((arg) => !arg || arg.startsWith("--"))) throw new Error("Invalid test command arguments; use --help"); -} - -async function main(): Promise { +async function main(): Promise { const [command, ...args] = process.argv.slice(2); - if (!command || command === "--help") { console.log(HELP); return; } - const counts: Record = { init: 1, "init-reconcile": 1, "update-service": 1, "update-service-drop-completion": 1, "update-service-verify": 1, - "update-service-reconcile": 1, "service-status": 0, "service-pause": 1, "service-resume": 1, - "target-show": 1, "target-episode": 2, - "preview-show-lifecycle": 2, "preview-episode-lifecycle": 3, "lifecycle-execute": 3, "lifecycle-retry": 3, "publication-retry": 1, "operation-status": 2, - "create-show": 2, "create-episode": 2, "update-show": 1, "update-episode": 2, "update-episode-audio": 3, "publish-show": 1, "publish-episode": 3 }; - if (!Object.hasOwn(counts, command)) throw new Error("Unknown test command; use --help"); - argumentsFor(args, command === "publish-episode" && args.length === 2 ? 2 : counts[command]!); + if (!command || command === "--help") return `Isolated M6 test binary; only explicitly named test resources are permitted.\n${Object.keys(M6_COMMAND_ARGUMENTS).join("\n")}\nupdate-service-drop-completion OPERATION_UUID (fault test only)`; const root = process.cwd(); - if (!lstatSync(root).isDirectory()) throw new Error("Test workspace must be a real directory"); const config = parseServiceConfig(readFileSync(join(root, "castloop.toml"), "utf8")); if (!config.service_id.startsWith("m6-test-") || [config.worker_name, config.bucket_name, config.queue_name, config.dlq_name] .some((name) => !name.startsWith("castloop-m6-test-"))) throw new Error("Only explicitly named isolated M6 test resources are permitted"); - const directory = join(root, ".castloop"); - const secretFile = join(directory, "secrets.json"); - if (existsSync(directory) && !lstatSync(directory).isDirectory()) throw new Error("Test state parent must not be a symlink"); - if (command === "init") { - if (!embeddedWorkerSource) throw new Error("Build the standalone --m6-test binary before initialization"); - if (!existsSync(directory)) mkdirSync(directory, { mode: 0o700 }); - if (!lstatSync(directory).isDirectory()) throw new Error("Test state parent must not be a symlink"); - if (!existsSync(secretFile)) { - const fd = openSync(secretFile, "wx", 0o600); - try { writeFileSync(fd, JSON.stringify({ CASTLOOP_ADMIN_KEY: randomBytes(32).toString("hex") })); fsyncSync(fd); } - finally { closeSync(fd); } - const parent = openSync(directory, "r"); - try { fsyncSync(parent); } finally { closeSync(parent); } - } - } - const secret = readBoundedLocalJournal(secretFile); - if (!secret || typeof secret !== "object" || !("CASTLOOP_ADMIN_KEY" in secret) || typeof secret.CASTLOOP_ADMIN_KEY !== "string") throw new Error("Missing local administrator key"); - const key = secret.CASTLOOP_ADMIN_KEY; - if (command === "publication-retry") { - const retained = readLocalPublicationJob(root, config, args[0]!); - if (!retained.client_state || retained.lock_present) throw new Error("Retry requires its retained publication journal without an unknown lock"); - const journal = createPublicationJournal(root, config, retained.client_state.publication); - await runPublicationRetry(journal, createPublicationOperationEffects(config, journal.load(), key)); - console.log(JSON.stringify({ result: "publication-requeued", job_id: args[0] })); - return; - } - if (command === "operation-status") { - console.log(JSON.stringify(await readRemoteOperationStatus(root, config, args[0]!, args[1]!, () => key))); - return; - } - if (command === "lifecycle-execute") { - const plan = validateM6LifecyclePlan(config, readBoundedLocalJournal(resolve(args[0]!))); - const confirmation = confirmLocalM6Lifecycle(plan.request.action, args[1]!, args[2]!); - const state = await executeLocalM6Lifecycle(root, config, plan, confirmation, key); - console.log(JSON.stringify({ result: "lifecycle-committed", job_id: state.claim.request.job_id })); - return; - } - if (command === "lifecycle-retry") { - const retained = readLocalLifecycleJob(root, config, args[0]!); - if (!retained.client_state || retained.lock_present) throw new Error("Retry requires its retained confirmed journal without an unknown lock"); - const claim = retained.client_state.claim; - const journal = createLifecycleJournal(root, config, { ...claim, confirmation: confirmLocalM6Lifecycle(claim.request.action, args[1]!, args[2]!) }); - await runLifecycleRetry(journal, createLifecycleOperationEffects(config, journal.load(), key)); - console.log(JSON.stringify({ result: "lifecycle-requeued", job_id: claim.request.job_id })); - return; - } - if (command.startsWith("preview-")) { - const showId = validateId(args[0]!, "show"); - const episode = command === "preview-episode-lifecycle"; - const target = episode ? { kind: "episode" as const, show_id: showId, episode_id: validateId(args[1]!, "episode") } : { kind: "show" as const, show_id: showId }; - const action = lifecycleOperationRequestSchema.shape.action.parse(args[episode ? 2 : 1]); - console.log(JSON.stringify(await previewLocalM6Lifecycle(config, target, action, key))); - return; - } - if (command === "init") { - const source = embeddedWorkerSource!; - const metadata = buildM6WorkerUploadMetadata(config, null, key, WORKER_COMPATIBILITY_DATE); - const journal = await createFreshM6InitializationJournal(root, config, args[0]!, source, metadata); - await runFreshM6Initialization(journal, createFreshM6RestEffects(new CloudflareApi(config), key), source, metadata); - console.log(JSON.stringify({ result: "initialized-paused", operation_id: args[0], runtime_readiness: journal.load().runtime_readiness })); - return; - } - if (command === "init-reconcile") { - const journal = await openFreshM6InitializationJournal(root, config); - if (journal.load().request.operation_id !== args[0]) throw new Error("Initialization reconciliation has another operation identity"); - const client = new M6SetupClient(config, key, new CloudflareApi(config)); - await reconcileFreshM6Initialization(journal, (_config, target) => client.observeCompleted({ target })); - console.log(JSON.stringify({ result: "initialized-paused", operation_id: args[0], runtime_readiness: journal.load().runtime_readiness })); - return; - } - if (["update-service", "update-service-drop-completion", "update-service-verify", "update-service-reconcile"].includes(command)) { - const operationId = m6ServiceUpdateRequestSchema.shape.operation_id.parse(args[0]); - const api = new CloudflareApi(config); - const client = new M6UpdateClient(config, key, api, command === "update-service-drop-completion" ? dropSetupCompletion() : undefined); - const effects = createM6UpdateRestEffects(api, { begin: (input) => client.begin(input), - admission: () => client.admission(), complete: (input, target) => client.complete(input, target) }); - if (command === "update-service-verify" || command === "update-service-reconcile") { - const retained = readLocalM6Update(root, config, operationId); - if (!retained.state || retained.lockPresent) throw new Error("Verification requires its retained journal without an unknown lock"); - const journal = await createM6UpdateJournal(root, config, retained.state.request); - if (command === "update-service-reconcile") await reconcileM6UpdateCompletion(journal, (request, target) => client.observeCompleted(request, target)); - else if (retained.state.phase === "deploy_requested") await resumeM6UpdateDeploymentVerification(journal, effects); - else await resumeM6UpdateCompletion(journal, effects); - console.log(JSON.stringify({ result: "updated-paused", operation_id: operationId, runtime_readiness: journal.load().runtime_readiness })); - return; - } - const source = embeddedWorkerSource; - if (!source) throw new Error("Build the standalone --m6-test binary before a compatible update"); - const status = await new M6ServiceClient(config, key).call({ service_id: config.service_id, action: "status" }); - if (status.admission.state !== "paused" || status.admission.invocations.length) throw new Error("Explicitly pause and drain before a compatible update"); - const metadata = await api.prepareCompatibleM6WorkerUpload(config, status.worker_version_id); - const request = m6ServiceUpdateRequestSchema.parse({ operation_id: operationId, service_id: config.service_id, - pause_id: status.admission.pause_id, expected_service_generation: status.admission.generation, - previous_worker_version_id: status.worker_version_id, service_config_sha256: await m6ServiceConfigHash(config), - worker_source_sha256: workerPayloadHash(source), worker_metadata_sha256: workerPayloadHash(metadata) }); - const journal = await createM6UpdateJournal(root, config, request); - await runM6Update(journal, effects, source, metadata); - console.log(JSON.stringify({ result: "updated-paused", operation_id: operationId, runtime_readiness: journal.load().runtime_readiness })); - return; - } - if (command.startsWith("service-")) { - const action = command.slice("service-".length) as "status" | "pause" | "resume"; - const client = new M6ServiceClient(config, key); - const response = await client.call(action === "status" ? { service_id: config.service_id, action } : { service_id: config.service_id, action, pause_id: args[0]! }); - console.log(JSON.stringify(response)); - return; - } - const showId = validateId(args[0]!, "show"); - if (command.startsWith("target-")) { - const target = command === "target-show" ? { kind: "show" as const, show_id: showId } : - { kind: "episode" as const, show_id: showId, episode_id: validateId(args[1]!, "episode") }; - console.log(JSON.stringify(await new TargetInspectionClient(config, key).inspect({ schema_version: 1, service_id: config.service_id, ...target }))); - return; - } - if (command === "create-show") { - showMetadataSchema.shape.site_url.parse(args[1]); - if (existsSync(join(root, showId))) throw new Error("Local Show directory already exists; do not create another registration"); - const reserve = { schema_version: 1 as const, action: "reserve" as const, service_id: config.service_id, show_id: showId, - reservation_id: crypto.randomUUID() }; - const journal = createShowRegistrationJournal(root, config, reserve); - await runShowRegistration(journal, createShowRegistrationEffects(config, key)); - console.log(await createM6LocalShowDraft(root, config, showId, args[1]!)); - return; - } - const episodeId = command.endsWith("show") ? undefined : validateId(args[1]!, "episode"); - if (command === "create-episode") { console.log(await createM6LocalEpisodeDraft(root, config, showId, episodeId!)); return; } - const target = episodeId ? { kind: "episode" as const, show_id: showId, episode_id: episodeId } : { kind: "show" as const, show_id: showId }; - if (command.startsWith("publish-")) { - const state = await publishLocalM6Draft(root, config, target, key, args[2] ? { audioPath: resolve(args[2]) } : {}); - console.log(JSON.stringify({ result: "publication-committed", job_id: state.publication.request.job_id })); - return; - } - const state = await updateLocalM6Draft(root, config, target, command === "update-show" ? { asset: "show" } : command === "update-episode" ? - { asset: "episode_metadata" } : { asset: "audio", audio_path: resolve(args[2]!) }, key, { - rest: { accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "", apiToken: process.env.CLOUDFLARE_API_TOKEN ?? "" }, - }); - if (state.finish_receipt !== "staged") throw new Error("Staging was explicitly aborted; its receipt was retained and publication was not authorized"); - console.log(JSON.stringify({ result: "staged", operation_id: state.upload.operation_id })); + const fault = command === "update-service-drop-completion"; + return runM6Command(root, config, fault ? "update-service" : command, args, { + workerSource: async () => { + if (!embeddedWorkerSource) throw new Error("Build the standalone --m6-test binary before initialization or update"); + return embeddedWorkerSource; + }, + ...(fault ? { updateClient: (api, key) => new M6UpdateClient(config, key, api, dropSetupCompletion()) } : {}), + }); } -await main().catch((error: unknown) => { +try { + const result = await main(); + console.log(typeof result === "string" ? result : JSON.stringify(result)); +} catch (error) { console.error(error instanceof Error ? error.message : "M6 test command failed"); - console.error("M6 test command did not complete. Preserve journals, locks and remote owners; do not automatically replay unknown requests."); + console.error("Preserve journals, locks and remote owners; do not automatically replay unknown requests."); process.exitCode = 1; -}); +} diff --git a/packages/cli/src/worker-payload.ts b/packages/cli/src/worker-payload.ts index f058d15..c7eeba6 100644 --- a/packages/cli/src/worker-payload.ts +++ b/packages/cli/src/worker-payload.ts @@ -1,2 +1,4 @@ -export const WORKER_COMPATIBILITY_DATE = "2026-09-23"; +import { M6_FRESH_WORKER_COMPATIBILITY_DATE } from "@castloop/shared"; + +export const WORKER_COMPATIBILITY_DATE = M6_FRESH_WORKER_COMPATIBILITY_DATE; export const embeddedWorkerSource: string | null = null; diff --git a/scripts/build-cli.ts b/scripts/build-cli.ts index 40e9ca0..b3049f1 100644 --- a/scripts/build-cli.ts +++ b/scripts/build-cli.ts @@ -1,4 +1,3 @@ -import { WORKER_COMPATIBILITY_DATE } from "../packages/cli/src/worker-payload"; import { M6_FRESH_WORKER_COMPATIBILITY_DATE } from "../packages/shared/src/index"; import { mkdirSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; @@ -22,7 +21,7 @@ const target = requestedTarget as keyof typeof TARGETS | undefined; const name = m6Test ? "castloop-m6-test" : "castloop"; const outfile = join(root, "dist", target ? `${name}-${target}${target === "windows-x64" ? ".exe" : ""}` : name); -const worker = await Bun.build({ entrypoints: [join(root, m6Test ? "src/m6-setup-worker.ts" : "src/index.ts")], +const worker = await Bun.build({ entrypoints: [join(root, m6Test ? "src/m6-setup-worker.ts" : "src/worker.ts")], target: "browser", minify: true, external: ["cloudflare:workers"] }); if (!worker.success || worker.outputs.length !== 1) throw new Error("Worker bundle build failed"); const source = await worker.outputs[0].text(); @@ -36,7 +35,7 @@ const result = await Bun.build({ name: "castloop-worker-bundle", setup(build) { build.onLoad({ filter: /worker-payload\.ts$/ }, () => ({ - contents: `export const WORKER_COMPATIBILITY_DATE = ${JSON.stringify(m6Test ? M6_FRESH_WORKER_COMPATIBILITY_DATE : WORKER_COMPATIBILITY_DATE)};\n` + + contents: `export const WORKER_COMPATIBILITY_DATE = ${JSON.stringify(M6_FRESH_WORKER_COMPATIBILITY_DATE)};\n` + `export const embeddedWorkerSource = ${JSON.stringify(source)};\n`, loader: "ts", })); diff --git a/src/m6-setup-worker.ts b/src/m6-setup-worker.ts index aaced09..0e68e8d 100644 --- a/src/m6-setup-worker.ts +++ b/src/m6-setup-worker.ts @@ -1,6 +1,5 @@ -import { fetchM6ManagementIntegration, queueM6Candidate } from "./m6-routes"; +import worker from "./worker"; import type { M6CandidateEnv } from "./m6-routes"; -import { consumeM6SetupProbe } from "./m6-setup-queue"; import { CachedPublicAssets as PublicAssetsEntrypoint } from "./lifecycle-cached-entrypoint"; import { parsePublicAssetPath } from "./public-assets"; @@ -15,11 +14,7 @@ export class CachedPublicAssets extends PublicAssetsEntrypoint { export default { async fetch(request, env, ctx): Promise { - const response = await fetchM6ManagementIntegration(request, env, ctx.exports.CachedPublicAssets, { setupRuntime: { - defaultFetch: (input) => ctx.exports.default.fetch(input), - cachedRuntime: () => ctx.exports.CachedPublicAssets.describeRuntime(), - invalidate: (showId) => ctx.exports.CachedPublicAssets.invalidate({ showId }), - } }); + const response = await worker.fetch(request, env, ctx); if (!parsePublicAssetPath(new URL(request.url).pathname)) return response; const headers = new Headers(response.headers); headers.set("X-Castloop-Test-Gateway-Invocation", crypto.randomUUID()); @@ -27,7 +22,6 @@ export default { return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); }, async queue(batch, env, ctx): Promise { - if (await consumeM6SetupProbe(batch, env)) return; - await queueM6Candidate(batch, env, ctx.exports.CachedPublicAssets); + await worker.queue(batch, env, ctx); }, } satisfies ExportedHandler; diff --git a/src/m6-worker.ts b/src/m6-worker.ts index 9224f84..579c92d 100644 --- a/src/m6-worker.ts +++ b/src/m6-worker.ts @@ -4,12 +4,6 @@ import { migrationBootstrapRequestSchema } from "../packages/shared/src/index"; export { CachedPublicAssets } from "./lifecycle-cached-entrypoint"; -declare global { - namespace Cloudflare { - interface GlobalProps { mainModule: typeof import("./m6-worker"); } - } -} - export default { async fetch(request, env, ctx): Promise { const bootstrapId = migrationBootstrapRequestSchema.shape.bootstrap_id.safeParse(env.CASTLOOP_VERSION_METADATA.tag); diff --git a/src/worker.ts b/src/worker.ts new file mode 100644 index 0000000..f99640d --- /dev/null +++ b/src/worker.ts @@ -0,0 +1,25 @@ +import { fetchM6ManagementIntegration, queueM6Candidate } from "./m6-routes"; +import type { M6CandidateEnv } from "./m6-routes"; +import { consumeM6SetupProbe } from "./m6-setup-queue"; + +export { CachedPublicAssets } from "./lifecycle-cached-entrypoint"; + +declare global { + namespace Cloudflare { + interface GlobalProps { mainModule: typeof import("./worker"); } + } +} + +export default { + async fetch(request, env, ctx): Promise { + return fetchM6ManagementIntegration(request, env, ctx.exports.CachedPublicAssets, { setupRuntime: { + defaultFetch: (input) => ctx.exports.default.fetch(input), + cachedRuntime: () => ctx.exports.CachedPublicAssets.describeRuntime(), + invalidate: (showId) => ctx.exports.CachedPublicAssets.invalidate({ showId }), + } }); + }, + async queue(batch, env, ctx): Promise { + if (await consumeM6SetupProbe(batch, env)) return; + await queueM6Candidate(batch, env, ctx.exports.CachedPublicAssets); + }, +} satisfies ExportedHandler; From 4c65e911f4ecb9e64039a24115948ffb5ad14adb Mon Sep 17 00:00:00 2001 From: Akira Shimosako Date: Sun, 4 Oct 2026 14:19:39 +0900 Subject: [PATCH 14/16] test(m6): record formal binary MVP acceptance and release readiness --- README.md | 2 + design/m6_implementation_log.md | 7 +++ design/m6_review_queue.md | 4 +- design/m6_standalone_acceptance.md | 12 ++++ .../m6/formal-entry-results-20261004.json | 56 +++++++++++++++++++ packages/cli/src/index.ts | 0 6 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 experiments/m6/formal-entry-results-20261004.json mode change 100644 => 100755 packages/cli/src/index.ts diff --git a/README.md b/README.md index 24879e1..93cbf4e 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,8 @@ Pause temporarily stops delivery and new mutations. Paused does not necessarily Preserve the workspace, journals, locks and remote owners after any error. Do not rerun unknown requests, delete locks, replace administrator secrets or release tokens because time elapsed or HEAD found no object. An unknown/forcibly interrupted invocation can remain blocked; universal recovery is outside this MVP. - `local-operation-status FAMILY ID` inspects local records without credentials or network access. `operation-status FAMILY ID` compares them with authenticated server status. Families: `staging`, `publication`, `lifecycle`, `show-registration`. +- Historical jobs may report `ownership: "superseded"` after a later operation. Use `target-show ID` or `target-episode SHOW_ID EPISODE_ID` for current ownership; this does not authorize retrying an old job. +- Read-only inspections can be unverified while progress changes. Repeat only read-only inspection; never replay a mutation or release ownership on that basis. - `retry-job JOB_UUID` and `lifecycle-retry JOB_UUID REQUEST_SHA256 ACK` explicitly retry the same acknowledged commit only when execution has safely settled; they refuse active/unknown execution. - `init-reconcile OPERATION_UUID` and `update-service-reconcile OPERATION_UUID` reconcile already completed, paused server operations with retained local requests. They do not repeat deployments. `update-service-verify OPERATION_UUID` continues only an acknowledged deployment's verification. - Initialization journals are `.castloop/service-initializations/SERVICE_ID.json`; update journals are `.castloop/service-updates/SERVICE_ID/OPERATION_UUID.json`. Retain these and their locks. Re-running `init` is not a recovery method for a started operation. diff --git a/design/m6_implementation_log.md b/design/m6_implementation_log.md index e5864d8..b603b15 100644 --- a/design/m6_implementation_log.md +++ b/design/m6_implementation_log.md @@ -1,5 +1,12 @@ # M6: 公開停止・削除 実装ログ +## 2026-10-04: 正式バイナリのMVP実機確認とRelease準備 + +- 正式入口で新規初期化・Show/Episode公開・metadata-only・通常更新・六lifecycle操作が合格した。known checkpointでのみ試験scriptの出力parser/readonly未確認から続行し、未知mutationの再送や強制解放はしない。 +- 最後のreadonly監査で六jobの完了/purge、既知8 payload物理不在、永久reservation/tombstone/commit marker保持を確認した。専用資源はversion `c2d68ee0-d81a-4d70-99b4-539751c88904`、paused/generation146/空registryで保持する。既存300MB/cache/広い非変更検証は再利用した。 +- 最終Worker sourceは実機bundleとbyte hashが一致し、test nonce headerは含まない。全893テスト/13526 assertions・両TypeScript検査・Linux buildと、認証情報なしstandaloneの全27 help/fault command拒否が合格。CLI sourceの実行権限も維持する。 +- 承認済みMVP範囲の残実装・検証は完了。README/helpと次期Release notes草案、checksum/license付きローカルartifactを用意した。正式version/tag/GitHub Releaseとmainへの反映はまだ行っていない。詳細は`m6_standalone_acceptance.md`、`experiments/m6/formal-entry-results-20261004.json`。 + ## 2026-10-04: 承認済みMVP範囲で正式CLI/Workerへ接続 - 既存runnerの呼出を`m6-commands.ts`へ共有し、正式CLIの旧state.json/直接PUT/公開処理を置き換えた。試験CLIは専用資源制限とfault injectionだけの薄いwrapperとし、状態機械・receipt検査は増やしていない。管理keyのprivate/fsync付き保存も一か所へ集約した。 diff --git a/design/m6_review_queue.md b/design/m6_review_queue.md index 09788ab..caa90f3 100644 --- a/design/m6_review_queue.md +++ b/design/m6_review_queue.md @@ -60,11 +60,11 @@ R2/CLIの[完全性検証調査](./m6_upload_integrity_review.md)で、管理RES ### 現在の到達点(2026-10-04) -下記の各module詳細には実装当時の未接続項目も残る。最新の到達点は新規初期化・通常更新の試験HTTP/standalone接続と実Cloudflare合格、Show/Episode六lifecycle操作の実機合格まで進んでいる。後者は配信404/410・復元時GUID/revision保持・物理削除・永久記録保持・owner/token通常返却を含む。通常公開CLI/entryはまだ切り替えていない。 +下記の各module詳細には実装当時の未接続項目も残る。正式CLI/Worker entryを既存runnerへ切り替え、専用新規環境でLinuxバイナリの初期化→明示再開→Show/Episode公開→metadata-only改訂→通常更新→六lifecycle操作→paused終了が合格した。GET/HEAD/Range/304、停止404/削除410、既知payload物理不在、永久記録保持、空registryまで確認した。正式version/tag/GitHub Releaseは未作成である。 通常更新の完了応答喪失→別standaloneの非書込照合と、feed/cover/小さい音源の実cache HIT・gateway毎回実行も実機合格した。改修前の300MB Worker hashはCPU超過となり、元serviceはpaused/owner/token/payloadを保持する。管理者承認のCLI全量SHA-256証拠+Worker HEAD照合+R2公開時検証へ集約した後、新規`castloop-m6-test-7c97f1a0`で300MB公開・audio-only/metadata-only改訂・全量配信/HEAD/Range/304・明示削除まで合格した。試験CLIのmetadata-only引数制約は修正し、publication未開始の確認済みcheckpointだけを続行した。課金/CPU limitは変更しておらず、費用測定計画を通常開発のgateへ戻すものではない。 -残件は正式CLI/Workerへの接続、変更入口の検証と正式バイナリの実機確認、利用案内・release準備である。300MB合格を旧未知owner/tokenの解放へ使わず、未実行/未完了のharnessを合格扱いしない。詳細は[standalone受け入れ](./m6_standalone_acceptance.md)。旧形式変換は必要時だけとする。 +承認済みMVP範囲の実装・検証・利用案内とRelease notes草案は完成した。残るのは正式version/tagと公開操作の確定・実施であり、万能なunknown IO復旧や追加fault matrixをgateへ戻さない。300MB合格を旧未知owner/tokenの解放へ使わず、未実行/未完了のharnessを合格扱いしない。詳細は[standalone受け入れ](./m6_standalone_acceptance.md)。旧形式変換は必要時だけとする。 2026-10-03に管理者が「共通journalリファクタリングをmainへmerge → 別branchで新規M6初期化/稼働判定の分離 → 別branchで形式を変えない通常更新 → 旧形式変換は必要時だけ」を承認した。旧形式変換を新規M6サービスの必須経路にしない。既存環境の即時削除や公開gate解除の承認とは扱わない。 diff --git a/design/m6_standalone_acceptance.md b/design/m6_standalone_acceptance.md index 9e53389..80a1318 100644 --- a/design/m6_standalone_acceptance.md +++ b/design/m6_standalone_acceptance.md @@ -89,3 +89,15 @@ CLIの全量SHA-256読み戻しとPUT receiptのETag/versionが、保存journal/ 続行は`--metadata-staged-workspace`でexact version/readiness・同じbase・finished local/remote released staging・読戻し証拠一致・publication未作成・lock/token不在を照合したcheckpointに限る。別の`wx` before fileを作り、元incompleteを保持する。再PUT/再staging/未知POSTの再送・ownerの強制解放はしていない。 最終pauseは`d89332da-8564-42c9-b605-e37a5af0596d`。private `large-media-acceptance.json`と[sanitized結果](../experiments/m6/large-media-results-20261004.json)を保存した。readonly snapshot競合は9件を有限観測で扱い、mutationは再送していない。全891テスト/13475 assertions、TypeScript/M6試験tsconfig、Linux binaryとlocal HTTPS readonly/production拒否も合格した。 + +## 正式CLI/WorkerのMVP walkthrough: 合格 + +既存runnerを共有した通常Linuxバイナリで、新規`castloop-m6-test-d5fd9895-4fe3e736`/bucket `castloop-m6-test-formal-d5fd9895`を使用した。workspaceは`/tmp/opencode/castloop-m6-test-formal-d5fd9895`。初期化operation `fe060824-fd94-483d-891e-311499aa3a68`のpaused完了・明示再開、サービスrootのShow commandとShow directoryのEpisode command、MP3引数付き初回公開と引数なしmetadata-only公開、GET全量SHA-256/HEAD/Range/304、private path非公開・管理API認証、試験nonce header不在を確認した。 + +通常更新operation `82aff843-2621-4521-a318-23af97a15629`はpausedで完了し、明示再開後のcurrent revision全体(GUID/日時/音源URL/hashを含む)が同じであった。六lifecycle操作の配信404/復元200/削除410と、全jobのcompleted/finished/purge確認・execution非稼働を確認した。終了後のreadonly R2監査で既知8 payloadの404、永久reservation identity・Episode tombstone・Show/Episode publication marker保持、service snapshot非変更が合格した。より広いimmutable object/300MB/cache HIT検証は上記の既存受け入れを再利用し、今回の小さい合成音源試験を聴取品質・複数colo・費用/停止時間測定へ一般化しない。 + +試験scriptは当初create-showのfile path出力をJSONとして読んで停止し、その後は処理中のreadonly照会未確認で停止した。初期化/登録または公開のlocal acknowledged receipt、remote完了・owner/lock/token不在、未開始の次工程を照合したcheckpointだけから続行した。mutationを再送せず、元incompleteを保持した。最後のrunではreadonly未確認16件を有限再照会で扱った。公開完了stateは`published`、lifecycle完了stateは`completed`であることもREADME/helpへ正しく反映した。 + +最終versionは`c2d68ee0-d81a-4d70-99b4-539751c88904`、pause `c626b2f5-94ed-48a5-b8d2-8903126f4446`、generation 146、空registry。正式Workerの最終source hashは試験済みbundleと同じ`ae052ebb5d6aa279ba5a14c97449ef14d1e9cdbfadc493813335f24c52a9bfdc`で、test headerは含まない。資源はpausedで保持し、既存環境/Paid/未知ownerには手を加えない。[sanitized結果](../experiments/m6/formal-entry-results-20261004.json)を保存した。 + +全893テスト/13526 assertions、両TypeScript検査、正式/試験Linux build、認証情報・ソースのないcwdで正式standaloneのversion/全27 help・fault command拒否も合格した。CLI実行権限を保持し、checksum/license同梱の候補artifactをローカルへ用意した。README/help/Release notes草案は完成したが、正式version/tag/GitHub Releaseはまだ作成していない。 diff --git a/experiments/m6/formal-entry-results-20261004.json b/experiments/m6/formal-entry-results-20261004.json new file mode 100644 index 0000000..bbdf082 --- /dev/null +++ b/experiments/m6/formal-entry-results-20261004.json @@ -0,0 +1,56 @@ +{ + "date": "2026-10-04", + "result": "formal_binary_mvp_walkthrough_passed", + "worker_name": "castloop-m6-test-d5fd9895-4fe3e736", + "bucket_name": "castloop-m6-test-formal-d5fd9895", + "initialization_operation_id": "fe060824-fd94-483d-891e-311499aa3a68", + "initial_worker_version_id": "8781bee2-e578-4406-af18-efa34028085b", + "update_operation_id": "82aff843-2621-4521-a318-23af97a15629", + "worker_version_id": "c2d68ee0-d81a-4d70-99b4-539751c88904", + "worker_source_sha256": "ae052ebb5d6aa279ba5a14c97449ef14d1e9cdbfadc493813335f24c52a9bfdc", + "final_linux_binary_sha256": "d2618931f65caa9d948d3ead15b00292a651ccfc4aa7fef88caf7ac9ca68f136", + "pause_id": "c626b2f5-94ed-48a5-b8d2-8903126f4446", + "service_generation": 146, + "jobs": { + "show_publication": "58bef828-5d22-4392-9bcc-9fa55f9d4a88", + "episode_publication": "96c9baad-ff63-4a8a-aed5-0db02b6f75bb", + "metadata_only_publication": "ea52bf64-0c87-46bd-9f72-e86ec699ae44", + "episode_unpublish": "bd5df591-9652-4589-b801-bca996d0e27b", + "episode_restore": "f22c2f7f-3777-4510-ab9c-811504c04aa3", + "show_unpublish": "6f0b7a0a-226b-401f-a6cd-9dcc5c5e372e", + "show_restore": "fb565a6c-2305-4501-8c75-5fa072263ecf", + "episode_delete": "b3d6c717-f4b9-40ad-9288-6100b4e67556", + "show_delete": "75216155-7ee4-4428-843d-acac8bf01b39" + }, + "audio_bytes": 20850, + "verified": [ + "fresh_init_paused_and_explicit_resume", + "formal_show_and_episode_directory_arguments", + "publication_with_original_mp3", + "metadata_only_publication_without_mp3", + "public_get_sha256_head_range_304", + "private_paths_hidden_and_management_authentication", + "test_headers_absent", + "compatible_update_paused_and_explicit_resume", + "current_revision_snapshot_unchanged_after_update", + "six_lifecycle_actions_and_public_404_200_410", + "six_completed_jobs_with_finished_purged_progress", + "eight_known_payloads_physically_absent", + "reservation_identity_tombstone_and_commit_markers_retained", + "readonly_audit_service_snapshot_unchanged", + "final_source_matches_tested_worker_bundle" + ], + "continuation_checkpoints": [ + "registered_before_staging_after_output_parser_error", + "published_before_metadata_revision_after_unverified_readonly_observation" + ], + "unknown_mutations_replayed": false, + "unknown_owners_or_locks_released": false, + "readonly_observations_unverified_in_final_run": 16, + "invocations_empty": true, + "resources_retained_paused": true, + "synthetic_mp3_frames": true, + "workers_paid_not_enabled": true, + "reused_large_media_evidence": "large-media-results-20261004.json", + "release_published": false +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts old mode 100644 new mode 100755 From b50bf607226d66b077b46b3cb8bcfa05e549d986 Mon Sep 17 00:00:00 2001 From: Akira Shimosako Date: Sun, 4 Oct 2026 16:08:28 +0900 Subject: [PATCH 15/16] ci: validate Linux binary builds on pull requests --- .github/workflows/build-binaries.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index cb0d330..9aeede6 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -2,6 +2,9 @@ name: Build and release Linux CLI on: workflow_dispatch: + pull_request: + branches: + - main push: tags: - "v*" From 122b9346eee39a8526a1f2b0a92af464cf027eb8 Mon Sep 17 00:00:00 2001 From: Akira Shimosako Date: Sun, 4 Oct 2026 16:11:22 +0900 Subject: [PATCH 16/16] ci: prepare private temporary workspace for tests --- .github/workflows/build-binaries.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 9aeede6..56caed5 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -25,6 +25,8 @@ jobs: bun-version: "1.4.2" - run: npm ci - run: npm run check + - name: Prepare private test workspace + run: mkdir -p -m 700 /tmp/opencode - run: bun test - name: Verify tag version if: github.ref_type == 'tag'