Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
7 changes: 5 additions & 2 deletions .github/workflows/deploy-try.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,19 +78,22 @@ jobs:
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: ../../node_modules/.bin/vp exec wrangler deploy --env prod --minify --dry-run
run: ../../node_modules/.bin/vp exec wrangler deploy --env prod --minify --dry-run --experimental-provision=false

- name: Dry-run Web Worker
working-directory: apps/web
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: ../../node_modules/.bin/vp exec wrangler deploy --env prod --dry-run
run: ../../node_modules/.bin/vp exec wrangler deploy --env prod --dry-run --experimental-provision=false

- name: Deploy
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
MOSOO_PROTOCOL_V3_SMOKE_AGENT_ID: ${{ vars.MOSOO_PROTOCOL_V3_SMOKE_AGENT_ID }}
MOSOO_PROTOCOL_V3_SMOKE_TOKEN: ${{ secrets.MOSOO_PROTOCOL_V3_SMOKE_TOKEN }}
VITE_MOSOO_DEPLOYMENT_MODE: cloud
VITE_MOSOO_ENVIRONMENT: production
VITE_POSTHOG_API_HOST: https://us.i.posthog.com
Expand Down
50 changes: 41 additions & 9 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Minimum generated-file rules:

Required tools:

- `bun >= 1.4.0-canary.1` with text `bun.lock` support.
- `bun >= 1.4.0` with text `bun.lock` support.
- `just >= 1.51`

The repository already pins Vite Plus and Git hook tooling dependencies. Human-facing repository operations use `just`; the `justfile` delegates to `bun run`, which resolves the pinned local Vite Plus binary after `bun install`. A global Vite Plus install is optional for direct shell use: `curl -fsSL https://vite.plus | bash`.
Expand Down Expand Up @@ -164,6 +164,12 @@ just test-file apps/api/tests/session-run-cancel.test.ts
mosoo uses generated Drizzle migrations for local and production D1:

- The schema source of truth is `pkgs/db/src/schema/**`.
- `just check` generates that schema in memory with `drizzle-kit/api` and
requires it to match the latest checked-in snapshot without writing temp
migration output.
- The production schema test also applies the complete migration chain to an
in-memory SQLite database and requires its managed catalog to match that same
snapshot.
- The chain is append-only. Once a migration SQL file, Drizzle snapshot, or
journal entry is merged or applied, do not modify, delete, rename, or
regenerate it.
Expand Down Expand Up @@ -191,14 +197,40 @@ D1 state and reapplies the chain. Never delete or regenerate established files
under `pkgs/db/drizzle`.

Production D1 is not reset during deploy. `just deploy-api` runs
`apps/api/bin/deploy-prod.ts`, whose first remote action is applying pending D1
migrations. It then verifies that every table in the latest Drizzle snapshot
exists in production — the DEPLOY-D1-001 missing-table guard — ensures the
environment-artifact queue, builds the Driver, and deploys the API Worker.
The guard does not compare columns, indexes, constraints, or extra live tables.
`apps/api/bin/deploy-prod.ts`, which builds the Driver and dry-runs the API
Worker before its first remote mutation. It then acquires the durable D1 deploy
lease before it ensures production queues or applies ordinary pending
migrations. Every D1, Queue, API Worker, or smoke mutation renews and verifies
that exact owner before and after the mutation through an independent final D1
read; the canonical lease table may have no triggers. The protocol v3 breaking migrations
use the one-shot paused-queue, D1 admission-gate, drain, legacy terminal
integrity preflight, Time Travel bookmark, deploy, live Driver handshake smoke,
durable queue-resume phase, and Queue API readback sequence documented in the
production runbook.
Migration `0013` is allowed to proceed only when its read-only inventory reports zero historical loss or conflict candidates, and the migration repeats that guard before its first rewrite.
Run `bun apps/api/bin/deploy-prod.ts --protocol-v3-lossy-migration-inventory` before scheduling a release with `0013` pending.
The legacy preflight requires a provably collision-free terminal identity and
fully committed Run, Session, assistant, cursor, and permission state; migration
`0014` normalizes only that proven identity and never guesses ambiguous history.
The migration itself repeats those fail-closed checks and requires a short-lived
authorization bound to the bookmark, exact candidate manifest, and current
deploy lease owner, so applying `0014` directly cannot bypass the cutover.
Run `bun apps/api/bin/deploy-prod.ts --protocol-v3-legacy-inventory` as the
read-only human gate before scheduling this one-shot production cutover.
After the breaking migration is recorded, deployment recovery is roll-forward
only with the exact same v3 release.
The bookmark is an emergency destructive backup, not a routine rollback: a D1
Time Travel restore loses every later D1 write and does not restore Durable
Object storage.
After migration, the script compares every managed table's columns, primary key,
defaults, nullability, named indexes and partial predicates, foreign keys, CHECK
expressions, autoincrement state, and exact migration-owned trigger definitions
with the latest migration contract before it deploys the API Worker.
Unknown extra application tables fail closed. The eight retained Channels and
WeChat tables are explicitly unmanaged legacy data because #577 removed their
runtime subsystem without an approved destructive data migration.
No other automated migration gate exists today: there is no trusted-Git-range
check, no clean-worktree check, and no dry-run inside the deploy script. Run
`just check` and the
or clean-worktree check. Run `just check` and the
[Production Deploy Verification](./docs/production-deploy-verification.md)
runbook before deploying.

Expand Down Expand Up @@ -407,7 +439,7 @@ just deploy-web # Web only — runs no gate; does not touch D1

Only `just deploy` runs the repository gate before publishing; `just deploy-api`
and `just deploy-web` publish directly. The API deploy applies pending remote D1
migrations as its first remote action (see
migrations only after its local Driver build and API dry-run (see
[Database And Migrations](#database-and-migrations)).

API production config lives in `apps/api/wrangler.toml`; web production config lives in `apps/web/wrangler.toml`. Cloudflare routes send `cloud.mosoo.ai/api/*` to the API Worker and `cloud.mosoo.ai/*` to the console Web Worker. The legacy console host redirects Web traffic to `cloud.mosoo.ai` but keeps `try.mosoo.ai/api/*` as a direct compatibility route. The public landing page and blog on `mosoo.ai/*` are owned by `langgenius/mosoo-website`.
Expand Down
74 changes: 74 additions & 0 deletions apps/api/bin/d1-json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
export type D1JsonRow = Readonly<Record<string, unknown>>;

function parseJsonArrayAt(raw: string, start: number): unknown {
let depth = 0;
let escaped = false;
let inString = false;
for (let index = start; index < raw.length; index += 1) {
const character = raw[index];
if (inString) {
if (escaped) escaped = false;
else if (character === "\\") escaped = true;
else if (character === '"') inString = false;
continue;
}
if (character === '"') inString = true;
else if (character === "[") depth += 1;
else if (character === "]") {
depth -= 1;
if (depth === 0) {
try {
return JSON.parse(raw.slice(start, index + 1));
} catch {
return null;
}
}
}
}
return null;
}

export function parseD1JsonResults(raw: string): D1JsonRow[][] {
for (let start = raw.indexOf("["); start !== -1; start = raw.indexOf("[", start + 1)) {
const value = parseJsonArrayAt(raw, start);
if (!Array.isArray(value) || value.length === 0) continue;

const statements: D1JsonRow[][] = [];
let valid = true;
for (const result of value) {
if (
typeof result !== "object" ||
result === null ||
!("results" in result) ||
!("success" in result) ||
result.success !== true ||
!Array.isArray(result.results)
) {
valid = false;
break;
}
const rows: D1JsonRow[] = [];
for (const row of result.results) {
if (typeof row !== "object" || row === null || Array.isArray(row)) {
valid = false;
break;
}
rows.push(row as D1JsonRow);
}
if (!valid) break;
statements.push(rows);
}
if (valid) return statements;
}
throw new Error("D1 output has no successful JSON statement results.");
}

export function requireSingleD1Row(raw: string): D1JsonRow {
const statements = parseD1JsonResults(raw);
if (statements.length !== 1) {
throw new Error("D1 JSON output must contain exactly one statement result.");
}
const rows = statements[0];
if (rows?.length !== 1) throw new Error("D1 JSON output must contain exactly one row.");
return rows[0];
}
Loading
Loading