Skip to content

release: promote staging to main - #796

Merged
harrymove-ctrl merged 96 commits into
mainfrom
staging
Aug 26, 2026
Merged

release: promote staging to main#796
harrymove-ctrl merged 96 commits into
mainfrom
staging

Conversation

@harrymove-ctrl

Copy link
Copy Markdown
Collaborator

Completes today's promotion chain (dev → staging merged in #788 at 474950a). Staging's tree is byte-identical to dev; the delta over the last production promotion (#757) is 8 PRs / 27 commits:

No conflicts with main. No migrations or env changes required.

Known CI caveat: the Python E2E jobs may fail with httpx.ReadTimeout — the third-party testnet JSON-RPC mirror (rpc-testnet.suiscan.xyz) is unresponsive and the Python SDK preflight still calls it (fix in #795, targets dev). This is an external outage, not a regression in this delta; the mainnet mirror used by prod is healthy.

Merge with a merge commit, not squash, so the history link between branches survives for the next promotion.

hien-p and others added 30 commits August 14, 2026 12:40
…oncile lookup

The whole point of PR #562's idempotency work is: if the sidecar crashes
right after minting a paid Walrus blob but before its journal write lands,
a retry must find and adopt that orphaned blob instead of minting a second
paid one. The lookup that's supposed to find it was searching for the wrong
tag.

The durable-upload register step (walrus-upload-journal.ts — the only path
every real /api/remember call goes through today) tagged the minted blob's
on-chain metadata with memwal_migration_job, an unrelated, dead-code
constant borrowed from the V1->V2 migration feature (its only reader,
findOwnedBlobObjects, has zero callers anywhere in the codebase). The
reconcile scan that runs on a lost journal, scanOwnerForJobBlob
(walrus-query.ts), searches for memwal_job_id instead. The two could never
match, so a crash at exactly the wrong moment would silently mint twice —
the precise failure PR #562 exists to prevent.

memwal_job_id was already the correct key, used correctly, by the legacy
(non-durable) upload path — but that path is dead code today, since every
current caller sets remember_job_id: Some(...), which always routes into
the durable path instead.

Fixes it by introducing a single shared MEMWAL_JOB_TAG_KEY constant in
util.ts and having both write sites (durable + legacy) and the read site
import it instead of each hardcoding the string literal, so the two sides
structurally cannot drift apart again without someone deliberately
un-importing the shared constant.

New regression test pins the constant's value and greps all three files to confirm none hardcodes a competing 'memwal_job_id' literal. 193/193 sidecar scripts tests pass (7/7 in the directly affected files). tsc --noEmit: no new errors (one pre-existing, unrelated error in mcp/__tests__/integration.test.ts, confirmed present on unmodified dev too).
Found on production (researcher.demo.memwal.ai), then reproduced and A/B'd
locally against the same condition.

getTitleModel() hardcoded "google/gemini-2.0-flash-001", which OpenRouter has
retired — it returns 404 "No endpoints found" for our own production key. Every
other call site already used google/gemini-2.5-flash; this one was missed.
Because titlePromise was awaited with no try/catch inside createUIMessageStream's
execute(), the rejection reached the stream's onError, which injected an
{"type":"error"} part — so the client rendered a fatal chat error even though the
real answer had streamed successfully underneath it.

Production impact when this was written: 6 of 43 chats have a user message and no
assistant reply (4 of them on or after the 2026-08-14 deploy), and 11 of 43 chats
are still titled "New chat".

apps/chatbot already had this guard from 334cf91 and researcher was missed at the
time. This ports it, and also fixes the root cause that commit left in place: the
retired model id itself. Moving it to a TITLE_MODEL constant beside chatModels
keeps it from drifting away from the models we actually support.

Auditing the rest of the picker with the production key turned up two more retired
ids — anthropic/claude-3.5-haiku and anthropic/claude-3.5-sonnet 404 identically.
Three of the six selectable models were dead. Removed, and recorded in the new
unit test so they cannot be reintroduced by copy-paste.

Verified locally (docker pgvector + next dev, real OpenRouter key, PLAYWRIGHT
unset so getTitleModel is not swapped for the mock):
  - with the guard, retired title model:    answer streams, no error frame
  - without the guard, retired title model: answer streams, error frame injected
  - with the production key: title generates for real, no error frame
  - pnpm test:unit 11/11; tsc --noEmit clean on the changed files

Not addressed here: production also shows chats with no persisted assistant row at
all, which did not reproduce locally — the assistant message persisted in both arms
of the A/B. That may be the older deployed build or the unhandledRejection aborting
before onFinish, and is worth re-checking once this ships. apps/chatbot still
carries the same retired model ids.
Two UI defects on the empty-chat screen.

Tooltips rendered as a ~2px sliver under their trigger. Not overflow clipping —
every ancestor was overflow:visible. TooltipContent was not wrapped in
TooltipPrimitive.Portal, so it stayed inside the chat header, and `position:
sticky` always creates a stacking context. The content's z-50 therefore only
applied within that 50px-tall header, and the part of the tooltip below 50px
painted underneath the page content that follows the header in DOM order. Only
the overlapping sliver was visible. Wrapping in Portal moves it to the body and
out of the header's stacking context, matching what noter's tooltip.tsx already
does. Affects every tooltip in the app, not just the header ones.

Suggestion pills wrapped to two lines when the text was long, making one pill
taller than the rest. The pills now hold a single line at a fixed 42px (matching
the loading skeleton) and truncate with an ellipsis, with the full string in
`title` — sprint-generated suggestions are arbitrary length, so the copy change
alone would not have held.

Shortening the default prompts also surfaced a trap: DEFAULT_SUGGESTIONS was
declared twice, byte-identical, in suggested-actions.tsx and use-sprint-greeting.
Only the hook's copy is reachable, because useSprintGreeting always supplies the
fallback through the sprintSuggestions prop — editing the component's copy
changed nothing on screen. Collapsed to one exported constant.

Verified in the browser at each step: tooltip renders in full on both the
sidebar toggle and My Stuff; all four pills sit on one line at equal height.
tsc --noEmit clean on the changed files; pnpm test:unit 11/11.

apps/chatbot has the same unportaled tooltip.
Reopening a chat whose last message is from the user threw a client
TypeError: Cannot read properties of undefined (reading 'state'), from
AbstractChat.resumeStream in AI SDK 6.0.37.

Root cause (diagnosed via live repro on a chat with one user message and no
assistant reply): chat/[id]/page.tsx enabled autoResume, so useAutoResume
called resumeStream() on mount — but resumable streaming was never
implemented; api/chat/[id]/stream/route.ts is a stub that always returns
204. Overlapping resume attempts (easy under dev double-invoked effects, but
not inherently dev-only) race inside the SDK: one clears activeResponse
while the other reads its .state.

Two changes:
- autoResume={false} on the chat page — honest, since there is nothing to
  resume until the stream route is real.
- useAutoResume now guards with a ref so at most one resume attempt fires.
  The hook's "we intentionally run this once" comment was aspiration, not
  enforcement — the dep array does not guarantee it.

This path matters beyond dev: production has 6 chats with a user message and
no assistant reply (fallout tracked in c031c47), and reopening any of them
walks this exact code path.

Verified: the repro chat now loads with a clean console (previously threw on
every load). tsc --noEmit clean; pnpm test:unit 11/11.
app/(chat)/layout.tsx loaded pyodide from a CDN with strategy
"beforeInteractive" — a leftover from the Vercel ai-chatbot template's Python
code-runner. Nothing in this app calls it: there is no loadPyodide/runPython
reference anywhere, and no code-execution feature. It cost a multi-MB script
download on every chat page for nothing.

As of Next 16 it is also an error, not just waste: beforeInteractive inside a
nested layout renders a raw <script> tag in the React tree, and every page
under (chat) logs "Encountered a script tag while rendering React component"
(surfaced right after login, at Layout in app/(chat)/layout.tsx:13).

Removing the tag fixes the error and the dead download in one move. Verified:
chat loads with a clean console after login; tsc --noEmit clean.

apps/chatbot has the same tag in its (chat)/layout.tsx — but chatbot may
actually use pyodide for its code artifacts, so it needs its own check rather
than a blind copy of this deletion.
…page title

Saved sprints were read-only in the narrow My Stuff panel — no way to copy or
export a report short of drag-selecting the text.

- Copy / Download / Expand actions in the sprint detail header. Copy puts the
  full report on the clipboard as markdown (toast-confirmed); Download saves it
  as <title-slug>.md. Both go through one pure helper, buildSprintMarkdown
  (title + summary + report + References), so clipboard and file are
  byte-identical. Helper is unit-tested (3 tests, repo test:unit style).
- Expand opens the sprint in a wide centered dialog with the same
  Report/Citations/Sources tabs; the tabbed body is extracted into an internal
  SprintBody component rendered by both the panel and the dialog.
  onOpenAutoFocus is suppressed so the Copy button's tooltip doesn't pop over
  the title when the dialog opens.
- Page metadata was still the template's ("Next.js Chatbot Template",
  metadataBase chat.vercel.ai) — now "Researcher | Walrus Memory" with the
  real domain, on the local branch and prod alike.

Verified in the browser against a real saved sprint: copy toast + clipboard
content (starts "# <title>", full markdown), expanded dialog opens/closes
clean, tab title updated. tsc --noEmit clean; pnpm test:unit 14/14.
Mirrors the Python SDK's tests/test_integration.py: no-auth health,
compatibility and auth-rejection checks that always run, plus
authenticated remember/recall/analyze/restore coverage (and the
JS-only bulk, manual-mode and embed surfaces) that skips without
MEMWAL_PRIVATE_KEY / MEMWAL_ACCOUNT_ID. Authenticated writes land in
a per-run sdk-e2e-<random> namespace so the shared bench account's
real namespaces stay clean, and remember waits get 120s of headroom
over the SDK's 60s default (a live write measures ~44s on dev).

Named *.e2e.mjs so the offline unit glob (test/**/*.test.mjs) never
picks the suite up; run it via the new test:e2e script. [WALM-353]
Sibling of test-python-sdk.yml. The unit job runs the offline suite on
Node 22 and 24 for every PR touching packages/sdk — closing the gap
where SDK unit tests never ran in CI at all. The e2e job reuses the
benchmark-dev environment credentials, is limited to dev pushes,
manual dispatch and a weekly cron (PRs are excluded: fork PRs get no
environment secrets, and every authenticated run writes real
memories), fails loudly when credentials are missing instead of
green-skipping, and uploads a junit artifact plus a run summary.

The cron sits 30 minutes after the Python suite's slot so the two
weekly runs don't write through the shared bench account at the same
time. [WALM-353]
Review of the authenticated half — which cannot run locally without
the bench credentials — caught two tests that were guaranteed to go
red on the first CI run:

- embed() POSTs /api/embed, which is absent from the relayer's
  protected route table entirely.
- rememberManual() sends blob_id, but RememberManualRequest requires
  encrypted_data and has no blob_id field, so axum 422s the call
  before the handler sees it.

Both methods are covered only by MemWalMock today, which is why the
drift went unnoticed. The suite documents the omission inline rather
than deleting it silently; the SDK bug is tracked in WALM-371.

Also hardens the remaining live tests: the analyze fan-out gets twice
the single-write budget (N facts, one wallet-job pipeline each, on a
contended shared account), the one-shot status probe tolerates a
background 'failed' since it asserts acceptance rather than pipeline
health, and MEMWAL_REMEMBER_TIMEOUT_MS now rejects unparseable input
instead of silently becoming NaN. [WALM-353]
Researcher had no automated tests — the app with the only real Walrus
writes was verified entirely by hand. This adds a 15-test Playwright
suite and wires it into CI as `researcher-e2e`, mirroring the existing
`chatbot-e2e` job.

The suite's reason for existing is the first spec: the retired
title-model P0 (c031c47) shipped because nothing would have caught it.
`chat-stream.test.ts` now asserts the raw SSE frames directly — a
failing title model must not put an `error` frame on the stream.
Verified as a real regression test, not just a passing one: with the
try/catch guard removed the spec fails with the exact production
symptom, `{"type":"error","errorText":"Oops, an error occurred!"}`,
while the answer still streams underneath.

Four mock seams, all keyed off the existing `isTestEnvironment`, so a
run never reaches OpenRouter, Sui, or the relayer:

- `providers.ts` — picker model ids now map onto the three registered
  mock models. The pre-existing test branch passed the raw id straight
  through, so `languageModel("google/gemini-2.5-flash")` would have
  thrown NoSuchModelError on the first message; the branch had simply
  never run.
- `models.mock.ts` — a `FAIL_TITLE_GENERATION` sentinel in a user
  message makes the title model reject, reproducing the P0 on demand.
- `delegate-account.mock.ts` — fabricates the `MemWalAccount` object
  for two fixture identities. The real binding validation still runs,
  so unregistered keys and unknown accounts fail exactly as they do
  on-chain (both asserted).
- `memwal.ts` — `MemWalMock` for the Walrus client, one instance per
  process, so CI can never write to the production relayer.

Coverage: delegate-key login (form and rejections), streamed replies,
reload persistence with zero uncaught page errors (guards the
auto-resume TypeError from 39e102a), SSE frame protocol, and the
Private/Public boundary across two real accounts.

Two behaviors pinned as they actually are rather than as expected:
a private chat opened by another user renders Next's not-found page
with HTTP 200, because `<Suspense>` flushes the shell before
`notFound()` runs; and anonymous visitors are redirected to /login even
for public chats, since the auth check precedes the visibility check.
Neither leaks content. The second is the known "Anyone with the link"
product gap.

`global-setup.ts` clears the auth limiter's Redis keys before each run.
The limiter allows 10 verify attempts per IP per minute and a run
spends five, so without it a second run inside a minute fails at
sign-in with a 429 that looks nothing like the real problem. Both
identities also sign in once in a setup project rather than per-test,
which keeps CI retries clear of the limit.

Verified locally: 15/15 green on three consecutive runs (~10s each),
from cold with Playwright managing its own webServer and with
AUTH_SECRET and the package id unset, so the config defaults used by CI
are exercised.
The dev-only restriction was inherited from test-python-sdk.yml, whose
comment justified it as 'staging and production follow once their
credentials exist'. That premise no longer holds: benchmark-staging
and benchmark-production both carry BENCH_DELEGATE_KEY,
BENCH_ACCOUNT_ID and BENCH_SERVER_URL today.

Each long-lived branch now tests the deployment it corresponds to —
dev, staging, main -> production — and workflow_dispatch takes an
explicit environment choice, following the selection pattern
benchmark-live.yml already uses. Scheduled runs stay on dev, since
cron fires on the default branch, so production is only reached by an
explicit main push or a deliberate dispatch.

Drops the hardcoded relayer URL fallback. With one environment a
literal default guarded against an unset variable; with three it
becomes the hazard it was meant to prevent, since a production run
with a missing variable would silently exercise dev. The credential
check now requires MEMWAL_SERVER_URL and fails the job without it.

The suite is safe to point at production because every authenticated
write is confined to a per-run sdk-e2e-<random> namespace; the only
reference to 'default' is the body of the 401-rejection tests, which
never write. [WALM-353]
…n account

Credentials lived in one global `~/.memwal/credentials.json`. Signing in from
one project repointed every other project on the machine at a different account
and delegate key, with the `label` field as the only visible signal and no
warning at the point of use. Memories written in that state land on the wrong
account, on immutable storage, with no delete path.

A `.memwal/credentials.json` in the working directory now takes precedence over
the global file, the way `.npmrc` and `.git/config` resolve. Presence-based on
purpose: creating the local file is the opt-in, so a machine without one behaves
exactly as before. Paths resolve per call rather than at module load, since the
working directory is not knowable at import time.

Replacing a DIFFERENT account now copies the outgoing file aside and reports
both ids — the account replaced and the one now in use. There was no backup of
any kind before, so an overwrite was unrecoverable. Same-account re-saves (a
label change, a rotated delegate) are left alone rather than churning a backup
per login.

`saveCreds` returns what it did instead of printing directly: the persistence
layer should not own user-facing output, and returning it keeps the behaviour
testable without capturing stdout.

Note on the recorded decision to refuse a different-account replacement unless
forced: the incoming `accountId` only arrives in the browser callback, after the
user approved and the delegate key was already registered on-chain. Refusing
there would discard a registration that cost gas and already grants access, so
this warns and backs up instead. A pre-approval notice belongs earlier in the
flow, where the outgoing account and target path are already known.

Refs #628 (WALM-361).
…age lying

Two gaps in the previous commit, both found by asking what was actually
verified rather than what was written.

The warning itself had no test. The unit tests asserted what `saveCreds`
returns, not that anything is ever shown — so the user-facing half of the fix,
which is the whole point of GH #628, rested on inspection. Extracted
`formatReplacementNotice` as a pure function and covered both arms: a
replacement names the outgoing account, the incoming one and the backup, and a
first sign-in or same-account re-save stays quiet.

The browser success page hardcoded "Credentials saved to
~/.memwal/credentials.json". Now that a project-local file can be the
destination, that sentence was capable of naming a file the login had not
touched — telling a user the wrong credential path is worse than telling them
nothing, especially in the exact scenario this ticket exists to fix. It now
reports the path actually written.

Also verified with the real binary, which the unit tests do not cover: two
projects sharing one HOME, one with a local `.memwal/` and one without, load
different accounts. Same command, same environment, only the working directory
differs.

Refs #628 (WALM-361).
Running the live check against the real binary showed the replacement warning
never fired from `memwal-mcp login`, and no backup was written — while the unit
tests were green.

`login` called `clearCreds()` up front, purely so the `loadCreds()` below would
return null and the sign-in flow would run. By the time `saveCreds` executed
there was no file left to compare against, so the account change was invisible
and there was nothing to back up.

The destructive part was never needed: forcing a fresh sign-in means ignoring
what is on disk, not deleting it. `login` now passes null instead, so the old
file survives until a successful sign-in replaces it.

That also closes a data-loss path that predates this ticket. Anyone who ran
`memwal-mcp login` and then abandoned the browser flow, or whose login timed
out or failed, was left with no credentials at all and nothing to recover from
— the delegate key on disk was gone before the new one was ever issued.

Adds the live check that caught it (`.live.mjs`, outside the `npm test` glob).
It drives the real login process end to end — its own listener, preflight,
callback parsing and save — and asserts on what the process actually printed.
The browser half is driven programmatically, so no wallet approval and no
on-chain registration; the replaced credentials are seeded locally, which is
all the account comparison keys off. 10/10 pass.

Refs #628 (WALM-361).
… step

Completes the two acceptance criteria left open on WALM-361.

Warn ahead of approval, not only after. The incoming account is unknown until
the callback arrives, by which point a delegate key is already registered
on-chain — so a warning that waits for both ids arrives too late to act on. The
browser step is the last moment the user can back out for free, so the account
currently saved and the file at risk are named before it opens. The
after-the-fact notice naming both ids stays; the two answer different questions.

Document the resolution order, migration, backup and recovery in the MCP
reference: how the two locations resolve, how to scope a project to its own
account, that migration is a no-op because creating the local file is the
opt-in, what a same-account versus different-account sign-in does, and how to
restore from a backup. Existing sections that hardcoded ~/.memwal — first-run
behaviour, the credential-file section, memwal_logout, and two CLI table rows —
now point at the resolved location instead of asserting the global one.

Also notes that backups are never pruned and each holds a delegate private key,
and that `.memwal/` belongs in .gitignore.

Refs #628 (WALM-361).
The comment claimed the realpath call was a macOS concern. It is not: cwd and
homedir both report resolved paths on every platform, and the sandbox already
sets HOME and USERPROFILE together, which is what os.homedir() needs on Windows
and POSIX respectively. Nothing in these tests is POSIX-only — the reason they
are unproven on Windows is that the repository has no Windows runner.
WALM-378: sqlx takes a session-scoped pg_advisory_lock for migrations.
Through Neon's transaction-mode pooler that lock (and Apalis's
set_config session GUCs leak onto reused backends. The next boot waits
15s, times out, and panics — taking the relayer down until a lucky
redeploy.

Run sqlx/Apalis migrations on the direct compute endpoint, retry lock
contention instead of .expect(), and stop setting session GUCs on the
pooled Apalis connections.
EOF
)
A leftover pooled session can still block the first boot after this
fix: advisory locks are database-wide, so the direct migrator waits on
the same key. On lock timeout, log the holder and pg_terminate_backend
only if that backend is idle (the WALM-378 shape). Also include the
direct host in migrate connect errors.
lock_timeout aborts the in-flight migrate statement. If sqlx had a
transaction open, later pg_locks / pg_terminate_backend queries fail
with "current transaction is aborted" and the idle holder is never
killed. ROLLBACK first, and treat idle-in-transaction like idle for
this lock key only.
Keep the explanation; do not stamp Linear identifiers into runtime
strings or source comments.
…the delegate-key error message

sessionAtom was atomWithStorage(..., { getOnInit: false }), so every fresh
page load (a window.location.href redirect after login, or a plain reload of
/note) rendered once with session=null before the async post-mount hydration
from sessionStorage caught up. useAuth's own effect read that stale null on
the same tick, concluded "logged out", and cleared isLoading before the real
session arrived, so /note's guard fired router.replace("/") on an already
authenticated user. getOnInit is safe to flip to true here: nothing renders
`session` directly, every page branches on the separate authAtom instead,
which always starts isLoading:true on both server and client, so there is no
markup for the eager read to mismatch against.

Separately, connectEnoki/connectDelegateKey flipped the global authAtom.isLoading
flag for the duration of their mutation, and app/page.tsx renders
<AuthButtonGroup /> only while !isAuthenticated && !isLoading. That unmounted
the login form mid-submit, taking its local `error` state with it before the
catch block's setError() could run, so an invalid delegate key just dropped
the user back on a silently collapsed form. isLoginPending (from the mutation
hooks themselves) already tracks per-call pending state for the submit
button, so the global flag no longer needs to move during a login attempt.
…cated Sui JSON-RPC

Sui's public JSON-RPC fullnodes were deprecated in 2026; fullnode.testnet.sui.io
now answers every JSON-RPC call with "Method not found ... migrate to gRPC or
GraphQL endpoints" and no CORS header, which Chrome reports as a generic
"blocked by CORS policy" failure. sui-providers.tsx's getJsonRpcFullnodeUrl and
enoki-login-card.tsx's useSuiClient() both hit that dead endpoint, so
registerEnokiWallets failed on mount ([enoki-login] Setup failed: TypeError:
Failed to fetch) and Google sign-in never got past the landing page.

dapp-kit's SuiClientProvider is still hard-typed to SuiJsonRpcClient even in
the latest published version (1.1.17), so it can't be pointed at a gRPC
client directly. Enoki's own `client` option and Transaction.build()'s
`client` option both accept the broader ClientWithCoreApi interface instead,
which SuiGrpcClient satisfies, so this bypasses SuiClientProvider only where
it was actually blocking things: registerEnokiWallets now gets a standalone
SuiGrpcClient (lib/sui/grpc-client.ts) instead of useSuiClientContext()'s
client, and enoki-login-card.tsx's on-chain reads (registry lookup, dynamic
field, transaction/event fetch) move to the gRPC client's include/mask-based
API. SuiClientProvider itself stays in place for WalletProvider's
wallet-standard connect/sign, which doesn't touch RPC directly.

gRPC object/dynamic-field/event reads return raw BCS bytes instead of
JSON-RPC's parsed `.fields`, so lib/sui/account-bcs.ts adds the BCS schemas
needed to decode them, verified by decoding a live testnet account and
matching its stored delegate public key, and round-tripping a registry
dynamic-field lookup back to the same account id.
Noter had zero automated tests; the on-chain registration flow, delegate-key
auth, and note CRUD were verified entirely by hand. 22 specs across app
shell, auth, note lifecycle, and the memory API contract, running against a
real Next.js dev server and a fresh Postgres.

Mock seam for the new delegate-account binding check
------------------------------------------------------
connectDelegateKey now calls assertDelegateAccountBinding (a separate,
already-merged change), which reads the claimed account off-chain via gRPC
and rejects any key that isn't registered in its delegate_keys list — so a
random, never-registered key/account pair can no longer reach an
authenticated session the way it could before that change landed. Mirroring
researcher's PR #680 pattern, delegate-account.ts now branches on
lib/constants.ts's isTestEnvironment (set by playwright.config.ts passing
PLAYWRIGHT=True to the webServer) and serves a fixture object from
delegate-account.mock.ts instead of the gRPC read, so the real validation
logic still runs meaningfully: an unknown account or an unregistered key
fails the exact same way it would on-chain.

Noter authenticates a fresh identity per test rather than reusing two shared
identities across a whole run (researcher's approach) — with `workers: 2` and
~15 login call sites, two fixed identities would have concurrent tests
collide on each other's notes. delegate-account.mock.ts and
fixtures/delegate-key.ts instead generate the same 24-entry deterministic
pool independently (index N -> accountId byte N repeated, privateKey byte
N+0x40 repeated), and the test fixture hands out a never-yet-used entry per
call, interleaved by Playwright's parallelIndex so two worker processes
never claim the same one. public_key is stored base64, not hex: the binding
check's parser tries fromBase64() before falling back to raw hex, and every
64-char hex string (alphabet 0-9a-f, always length-divisible-by-4) also
happens to be valid-but-wrong base64, so a hex value there silently decodes
to the wrong bytes instead of ever matching.

The memory-write specs assert against the real relayer response for a
fixture (unregistered) key, so there's no live-Walrus canary in this suite by
design, same as #680 documents for researcher: the real remember -> recall
round trip against production Walrus Memory stays a manual check.

CI job
------
noter-e2e mirrors chatbot-e2e's shape (Postgres service container, cached
Playwright browsers, report/trace upload on failure). noter-checks adds
tsc --noEmit and a full `next build` so a type or build regression fails CI
even on a change the e2e specs don't happen to cover.
Two separate PRs each added a job named noter-checks to the same workflow
file — one from an already-merged auth-hardening change (vitest unit tests),
one from this branch (tsc + next build). Neither touched the same lines, so
the merge went through cleanly with no conflict markers, but the resulting
file had two top-level jobs with the identical key. GitHub Actions rejects
that outright: the workflow run failed in 0s with no job output at all,
before any check even started, which is why this wasn't caught by the local
`tsc`/`next build`/e2e verification — those ran the commands directly, never
through the YAML that CI actually parses. python's yaml.safe_load didn't
catch it locally either; it silently keeps the last duplicate key rather
than erroring, which GitHub's stricter workflow parser does not do.

Folded the tsc/build steps into the existing job instead of renaming to
avoid the collision — one Noter CI job now covers unit tests, type-checking,
and the production build, sharing one checkout/install/SDK-build sequence
rather than paying for it twice.
…eprecated JSON-RPC

useSignTransaction pulls its client from SuiClientProvider, which is still
JSON-RPC (dapp-kit's hook types are hard-wired to it). transaction.toJSON()
needs that client to resolve move-call ABIs before handing the transaction
to the wallet for signing, and fullnode.testnet.sui.io no longer serves
CORS headers on its JSON-RPC endpoint, so the browser blocked every
sponsored transaction with what looked like a CORS failure.

Pre-serialize with our own gRPC client and pass the resulting string
instead: dapp-kit's hook accepts transaction as Transaction | string and
skips its own resolution when given a string.
Share one delegate fixture pool between the gRPC mock and Playwright so
they cannot drift, parse hex public keys before base64, and fail-closed
isTestEnvironment in production. CI now sets PLAYWRIGHT=True, times out
browser install at 8m (was hanging the 25m job), and uses an isolated
Playwright cache key.
fix(noter): gRPC migration, auth race fixes, e2e suite + CI
fix(sidecar): crash-recovery reconcile searches for the wrong on-chain tag
…-sdk

test(sdk): e2e suite and CI workflow against the dev relayer
nikola0x0 and others added 26 commits August 24, 2026 12:03
…lidate-bridge-credentials-on-logout

# Conflicts:
#	packages/mcp/CHANGELOG.md
#	packages/mcp/src/bridge.ts
… SQLSTATE

SET LOCAL in after_connect is a no-op under autocommit, so restore
statement_timeout / idle_in_transaction_session_timeout as session GUCs
and omit lock_timeout (the leak that aborted sqlx migrate). Bound
setup_pool.close() with the same startup timeout. Classify lock
contention on Postgres SQLSTATE (55P03 / 40P01) before mapping into
AppError, so a Display reword cannot disable retries.
…-lock

fix(server): stop leaking sqlx migration locks through the Neon pooler
…at-crash

fix(researcher): retired title model broke every new chat — plus tooltip, auto-resume, pyodide, and sprint-export fixes
…bridge-credentials-on-logout

fix(mcp): invalidate the live bridge session on memwal_logout
fix(sdk): surface dropped_count and redact loopback URLs
fix(sdk): align remember-manual, embed, hex, and env=dev
codex plugin marketplace add only looks at .agents/plugins/marketplace.json,
never .codex-plugin/marketplace.json. Promote the plugin+marketplace install
in docs and TESTING.md; keep the hooks installer as a fallback for older CLIs.
The required-field cast does not overlap RecallResult. Read dropped_count
as unknown and accept it only when it is a number.
fix(app): show namespace counts and persist playground namespace
…ealth

fix(relayer): align analyze charge windows and report write_ready
Plugin MCP servers are plugin-scoped, not a [mcp_servers.memwal] block, and
codex_hooks=true is a no-op on current CLI (hooks are stable/default-on;
the missing step is trusting the plugin's hook definitions via /hooks).
Also syncs the quickstart hub row and the fallback installer's own comment.
Drop em dashes and one future-tense verb flagged by the style guide audit.
staging carried only stale merge-backs from earlier dev promotions, so every
conflicting hunk was an older ancestor of what dev already has. Resolved all in
favour of dev and dropped .github/workflows/test-sdk.yml, which dev deleted when
the JS SDK suite was folded into test.yml. Resulting tree is byte-identical to
origin/dev.
`body.query.is_empty()` is a byte-length check, so a query of "   " passed validation and reached the embeddings API, returning a semantically meaningless vector and a plausible-looking 200 instead of a 400.

Trim before the emptiness check at the shared server gate, which every client path (SDK, MCP tool, raw HTTP) funnels through. Mirror it as a fail-fast check on the MCP Zod schema and in recallManual(), which had the same `if (!query)` pattern.

Closes #425
chore: restore staging history link on dev to unblock #788
… account

Addresses review feedback on #680.

Locally Playwright reuses whatever `pnpm dev` is already on the port. Started
without PLAYWRIGHT that process runs none of the mock seams, so model calls
reach OpenRouter, the delegate-account binding check Sui, and sprint saves the
real Walrus relayer. `/ping` now reports the seam as `x-researcher-test-mode`
and global setup aborts the run with the fix spelled out when it isn't set.

Reuse stays on so `PLAYWRIGHT=True pnpm dev` keeps the fast `--ui` loop. The
check also runs in CI, where it asserts the webServer env reached Next.

The test MemWal client is now keyed on accountId rather than one process-wide
mock, mirroring the real client's per-(key, accountId) construction, so a
later remember-as-A / recall-as-B spec cannot pass for the wrong reason.
test(researcher): Playwright e2e suite and CI job
fix(recall): reject whitespace-only queries (WALM-300)
…ugin-install-requires-cloned-repo-ship

fix(mcp): Codex plugin install via marketplace, not cloned repo
…ts, docs changelogs, and the verify script

package.json and pyproject.toml were bumped without the rest of the manual release set: memwal/__init__.py still said 0.1.7, docs/sdk and docs/python-sdk changelogs lacked the new sections, and scripts/verify-manual-sdk-release.mjs pinned the old versions, so the verify gate failed on dev. Ports the 0.1.5 / 0.1.8 sections from the package CHANGELOGs into the docs changelogs and refreshes their answer summaries.
chore(release): sync TS SDK 0.1.5 and Python SDK 0.1.8 release file set
…nit on a dead JSON-RPC mirror

The x-seal-session preflight called JSON-RPC sui_getObject against the suiRpcUrl advertised by GET /config, with no timeout. That URL points at a third-party mirror (rpc-testnet.suiscan.xyz) because JSON-RPC on public fullnodes is sunset, and when the mirror went unresponsive today every client init hung for the full read timeout and the whole e2e suite failed with httpx.ReadTimeout.

The preflight now queries the public Sui GraphQL endpoint (graphql.<network>.sui.io) for mainnet/testnet/devnet, keeps the JSON-RPC call only as a fallback for custom or local networks that still serve it, and caps both paths at 10s so an unresponsive endpoint fails fast instead of stalling init. GET /config parsing no longer hard-requires suiRpcUrl since the GraphQL path does not need it. Unit tests mock the GraphQL endpoint; all 136 pass.
fix(python-sdk): read package version via Sui GraphQL, stop hanging init on a dead JSON-RPC mirror
release: promote dev to staging
@jessiemongeon1

Copy link
Copy Markdown
Collaborator

Style Guide Audit

Audited 11 file(s) against the Sui Documentation Style Guide.

3 violation(s) found. All must be fixed before merge.

docs/mcp/changelog.mdx (2 violation(s))

2 violation(s) (0 regex, 2 claude)

  • Line 40 — passive_voice
    • Current: the delegate key in memory and the SSE session open, so later memwal_recall/memwal_remember still ran under the key the user just removed
    • Fix: Rewrite to active voice, for example: '...the bridge kept the delegate key in memory and held the SSE session open, so later memwal_recall/memwal`
  • Line 42 — passive_voice
    • Current: Refuse every request that arrives while signed out, not just memory tool calls. ping and other id-bearing requests previously fell through to a queu
    • Fix: Rewrite 'were never answered' to active voice, for example: '...nothing ever answered them'

docs/python-sdk/changelog.mdx (1 violation(s))

1 violation(s) (0 regex, 1 claude)

  • Line 47 — passive-voice
    • Current: raised when the relayer rejects a request because the signed timestamp is outside
    • Fix: the SDK raises this error when the relayer rejects a request because the signed timestamp is outside

Automated audit using the Sui Documentation Style Guide.

@ducnmm ducnmm left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — staging → main

Same promotion pattern as #757. Head is 474950a8 (merge of #788), base is main at 904eb040 (#757). Merge-base e9fbd82e. Tree delta: 100 files, +4354 / −510. Mergeable, no conflicts.

Checks

  • Required GitHub checks are green on this head (server clippy + e2e, MCP login handoff, SDK unit + live e2e, researcher / noter / chatbot Playwright, docs, Move).
  • Railway staging deploys succeeded for relayer, app, chatbot, noter, researcher, otel. Indexer correctly skipped (watched paths unchanged).
  • Python E2E / dev relayer is skipped. #795 (GraphQL package-version preflight, stop hanging init on a dead JSON-RPC mirror) is already in this delta, so the body note that the fix “targets dev” is stale — it ships here.
  • No services/server/migrations/*.sql and no new required env vars in the file list. The postgres URL helper only rewrites the existing DATABASE_URL to the direct host for sqlx/Apalis migrate (WALM-378). “No migrations or env changes required” holds.

What actually ships vs the PR body

The body lists 8 PRs / 27 commits. First-parent of staging is a single merge (#788), and the three-dot file list is the whole validated dev → staging bundle, which is larger than those 8:

  • Relayer: Neon pooler / sqlx migrate lock (#713), analyze charge windows + write_ready (#744), restore timeouts / oauth authorize cap / analyze idempotency (#736), /api/embed
  • MCP 0.0.10 → 0.0.11: logout actually drops the delegate key and invalidates the bridge (#699), session auth (#735), login TTY/single-flight (#738), serialized SSE POSTs (#745), Codex marketplace install (#763)
  • SDK 0.1.4 → 0.1.5 / Python 0.1.7 → 0.1.8: remember-manual/embed/hex/env (#737), dropped_count + loopback redaction (#747), whitespace recall (#790), GraphQL version preflight (#795)
  • App: dashboard namespace counts + persisted playground namespace (#746)
  • Researcher: retired title-model crash, tooltips, e2e CI (#673 / #680)

That undercount is a changelog issue, not a merge blocker — #788 is the staging evidence. Production-sensitive surface is relayer rate_limit / admin restore / recall / postgres_url plus MCP 0.0.11. Staging Railway health is the run-through.

Versions

Publishing on main will bump @mysten-incubation/memwal 0.1.5, MCP plugin 0.0.11, memwal (Python) 0.1.8. Confirm the Release SDK / Release MCP workflows are the ones you want to fire.

Merge

Approve. Merge with a merge commit, not squash, so the history link between staging and main survives for the next promotion.

@harrymove-ctrl
harrymove-ctrl merged commit 559531f into main Aug 26, 2026
57 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants