From 3cdb67f27e3d8461535419565c2a4e9563e973b7 Mon Sep 17 00:00:00 2001
From: Hongming Wang
Date: Wed, 6 May 2026 00:03:24 -0700
Subject: [PATCH 01/28] fix(workspace-server): CP orphan sweeper closes
deprovision split-write race (#2989)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The deprovision path marks `workspaces.status='removed'` BEFORE calling
the controlplane DELETE. If that CP call fails (transient 5xx, network
hiccup, AWS provider error), the DB row stays at 'removed' with
`instance_id` populated and there's no retry — the EC2 lives forever.
9 prod orphans accumulated over 3 days under this bug.
Adds a SaaS-mode counterpart to the existing Docker `orphan_sweeper`:
- 60s tick (matches the Docker sweeper cadence)
- LIMIT 100 per cycle so a sustained CP outage drains over multiple
cycles without blowing the request timeout
- Re-issues `cpProv.Stop` for any workspace at status='removed' with a
non-NULL `instance_id`. Stop is idempotent (AWS terminate on
already-terminated is a no-op; CP's Deprovision tolerates already-
deleted DNS) so retries are safe.
- On Stop success, NULLs `instance_id` so the next cycle skips the row.
- On Stop failure, leaves `instance_id` populated for next cycle.
The existing Docker sweeper is gated on `prov != nil`; the new sweeper
is gated on `cpProv != nil`. SaaS tenants get exactly one of the two,
self-hosted tenants get the Docker one — no overlap.
Why this shape over option A (CP-first ordering) or B (durable outbox):
the existing inline path already returns a loud 500 to the user when
CP fails — the only missing piece is automatic retry, which a 60s
sweeper provides without protocol changes, new tables, or new workers.
~30 LOC of production code vs. ~400 for an outbox. RFC discussion in
#2989 comment chain.
Tests:
- 9 unit tests covering happy path, Stop failure, UPDATE failure,
multiple orphans (one-fails-others-still-process), DB query error,
nil-DB defense, nil-reaper short-circuit, and the boot-immediate-then-
tick cadence contract.
- Mutation-tested: status='running' substitution and removed-UPDATE-
block both fail at least one test.
Out of scope:
- Backfilling the 9 named orphans — they'll heal automatically on the
first sweep cycle after this lands; no manual cleanup needed.
- Long-term durable-outbox architecture — separate RFC.
---
workspace-server/cmd/server/main.go | 13 +
.../internal/registry/cp_orphan_sweeper.go | 149 ++++++++++
.../registry/cp_orphan_sweeper_test.go | 266 ++++++++++++++++++
3 files changed, 428 insertions(+)
create mode 100644 workspace-server/internal/registry/cp_orphan_sweeper.go
create mode 100644 workspace-server/internal/registry/cp_orphan_sweeper_test.go
diff --git a/workspace-server/cmd/server/main.go b/workspace-server/cmd/server/main.go
index 45597367..cba0334c 100644
--- a/workspace-server/cmd/server/main.go
+++ b/workspace-server/cmd/server/main.go
@@ -266,6 +266,19 @@ func main() {
})
}
+ // CP-mode orphan sweeper — SaaS counterpart to the Docker sweeper
+ // above. Re-issues cpProv.Stop for any workspace at status='removed'
+ // with a non-NULL instance_id, healing the deprovision split-write
+ // race documented in #2989: tenant marks status='removed' BEFORE
+ // calling CP DELETE, so a transient CP failure leaves the EC2
+ // running with no retry path. cpProv.Stop is idempotent against
+ // already-terminated instances; on success we clear instance_id.
+ if cpProv != nil {
+ go supervised.RunWithRecover(ctx, "cp-orphan-sweeper", func(c context.Context) {
+ registry.StartCPOrphanSweeper(c, cpProv)
+ })
+ }
+
// Pending-uploads GC sweep — deletes acked rows past their retention
// window plus unacked rows past expires_at. Without this the
// pending_uploads table grows unbounded; even with the 24h hard TTL,
diff --git a/workspace-server/internal/registry/cp_orphan_sweeper.go b/workspace-server/internal/registry/cp_orphan_sweeper.go
new file mode 100644
index 00000000..1dc4906d
--- /dev/null
+++ b/workspace-server/internal/registry/cp_orphan_sweeper.go
@@ -0,0 +1,149 @@
+package registry
+
+// cp_orphan_sweeper.go — SaaS-mode counterpart to orphan_sweeper.go.
+//
+// The Docker sweeper (StartOrphanSweeper) runs only when prov != nil
+// (single-tenant Docker mode); SaaS tenants run cpProv != nil and prov
+// == nil, so they get no sweep coverage from that path. This file fills
+// the gap for the deprovision split-write race documented in #2989:
+//
+// 1. handlers/workspace_crud.go:365 marks workspaces.status = 'removed'.
+// 2. workspace_crud.go:439 calls StopWorkspaceAuto → cpProv.Stop, which
+// issues DELETE /cp/workspaces/:id?instance_id=… to controlplane.
+// 3. If step 2 fails (CP transient 5xx, network blip, AWS hiccup), the
+// inline path returns a 500 to the canvas — but the DB row is already
+// at status='removed' with instance_id still populated. There's no
+// retry, and the EC2 lives forever.
+//
+// This sweeper closes that gap by re-issuing cpProv.Stop on every cycle
+// for any workspace at status='removed' with a non-NULL instance_id.
+// Stop is idempotent: AWS TerminateInstance on an already-terminated
+// instance is a no-op (per AWS docs), and CP's Deprovision handler
+// (controlplane/internal/handlers/workspace_provision.go:289) handles
+// the already-terminated and already-deleted-DNS cases via best-effort
+// guards. On Stop success, the sweeper clears instance_id so the next
+// cycle skips the row.
+//
+// Cadence + safety filters mirror the Docker sweeper:
+// - 60s tick (OrphanSweepInterval)
+// - 30s per-cycle deadline (orphanSweepDeadline)
+// - LIMIT 100 per cycle so a sustained CP outage that backs up many
+// orphans doesn't blow the request timeout; subsequent cycles drain.
+//
+// SSOT note: Stop's idempotency (no-op on empty instance_id, AWS
+// terminate on already-terminated) is the load-bearing invariant. Any
+// future change that adds non-idempotent side effects to cpProv.Stop
+// must also gate this sweeper, or it will re-execute those side effects
+// every 60s for every cleared-but-not-yet-NULL row.
+
+import (
+ "context"
+ "log"
+ "time"
+
+ "github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
+)
+
+// CPOrphanReaper is the dependency the SaaS-mode sweeper takes from
+// the CP provisioner. *provisioner.CPProvisioner satisfies this
+// naturally; tests inject fakes.
+type CPOrphanReaper interface {
+ Stop(ctx context.Context, workspaceID string) error
+}
+
+// cpSweepLimit caps the per-cycle row count so a sustained CP outage
+// can't make a single sweep cycle blow orphanSweepDeadline. With a
+// 60s cadence and 100-row limit, drain rate is up to 100 orphans/min,
+// which has never been approached even during the worst leak windows.
+const cpSweepLimit = 100
+
+// StartCPOrphanSweeper runs the SaaS-mode reconcile loop until ctx is
+// cancelled. nil reaper makes the loop a no-op (matches the Docker
+// sweeper's nil-tolerant pattern).
+//
+// Caller is expected to gate on `cpProv != nil` (matching how
+// StartOrphanSweeper is gated on `prov != nil` at the call site in
+// cmd/server/main.go) — passing a nil *CPProvisioner here would also
+// short-circuit but the gate at the wiring site keeps the call shape
+// symmetric across the two sweepers.
+func StartCPOrphanSweeper(ctx context.Context, reaper CPOrphanReaper) {
+ if reaper == nil {
+ log.Println("CP orphan sweeper: reaper is nil — sweeper disabled")
+ return
+ }
+ log.Printf("CP orphan sweeper started — reconciling every %s", OrphanSweepInterval)
+ ticker := time.NewTicker(OrphanSweepInterval)
+ defer ticker.Stop()
+ cpSweepOnce(ctx, reaper)
+ for {
+ select {
+ case <-ctx.Done():
+ log.Println("CP orphan sweeper: shutdown")
+ return
+ case <-ticker.C:
+ cpSweepOnce(ctx, reaper)
+ }
+ }
+}
+
+// cpSweepOnce executes one reconcile pass. Defensive against db.DB
+// being nil so a misconfigured boot doesn't panic.
+func cpSweepOnce(parent context.Context, reaper CPOrphanReaper) {
+ if db.DB == nil {
+ return
+ }
+ ctx, cancel := context.WithTimeout(parent, orphanSweepDeadline)
+ defer cancel()
+
+ rows, err := db.DB.QueryContext(ctx, `
+ SELECT id::text
+ FROM workspaces
+ WHERE status = 'removed'
+ AND instance_id IS NOT NULL
+ AND instance_id != ''
+ ORDER BY updated_at DESC
+ LIMIT $1
+ `, cpSweepLimit)
+ if err != nil {
+ log.Printf("CP orphan sweeper: DB query failed: %v", err)
+ return
+ }
+ defer rows.Close()
+
+ var orphanIDs []string
+ for rows.Next() {
+ var id string
+ if scanErr := rows.Scan(&id); scanErr != nil {
+ log.Printf("CP orphan sweeper: row scan failed: %v", scanErr)
+ continue
+ }
+ orphanIDs = append(orphanIDs, id)
+ }
+ if iterErr := rows.Err(); iterErr != nil {
+ log.Printf("CP orphan sweeper: rows iteration failed: %v", iterErr)
+ return
+ }
+
+ for _, id := range orphanIDs {
+ log.Printf("CP orphan sweeper: terminating leaked EC2 for removed workspace %s", id)
+ if stopErr := reaper.Stop(ctx, id); stopErr != nil {
+ // CP-side error — transient 5xx, network, AWS hiccup. Leave
+ // instance_id populated so the next cycle retries. Loud-fail
+ // only at the log layer; the user-visible 500 was already
+ // returned by the inline path that triggered this orphan.
+ log.Printf("CP orphan sweeper: Stop failed for %s: %v — retry next cycle", id, stopErr)
+ continue
+ }
+ // Stop succeeded — clear instance_id so the next cycle skips this
+ // row. We can't use a tombstone column (no schema change in this
+ // PR); NULL'ing instance_id is the SSOT signal for "no live
+ // EC2 attached." The matching SELECT predicate above stays in
+ // sync with this UPDATE.
+ if _, updErr := db.DB.ExecContext(ctx,
+ `UPDATE workspaces SET instance_id = NULL, updated_at = now() WHERE id = $1`,
+ id,
+ ); updErr != nil {
+ log.Printf("CP orphan sweeper: clear instance_id failed for %s: %v — next cycle will re-Stop (idempotent)", id, updErr)
+ }
+ }
+}
diff --git a/workspace-server/internal/registry/cp_orphan_sweeper_test.go b/workspace-server/internal/registry/cp_orphan_sweeper_test.go
new file mode 100644
index 00000000..f2d57d0e
--- /dev/null
+++ b/workspace-server/internal/registry/cp_orphan_sweeper_test.go
@@ -0,0 +1,266 @@
+package registry
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/DATA-DOG/go-sqlmock"
+
+ "github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
+)
+
+// fakeCPReaper is a hand-rolled CPOrphanReaper for the SaaS-mode
+// sweeper tests. Records every Stop call so tests can assert which
+// workspace IDs were re-issued.
+type fakeCPReaper struct {
+ mu sync.Mutex
+ stopErr map[string]error
+ stopCalls []string
+}
+
+func (f *fakeCPReaper) Stop(_ context.Context, wsID string) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.stopCalls = append(f.stopCalls, wsID)
+ return f.stopErr[wsID]
+}
+
+// TestCPSweepOnce_StopSucceeds_ClearsInstanceID — happy path. Single
+// removed-row with non-NULL instance_id; Stop succeeds; instance_id
+// gets NULL'd so the next cycle won't re-sweep it.
+func TestCPSweepOnce_StopSucceeds_ClearsInstanceID(t *testing.T) {
+ mock := setupTestDB(t)
+ reaper := &fakeCPReaper{}
+
+ mock.ExpectQuery(`(?s)^\s*SELECT id::text\s+FROM workspaces\s+WHERE status = 'removed'\s+AND instance_id IS NOT NULL\s+AND instance_id != ''\s+ORDER BY updated_at DESC\s+LIMIT \$1`).
+ WithArgs(cpSweepLimit).
+ WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-uuid-1"))
+ mock.ExpectExec(`UPDATE workspaces SET instance_id = NULL, updated_at = now\(\) WHERE id = \$1`).
+ WithArgs("ws-uuid-1").
+ WillReturnResult(sqlmock.NewResult(0, 1))
+
+ cpSweepOnce(context.Background(), reaper)
+
+ if len(reaper.stopCalls) != 1 || reaper.stopCalls[0] != "ws-uuid-1" {
+ t.Fatalf("expected Stop(ws-uuid-1), got %v", reaper.stopCalls)
+ }
+ if err := mock.ExpectationsWereMet(); err != nil {
+ t.Fatalf("unmet expectations: %v", err)
+ }
+}
+
+// TestCPSweepOnce_StopFails_KeepsInstanceID — CP transient failure.
+// Stop returns an error; instance_id MUST stay populated so the next
+// cycle retries. UPDATE must NOT fire.
+func TestCPSweepOnce_StopFails_KeepsInstanceID(t *testing.T) {
+ mock := setupTestDB(t)
+ reaper := &fakeCPReaper{
+ stopErr: map[string]error{"ws-uuid-1": errors.New("CP returned 503")},
+ }
+
+ mock.ExpectQuery(`(?s)^\s*SELECT id::text\s+FROM workspaces`).
+ WithArgs(cpSweepLimit).
+ WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-uuid-1"))
+ // No ExpectExec for the UPDATE — sqlmock fails the test if the
+ // UPDATE fires.
+
+ cpSweepOnce(context.Background(), reaper)
+
+ if len(reaper.stopCalls) != 1 || reaper.stopCalls[0] != "ws-uuid-1" {
+ t.Fatalf("expected Stop(ws-uuid-1), got %v", reaper.stopCalls)
+ }
+ if err := mock.ExpectationsWereMet(); err != nil {
+ t.Fatalf("unmet expectations (UPDATE should NOT have fired): %v", err)
+ }
+}
+
+// TestCPSweepOnce_NoOrphans — empty result set is the steady state in
+// healthy operation. No Stop, no UPDATE.
+func TestCPSweepOnce_NoOrphans(t *testing.T) {
+ mock := setupTestDB(t)
+ reaper := &fakeCPReaper{}
+
+ mock.ExpectQuery(`(?s)^\s*SELECT id::text\s+FROM workspaces`).
+ WithArgs(cpSweepLimit).
+ WillReturnRows(sqlmock.NewRows([]string{"id"}))
+
+ cpSweepOnce(context.Background(), reaper)
+
+ if len(reaper.stopCalls) != 0 {
+ t.Fatalf("expected zero Stop calls, got %v", reaper.stopCalls)
+ }
+ if err := mock.ExpectationsWereMet(); err != nil {
+ t.Fatalf("unmet expectations: %v", err)
+ }
+}
+
+// TestCPSweepOnce_MultipleOrphans — all rows in the batch get Stop'd
+// independently; one failure doesn't block others.
+func TestCPSweepOnce_MultipleOrphans(t *testing.T) {
+ mock := setupTestDB(t)
+ reaper := &fakeCPReaper{
+ stopErr: map[string]error{"ws-uuid-2": errors.New("CP 503 on ws-uuid-2")},
+ }
+
+ mock.ExpectQuery(`(?s)^\s*SELECT id::text\s+FROM workspaces`).
+ WithArgs(cpSweepLimit).
+ WillReturnRows(sqlmock.NewRows([]string{"id"}).
+ AddRow("ws-uuid-1").
+ AddRow("ws-uuid-2").
+ AddRow("ws-uuid-3"))
+ // ws-uuid-1 succeeds → UPDATE fires.
+ mock.ExpectExec(`UPDATE workspaces SET instance_id = NULL`).
+ WithArgs("ws-uuid-1").
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ // ws-uuid-2 fails → no UPDATE.
+ // ws-uuid-3 succeeds → UPDATE fires.
+ mock.ExpectExec(`UPDATE workspaces SET instance_id = NULL`).
+ WithArgs("ws-uuid-3").
+ WillReturnResult(sqlmock.NewResult(0, 1))
+
+ cpSweepOnce(context.Background(), reaper)
+
+ if len(reaper.stopCalls) != 3 {
+ t.Fatalf("expected Stop on all 3 ids, got %v", reaper.stopCalls)
+ }
+ if err := mock.ExpectationsWereMet(); err != nil {
+ t.Fatalf("unmet expectations: %v", err)
+ }
+}
+
+// TestCPSweepOnce_QueryError — DB transient failure. Sweep returns
+// without panicking. No Stop calls.
+func TestCPSweepOnce_QueryError(t *testing.T) {
+ mock := setupTestDB(t)
+ reaper := &fakeCPReaper{}
+
+ mock.ExpectQuery(`(?s)^\s*SELECT id::text\s+FROM workspaces`).
+ WithArgs(cpSweepLimit).
+ WillReturnError(errors.New("connection refused"))
+
+ cpSweepOnce(context.Background(), reaper)
+
+ if len(reaper.stopCalls) != 0 {
+ t.Fatalf("expected zero Stop calls on query error, got %v", reaper.stopCalls)
+ }
+ if err := mock.ExpectationsWereMet(); err != nil {
+ t.Fatalf("unmet expectations: %v", err)
+ }
+}
+
+// TestCPSweepOnce_UpdateError_LogsButContinues — Stop succeeded but
+// the UPDATE to clear instance_id failed. Subsequent rows in the batch
+// must still process; comment in cpSweepOnce promises idempotent re-Stop
+// next cycle.
+func TestCPSweepOnce_UpdateError_LogsButContinues(t *testing.T) {
+ mock := setupTestDB(t)
+ reaper := &fakeCPReaper{}
+
+ mock.ExpectQuery(`(?s)^\s*SELECT id::text\s+FROM workspaces`).
+ WithArgs(cpSweepLimit).
+ WillReturnRows(sqlmock.NewRows([]string{"id"}).
+ AddRow("ws-uuid-1").
+ AddRow("ws-uuid-2"))
+ mock.ExpectExec(`UPDATE workspaces SET instance_id = NULL`).
+ WithArgs("ws-uuid-1").
+ WillReturnError(errors.New("UPDATE timeout"))
+ mock.ExpectExec(`UPDATE workspaces SET instance_id = NULL`).
+ WithArgs("ws-uuid-2").
+ WillReturnResult(sqlmock.NewResult(0, 1))
+
+ cpSweepOnce(context.Background(), reaper)
+
+ if len(reaper.stopCalls) != 2 {
+ t.Fatalf("expected Stop on both ids despite UPDATE error on first, got %v", reaper.stopCalls)
+ }
+ if err := mock.ExpectationsWereMet(); err != nil {
+ t.Fatalf("unmet expectations: %v", err)
+ }
+}
+
+// TestCPSweepOnce_NilDB — defensive against db.DB being nil. Must not
+// panic; must not call Stop.
+func TestCPSweepOnce_NilDB(t *testing.T) {
+ saved := db.DB
+ db.DB = nil
+ t.Cleanup(func() { db.DB = saved })
+
+ reaper := &fakeCPReaper{}
+ cpSweepOnce(context.Background(), reaper)
+
+ if len(reaper.stopCalls) != 0 {
+ t.Fatalf("expected zero Stop calls when db.DB is nil, got %v", reaper.stopCalls)
+ }
+}
+
+// TestStartCPOrphanSweeper_NilReaperDisabled — boot-safety: a SaaS CP
+// without cpProv configured must not start the loop (immediate return,
+// no goroutine leak).
+func TestStartCPOrphanSweeper_NilReaperDisabled(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ done := make(chan struct{})
+ go func() {
+ StartCPOrphanSweeper(ctx, nil)
+ close(done)
+ }()
+ select {
+ case <-done:
+ // expected — nil reaper short-circuits.
+ case <-time.After(500 * time.Millisecond):
+ t.Fatal("StartCPOrphanSweeper(nil) did not return immediately")
+ }
+}
+
+// TestStartCPOrphanSweeper_RunsOnceImmediatelyAndOnTick — cadence
+// contract: kick off one sweep at boot (so a platform restart starts
+// healing immediately), then once per OrphanSweepInterval. Verifies
+// the loop terminates on ctx cancel.
+func TestStartCPOrphanSweeper_RunsOnceImmediatelyAndOnTick(t *testing.T) {
+ mock := setupTestDB(t)
+ reaper := &fakeCPReaper{}
+
+ // Two sweeps within the test window: one immediate, one on the
+ // first tick. We can't shrink OrphanSweepInterval (it's a const),
+ // so assert "at least one immediate sweep" and let cancel close
+ // the loop.
+ mock.ExpectQuery(`(?s)^\s*SELECT id::text\s+FROM workspaces`).
+ WithArgs(cpSweepLimit).
+ WillReturnRows(sqlmock.NewRows([]string{"id"}))
+ // The ticker may or may not fire in the test window depending on
+ // scheduler; tolerate both shapes by registering a second optional
+ // expectation. sqlmock fails on UNREGISTERED queries, so register
+ // one more then accept either 1 or 2 fires.
+ mock.ExpectQuery(`(?s)^\s*SELECT id::text\s+FROM workspaces`).
+ WithArgs(cpSweepLimit).
+ WillReturnRows(sqlmock.NewRows([]string{"id"}))
+
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+ go func() {
+ StartCPOrphanSweeper(ctx, reaper)
+ close(done)
+ }()
+ // 100ms is well past the boot-sweep but well shy of the 60s
+ // interval, so the second query expectation is intentionally
+ // unmet — that's fine, sqlmock distinguishes "expected but not
+ // received" (we don't enforce here) from "unexpected query"
+ // (which would fail).
+ time.Sleep(100 * time.Millisecond)
+ cancel()
+ select {
+ case <-done:
+ // expected
+ case <-time.After(2 * time.Second):
+ t.Fatal("StartCPOrphanSweeper did not exit on ctx cancel")
+ }
+
+ // Boot sweep must have happened — without it, an operator restart
+ // after a CP outage would leave a 60s gap before the first heal.
+ // We don't assert mock.ExpectationsWereMet() here because the
+ // second query is intentionally optional.
+}
--
2.45.2
From 75a72bf5a2b0330a67692f65de2728c235e0ea0e Mon Sep 17 00:00:00 2001
From: "claude-ceo-assistant (Claude Opus 4.7 on Hongming's MacBook)"
Date: Wed, 6 May 2026 16:55:00 -0700
Subject: [PATCH 02/28] feat(canvas/chat-server): canvas consumes /chat-history
+ server-side row-aware reverse (RFC #2945 PR-C-2)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes the SSOT story shipped in PR-C/D: canvas now consumes the typed
/chat-history endpoint instead of /activity?type=a2a_receive, and the
server emits messages in display-ready chronological order so the
client doesn't have to re-order them.
## Canvas (consumer migration)
- loadMessagesFromDB swaps from /activity to /chat-history.
- Drops type=a2a_receive + source=canvas params (server applies the
filter centrally now).
- Drops [...activities].reverse() — wire is already display-ready.
- Drops the local INTERNAL_SELF_MESSAGE_PREFIXES constant +
isInternalSelfMessage helper. Server-side IsInternalSelfMessage
applies the same predicate before emitting rows.
- Drops the activityRowToMessages + ActivityRowForHydration imports
from historyHydration.ts. The TS parser stays in tree because
message-parser.ts is still load-bearing for live A2A WebSocket
messages (ChatTab.tsx:805, AgentCommsPanel.tsx, canvas-events.ts).
## Server (row-aware wire-order fix)
The pre-PR-C-2 client did `[...activities].reverse()` over ROWS, then
flattened each row into [user, agent] messages. The reversal was
ROW-aware. After PR-C/D, the server returned a flat ChatMessage slice
in `ORDER BY created_at DESC` order, with [user, agent] within each
row. A naive client-side flat reverse would FLIP each pair (agent
before user at same timestamp).
Two ways to fix it:
A) Server emits oldest-first within page; canvas does NOT reverse.
B) Canvas does row-aware reversal (group by timestamp, reverse).
Option A is cleaner — server owns the wire-order responsibility, every
client trusts `for m of messages` to render chronologically. Server
adds reverseRowChunks() that:
1. Groups consecutive same-Timestamp messages into row chunks
(1-2 messages per row).
2. Reverses the chunk order (newest-row-first → oldest-row-first).
3. Flattens. Within-chunk [user, agent] order is preserved.
Single-message rows (agent reply not yet recorded, attachments-only
user upload) collapse to 1-element chunks and reverse correctly too.
## Tests
Server: 3 new unit tests on reverseRowChunks (paired across rows,
single-message rows, empty input) + 1 sqlmock integration test on
List() that drives the full SQL → reverse → wire path. Mutation-tested:
removed `messages = reverseRowChunks(messages)` from List(), confirmed
the integration test fires red with all 4 misordered indices flagged.
Restored, all 25 messagestore tests + 9 chat-history handler tests
green.
Canvas: 8 lazyHistory pagination tests refactored to mock
/chat-history (not /activity) and assert against the new wire shape
({messages, reached_end} not raw activity rows). All 1389/1389 vitest
tests green; tsc --noEmit clean.
## Three weakest spots (hostile-reviewer self-pass)
1. reverseRowChunks groups by Timestamp string equality. If two
distinct rows had the SAME timestamp (legitimately possible at sub-
millisecond granularity), the algorithm would treat them as one
chunk and not reverse them relative to each other. Mitigated:
activity_logs.created_at uses microsecond resolution; concurrent
inserts at exact-same microsecond are vanishingly rare. If a
collision happens, the within-chunk order is whatever the SQL
returned — both rows render at the same timestamp, no user-visible
misordering.
2. The pre-existing TS parser files (historyHydration.ts +
message-parser.ts) stay in tree. historyHydration.ts is now dead
code (no consumers post-migration); deletion is parked as a follow-
up after a one-week observation window confirms no live-message
consumer reaches it.
3. canvas's loadMessagesFromDB returns `resp.messages ?? []`. If the
server were ever to return `null` instead of `[]` (it currently
doesn't — handler defensively coerces nil to []), the nullish coalesce
keeps the canvas from crashing. A stricter wire schema would assert
the never-null invariant; for today's pragmatic safety, the ?? is
enough.
## Security review
- Untrusted input? Same as PR-C — agent JSON parsed defensively in
the messagestore parser. No new exposure.
- Trust boundary? Same. Canvas → /chat-history → wsAuth → messagestore.
- Output sanitization? Plain text + opaque attachment URIs as before.
No security-relevant changes beyond what /chat-history already
exposes via PR-C. Considered, not skipped.
## Versioning / backwards compat
- /activity endpoint unchanged.
- /chat-history endpoint shape unchanged (still {messages, reached_end});
only the wire ORDER within a page changed (newest-first row → oldest-
first row). Canvas is the only consumer in tree; no API consumers
depend on the previous order.
- canvas's loadMessagesFromDB call signature unchanged — internal
refactor.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---
canvas/src/components/tabs/ChatTab.tsx | 92 +++---
.../__tests__/ChatTab.lazyHistory.test.tsx | 269 +++++++++---------
.../internal/messagestore/postgres_store.go | 45 +++
.../messagestore/postgres_store_test.go | 142 +++++++++
4 files changed, 358 insertions(+), 190 deletions(-)
diff --git a/canvas/src/components/tabs/ChatTab.tsx b/canvas/src/components/tabs/ChatTab.tsx
index f343b63c..21e9f665 100644
--- a/canvas/src/components/tabs/ChatTab.tsx
+++ b/canvas/src/components/tabs/ChatTab.tsx
@@ -13,7 +13,6 @@ import { AttachmentPreview } from "./chat/AttachmentPreview";
import { extractFilesFromTask } from "./chat/message-parser";
import { AgentCommsPanel } from "./chat/AgentCommsPanel";
import { appendActivityLine } from "./chat/activityLog";
-import { activityRowToMessages, type ActivityRowForHydration } from "./chat/historyHydration";
import { runtimeDisplayName } from "@/lib/runtime-names";
import { ConfirmDialog } from "@/components/ConfirmDialog";
@@ -50,38 +49,12 @@ interface A2AResponse {
};
}
-/** Detect activity-log rows that the workspace's own runtime fired
- * against itself but were misclassified as canvas-source. The proper
- * fix is the X-Workspace-ID header from `self_source_headers()` in
- * workspace/platform_auth.py, which makes the platform record
- * source_id = workspace_id. But three failure modes still leak a
- * self-message into "My Chat":
- *
- * 1. Historical rows already in the DB with source_id=NULL.
- * 2. Workspace containers running pre-fix heartbeat.py / main.py
- * (the fix only takes effect after an image rebuild + redeploy).
- * 3. Future internal triggers added without the helper.
- *
- * This client-side filter recognises the heartbeat trigger by its
- * exact prefix — the heartbeat assembles
- *
- * "Delegation results are ready. Review them and take appropriate
- * action:\n" + summary_lines + report_instruction
- *
- * in workspace/heartbeat.py. The prefix is template-fixed so a
- * string match is reliable. If the heartbeat copy ever changes,
- * update this constant in the same commit.
- *
- * This is a backstop, not the primary defence — the X-Workspace-ID
- * header is. Filtering content is fragile to copy edits, so keep
- * the list narrow. */
-const INTERNAL_SELF_MESSAGE_PREFIXES = [
- "Delegation results are ready. Review them and take appropriate action",
-];
-
-function isInternalSelfMessage(text: string): boolean {
- return INTERNAL_SELF_MESSAGE_PREFIXES.some((p) => text.startsWith(p));
-}
+// Internal-self-message filtering moved server-side in RFC #2945
+// PR-C/D — the platform's /chat-history endpoint applies the
+// IsInternalSelfMessage predicate before returning rows, so the
+// client no longer needs the local backstop on the history path.
+// The proper fix is still X-Workspace-ID header (source_id=workspace_id);
+// the platform-side prefix filter handles the residual cases.
// extractReplyText pulls the agent's text reply out of an A2A response.
// Concatenates ALL text parts (joined with "\n") rather than returning
@@ -134,8 +107,19 @@ const INITIAL_HISTORY_LIMIT = 10;
const OLDER_HISTORY_BATCH = 20;
/**
- * Load chat history from the activity_logs database via the platform API.
- * Uses source=canvas to only get user-initiated messages (not agent-to-agent).
+ * Load chat history from the platform's typed /chat-history endpoint.
+ *
+ * Server-side rendering of activity_logs rows into ChatMessage shape
+ * lives in workspace-server/internal/messagestore/postgres_store.go
+ * (RFC #2945 PR-C/D). The server already applies the canvas-source
+ * filter, the internal-self-message predicate, the role decision
+ * (status=error vs agent-error prefix → system), and the v0/v1
+ * file-shape extraction. Canvas just renders what it receives.
+ *
+ * Wire shape (mirrors ChatMessage exactly, no per-row mapping needed):
+ *
+ * GET /workspaces/:id/chat-history?limit=N&before_ts=T
+ * 200 → {"messages": ChatMessage[], "reached_end": boolean}
*
* Pagination:
* - Pass `limit` to bound the page size (newest-first from server).
@@ -143,10 +127,10 @@ const OLDER_HISTORY_BATCH = 20;
* timestamp. Combined with limit, this yields the next-older page
* when scrolling backward through history.
*
- * `reachedEnd` is true when the server returned fewer rows than asked
- * for — caller uses this to disable further older-batch fetches.
- * (Counts row-level returns, not chat-bubble count: each row may
- * produce 1-2 bubbles.)
+ * `reachedEnd` is propagated from the server. The server computes it
+ * by comparing rowCount vs limit so a partial last page is correctly
+ * detected even when the row→bubble fan-out is non-1:1 (each row
+ * produces 1-2 bubbles).
*/
async function loadMessagesFromDB(
workspaceId: string,
@@ -154,25 +138,23 @@ async function loadMessagesFromDB(
beforeTs?: string,
): Promise<{ messages: ChatMessage[]; error: string | null; reachedEnd: boolean }> {
try {
- const params = new URLSearchParams({
- type: "a2a_receive",
- source: "canvas",
- limit: String(limit),
- });
+ const params = new URLSearchParams({ limit: String(limit) });
if (beforeTs) params.set("before_ts", beforeTs);
- const activities = await api.get(
- `/workspaces/${workspaceId}/activity?${params.toString()}`,
+ const resp = await api.get<{ messages: ChatMessage[]; reached_end: boolean }>(
+ `/workspaces/${workspaceId}/chat-history?${params.toString()}`,
);
- const messages: ChatMessage[] = [];
- // Activities are newest-first, reverse for chronological order.
- // Per-row mapping lives in chat/historyHydration.ts so it can be
- // unit-tested without spinning up the full ChatTab component
- // (regression cover for the timestamp-collapse bug).
- for (const a of [...activities].reverse()) {
- messages.push(...activityRowToMessages(a, isInternalSelfMessage));
- }
- return { messages, error: null, reachedEnd: activities.length < limit };
+ // Server emits oldest-first within the page (RFC #2945 PR-C-2
+ // post-fix: server reverses row-aware before returning so the
+ // wire is display-ready). Canvas appends/prepends without
+ // reordering — this avoids the pair-flip bug a naive flat
+ // reverse causes when each row produces a (user, agent) pair
+ // with the same timestamp.
+ return {
+ messages: resp.messages ?? [],
+ error: null,
+ reachedEnd: resp.reached_end,
+ };
} catch (err) {
return {
messages: [],
diff --git a/canvas/src/components/tabs/__tests__/ChatTab.lazyHistory.test.tsx b/canvas/src/components/tabs/__tests__/ChatTab.lazyHistory.test.tsx
index 47f328ed..577c4587 100644
--- a/canvas/src/components/tabs/__tests__/ChatTab.lazyHistory.test.tsx
+++ b/canvas/src/components/tabs/__tests__/ChatTab.lazyHistory.test.tsx
@@ -1,13 +1,11 @@
// @vitest-environment jsdom
//
-// Pins the lazy-loading chat-history pagination added 2026-05-05.
+// Pins the lazy-loading chat-history pagination.
//
-// Pre-fix: ChatTab fetched the newest 50 messages on every mount and
-// scrolled to bottom, paying full DOM cost up-front even when the user
-// only wanted to read the last few bubbles. Post-fix: initial load is
-// bounded to 10 newest, and an IntersectionObserver on a top sentinel
-// triggers loadOlder() (batch of 20 with `before_ts` cursor) when the
-// user scrolls up.
+// PR-C-2 (RFC #2945): canvas was migrated from /activity?type=a2a_receive
+// to /chat-history. Server now returns typed ChatMessage[] in
+// display-ready oldest-first order. These tests guard the canvas-side
+// pagination invariants against the new endpoint surface.
//
// Pinned branches:
// 1. Initial fetch carries `limit=10` and NO before_ts (newest-first
@@ -20,11 +18,10 @@
// asserting the rendered bubble count matches the full page).
// 4. The retry button after a failed initial load uses the same
// INITIAL_HISTORY_LIMIT (10), not the legacy 50.
-//
-// IntersectionObserver / scroll-anchor restoration is exercised by the
-// E2E synth-canary suite — pinning it in jsdom would require mocking
-// the observer and faking layout, which is brittler than trusting a
-// live-DOM canary against the staging tenant.
+// 5. before_ts cursor is the OLDEST timestamp from the current page,
+// passed verbatim to walk backward.
+// 6. Inflight guard rejects duplicate IO triggers while a loadOlder
+// fetch is in flight.
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
@@ -33,24 +30,31 @@ import React from "react";
afterEach(cleanup);
// Both ChatTab sub-panels (MyChat + AgentComms) mount simultaneously so
-// keyboard tab order and aria-controls land on a real DOM. Both fire
-// /activity GETs on mount: MyChat's hits `type=a2a_receive&source=canvas`,
-// AgentComms's hits a different filter. Route the mock by URL so each
-// gets a sensible default and only MyChat's call is what the assertions
-// scrutinise.
-const myChatActivityCalls: string[] = [];
-let myChatNextResponse: { ok: true; rows: unknown[] } | { ok: false; err: Error } = {
- ok: true,
- rows: [],
-};
+// keyboard tab order and aria-controls land on a real DOM. MyChat's
+// loadMessagesFromDB hits /chat-history; AgentComms's polling hits a
+// different URL. Route the mock by URL so each gets a sensible default
+// and only MyChat's calls land in the assertion array.
+const myChatHistoryCalls: string[] = [];
+let myChatNextResponse:
+ | { ok: true; messages: unknown[]; reachedEnd?: boolean }
+ | { ok: false; err: Error } = { ok: true, messages: [] };
+
const apiGet = vi.fn((path: string): Promise => {
- if (path.includes("type=a2a_receive") && path.includes("source=canvas")) {
- myChatActivityCalls.push(path);
- if (myChatNextResponse.ok) return Promise.resolve(myChatNextResponse.rows);
+ if (path.includes("/chat-history")) {
+ myChatHistoryCalls.push(path);
+ if (myChatNextResponse.ok) {
+ const reached_end =
+ myChatNextResponse.reachedEnd !== undefined
+ ? myChatNextResponse.reachedEnd
+ : myChatNextResponse.messages.length < 10;
+ return Promise.resolve({
+ messages: myChatNextResponse.messages,
+ reached_end,
+ });
+ }
return Promise.reject(myChatNextResponse.err);
}
- // AgentComms / heartbeat / anything else — empty array is a safe
- // default that won't blow up the corresponding component's .then().
+ // AgentComms / heartbeat / anything else — empty array safe default.
return Promise.resolve([]);
});
const apiPost = vi.fn();
@@ -84,8 +88,8 @@ const ioInstances: IOInstance[] = [];
beforeEach(() => {
apiGet.mockClear();
apiPost.mockReset();
- myChatActivityCalls.length = 0;
- myChatNextResponse = { ok: true, rows: [] };
+ myChatHistoryCalls.length = 0;
+ myChatNextResponse = { ok: true, messages: [] };
ioInstances.length = 0;
class FakeIO {
private inst: IOInstance;
@@ -101,20 +105,12 @@ beforeEach(() => {
this.inst.disconnected = true;
}
}
- // Install on every reachable global — different bundlers / module
- // graphs can resolve `IntersectionObserver` via `window`, `globalThis`,
- // or the bare global. Without all three, jsdom's own (pre-existing)
- // stub silently wins and ioInstances stays empty.
(window as unknown as { IntersectionObserver: unknown }).IntersectionObserver = FakeIO;
(globalThis as unknown as { IntersectionObserver: unknown }).IntersectionObserver = FakeIO;
- // jsdom doesn't implement scrollIntoView; ChatTab calls it after every
- // messages update.
Element.prototype.scrollIntoView = vi.fn();
});
function triggerIntersection(instanceIdx = -1) {
- // -1 → the latest observer (the live one). Tests targeting an old
- // (disconnected) instance pass a positive index.
const inst = ioInstances.at(instanceIdx);
if (!inst) throw new Error(`no IO instance at ${instanceIdx}`);
inst.callback(
@@ -125,25 +121,30 @@ function triggerIntersection(instanceIdx = -1) {
import { ChatTab } from "../ChatTab";
-function makeActivityRow(seq: number): Record {
- // Zero-pad seq into the minute slot so "seq=10" doesn't produce
- // the invalid timestamp "00:010:00Z" (caught by the loadOlder URL
- // assertion below — first version of the helper used `0${seq}` and
- // the test failed on `before_ts` having an extra digit).
+// makeMessagePair returns a (user, agent) pair sharing a timestamp,
+// matching the wire shape /chat-history emits per activity_logs row.
+// Server-side reverseRowChunks ensures the wire is oldest-first across
+// rows but [user, agent] within each row.
+function makeMessagePair(seq: number): unknown[] {
+ // Zero-pad seq into the minute slot so seq=10 produces a valid
+ // timestamp (00:10:00Z, not 00:010:00Z).
const mm = String(seq).padStart(2, "0");
- return {
- activity_type: "a2a_receive",
- status: "ok",
- created_at: `2026-05-05T00:${mm}:00Z`,
- request_body: { params: { message: { parts: [{ kind: "text", text: `user msg ${seq}` }] } } },
- response_body: { result: `agent reply ${seq}` },
- };
+ const ts = `2026-05-05T00:${mm}:00Z`;
+ return [
+ { id: `u-${seq}`, role: "user", content: `user msg ${seq}`, timestamp: ts },
+ { id: `a-${seq}`, role: "agent", content: `agent reply ${seq}`, timestamp: ts },
+ ];
}
-// Server returns newest-first; the helper builds a server-shape page
-// so the order in the rendered messages array matches production.
-function newestFirstPage(start: number, count: number): unknown[] {
- return Array.from({ length: count }, (_, i) => makeActivityRow(start + count - 1 - i));
+// pageOldestFirst builds a wire-shape page (oldest-first within page)
+// of `count` row-pairs starting at seq=`start`. Mirrors the server's
+// post-reverseRowChunks emission order.
+function pageOldestFirst(start: number, count: number): unknown[] {
+ const out: unknown[] = [];
+ for (let i = 0; i < count; i++) {
+ out.push(...makeMessagePair(start + i));
+ }
+ return out;
}
const minimalData = {
@@ -153,28 +154,30 @@ const minimalData = {
} as unknown as Parameters[0]["data"];
describe("ChatTab lazy history pagination", () => {
- it("initial fetch carries limit=10 (not the legacy 50)", async () => {
- myChatNextResponse = { ok: true, rows: [makeActivityRow(1)] };
+ it("initial fetch carries limit=10 (not the legacy 50) and hits /chat-history", async () => {
+ myChatNextResponse = { ok: true, messages: makeMessagePair(1) };
render();
- await waitFor(() => expect(myChatActivityCalls.length).toBe(1));
- const url = myChatActivityCalls[0];
+ await waitFor(() => expect(myChatHistoryCalls.length).toBe(1));
+ const url = myChatHistoryCalls[0];
+ expect(url).toContain("/chat-history");
expect(url).toContain("limit=10");
expect(url).not.toContain("limit=50");
// before_ts should NOT be set on the initial fetch — that's the
// newest-first slice the user lands on.
expect(url).not.toContain("before_ts");
+ // /chat-history filters source-canvas server-side; client should
+ // NOT pass type/source params (they belonged to /activity).
+ expect(url).not.toContain("type=a2a_receive");
+ expect(url).not.toContain("source=canvas");
});
it("hides the top sentinel when initial fetch returns fewer than the limit", async () => {
// 3 < 10 → server says "no more older history exists"; sentinel
// should NOT mount and the "Loading older messages…" line should
- // never appear (it can't, since the sentinel is what triggers it).
- myChatNextResponse = {
- ok: true,
- rows: [makeActivityRow(1), makeActivityRow(2), makeActivityRow(3)],
- };
+ // never appear.
+ myChatNextResponse = { ok: true, messages: pageOldestFirst(1, 3) };
render();
- await waitFor(() => expect(myChatActivityCalls.length).toBe(1));
+ await waitFor(() => expect(myChatHistoryCalls.length).toBe(1));
await waitFor(() => {
expect(screen.queryByText(/Loading chat history/i)).toBeNull();
});
@@ -182,15 +185,15 @@ describe("ChatTab lazy history pagination", () => {
});
it("renders all messages when initial fetch returns exactly the limit", async () => {
- // 10 == limit → server might have more older rows; sentinel SHOULD
- // mount so the IO observer can fire loadOlder() on scroll-up. We
- // verify by checking the rendered bubble count — if hasMore stayed
- // true the sentinel render path doesn't crash and all 10 rows
- // produced their pair of bubbles.
- const fullPage = Array.from({ length: 10 }, (_, i) => makeActivityRow(i + 1));
- myChatNextResponse = { ok: true, rows: fullPage };
+ // limit=10 row-pairs → 20 ChatMessages. reachedEnd should be FALSE
+ // so the sentinel mounts. Verified by bubble counts.
+ myChatNextResponse = {
+ ok: true,
+ messages: pageOldestFirst(1, 10),
+ reachedEnd: false,
+ };
render();
- await waitFor(() => expect(myChatActivityCalls.length).toBe(1));
+ await waitFor(() => expect(myChatHistoryCalls.length).toBe(1));
await waitFor(() => {
expect(screen.queryByText(/Loading chat history/i)).toBeNull();
});
@@ -202,54 +205,67 @@ describe("ChatTab lazy history pagination", () => {
myChatNextResponse = { ok: false, err: new Error("network down") };
render();
const retry = await screen.findByText(/Retry/);
- myChatNextResponse = { ok: true, rows: [makeActivityRow(1)] };
+ myChatNextResponse = { ok: true, messages: makeMessagePair(1) };
fireEvent.click(retry);
- await waitFor(() => expect(myChatActivityCalls.length).toBe(2));
- const retryUrl = myChatActivityCalls[1];
+ await waitFor(() => expect(myChatHistoryCalls.length).toBe(2));
+ const retryUrl = myChatHistoryCalls[1];
+ expect(retryUrl).toContain("/chat-history");
expect(retryUrl).toContain("limit=10");
expect(retryUrl).not.toContain("limit=50");
});
it("loadOlder fetches limit=20 with before_ts=oldest.timestamp", async () => {
- // Initial page = 10 rows in newest-first order (seq 10..1). After
- // the component reverses to oldest-first for display, messages[0]
- // is built from seq=1 — the oldest — and its timestamp is what
- // before_ts should carry.
- myChatNextResponse = { ok: true, rows: newestFirstPage(1, 10) };
+ // Initial page = 10 row-pairs in oldest-first order (seq 1..10).
+ // The oldest (and so the cursor for loadOlder) is seq=1's
+ // timestamp 2026-05-05T00:01:00Z.
+ myChatNextResponse = {
+ ok: true,
+ messages: pageOldestFirst(1, 10),
+ reachedEnd: false,
+ };
render();
- await waitFor(() => expect(myChatActivityCalls.length).toBe(1));
+ await waitFor(() => expect(myChatHistoryCalls.length).toBe(1));
await waitFor(() => expect(ioInstances.length).toBeGreaterThan(0));
- // Stage the older-batch response, then fire the IO callback.
- myChatNextResponse = { ok: true, rows: newestFirstPage(0, 1) };
+ // Stage older-batch response, then fire IO callback.
+ myChatNextResponse = {
+ ok: true,
+ messages: pageOldestFirst(0, 1),
+ reachedEnd: true,
+ };
triggerIntersection();
- await waitFor(() => expect(myChatActivityCalls.length).toBe(2));
- const olderUrl = myChatActivityCalls[1];
+ await waitFor(() => expect(myChatHistoryCalls.length).toBe(2));
+ const olderUrl = myChatHistoryCalls[1];
+ expect(olderUrl).toContain("/chat-history");
expect(olderUrl).toContain("limit=20");
expect(olderUrl).toContain("before_ts=");
expect(decodeURIComponent(olderUrl)).toContain("before_ts=2026-05-05T00:01:00Z");
});
it("inflight guard rejects a second IO trigger while first loadOlder is in flight", async () => {
- myChatNextResponse = { ok: true, rows: newestFirstPage(1, 10) };
+ myChatNextResponse = {
+ ok: true,
+ messages: pageOldestFirst(1, 10),
+ reachedEnd: false,
+ };
render();
- await waitFor(() => expect(myChatActivityCalls.length).toBe(1));
+ await waitFor(() => expect(myChatHistoryCalls.length).toBe(1));
await waitFor(() => expect(ioInstances.length).toBeGreaterThan(0));
// Hold the next loadOlder fetch open with a manual deferred so we
// can fire the second trigger while the first is in-flight.
- let release!: (rows: unknown[]) => void;
- const deferred = new Promise((res) => {
+ let release!: (resp: unknown) => void;
+ const deferred = new Promise((res) => {
release = res;
});
apiGet.mockImplementationOnce((path: string): Promise => {
- myChatActivityCalls.push(path);
+ myChatHistoryCalls.push(path);
return deferred;
});
triggerIntersection(); // start loadOlder #1
- await waitFor(() => expect(myChatActivityCalls.length).toBe(2));
+ await waitFor(() => expect(myChatHistoryCalls.length).toBe(2));
// Second IO trigger lands while #1 is still pending.
triggerIntersection();
@@ -258,79 +274,62 @@ describe("ChatTab lazy history pagination", () => {
// Without the inflight guard, each of these would have started a
// new fetch. With the guard, none of them do — call count stays 2.
await new Promise((r) => setTimeout(r, 10));
- expect(myChatActivityCalls.length).toBe(2);
+ expect(myChatHistoryCalls.length).toBe(2);
- // Release the first fetch. Inflight clears in the finally block;
- // a subsequent IO trigger is permitted again (verified by checking
- // we can fire a follow-up after release without hanging the test).
- release([]);
- await waitFor(() => expect(myChatActivityCalls.length).toBe(2));
+ // Release the first fetch with a valid wire response shape.
+ release({ messages: [], reached_end: true });
+ await waitFor(() => expect(myChatHistoryCalls.length).toBe(2));
});
it("empty older response clears the scroll anchor and unmounts the sentinel", async () => {
- // The bug we're pinning: if loadOlder returns 0 rows, the
- // scrollAnchorRef must be cleared so the next paint doesn't try to
- // restore against a no-op prepend (which would fight the natural
- // bottom-pin for any subsequent live message). hasMore flipping to
- // false is the same flag-flip path; sentinel disappearing is the
- // observable proxy.
- myChatNextResponse = { ok: true, rows: newestFirstPage(1, 10) };
+ myChatNextResponse = {
+ ok: true,
+ messages: pageOldestFirst(1, 10),
+ reachedEnd: false,
+ };
render();
- await waitFor(() => expect(myChatActivityCalls.length).toBe(1));
+ await waitFor(() => expect(myChatHistoryCalls.length).toBe(1));
await waitFor(() => expect(ioInstances.length).toBeGreaterThan(0));
- myChatNextResponse = { ok: true, rows: [] }; // empty → reachedEnd
+ myChatNextResponse = {
+ ok: true,
+ messages: [],
+ reachedEnd: true,
+ };
triggerIntersection();
- await waitFor(() => expect(myChatActivityCalls.length).toBe(2));
+ await waitFor(() => expect(myChatHistoryCalls.length).toBe(2));
- // After reachedEnd the sentinel unmounts (hasMore=false). We can't
- // peek scrollAnchorRef directly, but we can assert the consequence:
- // scrollIntoView (the bottom-pin for live appends) is not blocked
- // by a stale anchor. Trigger a re-render via an unrelated state
- // change… in practice the safest assertion here is that the
- // sentinel disappeared (proving the empty response propagated to
- // hasMore correctly, which is the same flag-flip path as anchor
- // clearing).
await waitFor(() => {
expect(screen.queryByText(/Loading older messages/i)).toBeNull();
});
});
it("IntersectionObserver does not churn when older messages prepend", async () => {
- // Whole-PR perf invariant: prepending older history (the load-bearing
- // user gesture) must NOT tear down + re-arm the IO observer.
- // Triggering loadOlder is the cleanest way to drive a messages
- // mutation from inside the test, since live agent push goes through
- // a Zustand store that's harder to drive reliably from jsdom.
- //
- // Pre-fix, loadOlder depended on `messages`, so every prepend
- // recreated loadOlder → re-ran the IO effect → new observer. Each
- // call to triggerIntersection() produced a fresh disconnected
- // observer + a new live one. Post-fix, the observer survives.
- myChatNextResponse = { ok: true, rows: newestFirstPage(1, 10) };
+ myChatNextResponse = {
+ ok: true,
+ messages: pageOldestFirst(1, 10),
+ reachedEnd: false,
+ };
render();
- await waitFor(() => expect(myChatActivityCalls.length).toBe(1));
+ await waitFor(() => expect(myChatHistoryCalls.length).toBe(1));
await waitFor(() => expect(ioInstances.length).toBeGreaterThan(0));
- // Snapshot the observer instance after first paint stabilises.
const observerBefore = ioInstances.at(-1);
expect(observerBefore).toBeDefined();
expect(observerBefore!.disconnected).toBe(false);
// Trigger three older-batch prepends. Each batch returns the full
- // OLDER_HISTORY_BATCH (20 rows) so reachedEnd stays false and the
- // sentinel keeps mounting. Pre-fix, each prepend mutated `messages`
- // → recreated loadOlder → re-ran the IO effect → new observer.
+ // OLDER_HISTORY_BATCH (20 row-pairs = 40 messages) so reachedEnd
+ // stays false and the sentinel keeps mounting.
for (let batch = 0; batch < 3; batch++) {
myChatNextResponse = {
ok: true,
- rows: newestFirstPage(-(batch + 1) * 20, 20),
+ messages: pageOldestFirst(-(batch + 1) * 20, 20),
+ reachedEnd: false,
};
- const callsBefore = myChatActivityCalls.length;
+ const callsBefore = myChatHistoryCalls.length;
triggerIntersection();
- await waitFor(() =>
- expect(myChatActivityCalls.length).toBe(callsBefore + 1),
- );
+ await waitFor(() => expect(myChatHistoryCalls.length).toBe(callsBefore + 1));
}
// The original observer is still the live one — no churn.
diff --git a/workspace-server/internal/messagestore/postgres_store.go b/workspace-server/internal/messagestore/postgres_store.go
index 7e75315f..67987569 100644
--- a/workspace-server/internal/messagestore/postgres_store.go
+++ b/workspace-server/internal/messagestore/postgres_store.go
@@ -110,10 +110,55 @@ func (s *PostgresMessageStore) List(ctx context.Context, workspaceID string, opt
return nil, false, err
}
+ // Wire order: oldest-first within the page so canvas (and any
+ // future client) can render chronologically without per-pair
+ // reordering. The SQL is `ORDER BY created_at DESC LIMIT N` for
+ // pagination correctness, and activityRowToChatMessages emits
+ // [user, agent] within a row — so a naive client-side flat-reverse
+ // would swap the pair (agent before user at the same timestamp).
+ // Reversing ROW-AWARE here keeps the wire shape display-ready.
+ //
+ // Algorithm: group consecutive same-timestamp messages into row
+ // chunks (1-2 messages each), reverse the chunk order, flatten.
+ // Within-row [user, agent] order is preserved. Single-message
+ // rows (no agent reply yet, or attachments-only) collapse to
+ // 1-element chunks and still reverse correctly.
+ messages = reverseRowChunks(messages)
+
reachedEnd := rowCount < opts.Limit
return messages, reachedEnd, nil
}
+// reverseRowChunks groups msgs by adjacent same-Timestamp runs and
+// reverses the run order, preserving within-run order. Pairs of
+// (user, agent) emitted by activityRowToChatMessages share a
+// timestamp, so this keeps each pair internally ordered while
+// reversing the row sequence.
+func reverseRowChunks(msgs []ChatMessage) []ChatMessage {
+ if len(msgs) == 0 {
+ return msgs
+ }
+ var chunks [][]ChatMessage
+ cur := []ChatMessage{msgs[0]}
+ for i := 1; i < len(msgs); i++ {
+ if msgs[i].Timestamp == cur[len(cur)-1].Timestamp {
+ cur = append(cur, msgs[i])
+ } else {
+ chunks = append(chunks, cur)
+ cur = []ChatMessage{msgs[i]}
+ }
+ }
+ chunks = append(chunks, cur)
+ for i, j := 0, len(chunks)-1; i < j; i, j = i+1, j-1 {
+ chunks[i], chunks[j] = chunks[j], chunks[i]
+ }
+ out := make([]ChatMessage, 0, len(msgs))
+ for _, chunk := range chunks {
+ out = append(out, chunk...)
+ }
+ return out
+}
+
// queryActivityRows is split from List so unit tests can exercise the
// parser without spinning a real DB. Internal — alternative impls
// shouldn't depend on the SQL shape.
diff --git a/workspace-server/internal/messagestore/postgres_store_test.go b/workspace-server/internal/messagestore/postgres_store_test.go
index bcdda6fa..5f7cce8a 100644
--- a/workspace-server/internal/messagestore/postgres_store_test.go
+++ b/workspace-server/internal/messagestore/postgres_store_test.go
@@ -14,10 +14,13 @@ package messagestore
// legacy source the server replaces; divergence == regression.
import (
+ "context"
"encoding/json"
"strings"
"testing"
"time"
+
+ "github.com/DATA-DOG/go-sqlmock"
)
const fixedTimestamp = "2026-04-25T18:00:00Z"
@@ -282,6 +285,145 @@ func TestChatHistory_NoAgentMessageWhenResponseHasNoTextNoFiles(t *testing.T) {
}
}
+// =====================================================================
+// List() integration — sqlmock-backed end-to-end via the real handler
+// =====================================================================
+
+// TestList_WireOrderIsOldestFirstAcrossPagedRows pins the integration
+// invariant: List() returns wire-display-ready messages even though
+// the underlying SQL is `ORDER BY created_at DESC`. This is the
+// load-bearing test for PR-C-2 — without the row-aware reversal,
+// canvas would render every paired bubble in the wrong order on every
+// chat reload (agent before user within each timestamp).
+//
+// Mutation-test cover: removing the `messages = reverseRowChunks(...)`
+// call in List() must turn this test red. (The lower-level
+// TestReverseRowChunks_PreservesPairOrderAcrossRows pins the helper
+// itself; this test pins that List ACTUALLY CALLS the helper.)
+func TestList_WireOrderIsOldestFirstAcrossPagedRows(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ if err != nil {
+ t.Fatalf("sqlmock.New: %v", err)
+ }
+ defer db.Close()
+
+ // Server's SQL is ORDER BY created_at DESC. Build mock rows in
+ // THAT order so the row-aware reversal has work to do.
+ rows := sqlmock.NewRows([]string{"created_at", "status", "request_body", "response_body"}).
+ AddRow(mustParseTime(t, "2026-05-05T00:03:00Z"), "ok",
+ `{"params":{"message":{"parts":[{"kind":"text","text":"u3"}]}}}`,
+ `{"result":"a3"}`).
+ AddRow(mustParseTime(t, "2026-05-05T00:02:00Z"), "ok",
+ `{"params":{"message":{"parts":[{"kind":"text","text":"u2"}]}}}`,
+ `{"result":"a2"}`).
+ AddRow(mustParseTime(t, "2026-05-05T00:01:00Z"), "ok",
+ `{"params":{"message":{"parts":[{"kind":"text","text":"u1"}]}}}`,
+ `{"result":"a1"}`)
+
+ mock.ExpectQuery(`SELECT created_at, status, request_body::text, response_body::text`).
+ WillReturnRows(rows)
+
+ store := NewPostgresMessageStore(db)
+ msgs, reachedEnd, err := store.List(context.Background(), "ws-1", ListOptions{Limit: 10})
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+
+ wantContents := []string{"u1", "a1", "u2", "a2", "u3", "a3"}
+ if len(msgs) != len(wantContents) {
+ t.Fatalf("len(msgs)=%d want %d; got=%v", len(msgs), len(wantContents), msgs)
+ }
+ for i, w := range wantContents {
+ if msgs[i].Content != w {
+ t.Errorf("idx %d: got %q want %q (full slice ordering broken; reverseRowChunks regressed?)", i, msgs[i].Content, w)
+ }
+ }
+ if !reachedEnd {
+ t.Errorf("3 rows < limit 10 should reach end, got reachedEnd=false")
+ }
+ if err := mock.ExpectationsWereMet(); err != nil {
+ t.Errorf("sqlmock expectations: %v", err)
+ }
+}
+
+// =====================================================================
+// reverseRowChunks — wire-order helper added in PR-C-2
+// =====================================================================
+
+// TestReverseRowChunks_PreservesPairOrderAcrossRows pins the
+// row-aware reversal that List() applies before returning. Server's
+// SQL is `ORDER BY created_at DESC`, so messages come out
+// newest-row-first; activityRowToChatMessages emits [user, agent]
+// per row with same timestamp. A naive flat reversal of the messages
+// slice would flip each pair (agent before user). reverseRowChunks
+// reverses ROWS, preserving pair-internal order. Without this, canvas
+// would render every paired bubble in the wrong order on every chat
+// reload — the canvas-side reverse used to do the right thing because
+// it reversed ROWS BEFORE flattening, but PR-C/D moved the flattening
+// into the server, so the row-awareness has to live there too.
+func TestReverseRowChunks_PreservesPairOrderAcrossRows(t *testing.T) {
+ // Build messages newest-row-first as List() collects them. Each
+ // row is a pair sharing a timestamp, with [user, agent] order.
+ in := []ChatMessage{
+ {Role: "user", Content: "user_3", Timestamp: "2026-05-05T00:03:00Z"},
+ {Role: "agent", Content: "agent_3", Timestamp: "2026-05-05T00:03:00Z"},
+ {Role: "user", Content: "user_2", Timestamp: "2026-05-05T00:02:00Z"},
+ {Role: "agent", Content: "agent_2", Timestamp: "2026-05-05T00:02:00Z"},
+ {Role: "user", Content: "user_1", Timestamp: "2026-05-05T00:01:00Z"},
+ {Role: "agent", Content: "agent_1", Timestamp: "2026-05-05T00:01:00Z"},
+ }
+ got := reverseRowChunks(in)
+
+ want := []struct {
+ role, content string
+ }{
+ {"user", "user_1"}, {"agent", "agent_1"},
+ {"user", "user_2"}, {"agent", "agent_2"},
+ {"user", "user_3"}, {"agent", "agent_3"},
+ }
+ if len(got) != len(want) {
+ t.Fatalf("len(got)=%d len(want)=%d", len(got), len(want))
+ }
+ for i, w := range want {
+ if got[i].Role != w.role || got[i].Content != w.content {
+ t.Errorf("idx %d: got role=%q content=%q want role=%q content=%q",
+ i, got[i].Role, got[i].Content, w.role, w.content)
+ }
+ }
+}
+
+// TestReverseRowChunks_HandlesSingleMessageRows pins the case where
+// a row has only a user OR only an agent message (e.g., agent reply
+// not yet recorded, attachments-only user upload). Naive reversal
+// still works for single-message chunks; the test guards against a
+// future change that special-cases the 2-message-row path.
+func TestReverseRowChunks_HandlesSingleMessageRows(t *testing.T) {
+ in := []ChatMessage{
+ {Role: "user", Content: "u3", Timestamp: "2026-05-05T00:03:00Z"},
+ {Role: "user", Content: "u2", Timestamp: "2026-05-05T00:02:00Z"}, // single, no agent
+ {Role: "agent", Content: "a2", Timestamp: "2026-05-05T00:02:00Z"},
+ {Role: "user", Content: "u1", Timestamp: "2026-05-05T00:01:00Z"},
+ }
+ got := reverseRowChunks(in)
+ wantContents := []string{"u1", "u2", "a2", "u3"}
+ if len(got) != len(wantContents) {
+ t.Fatalf("len got=%d want=%d", len(got), len(wantContents))
+ }
+ for i, w := range wantContents {
+ if got[i].Content != w {
+ t.Errorf("idx %d: got %q want %q", i, got[i].Content, w)
+ }
+ }
+}
+
+// TestReverseRowChunks_EmptyInput returns nil/empty without panic.
+func TestReverseRowChunks_EmptyInput(t *testing.T) {
+ got := reverseRowChunks(nil)
+ if len(got) != 0 {
+ t.Errorf("nil input should return empty, got %v", got)
+ }
+}
+
// =====================================================================
// end-to-end shape — paired user + agent with same timestamp
// =====================================================================
--
2.45.2
From 624ef4d06dd2816f20c708c0d8b5717a79b013bf Mon Sep 17 00:00:00 2001
From: claude-ceo-assistant
Date: Wed, 6 May 2026 23:17:58 -0700
Subject: [PATCH 03/28] perf(workspace-server,canvas): EIC tunnel pool + canvas
Promise.all (closes core#11)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Symptom
Canvas detail-panel "config + filesystem load" took ~20s. Reported on
production hongming tenant, workspace c7c28c0b-... (Claude Code Agent T2).
## Two stacked latency sources
### 1. Server-side: per-call EIC tunnel setup (~80% of the win)
`workspace-server/internal/handlers/template_files_eic.go::realWithEICTunnel`
performed ssh-keygen + SendSSHPublicKey + open-tunnel + waitForPort PER call.
4 callers (read/write/list/delete) each paid the full ~3-5s setup cost even
when fired back-to-back on the same workspace EC2.
Fix: refcounted pool keyed on instanceID with TTL ≤ 50s (under the 60s
SendSSHPublicKey grant). One tunnel serves N file ops; concurrent acquires
for the same instance share the slot via a pendingSetups gate; LRU eviction
caps simultaneous tracked instances at 32. Poisons entries on tunnel-fatal
errors (connection refused, broken pipe, auth failed) so the next acquire
builds fresh. Cleanup on panic via defer-release pattern (added after
self-review caught a refcount-leak hazard).
Public API unchanged — `var withEICTunnel` rebinds to `pooledWithEICTunnel`
at package init, so all 4 callers inherit pooling for free.
10 unit tests pin: 4-ops-amortise (1 setup), different-instances-do-not-share,
TTL eviction, poison invalidates, concurrent-acquire-single-setup,
TTL=0 escape hatch, LRU eviction at cap, error classification heuristic,
refcount blocks expired eviction, panic poisons entry. All green.
### 2. Canvas-side: serial fan-out + duplicate fetch (~20% of the win)
`canvas/src/components/tabs/ConfigTab.tsx::loadConfig` awaited 3 independent
metadata GETs (`/workspaces/{id}`, `/model`, `/provider`) serially.
`AgentCardSection` fired a SECOND `/workspaces/{id}` from its own useEffect.
Fix: Promise.all over the 3 metadata GETs (each leg keeps its existing
.catch fallback semantics). AgentCardSection now reads `agentCard` from
the canvas store (`useCanvasStore`) instead of refetching — the canvas
already hydrates `node.data.agentCard` from the platform event stream.
Defensive selector handles test mocks without a `nodes` array.
## Verification
- `go test ./internal/handlers/` 5.07s green (full handlers package, including
10 new pool tests)
- `go vet ./internal/handlers/` clean
- `npx vitest run` — 1380/1380 canvas unit tests pass (2 test FILES fail on
a pre-existing xyflow CSS-load issue in vitest config, unrelated to this
change)
- `npx tsc --noEmit` clean
Live wall-time verification deferred to Phase 4 / E2E (canvas browser session
required; external probe blocked by 403 since the canvas auth chain is
session-cookie + Origin header, not a bearer token I can fabricate).
## Backwards compatibility
API surface unchanged. All 4 EIC handler callers use the rebound var; no
caller migration. Pool defaults to enabled (TTL=50s); tests can disable by
setting poolTTL=0 or by overwriting withEICTunnel directly (existing stub
pattern in template_files_eic_dispatch_test.go preserved).
## Hostile self-review (3 weakest spots)
1. `fnErrIndicatesTunnelFault` is a substring grep on err.Error() — the
marker list is hand-curated and ssh client error formats vary across
OpenSSH versions. A future ssh that reports a tunnel failure via a
phrasing not in the list would NOT poison the entry → next callers reuse
a dead tunnel until TTL evicts. Acceptable: TTL bounds the impact (≤50s
of bad reuse), and the heuristic covers every tunnel-error shape that
appears in the existing test fixtures and known incidents.
2. `acquire`'s for-loop has unbounded retry potential under pathological
churn (signal closed → new acquirer → setup fails → repeat). No bounded
retry counter. Today there is no test exercise for "flaky setup that
succeeds-then-fails-then-succeeds"; if observability ever shows this
shape, add a max-retry guard. Filed as a known limitation, not blocking.
3. The substring assertion `strings.Contains` style I used for tunnel-fault
classification could false-positive on app-level error messages that
happen to contain "permission denied" or "broken pipe" verbatim. The
classification test covers the discriminator but only against the
error shapes we know today. Acceptable: poisoning errs on the side of
building fresh, which is correct-but-slightly-slow rather than incorrect.
## Phase 4 / E2E plan
- Live timing of the canvas detail-panel open against a real workspace
(browser session, not external probe).
- Target: perceived latency under 2s on warm pool. Cold open still pays
one tunnel setup (~3-5s) — the pool buys you the SECOND through Nth
panel-open within the TTL window.
- Memory `feedback_chase_verification_to_staging` applies — will not
declare done at PR-merge; will follow through to user-visible behavior
on staging.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
canvas/src/components/tabs/ConfigTab.tsx | 111 +++--
.../internal/handlers/eic_tunnel_pool.go | 437 ++++++++++++++++
.../handlers/eic_tunnel_pool_setup.go | 136 +++++
.../internal/handlers/eic_tunnel_pool_test.go | 467 ++++++++++++++++++
4 files changed, 1106 insertions(+), 45 deletions(-)
create mode 100644 workspace-server/internal/handlers/eic_tunnel_pool.go
create mode 100644 workspace-server/internal/handlers/eic_tunnel_pool_setup.go
create mode 100644 workspace-server/internal/handlers/eic_tunnel_pool_test.go
diff --git a/canvas/src/components/tabs/ConfigTab.tsx b/canvas/src/components/tabs/ConfigTab.tsx
index 2250f3f1..ab229632 100644
--- a/canvas/src/components/tabs/ConfigTab.tsx
+++ b/canvas/src/components/tabs/ConfigTab.tsx
@@ -21,20 +21,39 @@ interface Props {
// --- Agent Card Section ---
function AgentCardSection({ workspaceId }: { workspaceId: string }) {
- const [card, setCard] = useState | null>(null);
- const [loading, setLoading] = useState(true);
+ // Initial card value comes from the canvas store — node.data.agentCard
+ // is hydrated by the platform stream when the workspace appears in the
+ // graph, so reading it here avoids a duplicate `GET /workspaces/${id}`
+ // (the parent ConfigTab.loadConfig already fetches workspace metadata,
+ // and refetching here adds a serialised RTT to the panel-open path —
+ // contributed to the ~20s detail-panel load reported in core#11).
+ // Local state still tracks the edited/saved value so the editor flow
+ // is unchanged.
+ const storeCard = useCanvasStore((s) => {
+ // Defensive against test mocks that omit `nodes` (some test files
+ // stub the store with a minimal shape). In production `nodes` is
+ // always an array — empty or not — so the optional chaining only
+ // matters for the test path.
+ const node = s.nodes?.find?.((n) => n.id === workspaceId);
+ return (node?.data.agentCard as
+ | Record
+ | null
+ | undefined) ?? null;
+ });
+ const [card, setCard] = useState | null>(storeCard);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
+ // If the store updates while this section is mounted (another tab
+ // pushed an update via the platform event stream), reflect that —
+ // unless the user is mid-edit, in which case we don't clobber their
+ // unsaved draft.
useEffect(() => {
- api.get>(`/workspaces/${workspaceId}`)
- .then((ws) => setCard((ws.agent_card as Record) || null))
- .catch(() => {})
- .finally(() => setLoading(false));
- }, [workspaceId]);
+ if (!editing) setCard(storeCard);
+ }, [storeCard, editing]);
const handleSave = async () => {
setError(null);
@@ -53,9 +72,7 @@ function AgentCardSection({ workspaceId }: { workspaceId: string }) {
return (
- {loading ? (
-
-[](https://railway.app/new/template?template=https://github.com/Molecule-AI/molecule-core)
-[](https://render.com/deploy?repo=https://github.com/Molecule-AI/molecule-core)
+[](https://railway.app/new/template?template=https://git.moleculesai.app/molecule-ai/molecule-core)
+[](https://render.com/deploy?repo=https://git.moleculesai.app/molecule-ai/molecule-core)
@@ -248,7 +248,7 @@ Workspace Runtime (Python image with adapters)
## 快速开始
```bash
-git clone https://github.com/Molecule-AI/molecule-core.git
+git clone https://git.moleculesai.app/molecule-ai/molecule-core.git
cd molecule-core
cp .env.example .env
diff --git a/docs/architecture/canary-release.md b/docs/architecture/canary-release.md
index d6873a8d..f0f99a72 100644
--- a/docs/architecture/canary-release.md
+++ b/docs/architecture/canary-release.md
@@ -4,7 +4,7 @@ How a workspace-server code change reaches the prod tenant fleet — and how to
> **⚠️ State note (2026-04-22):** this doc describes the **intended design**. As of this write, the canary fleet described below is **not actually running** — no canary tenants are provisioned, `CANARY_TENANT_URLS` / `CANARY_ADMIN_TOKENS` / `CANARY_CP_SHARED_SECRET` are empty in repo secrets, and `canary-verify.yml` fails every run.
>
-> Current merges gate on manual `promote-latest.yml` dispatches, not canary. See [molecule-controlplane/docs/canary-tenants.md](https://github.com/Molecule-AI/molecule-controlplane/blob/main/docs/canary-tenants.md) for the Phase 1 code work that's already shipped + the Phase 2 plan for actually standing up the fleet + a "should we even do this now?" decision framework.
+> Current merges gate on manual `promote-latest.yml` dispatches, not canary. See [molecule-controlplane/docs/canary-tenants.md](https://git.moleculesai.app/molecule-ai/molecule-controlplane/src/branch/main/docs/canary-tenants.md) for the Phase 1 code work that's already shipped + the Phase 2 plan for actually standing up the fleet + a "should we even do this now?" decision framework.
>
> **Account-specific identifiers (AWS account ID, IAM role name) referenced below in the original design have been redacted from this public doc.** The actual values — if they exist — are in `Molecule-AI/internal/runbooks/canary-fleet.md`. If you're implementing Phase 2, start there.
>
diff --git a/docs/architecture/molecule-technical-doc.md b/docs/architecture/molecule-technical-doc.md
index cd3dc957..79819dd5 100644
--- a/docs/architecture/molecule-technical-doc.md
+++ b/docs/architecture/molecule-technical-doc.md
@@ -1,7 +1,7 @@
# Molecule AI — Comprehensive Technical Documentation
> Definitive technical reference for the Molecule AI Agent Team platform.
-> Based on a full non-invasive scan of the [molecule-monorepo](https://github.com/Molecule-AI/molecule-monorepo) repository.
+> Based on a full non-invasive scan of the [molecule-monorepo](https://git.moleculesai.app/molecule-ai/molecule-monorepo) repository.
---
@@ -1149,11 +1149,11 @@ Molecule AI's workspace abstraction is **runtime-agnostic by design**. A workspa
## Links
-- **GitHub**: https://github.com/Molecule-AI/molecule-monorepo
-- **Architecture Docs**: https://github.com/Molecule-AI/molecule-monorepo/tree/main/docs/architecture
-- **API Protocol**: https://github.com/Molecule-AI/molecule-monorepo/tree/main/docs/api-protocol
-- **Agent Runtime**: https://github.com/Molecule-AI/molecule-monorepo/tree/main/docs/agent-runtime
-- **Product Docs**: https://github.com/Molecule-AI/molecule-monorepo/tree/main/docs/product
+- **GitHub**: https://git.moleculesai.app/molecule-ai/molecule-monorepo
+- **Architecture Docs**: https://git.moleculesai.app/molecule-ai/molecule-monorepo/src/branch/main/docs/architecture
+- **API Protocol**: https://git.moleculesai.app/molecule-ai/molecule-monorepo/src/branch/main/docs/api-protocol
+- **Agent Runtime**: https://git.moleculesai.app/molecule-ai/molecule-monorepo/src/branch/main/docs/agent-runtime
+- **Product Docs**: https://git.moleculesai.app/molecule-ai/molecule-monorepo/src/branch/main/docs/product
---
diff --git a/docs/architecture/secrets-key-custody.md b/docs/architecture/secrets-key-custody.md
index 75e9f9c4..ebf5651d 100644
--- a/docs/architecture/secrets-key-custody.md
+++ b/docs/architecture/secrets-key-custody.md
@@ -79,7 +79,7 @@ For SOC2 / ISO 27001 / customer security questionnaires:
## Pointers
-- KMS envelope code: [`molecule-controlplane/internal/crypto/kms.go`](https://github.com/Molecule-AI/molecule-controlplane/blob/main/internal/crypto/kms.go)
-- Static-key fallback: [`molecule-controlplane/internal/crypto/aes.go`](https://github.com/Molecule-AI/molecule-controlplane/blob/main/internal/crypto/aes.go)
+- KMS envelope code: [`molecule-controlplane/internal/crypto/kms.go`](https://git.moleculesai.app/molecule-ai/molecule-controlplane/src/branch/main/internal/crypto/kms.go)
+- Static-key fallback: [`molecule-controlplane/internal/crypto/aes.go`](https://git.moleculesai.app/molecule-ai/molecule-controlplane/src/branch/main/internal/crypto/aes.go)
- Tenant secrets handler: [`workspace-server/internal/crypto/aes.go`](../../workspace-server/internal/crypto/aes.go)
- Tenant secrets schema: [database-schema.md](./database-schema.md#workspace_secrets)
diff --git a/docs/blog/2026-04-20-chrome-devtools-mcp-seo/index.md b/docs/blog/2026-04-20-chrome-devtools-mcp-seo/index.md
index ccfa1d8b..9a9c7fb5 100644
--- a/docs/blog/2026-04-20-chrome-devtools-mcp-seo/index.md
+++ b/docs/blog/2026-04-20-chrome-devtools-mcp-seo/index.md
@@ -299,8 +299,8 @@ Or use the Canvas UI: Workspace → Config → MCP Servers → Add browser MCP s
**Try it free** — Molecule AI is open source and self-hostable. Get a workspace running in under 5 minutes.
-→ [Get started on GitHub →](https://github.com/Molecule-AI/molecule-core)
+→ [Get started on GitHub →](https://git.moleculesai.app/molecule-ai/molecule-core)
---
-*Have a browser automation use case you want to see covered? Open a discussion on [GitHub Discussions](https://github.com/Molecule-AI/molecule-core/discussions) — or file an issue with the `enhancement` label.*
+*Have a browser automation use case you want to see covered? File an issue with the `enhancement` label on the [molecule-core issue tracker](https://git.moleculesai.app/molecule-ai/molecule-core/issues).*
diff --git a/docs/blog/2026-04-20-remote-workspaces/index.md b/docs/blog/2026-04-20-remote-workspaces/index.md
index cbd9e787..db660050 100644
--- a/docs/blog/2026-04-20-remote-workspaces/index.md
+++ b/docs/blog/2026-04-20-remote-workspaces/index.md
@@ -148,7 +148,7 @@ Then follow the [quick-start guide](/docs/guides/remote-workspaces.md).
Or run the annotated example directly:
```bash
-git clone https://github.com/Molecule-AI/molecule-sdk-python
+git clone https://git.moleculesai.app/molecule-ai/molecule-sdk-python
cd molecule-sdk-python/examples/remote-agent
# Create workspace with runtime:external, grab the ID, then:
WORKSPACE_ID= PLATFORM_URL=https://acme.moleculesai.app python3 run.py
@@ -160,6 +160,6 @@ The agent appears on the canvas within seconds.
→ [Remote Workspaces Guide →](/docs/guides/remote-workspaces.md)
→ [External Agent Registration Reference →](/docs/guides/external-agent-registration.md)
-→ [molecule-sdk-python →](https://github.com/Molecule-AI/molecule-sdk-python)
+→ [molecule-sdk-python →](https://git.moleculesai.app/molecule-ai/molecule-sdk-python)
*Phase 30 shipped in PRs #1075–#1083 and #1085–#1100 on `molecule-core`.*
diff --git a/docs/blog/2026-04-22-a2a-v1-agent-platform/index.md b/docs/blog/2026-04-22-a2a-v1-agent-platform/index.md
index 2e57780f..5e25694d 100644
--- a/docs/blog/2026-04-22-a2a-v1-agent-platform/index.md
+++ b/docs/blog/2026-04-22-a2a-v1-agent-platform/index.md
@@ -133,4 +133,4 @@ With protocol-native A2A, you get:
Molecule AI's external agent registration is production-ready. Documentation is live at [External Agent Registration Guide](https://docs.molecule.ai/docs/guides/external-agent-registration). The npm package for the MCP server is available at [`@molecule-ai/mcp-server`](https://www.npmjs.com/package/@molecule-ai/mcp-server).
-Read the full [A2A v1.0 protocol spec](https://github.com/Molecule-AI/molecule-core/blob/main/docs/api-protocol/a2a-protocol.md) on GitHub.
\ No newline at end of file
+Read the full [A2A v1.0 protocol spec](https://git.moleculesai.app/molecule-ai/molecule-core/src/branch/main/docs/api-protocol/a2a-protocol.md) on GitHub.
\ No newline at end of file
diff --git a/docs/blog/2026-04-22-remote-workspaces/index.md b/docs/blog/2026-04-22-remote-workspaces/index.md
index a8780ece..85b4d25b 100644
--- a/docs/blog/2026-04-22-remote-workspaces/index.md
+++ b/docs/blog/2026-04-22-remote-workspaces/index.md
@@ -45,7 +45,7 @@ canonicalUrl: "https://docs.molecule.ai/blog/remote-workspaces"
" proficiencyLevel": "Expert",
"genre": ["technical documentation", "product announcement"],
"sameAs": [
- "https://github.com/Molecule-AI/molecule-core",
+ "https://git.moleculesai.app/molecule-ai/molecule-core",
"https://molecule.ai"
]
}
@@ -270,7 +270,7 @@ Configure it in your project's `.mcp.json` and any AI agent (Claude Code, Cursor
→ [External Agent Registration Guide](/docs/guides/external-agent-registration) — full step-by-step with Python and Node.js reference implementations
-→ [GitHub: molecule-core](https://github.com/Molecule-AI/molecule-core) — source and issues
+→ [GitHub: molecule-core](https://git.moleculesai.app/molecule-ai/molecule-core) — source and issues
→ [Phase 30 Launch Thread on X](https://x.com) — follow for updates
diff --git a/docs/blog/a2a-v1-production-reference-2026-04-24.md b/docs/blog/a2a-v1-production-reference-2026-04-24.md
index 181c1335..c4306cca 100644
--- a/docs/blog/a2a-v1-production-reference-2026-04-24.md
+++ b/docs/blog/a2a-v1-production-reference-2026-04-24.md
@@ -170,4 +170,4 @@ The `staging` branch is now on `a2a-sdk` 1.0.0. The `main` branch still carries
If you're running `a2a-sdk` 0.3.x and planning the 1.0.0 migration, this post is the reference. The four breaking changes are well-contained, the migration is a single PR, and the eight smoke scenarios above will tell you whether the upgrade is clean before you merge.
-Questions? The [A2A protocol spec](https://github.com/google-a2a/a2a-specification) is the authoritative source. For Molecule AI's production A2A implementation, see [External Agent Registration](https://docs.molecule.ai/docs/guides/external-agent-registration) or open an issue in the [molecule-core](https://github.com/Molecule-AI/molecule-core) repo.
+Questions? The [A2A protocol spec](https://github.com/google-a2a/a2a-specification) is the authoritative source. For Molecule AI's production A2A implementation, see [External Agent Registration](https://docs.molecule.ai/docs/guides/external-agent-registration) or open an issue in the [molecule-core](https://git.moleculesai.app/molecule-ai/molecule-core) repo.
diff --git a/docs/guides/external-workspace-quickstart.md b/docs/guides/external-workspace-quickstart.md
index 4f7f0aba..e283312e 100644
--- a/docs/guides/external-workspace-quickstart.md
+++ b/docs/guides/external-workspace-quickstart.md
@@ -215,7 +215,7 @@ Push mode (this guide) works today but requires an inbound-reachable URL — whi
Your agent makes only outbound HTTPS calls to the platform, pulling messages from an inbox queue and posting replies back. Works behind any NAT/firewall, tolerates offline laptops, no tunnel needed.
-See the [design doc](https://github.com/Molecule-AI/internal/blob/main/product/external-workspaces-polling.md) (internal) and [implementation tracking issue](https://github.com/Molecule-AI/molecule-core/issues?q=polling+mode) once opened.
+See the [design doc](https://git.moleculesai.app/molecule-ai/internal/src/branch/main/product/external-workspaces-polling.md) (internal) and the implementation tracking issue (search `polling+mode` on the [molecule-core issue tracker](https://git.moleculesai.app/molecule-ai/molecule-core/issues)).
---
diff --git a/docs/guides/remote-workspaces.md b/docs/guides/remote-workspaces.md
index 6fb45574..a6740665 100644
--- a/docs/guides/remote-workspaces.md
+++ b/docs/guides/remote-workspaces.md
@@ -143,5 +143,5 @@ The agent appears on the canvas with a **purple REMOTE badge** within seconds. F
## Next Steps
- **[External Agent Registration Guide →](/docs/guides/external-agent-registration)** — full endpoint reference, Python + Node.js examples, troubleshooting
-- **[molecule-sdk-python →](https://github.com/Molecule-AI/molecule-sdk-python)** — SDK source, `RemoteAgentClient` API docs
-- **[SDK Examples →](https://github.com/Molecule-AI/molecule-sdk-python/tree/main/examples/remote-agent)** — `run.py` demo script, annotated walkthrough
+- **[molecule-sdk-python →](https://git.moleculesai.app/molecule-ai/molecule-sdk-python)** — SDK source, `RemoteAgentClient` API docs
+- **[SDK Examples →](https://git.moleculesai.app/molecule-ai/molecule-sdk-python/src/branch/main/examples/remote-agent)** — `run.py` demo script, annotated walkthrough
diff --git a/docs/guides/skill-catalog.md b/docs/guides/skill-catalog.md
index 337becc2..94f5a53d 100644
--- a/docs/guides/skill-catalog.md
+++ b/docs/guides/skill-catalog.md
@@ -61,7 +61,7 @@ molecule skills install arxiv-research --from community
Community skills are reviewed by the Molecule AI team before being
listed. Submit a skill for review by opening a PR against
-[`molecule-ai/skills`](https://github.com/Molecule-AI/skills).
+[`molecule-ai/skills`](https://git.moleculesai.app/molecule-ai/skills).
## Installing via config.yaml
@@ -151,7 +151,7 @@ molecule skills bundle my-custom-skill --output ./org-templates/my-role/
```
**Publishing to the community:** Open a PR against
-[`molecule-ai/skills`](https://github.com/Molecule-AI/skills) with a
+[`molecule-ai/skills`](https://git.moleculesai.app/molecule-ai/skills) with a
complete skill package. Community skills are reviewed for security and
correctness before listing.
diff --git a/docs/integrations/runtime-native-mcp-status.md b/docs/integrations/runtime-native-mcp-status.md
index b322ebc8..2916ad7e 100644
--- a/docs/integrations/runtime-native-mcp-status.md
+++ b/docs/integrations/runtime-native-mcp-status.md
@@ -96,7 +96,7 @@ fork needed in production.
`resolve_platform_id` for plugin-platform-safe deserialization, and
`self.adapters[adapter.platform]` keying fix (caught by real-subprocess
test before merge — see below).
-- **Plugin package**: [Molecule-AI/hermes-platform-molecule-a2a](https://github.com/Molecule-AI/hermes-platform-molecule-a2a)
+- **Plugin package**: [Molecule-AI/hermes-platform-molecule-a2a](https://git.moleculesai.app/molecule-ai/hermes-platform-molecule-a2a)
v0.1.0 — public, MIT-licensed. 11 unit tests + 8 in-process E2E
+ 4 real-subprocess E2E checkpoints all green.
- **Workspace template patch**: [Molecule-AI/molecule-ai-workspace-template-hermes#32](https://github.com/Molecule-AI/molecule-ai-workspace-template-hermes/pull/32)
@@ -154,7 +154,7 @@ intermediate shim earns its complexity.
## Codex (OpenAI Codex CLI)
**Status:** Template SHIPPED. Repo live at
-[`Molecule-AI/molecule-ai-workspace-template-codex`](https://github.com/Molecule-AI/molecule-ai-workspace-template-codex)
+[`Molecule-AI/molecule-ai-workspace-template-codex`](https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-template-codex)
(14 files, 1411 LOC, 12/12 tests). molecule-core registration in
[PR #2512](https://github.com/Molecule-AI/molecule-core/pull/2512).
E2E with real A2A traffic remains.
diff --git a/docs/quickstart.md b/docs/quickstart.md
index 4f0f2ff7..e8e16a6c 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -17,7 +17,7 @@ This path is aligned to the current repository and current UI. It gets you from
## The one-command path
```bash
-git clone https://github.com/Molecule-AI/molecule-monorepo.git
+git clone https://git.moleculesai.app/molecule-ai/molecule-monorepo.git
cd molecule-monorepo
./scripts/dev-start.sh
```
@@ -42,7 +42,7 @@ If you'd rather run each component yourself — useful when you're iterating on
### Step 1: Clone the repository
```bash
-git clone https://github.com/Molecule-AI/molecule-monorepo.git
+git clone https://git.moleculesai.app/molecule-ai/molecule-monorepo.git
cd molecule-monorepo
```
diff --git a/scripts/README.md b/scripts/README.md
index 71c603f3..e4360c63 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -11,7 +11,7 @@ There are three related scripts; pick the right one:
|---|---|---|
| `measure-coordinator-task-bounds.sh` | **Canonical** v1 harness for the RFC #2251 / Issue 4 reproduction. Provisions a PM coordinator + Researcher child via `claude-code-default` + `langgraph` templates, sends a synthesis-heavy A2A kickoff, observes elapsed time + activity trace. | OSS-shape platform — localhost or any `/workspaces`-shaped endpoint. Has tenant/admin-token guards for non-localhost runs. |
| `measure-coordinator-task-bounds-runner.sh` | Generalised runner for the same measurement contract but with **arbitrary template + secret + model combinations** (Hermes/MiniMax, etc.). Useful for cross-runtime variants without modifying the canonical harness. | Same as above (local or SaaS via `MODE=saas`). |
-| `measure-coordinator-task-bounds.sh` (in [molecule-controlplane](https://github.com/Molecule-AI/molecule-controlplane)) | **Production-shape** variant that bootstraps a real staging tenant via `POST /cp/admin/orgs`, then runs the same measurement against `.staging.moleculesai.app`. | Staging controlplane only — refuses to run against production. |
+| `measure-coordinator-task-bounds.sh` (in [molecule-controlplane](https://git.moleculesai.app/molecule-ai/molecule-controlplane)) | **Production-shape** variant that bootstraps a real staging tenant via `POST /cp/admin/orgs`, then runs the same measurement against `.staging.moleculesai.app`. | Staging controlplane only — refuses to run against production. |
See `reference_harness_pair_pattern` (auto-memory) for when to use which
and the cross-repo design rationale.
--
2.45.2
From ce3f1f48a4ef4c53c0c69cdde2b4da4c4c54b366 Mon Sep 17 00:00:00 2001
From: claude-ceo-assistant
Date: Thu, 7 May 2026 01:31:37 -0700
Subject: [PATCH 08/28] fix(ci): port publish-runtime cascade to Gitea
repo-dispatch API (closes molecule-core#14)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Symptom
`publish-runtime.yml::cascade` fired a `repository_dispatch` to 10 workspace-template
repos via direct curl to `https://api.github.com/repos/...`. Post-2026-05-06 the
org's GitHub presence is suspended; every invocation 404s. The job's
`::warning::` posture meant the failure didn't propagate, leaving the runtime
PyPI publish → template image rebuild pipeline silently broken.
## Why Option A (rewrite) and not Option B (delete)
Verified 2026-05-07 by devops-engineer (molecule-core#14 thread):
- The cron-poll mechanism (/etc/cron.d/molecule-deploy-poll) tracks ONLY the
Vercel/Railway-deployed repos (landingpage/docs/molecule-app/molecules-market
/molecule-controlplane). It does NOT track workspace-template-* repos.
- Each of the 9 template `publish-image.yml` workflows has
`repository_dispatch: types: [runtime-published]` as a load-bearing trigger.
Without the cascade, when the runtime ships a new PyPI version, templates
don't auto-rebuild.
So Option B (delete) would silently break the runtime → template fan-out.
Option A (rewrite to Gitea's API shape) is the right call. Security-auditor
agreed after seeing the cron-poll TRACKED list.
## API surface change
| Concern | Pre-fix (GitHub) | Post-fix (Gitea) |
|---|---|---|
| URL | `https://api.github.com/repos/$REPO/dispatches` | `${GITEA_URL}/api/v1/repos/$REPO/dispatches` |
| Owner case | `Molecule-AI/...` | `molecule-ai/...` (lowercase, Gitea is case-sensitive) |
| Auth header | `Authorization: Bearer $DISPATCH_TOKEN` | `Authorization: token $DISPATCH_TOKEN` |
| Body shape | `{event_type, client_payload}` | UNCHANGED — Gitea is GitHub-compatible here |
| Success code | `204 No Content` | `204 No Content` (unchanged) |
`GITEA_URL` defaults to `https://git.moleculesai.app`; overridable via job env.
## Out-of-band: DISPATCH_TOKEN secret rotation
The DISPATCH_TOKEN secret was a GitHub PAT. It must be re-minted as a Gitea
PAT for the new API to authenticate. Per saved memory
`feedback_per_agent_gitea_identity_default`, this should be a dedicated
`publish-runtime-bot` persona token with `write:repository` scope on the
9 target repos — NOT the founder PAT.
This PR ships the workflow change. Token rotation is the operator-host
follow-up (security-auditor's lane) — coordinate the merge so the token
is in place before the next runtime release fires.
## Backwards compatibility
The workflow ran silently-broken since 2026-05-06 (every invocation 404
+ ::warning:: but no failure). So there is no functional regression from
"silently broken" to "actually working". Any in-progress operator-managed
manual dispatch path is unaffected; the Gitea API parallel path doesn't
require operator intervention.
## Test plan
- [x] YAML parse OK on the modified workflow file
- [ ] Smoke test: trigger a runtime publish (or simulate via dispatching to one
template) post-merge; verify HTTP 204 + the template's publish-image
workflow fires + the template's image gets re-pushed against the new
runtime version. Phase 4 verification belongs to internal#46 follow-up.
## Hostile self-review (3 weakest spots)
1. The fan-out remains all-or-nothing: a single template failure surfaces as
a `::warning::` but PyPI publish proceeds. With 9 templates this is a
~10% per-template chance of stale-image-on-runtime-bump if any one fails.
Defense: the warning shows up in the workflow summary; operators retry.
Future hardening: requeue-on-fail with bounded retry, or a separate
reconcile cron that detects template/runtime version drift and re-dispatches.
2. `DISPATCH_TOKEN` validity is enforced by the Gitea API (401 on stale)
but the workflow doesn't differentiate 401 from 404. Either way the
warning fires. Future hardening: explicit token-shape check at the start
of the cascade job (curl `/api/v1/user` once, fail-fast if 401).
3. Owner-case lowercase is right today but couples the workflow to the
current Gitea org slug. If the org is ever renamed, this workflow
breaks silently. Less fragile alternative: derive REPO from a
canonical config (e.g. `gh repo list molecule-ai`) instead of
string-concatenating. Acceptable today; filed as the same future
hardening pass as item 1.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.github/workflows/publish-runtime.yml | 35 +++++++++++++++++++++++----
1 file changed, 30 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/publish-runtime.yml b/.github/workflows/publish-runtime.yml
index b3750a61..984ee0bb 100644
--- a/.github/workflows/publish-runtime.yml
+++ b/.github/workflows/publish-runtime.yml
@@ -339,16 +339,41 @@ jobs:
# Long-term: derive this list from manifest.json so cascade
# scope can't drift from E2E scope — tracked in RFC #388 as a
# Phase-1 invariant.
+ # Fan out via Gitea's repository_dispatch API (post-2026-05-06; the
+ # GitHub-org's hostname is no longer reachable). API contract:
+ # POST {GITEA_URL}/api/v1/repos/{owner}/{repo}/dispatches
+ # Authorization: token (NOT "Bearer" like GitHub)
+ # body: {event_type, client_payload} (same shape as GitHub)
+ # The 9 template repos all have publish-image.yml waiting on
+ # `repository_dispatch: types: [runtime-published]` with
+ # client_payload.runtime_version (verified by devops-engineer
+ # 2026-05-07 when assessing molecule-core#14 Option B safety).
+ #
+ # DISPATCH_TOKEN must be a Gitea PAT (not a GitHub PAT) with
+ # write:repository scope on each of the 9 target repos. Per saved
+ # memory feedback_per_agent_gitea_identity_default this should be
+ # a per-agent-persona token (recommend: dedicated
+ # `publish-runtime-bot` persona), not the founder PAT. Token
+ # rotation is an out-of-band operator-host task; the workflow
+ # consumes whatever value is in the secret.
+ #
+ # GITEA_URL defaults to https://git.moleculesai.app; override via
+ # job env if the platform's Gitea host changes.
+ GITEA_URL="${GITEA_URL:-https://git.moleculesai.app}"
TEMPLATES="claude-code hermes openclaw codex langgraph crewai autogen deepagents gemini-cli"
FAILED=""
for tpl in $TEMPLATES; do
- REPO="Molecule-AI/molecule-ai-workspace-template-$tpl"
+ # Gitea is owner-case-sensitive: the org slug is lowercase
+ # `molecule-ai`, not `Molecule-AI`. GitHub auto-lowercased on
+ # the receive side; Gitea returns 404 on the wrong case.
+ REPO="molecule-ai/molecule-ai-workspace-template-$tpl"
STATUS=$(curl -sS -o /tmp/dispatch.out -w "%{http_code}" \
- -X POST "https://api.github.com/repos/$REPO/dispatches" \
- -H "Authorization: Bearer $DISPATCH_TOKEN" \
- -H "Accept: application/vnd.github+json" \
- -H "X-GitHub-Api-Version: 2022-11-28" \
+ -X POST "$GITEA_URL/api/v1/repos/$REPO/dispatches" \
+ -H "Authorization: token $DISPATCH_TOKEN" \
+ -H "Accept: application/json" \
+ -H "Content-Type: application/json" \
-d "{\"event_type\":\"runtime-published\",\"client_payload\":{\"runtime_version\":\"$VERSION\"}}")
+ # Gitea returns 204 No Content on success, same as GitHub.
if [ "$STATUS" = "204" ]; then
echo "✓ dispatched $tpl ($VERSION)"
else
--
2.45.2
From 569df259ba08ac1d3c76390a7bf4146405cb32f5 Mon Sep 17 00:00:00 2001
From: Hongming Wang
Date: Thu, 7 May 2026 02:38:20 -0700
Subject: [PATCH 09/28] fix(ci): align secret name to plumbed DISPATCH_TOKEN
(closes #14)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The cascade workflow was reading from `secrets.TEMPLATE_DISPATCH_TOKEN`
but the plumbed secret name is `DISPATCH_TOKEN` (verified just now via
GET /repos/molecule-ai/molecule-core/actions/secrets — only DISPATCH_TOKEN
is set). Without this rename the cascade would always evaluate "secret
missing" and exit 1 on the next push to staging, defeating the entire
point of grant-role-access.sh --apply that just landed.
Three references updated:
- env mapping (`secrets.X` → `secrets.DISPATCH_TOKEN`)
- workflow_dispatch warning text
- push-trigger error text
The bash-side variable name is unchanged (still `DISPATCH_TOKEN`) so
the curl invocation at line 372 is unaffected. YAML round-trip parses
clean.
---
.github/workflows/publish-runtime.yml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/publish-runtime.yml b/.github/workflows/publish-runtime.yml
index 984ee0bb..47b2f9c8 100644
--- a/.github/workflows/publish-runtime.yml
+++ b/.github/workflows/publish-runtime.yml
@@ -287,7 +287,7 @@ jobs:
# Fine-grained PAT with `actions:write` on the 8 template repos.
# GITHUB_TOKEN can't fire dispatches across repos — needs an explicit
# token. Stored as a repo secret; rotate per the standard schedule.
- DISPATCH_TOKEN: ${{ secrets.TEMPLATE_DISPATCH_TOKEN }}
+ DISPATCH_TOKEN: ${{ secrets.DISPATCH_TOKEN }}
# Single source of truth: the publish job's output, which handles
# tag/manual-input/auto-bump uniformly. The previous fallback
# (`steps.version.outputs.version` from inside the cascade job)
@@ -313,11 +313,11 @@ jobs:
# after fixing the secret)
if [ -z "$DISPATCH_TOKEN" ]; then
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
- echo "::warning::TEMPLATE_DISPATCH_TOKEN secret not set — skipping cascade."
+ echo "::warning::DISPATCH_TOKEN secret not set — skipping cascade."
echo "::warning::set it at Settings → Secrets and Variables → Actions, then rerun. Templates will stay on the prior runtime version until either this token is set or each template is rebuilt manually."
exit 0
fi
- echo "::error::TEMPLATE_DISPATCH_TOKEN secret missing — cascade cannot fan out."
+ echo "::error::DISPATCH_TOKEN secret missing — cascade cannot fan out."
echo "::error::PyPI was published, but the 8 template repos will NOT pick up the new version until this token is restored and a republish dispatches the cascade."
echo "::error::set it at Settings → Secrets and Variables → Actions; then re-trigger publish-runtime via workflow_dispatch."
exit 1
--
2.45.2
From 1ff7342e91fd04c485827ca24b9e0ed6f03fd187 Mon Sep 17 00:00:00 2001
From: Hongming Wang
Date: Thu, 7 May 2026 03:01:23 -0700
Subject: [PATCH 10/28] chore: retrigger CI after runner config fix
--
2.45.2
From 607444e71beeb3a28de7f1b67511f2d90632530c Mon Sep 17 00:00:00 2001
From: Hongming Wang
Date: Thu, 7 May 2026 03:17:38 -0700
Subject: [PATCH 11/28] feat(ci): replace curl-dispatch with push-mode cascade
(v2)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Empirical blocker on v1: Gitea 1.22.6 has no repository_dispatch /
workflow_dispatch trigger API (verified across 6 candidate paths in
issuecomment-913). v1's curl-POST loop would always exit-1.
v2 pivots to push-mode: each template repo got a small companion PR
(merged 2026-05-07) adding a `.runtime-version` file at root + a
`resolve-version` job in publish-image.yml that reads the file and
forwards the value to the reusable build workflow. publish-runtime
now updates that file via git-clone + commit + push, which trips
each template's existing `on: push: branches: [main]` trigger.
Behaviour changes vs v1:
- Templates list dropped from 9 → 8 (codex has no publish-image.yml
so was never part of the cascade in practice).
- 3-retry pull-rebase loop per template (handles concurrent-push
races without force-push). Failures collected, job exits 1 with
the failed-template list at the end.
- Idempotency: when re-run with the same version, templates already
pinned to that version contribute zero commits — operator can
safely re-run to retry partial failures.
- Author line: "publish-runtime cascade " trailer makes it clear the commit is workflow-driven, not
human (per memory feedback_github_botring_fingerprint).
DISPATCH_TOKEN secret name unchanged (still consumed at
secrets.DISPATCH_TOKEN per 569df259).
Refs molecule-core#14, builds on molecule-core#20 issuecomment-923
(Phase 2 design).
---
.github/workflows/publish-runtime.yml | 167 ++++++++++++++------------
1 file changed, 93 insertions(+), 74 deletions(-)
diff --git a/.github/workflows/publish-runtime.yml b/.github/workflows/publish-runtime.yml
index 47b2f9c8..29134aff 100644
--- a/.github/workflows/publish-runtime.yml
+++ b/.github/workflows/publish-runtime.yml
@@ -282,35 +282,26 @@ jobs:
echo "::error::Refusing to fan out cascade against stale or corrupt PyPI surfaces."
exit 1
- - name: Fan out repository_dispatch
+ - name: Fan out via push to .runtime-version
env:
- # Fine-grained PAT with `actions:write` on the 8 template repos.
- # GITHUB_TOKEN can't fire dispatches across repos — needs an explicit
- # token. Stored as a repo secret; rotate per the standard schedule.
+ # Gitea PAT with write:repository scope on the 8 cascade-active
+ # template repos. Used here for `git push` (NOT for an API
+ # dispatch — Gitea 1.22.6 has no repository_dispatch endpoint;
+ # empirically verified across 6 candidate paths in molecule-
+ # core#20 issuecomment-913). The push trips each template's
+ # existing `on: push: branches: [main]` trigger on
+ # publish-image.yml, which then reads the updated
+ # .runtime-version via its resolve-version job.
DISPATCH_TOKEN: ${{ secrets.DISPATCH_TOKEN }}
- # Single source of truth: the publish job's output, which handles
- # tag/manual-input/auto-bump uniformly. The previous fallback
- # (`steps.version.outputs.version` from inside the cascade job)
- # was a dead reference — different job, no shared step scope.
RUNTIME_VERSION: ${{ needs.publish.outputs.version }}
run: |
set +e # don't abort on a single repo failure — collect them all
- # Schedule-vs-dispatch behaviour split (hardened 2026-04-28
- # after the sweep-cf-orphans soft-skip incident — same class
- # of bug):
- #
- # The earlier "skipping cascade. templates will pick up the
- # new version on their own next rebuild" message was wrong —
- # templates only build on this dispatch trigger; without it
- # they stay pinned to whatever runtime version they last saw.
- # A silent skip here means "PyPI is current, templates are
- # not" and the gap is invisible until someone notices a
- # template still on the old version weeks later.
- #
- # - push → exit 1 (red CI surfaces the gap)
- # - workflow_dispatch → exit 0 with a warning (operator
- # ran this ad-hoc; let them rerun
- # after fixing the secret)
+
+ # Soft-skip on workflow_dispatch when the token is missing
+ # (operator ad-hoc test); hard-fail on push so unattended
+ # publishes can't silently skip the cascade. Same shape as
+ # the original v1, intentional split per the schedule-vs-
+ # dispatch hardening 2026-04-28.
if [ -z "$DISPATCH_TOKEN" ]; then
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "::warning::DISPATCH_TOKEN secret not set — skipping cascade."
@@ -327,62 +318,90 @@ jobs:
echo "::error::publish job did not expose a version output — cascade cannot fan out"
exit 1
fi
- # All 9 active workspace template repos. The PR #2536 pruning
- # ("deprecated, no shipping images") was empirically wrong:
- # continuous-synth-e2e.yml defaults to langgraph as its primary
- # canary (line 44), and every excluded template had successful
- # publish-image runs as of 2026-05-03 — none were dormant.
- # Symptom of the prune: today's a2a-sdk strict-mode fix
- # (#2566 / commit e1628c4) cascaded to 4 templates but never
- # reached langgraph, so the synth-E2E correctly canary'd a fix
- # that had landed but not deployed. Re-added the 5 templates.
- # Long-term: derive this list from manifest.json so cascade
- # scope can't drift from E2E scope — tracked in RFC #388 as a
- # Phase-1 invariant.
- # Fan out via Gitea's repository_dispatch API (post-2026-05-06; the
- # GitHub-org's hostname is no longer reachable). API contract:
- # POST {GITEA_URL}/api/v1/repos/{owner}/{repo}/dispatches
- # Authorization: token (NOT "Bearer" like GitHub)
- # body: {event_type, client_payload} (same shape as GitHub)
- # The 9 template repos all have publish-image.yml waiting on
- # `repository_dispatch: types: [runtime-published]` with
- # client_payload.runtime_version (verified by devops-engineer
- # 2026-05-07 when assessing molecule-core#14 Option B safety).
- #
- # DISPATCH_TOKEN must be a Gitea PAT (not a GitHub PAT) with
- # write:repository scope on each of the 9 target repos. Per saved
- # memory feedback_per_agent_gitea_identity_default this should be
- # a per-agent-persona token (recommend: dedicated
- # `publish-runtime-bot` persona), not the founder PAT. Token
- # rotation is an out-of-band operator-host task; the workflow
- # consumes whatever value is in the secret.
- #
- # GITEA_URL defaults to https://git.moleculesai.app; override via
- # job env if the platform's Gitea host changes.
+
+ # 8 cascade-active workspace templates. codex was in the v1
+ # list but has no .github/workflows/publish-image.yml — never
+ # part of the cascade in practice; dropped here to match
+ # ground truth. Long-term goal: derive this list from
+ # manifest.json so it can't drift from E2E scope (RFC #388
+ # Phase-1 invariant).
GITEA_URL="${GITEA_URL:-https://git.moleculesai.app}"
- TEMPLATES="claude-code hermes openclaw codex langgraph crewai autogen deepagents gemini-cli"
+ TEMPLATES="claude-code hermes openclaw langgraph crewai autogen deepagents gemini-cli"
FAILED=""
+
+ # Configure git identity once. The persona owning DISPATCH_TOKEN
+ # is the same identity that authored this commit on each
+ # template; using a generic "publish-runtime cascade" co-author
+ # trailer in the message keeps the audit trail honest about the
+ # workflow-driven origin.
+ git config --global user.name "publish-runtime cascade"
+ git config --global user.email "publish-runtime@moleculesai.app"
+
+ WORKDIR="$(mktemp -d)"
for tpl in $TEMPLATES; do
- # Gitea is owner-case-sensitive: the org slug is lowercase
- # `molecule-ai`, not `Molecule-AI`. GitHub auto-lowercased on
- # the receive side; Gitea returns 404 on the wrong case.
REPO="molecule-ai/molecule-ai-workspace-template-$tpl"
- STATUS=$(curl -sS -o /tmp/dispatch.out -w "%{http_code}" \
- -X POST "$GITEA_URL/api/v1/repos/$REPO/dispatches" \
- -H "Authorization: token $DISPATCH_TOKEN" \
- -H "Accept: application/json" \
- -H "Content-Type: application/json" \
- -d "{\"event_type\":\"runtime-published\",\"client_payload\":{\"runtime_version\":\"$VERSION\"}}")
- # Gitea returns 204 No Content on success, same as GitHub.
- if [ "$STATUS" = "204" ]; then
- echo "✓ dispatched $tpl ($VERSION)"
- else
- echo "::warning::✗ failed to dispatch $tpl: HTTP $STATUS — $(cat /tmp/dispatch.out)"
+ CLONE="$WORKDIR/$tpl"
+
+ # Use a per-template attempt loop so a transient race (e.g.
+ # human pushing to the same template at the same instant)
+ # doesn't lose the cascade. Bounded retries (3) — beyond
+ # that we surface the failure and let the operator retry.
+ attempt=0
+ success=false
+ while [ $attempt -lt 3 ]; do
+ attempt=$((attempt + 1))
+ rm -rf "$CLONE"
+ if ! git clone --depth=1 \
+ "https://x-access-token:${DISPATCH_TOKEN}@${GITEA_URL#https://}/$REPO.git" \
+ "$CLONE" >/tmp/clone.log 2>&1; then
+ echo "::warning::clone $tpl attempt $attempt failed: $(tail -n3 /tmp/clone.log)"
+ sleep 2
+ continue
+ fi
+
+ cd "$CLONE"
+ echo "$VERSION" > .runtime-version
+
+ # Idempotency guard: if the file already matches, this
+ # publish is a re-run for a version already cascaded.
+ # Don't push a no-op commit (would spuriously re-trip the
+ # template's on-push and rebuild for nothing).
+ if git diff --quiet -- .runtime-version; then
+ echo "✓ $tpl already at $VERSION — no commit needed (idempotent)"
+ success=true
+ cd - >/dev/null
+ break
+ fi
+
+ git add .runtime-version
+ git commit -m "chore: pin runtime to $VERSION (publish-runtime cascade)" \
+ -m "Co-Authored-By: publish-runtime cascade " \
+ >/dev/null
+
+ if git push origin HEAD:main >/tmp/push.log 2>&1; then
+ echo "✓ $tpl pushed $VERSION on attempt $attempt"
+ success=true
+ cd - >/dev/null
+ break
+ fi
+
+ # Likely a non-fast-forward — pull-rebase and retry.
+ # Don't force-push: that would silently overwrite a racing
+ # human/cascade commit.
+ echo "::warning::push $tpl attempt $attempt failed, pull-rebasing: $(tail -n3 /tmp/push.log)"
+ git pull --rebase origin main >/tmp/rebase.log 2>&1 || true
+ cd - >/dev/null
+ done
+
+ if [ "$success" != "true" ]; then
FAILED="$FAILED $tpl"
fi
done
+ rm -rf "$WORKDIR"
+
if [ -n "$FAILED" ]; then
- echo "::warning::Cascade incomplete. Failed templates:$FAILED"
- # Don't fail the whole job — PyPI publish already succeeded;
- # operators can retry the failed templates manually.
+ echo "::error::Cascade incomplete after 3 retries each. Failed templates:$FAILED"
+ echo "::error::PyPI publish succeeded; failed templates lag the new version. Re-run this workflow_dispatch with the same version to retry only the laggers (idempotent — already-cascaded templates skip)."
+ exit 1
fi
+ echo "Cascade complete: 8 templates pinned to $VERSION."
--
2.45.2
From 4279fecde523b8ef7640f1eab424900fab5a79ce Mon Sep 17 00:00:00 2001
From: Hongming Wang
Date: Thu, 7 May 2026 03:32:53 -0700
Subject: [PATCH 12/28] fix(ci): keep codex in TEMPLATES +
skip-if-no-publish-image.yml
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The v2 dropped codex from TEMPLATES on the basis of "no
publish-image.yml = not part of cascade today." That was correct
about the immediate behavior but tripped cascade-list-drift-gate.yml
because manifest.json still declares codex (it IS a live runtime —
referenced from workspace/config.py and cloned into dev envs by
clone-manifest.sh; only the image-publish path is missing).
Restore codex to TEMPLATES (matching manifest) and add a runtime
soft-skip: probe each repo for .github/workflows/publish-image.yml
via the Gitea contents API and skip cleanly if 404. Final job log
distinguishes "complete across all" vs "complete with soft-skips".
This preserves the drift gate's invariant (TEMPLATES == manifest)
while honoring the empirical fact that codex has no publish-image
workflow yet. If codex later gains the workflow, no change here is
needed — the probe will see 200 and the cascade will fan out to it
naturally.
Refs molecule-core#14, molecule-core#20.
---
.github/workflows/publish-runtime.yml | 45 ++++++++++++++++++++++-----
1 file changed, 37 insertions(+), 8 deletions(-)
diff --git a/.github/workflows/publish-runtime.yml b/.github/workflows/publish-runtime.yml
index 29134aff..c565ee23 100644
--- a/.github/workflows/publish-runtime.yml
+++ b/.github/workflows/publish-runtime.yml
@@ -319,15 +319,22 @@ jobs:
exit 1
fi
- # 8 cascade-active workspace templates. codex was in the v1
- # list but has no .github/workflows/publish-image.yml — never
- # part of the cascade in practice; dropped here to match
- # ground truth. Long-term goal: derive this list from
- # manifest.json so it can't drift from E2E scope (RFC #388
- # Phase-1 invariant).
+ # All 9 workspace templates declared in manifest.json. The list
+ # MUST stay aligned with manifest.json's workspace_templates —
+ # cascade-list-drift-gate.yml enforces this in CI per the
+ # codex-stuck-on-stale-runtime invariant from PR #2556.
+ # Long-term goal: derive this list from manifest.json so it
+ # can't drift even on a manifest edit (RFC #388 Phase-1).
+ #
+ # Per-template publish-image.yml presence is checked at
+ # cascade-time below: codex doesn't ship one today, so the
+ # cascade soft-skips it with an informational message rather
+ # than dropping it from this list (which would re-introduce
+ # the drift the gate exists to catch).
GITEA_URL="${GITEA_URL:-https://git.moleculesai.app}"
- TEMPLATES="claude-code hermes openclaw langgraph crewai autogen deepagents gemini-cli"
+ TEMPLATES="claude-code hermes openclaw codex langgraph crewai autogen deepagents gemini-cli"
FAILED=""
+ SKIPPED=""
# Configure git identity once. The persona owning DISPATCH_TOKEN
# is the same identity that authored this commit on each
@@ -342,6 +349,24 @@ jobs:
REPO="molecule-ai/molecule-ai-workspace-template-$tpl"
CLONE="$WORKDIR/$tpl"
+ # Pre-check: skip templates without a publish-image.yml.
+ # The cascade's job is to trip the template's on-push
+ # rebuild — if there's no rebuild workflow, pushing a
+ # .runtime-version commit is just noise on the target
+ # repo. Use the Gitea contents API (no clone required for
+ # the probe). 200 = present; 404 = absent.
+ HTTP=$(curl -sS -o /dev/null -w "%{http_code}" \
+ -H "Authorization: token $DISPATCH_TOKEN" \
+ "$GITEA_URL/api/v1/repos/$REPO/contents/.github/workflows/publish-image.yml")
+ if [ "$HTTP" = "404" ]; then
+ echo "↷ $tpl has no publish-image.yml — soft-skip (informational; manifest still tracks it)"
+ SKIPPED="$SKIPPED $tpl"
+ continue
+ fi
+ if [ "$HTTP" != "200" ]; then
+ echo "::warning::$tpl publish-image.yml probe returned HTTP $HTTP — proceeding anyway, push will surface the real failure if any"
+ fi
+
# Use a per-template attempt loop so a transient race (e.g.
# human pushing to the same template at the same instant)
# doesn't lose the cascade. Bounded retries (3) — beyond
@@ -404,4 +429,8 @@ jobs:
echo "::error::PyPI publish succeeded; failed templates lag the new version. Re-run this workflow_dispatch with the same version to retry only the laggers (idempotent — already-cascaded templates skip)."
exit 1
fi
- echo "Cascade complete: 8 templates pinned to $VERSION."
+ if [ -n "$SKIPPED" ]; then
+ echo "Cascade complete: pinned $VERSION on cascade-active templates. Soft-skipped (no publish-image.yml):$SKIPPED"
+ else
+ echo "Cascade complete: $VERSION pinned across all manifest workspace_templates."
+ fi
--
2.45.2
From 132f97d261ca7fc829b4b858071dadb4d754cbdf Mon Sep 17 00:00:00 2001
From: "claude-ceo-assistant (Claude Opus 4.7 on Hongming's MacBook)"
Date: Wed, 6 May 2026 16:56:10 -0700
Subject: [PATCH 13/28] =?UTF-8?q?docs(README):=20comprehensive=20refresh?=
=?UTF-8?q?=20=E2=80=94=20landing-page=20icon=20(SVG,=20light/dark)=20+=20?=
=?UTF-8?q?8=20runtimes=20+=20Canvas=20v4=20+=20Memory=20v2=20+=20SaaS=20+?=
=?UTF-8?q?=20channel=20plugin?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The README hadn't been refreshed since the v0 wave. Several major
shipped surfaces weren't called out (Canvas v4 warm-paper theme,
Memory v2 with pgvector, RFC #2967 typed-SSOT A2A response path,
the SaaS control plane, the molecule-mcp-claude-channel plugin we
just shipped via v0.4.0/0.4.1/0.4.2). The runtime list still said
"6" when 8 are in production. The icon was a 1.3 MB PNG with no
light-mode variant.
- New `docs/assets/branding/molecule-icon.svg` matches the landing
page's `public/favicon.svg` shape (5-spoke molecular graph) but
carries `prefers-color-scheme` styles so it adapts to GitHub's
light/dark modes. The PNG stays for back-compat with anything
that hotlinks it.
- `docs/assets/branding/molecule-logo.svg` adds a wordmark variant
for places that want the brand name alongside the icon.
- README hero replaces the PNG `` with the SVG so contributors
reading on GitHub light see a tinted version that doesn't blow
out the page background.
- **8 production runtimes** named explicitly throughout: Claude
Code, Hermes, Gemini CLI, LangGraph, DeepAgents, CrewAI, AutoGen,
OpenClaw. Comparison table grew Hermes 4 + Gemini CLI rows with
the integration mechanism (Option B upstream hook, A2A bridge,
multi-provider derivation).
- **Canvas v4** — warm-paper theme system (light / dark / follow-
system) called out alongside the existing Next.js 15 / React Flow /
Zustand stack.
- **Memory v2 backed by pgvector** — semantic recall callout in
both the "memory model" pitch line and the runtime stack section.
- **RFC #2967 typed-SSOT A2A response path** named in the platform
ship list + architecture diagram.
- **SaaS surface section** added — multi-tenant EC2 + Neon +
Cloudflare Tunnels, WorkOS + Stripe, KMS envelope, tenant_resources
audit + 30-min reconciler. Cross-links to molecule-controlplane.
- **molecule-mcp-claude-channel plugin** added — entry point for
Claude Code users to bridge A2A traffic into a local session via
MCP. Documents the standard marketplace install flow + multi-
tenant config.
- **Architecture diagram** redrawn with Canvas → Platform → Postgres
+ Provisioner (Docker | EC2+SSM) layout, plus a SaaS control plane
block.
- **Quick Start** repo URL fixed (`molecule-monorepo` → `molecule-core`),
Go version bumped to 1.25, Python ≥3.11 noted.
- Deploy buttons + Quick Start URL all bump from the old
`molecule-monorepo` name to the current `molecule-core`. Pre-fix
these clicked through to a 404.
The provisioner refactor (`registry.go` deletion + RegistryPrefix
env-driven changes) that lived alongside an earlier draft of this
README on the `docs/readme-refresh-2026-05-06` branch is OUT of
this PR — that work shipped separately via #6. This branch is
docs-only so the review surface is small and the merge is reversible.
- `git diff staging --stat`:
```
README.md | 75 +++++++++++++++++++++++-----------
docs/assets/branding/molecule-icon.svg | 28 +++++++++++++
docs/assets/branding/molecule-logo.svg | 17 ++++++++
3 files changed, 97 insertions(+), 23 deletions(-)
```
- SVGs validated in a browser at light + dark `prefers-color-scheme`.
- All linked docs (./docs/index.md, ./docs/quickstart.md, ./docs/
architecture/architecture.md, ./docs/api-protocol/platform-api.md,
./docs/agent-runtime/workspace-runtime.md, ./LICENSE, etc.) verified
to exist on staging.
- README.zh-CN.md mirror — non-trivial translation work; file as
separate issue if mirror is wanted.
- molecule-ai/.github org-profile README — Gitea has no equivalent
to GitHub's org-profile surface, and the GitHub org is suspended.
Skipped.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---
README.md | 75 ++++++++++++++++++--------
docs/assets/branding/molecule-icon.svg | 28 ++++++++++
docs/assets/branding/molecule-logo.svg | 17 ++++++
3 files changed, 97 insertions(+), 23 deletions(-)
create mode 100644 docs/assets/branding/molecule-icon.svg
create mode 100644 docs/assets/branding/molecule-logo.svg
diff --git a/README.md b/README.md
index 9f2ace01..424bee6a 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
-[](https://railway.app/new/template?template=https://git.moleculesai.app/molecule-ai/molecule-monorepo)
-[](https://render.com/deploy?repo=https://git.moleculesai.app/molecule-ai/molecule-monorepo)
+[](https://railway.app/new/template?template=https://git.moleculesai.app/molecule-ai/molecule-core)
+[](https://render.com/deploy?repo=https://git.moleculesai.app/molecule-ai/molecule-core)
@@ -53,8 +53,8 @@ Molecule AI is the most powerful way to govern an AI agent organization in produ
It combines the parts that are usually scattered across demos, internal glue code, and framework-specific tooling into one product:
- one org-native control plane for teams, roles, hierarchy, and lifecycle
-- one runtime layer that lets LangGraph, DeepAgents, Claude Code, CrewAI, AutoGen, and OpenClaw run side by side
-- one memory model that keeps recall, sharing, and skill evolution aligned with organizational boundaries
+- one runtime layer that lets **eight** agent runtimes — LangGraph, DeepAgents, Claude Code, CrewAI, AutoGen, **Hermes**, **Gemini CLI**, and OpenClaw — run side by side behind one workspace contract
+- one memory model that keeps recall, sharing, and skill evolution aligned with organizational boundaries (Memory v2 backed by pgvector for semantic recall)
- one operational surface for observing, pausing, restarting, inspecting, and improving live workspaces
Most teams can build a workflow, a strong single agent, a coding agent, or a custom multi-agent graph.
@@ -75,7 +75,7 @@ You do not wire collaboration paths by hand. Hierarchy defines the default commu
### 3. Runtime choice stops being a dead-end decision
-LangGraph, DeepAgents, Claude Code, CrewAI, AutoGen, and OpenClaw can all plug into the same workspace abstraction. Teams can standardize governance without forcing every group onto one runtime.
+LangGraph, DeepAgents, Claude Code, CrewAI, AutoGen, Hermes, Gemini CLI, and OpenClaw can all plug into the same workspace abstraction. Teams can standardize governance without forcing every group onto one runtime.
### 4. Memory is treated like infrastructure
@@ -117,6 +117,8 @@ Molecule AI is not trying to replace the frameworks below. It is the system that
| **Claude Code** | Shipping on `main` | Real coding workflows, CLI-native continuity | Secure workspace abstraction, A2A delegation, org boundaries, shared control plane |
| **CrewAI** | Shipping on `main` | Role-based crews | Persistent workspace identity, policy consistency, shared canvas and registry |
| **AutoGen** | Shipping on `main` | Assistant/tool orchestration | Standardized deployment, hierarchy-aware collaboration, shared ops plane |
+| **Hermes 4** | Shipping on `main` | Hybrid reasoning, native tools, json_schema (NousResearch/hermes-agent) | Option B upstream hook, A2A bridge to OpenAI-compat API, multi-provider provider derivation |
+| **Gemini CLI** | Shipping on `main` | Google Gemini CLI continuity | Workspace lifecycle, A2A, hierarchy-aware collaboration, shared ops plane |
| **OpenClaw** | Shipping on `main` | CLI-native runtime with its own session model | Workspace lifecycle, templates, activity logs, topology-aware collaboration |
| **NemoClaw** | WIP on `feat/nemoclaw-t4-docker` | NVIDIA-oriented runtime path | Planned to join the same abstraction once merged; not yet part of `main` |
@@ -182,9 +184,10 @@ The result is not just “an agent that learns.” It is **an organization that
## What Ships In `main`
-### Canvas
+### Canvas (v4)
- Next.js 15 + React Flow + Zustand
+- **warm-paper theme system** — light / dark / follow-system, SSR cookie + nonce'd boot script + ThemeProvider; terminal + code surfaces stay dark unconditionally
- drag-to-nest team building
- empty-state deployment + onboarding wizard
- template palette
@@ -193,8 +196,9 @@ The result is not just “an agent that learns.” It is **an organization that
### Platform
-- Go/Gin control plane
-- workspace CRUD and provisioning
+- Go 1.25 / Gin control plane (80+ HTTP endpoints + Gorilla WebSocket fanout)
+- workspace CRUD and provisioning (pluggable Provisioner — Docker locally, EC2 + SSM in production)
+- **A2A response path is a typed discriminated union (RFC #2967)** — frozen dataclasses + total parser; 100% unit + adversarial fuzz coverage
- registry and heartbeats
- browser-safe A2A proxy
- team expansion/collapse
@@ -204,10 +208,10 @@ The result is not just “an agent that learns.” It is **an organization that
### Runtime
-- unified `workspace/` image
-- adapter-driven execution
+- unified `workspace/` image; thin AMI in production (us-east-2)
+- adapter-driven execution across **8 runtimes** (Claude Code, Hermes, Gemini CLI, LangGraph, DeepAgents, CrewAI, AutoGen, OpenClaw)
- Agent Card registration
-- awareness-backed memory integration
+- awareness-backed memory integration; **Memory v2 backed by pgvector** for semantic recall
- plugin-mounted shared rules/skills
- hot-reloadable local skills
- coordinator-only delegation path
@@ -221,6 +225,21 @@ The result is not just “an agent that learns.” It is **an organization that
- runtime tiers
- direct workspace inspection through terminal and files
+### SaaS (via [`molecule-controlplane`](https://github.com/Molecule-AI/molecule-controlplane))
+
+- multi-tenant on AWS EC2 + Neon (per-tenant Postgres branch) + Cloudflare Tunnels (per-tenant, no public ports)
+- WorkOS AuthKit + Stripe Checkout + Customer Portal
+- AWS KMS envelope encryption (DB / Redis connection strings); AWS Secrets Manager for tenant bootstrap
+- `tenant_resources` audit table + 30-min boot-event-aware reconciler — every CF / AWS lifecycle event recorded, claim vs live state diffed
+
+### Bring your own Claude Code session (via [`molecule-mcp-claude-channel`](https://github.com/Molecule-AI/molecule-mcp-claude-channel))
+
+- Claude Code plugin that bridges Molecule A2A traffic into a local Claude Code session via MCP
+- subscribe to one or more workspaces; peer messages surface as conversation turns; replies route back through Molecule's A2A
+- no tunnel, no public endpoint — the plugin self-registers each watched workspace as `delivery_mode=poll` and long-polls `/activity?since_id=…`
+- multi-tenant friendly: one plugin install can watch workspaces across multiple Molecule tenants (`MOLECULE_PLATFORM_URLS` per-workspace)
+- install via the standard marketplace flow: `/plugin marketplace add Molecule-AI/molecule-mcp-claude-channel` → `/plugin install molecule-channel@molecule-mcp-claude-channel`
+
## Built For Teams That Need More Than A Demo
Molecule AI is especially strong when you need to run:
@@ -233,24 +252,30 @@ Molecule AI is especially strong when you need to run:
## Architecture
```text
-Canvas (Next.js :3000) <--HTTP / WS--> Platform (Go :8080) <---> Postgres + Redis
- | |
- | +--> Docker provisioner / bundles / templates / secrets
+Canvas (Next.js 15, warm-paper :3000) <--HTTP / WS--> Platform (Go 1.25 :8080) <---> Postgres + Redis
+ | |
+ | +--> Provisioner: Docker (local) / EC2 + SSM (prod)
+ | +--> bundles · templates · secrets · KMS
|
- +-------------------- shows --------------------> workspaces, teams, tasks, traces, events
+ +------------------------- shows ------------------------> workspaces, teams, tasks, traces, events
-Workspace Runtime (Python image with adapters)
- - LangGraph / DeepAgents / Claude Code / CrewAI / AutoGen / OpenClaw
- - Agent Card + A2A server
- - heartbeat + activity + awareness-backed memory
+Workspace Runtime (Python ≥3.11, image with adapters)
+ - 8 adapters: LangGraph / DeepAgents / Claude Code / CrewAI / AutoGen / Hermes / Gemini CLI / OpenClaw
+ - Agent Card + A2A server (typed-SSOT response path, RFC #2967)
+ - heartbeat + activity + awareness-backed memory (Memory v2 — pgvector semantic recall)
- skills + plugins + hot reload
+
+SaaS Control Plane (molecule-controlplane, private)
+ - per-tenant EC2 + Neon (Postgres branch) + Cloudflare Tunnel
+ - WorkOS · Stripe · KMS · AWS Secrets Manager
+ - tenant_resources audit + 30-min reconciler
```
## Quick Start
```bash
-git clone https://git.moleculesai.app/molecule-ai/molecule-monorepo.git
-cd molecule-monorepo
+git clone https://git.moleculesai.app/molecule-ai/molecule-core.git
+cd molecule-core
cp .env.example .env
# Defaults boot the stack locally out of the box. See .env.example for
@@ -303,7 +328,11 @@ Then open `http://localhost:3000`:
## Current Scope
-The current `main` branch already includes the core platform, canvas, memory model, six production adapters, skill lifecycle, and operational surfaces. Adjacent runtime work such as **NemoClaw** remains branch-level until merged, and this README keeps that distinction explicit on purpose.
+The current `main` branch ships the core platform, Canvas v4 (warm-paper themed), Memory v2 (pgvector semantic recall), the typed-SSOT A2A response path (RFC #2967), **eight production adapters** (Claude Code, Hermes, Gemini CLI, LangGraph, DeepAgents, CrewAI, AutoGen, OpenClaw), skill lifecycle, and operational surfaces.
+
+The companion private repo [`molecule-controlplane`](https://github.com/Molecule-AI/molecule-controlplane) provides the SaaS surface — multi-tenant orchestration on EC2 + Neon + Cloudflare Tunnels, KMS envelope encryption, WorkOS auth, Stripe billing, and a `tenant_resources` audit table with a 30-min reconciler.
+
+Adjacent runtime work such as **NemoClaw** remains branch-level until merged, and this README keeps that distinction explicit on purpose.
## License
diff --git a/docs/assets/branding/molecule-icon.svg b/docs/assets/branding/molecule-icon.svg
new file mode 100644
index 00000000..b6a7814c
--- /dev/null
+++ b/docs/assets/branding/molecule-icon.svg
@@ -0,0 +1,28 @@
+
diff --git a/docs/assets/branding/molecule-logo.svg b/docs/assets/branding/molecule-logo.svg
new file mode 100644
index 00000000..839c5aa1
--- /dev/null
+++ b/docs/assets/branding/molecule-logo.svg
@@ -0,0 +1,17 @@
+
--
2.45.2
From ea7f35b724e7900539908ad3bac5491c755db7ef Mon Sep 17 00:00:00 2001
From: "claude-ceo-assistant (Claude Opus 4.7 on Hongming's MacBook)"
Date: Wed, 6 May 2026 17:00:22 -0700
Subject: [PATCH 14/28] =?UTF-8?q?docs(README.zh-CN):=20mirror=20EN=20refre?=
=?UTF-8?q?sh=20=E2=80=94=208=20runtimes=20+=20Canvas=20v4=20+=20Memory=20?=
=?UTF-8?q?v2=20+=20SaaS=20+=20channel=20plugin?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Brings the Chinese README to parity with the comprehensive English
refresh in the same PR:
- Icon: PNG → SVG (light/dark adaptive)
- Runtimes: 6 → 8 (added Hermes 4 + Gemini CLI to pitch line, "Runtime
choice" section, comparison table)
- Canvas v4 — warm-paper 主题系统 callout
- Memory v2 — pgvector 语义召回 callout
- RFC #2967 typed-SSOT A2A 响应路径 — platform ship list + arch diagram
- SaaS section — 多租户 EC2 + Neon + Cloudflare Tunnels, WorkOS, Stripe,
KMS, tenant_resources 审计 + 30 分钟 reconciler
- molecule-mcp-claude-channel section — 在 Claude Code 里直接接入,
marketplace 安装流程, 多租户配置
- Architecture diagram redrawn (Canvas v4 → Platform 1.25 → Provisioner
Docker|EC2+SSM, plus SaaS Control Plane block)
- "Current Scope" updated — Canvas v4, Memory v2, 8 adapters, RFC
#2967, SaaS surface
Translation kept idiomatic — used Chinese tech terms where natural
(语义召回, 多租户, 信封加密) and kept English for established
proper nouns (Hermes, Gemini CLI, RFC #2967, pgvector, WorkOS, KMS).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---
README.zh-CN.md | 67 +++++++++++++++++++++++++++++++++++--------------
1 file changed, 48 insertions(+), 19 deletions(-)
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 52ca6fb3..2b73208b 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -1,7 +1,7 @@
-
+
@@ -52,8 +52,8 @@ Molecule AI 是目前最强的 AI Agent 组织治理方案之一,用来把 age
它把过去分散在 demo、内部胶水代码和各类 framework 私有工具里的关键能力,收敛成一个产品:
- 一套组织原生 control plane,管理团队、角色、层级和生命周期
-- 一套 runtime abstraction,让 LangGraph、DeepAgents、Claude Code、CrewAI、AutoGen、OpenClaw 并存运行
-- 一套与组织边界对齐的 memory 模型,把 recall、sharing 和 skill evolution 放进同一体系
+- 一套 runtime abstraction,让 **8 个** agent runtime —— LangGraph、DeepAgents、Claude Code、CrewAI、AutoGen、**Hermes**、**Gemini CLI**、OpenClaw —— 共用一套 workspace 契约
+- 一套与组织边界对齐的 memory 模型,把 recall、sharing 和 skill evolution 放进同一体系(Memory v2 由 pgvector 支撑语义召回)
- 一套面向线上 workspace 的运维面,统一完成观测、暂停、重启、检查和持续改进
今天很多团队能做好 workflow、单 agent、coding agent,或者自定义 multi-agent graph 中的一种。
@@ -74,7 +74,7 @@ Molecule AI 填的就是这个空白。
### 3. Runtime 选择不再是死路
-LangGraph、DeepAgents、Claude Code、CrewAI、AutoGen、OpenClaw 都可以挂到同一个 workspace abstraction 下。团队可以统一治理方式,而不必统一到底层 runtime。
+LangGraph、DeepAgents、Claude Code、CrewAI、AutoGen、Hermes、Gemini CLI、OpenClaw 都可以挂到同一个 workspace abstraction 下。团队可以统一治理方式,而不必统一到底层 runtime。
### 4. Memory 被当成基础设施来做
@@ -116,6 +116,8 @@ Molecule AI 并不是要替代下面这些 framework,而是把它们纳入更
| **Claude Code** | `main` 已支持 | 真实编码工作流、CLI-native continuity | 安全 workspace 抽象、A2A delegation、组织边界、共享 control plane |
| **CrewAI** | `main` 已支持 | 角色型 crew 模式清晰 | 持久 workspace 身份、统一策略、共享 Canvas 和 registry |
| **AutoGen** | `main` 已支持 | assistant/tool orchestration | 统一部署、层级协作、共享运维平面 |
+| **Hermes 4** | `main` 已支持 | 混合推理、原生工具调用、json_schema 输出(NousResearch/hermes-agent) | Option B 上游 hook、A2A 桥接 OpenAI 兼容 API、多 provider 自动派生 |
+| **Gemini CLI** | `main` 已支持 | Google Gemini CLI 持续会话 | workspace 生命周期、A2A、层级感知协作、共享运维平面 |
| **OpenClaw** | `main` 已支持 | CLI-native runtime,自有 session 模型 | workspace 生命周期、templates、activity logs、拓扑感知协作 |
| **NemoClaw** | `feat/nemoclaw-t4-docker` 分支 WIP | NVIDIA 方向 runtime 路线 | 计划并入同一抽象层,但当前还不是 `main` 已合并能力 |
@@ -181,9 +183,10 @@ Molecule AI 并不是要替代下面这些 framework,而是把它们纳入更
## `main` 分支已经具备什么
-### Canvas
+### Canvas(v4)
- Next.js 15 + React Flow + Zustand
+- **warm-paper 主题系统** —— light / dark / 跟随系统;SSR cookie + nonce'd boot 脚本 + ThemeProvider;终端与代码面板始终保持深色
- drag-to-nest 团队构建
- empty state + onboarding wizard
- template palette
@@ -192,8 +195,9 @@ Molecule AI 并不是要替代下面这些 framework,而是把它们纳入更
### Platform
-- Go/Gin control plane
-- workspace CRUD 和 provisioning
+- Go 1.25 / Gin control plane(80+ HTTP 端点 + Gorilla WebSocket fanout)
+- workspace CRUD 和 provisioning(可插拔 Provisioner —— 本地 Docker、生产 EC2 + SSM)
+- **A2A 响应路径已收敛为类型化的判别联合(RFC #2967)** —— 冻结 dataclass + 全量 parser;100% 单元测试 + 对抗性 fuzz 覆盖
- registry 与 heartbeat
- 浏览器安全的 A2A proxy
- team expansion/collapse
@@ -203,10 +207,10 @@ Molecule AI 并不是要替代下面这些 framework,而是把它们纳入更
### Runtime
-- 统一 `workspace/` 镜像
-- adapter 驱动执行
+- 统一 `workspace/` 镜像;生产环境采用 thin AMI(us-east-2)
+- adapter 驱动执行,覆盖 **8 个 runtime**(Claude Code、Hermes、Gemini CLI、LangGraph、DeepAgents、CrewAI、AutoGen、OpenClaw)
- Agent Card 注册
-- awareness-backed memory
+- awareness-backed memory;**Memory v2 由 pgvector 支撑**语义召回
- plugin 挂载共享 rules/skills
- 本地 skills 热加载
- coordinator-only delegation 路径
@@ -220,6 +224,21 @@ Molecule AI 并不是要替代下面这些 framework,而是把它们纳入更
- runtime tiers
- 终端与文件层面的 workspace 直接排障
+### SaaS(由 [`molecule-controlplane`](https://github.com/Molecule-AI/molecule-controlplane) 提供)
+
+- 多租户运行在 AWS EC2 + Neon(每租户一个 Postgres branch)+ Cloudflare Tunnels(每租户一条隧道,对外不开任何端口)
+- WorkOS AuthKit + Stripe Checkout + Customer Portal
+- AWS KMS 信封加密(DB / Redis 连接串);AWS Secrets Manager 负责租户 bootstrap
+- `tenant_resources` 审计表 + 30 分钟 boot-event-aware reconciler —— 每个 CF / AWS lifecycle 事件都有记录,每 30 分钟比对 claim 与实际状态
+
+### 在 Claude Code 里直接接入(由 [`molecule-mcp-claude-channel`](https://github.com/Molecule-AI/molecule-mcp-claude-channel) 提供)
+
+- 把 Molecule A2A 流量桥接到本地 Claude Code 会话的 MCP 插件
+- 订阅一个或多个 workspace;peer 的消息会以 user-turn 出现,回复会经 Molecule A2A 路由出去
+- 无需公网隧道、无需公开端点 —— 插件启动时自动把每个 watched workspace 注册成 `delivery_mode=poll`,长轮询 `/activity?since_id=…`
+- 多租户友好:单次安装即可同时 watch 跨多个 Molecule 租户的 workspace(`MOLECULE_PLATFORM_URLS` 按 workspace 配置)
+- 通过标准 marketplace 流程安装:`/plugin marketplace add Molecule-AI/molecule-mcp-claude-channel` → `/plugin install molecule-channel@molecule-mcp-claude-channel`
+
## 适合什么团队
Molecule AI 特别适合下面这些场景:
@@ -232,17 +251,23 @@ Molecule AI 特别适合下面这些场景:
## 架构总览
```text
-Canvas (Next.js :3000) <--HTTP / WS--> Platform (Go :8080) <---> Postgres + Redis
- | |
- | +--> Docker provisioner / bundles / templates / secrets
+Canvas (Next.js 15, warm-paper :3000) <--HTTP / WS--> Platform (Go 1.25 :8080) <---> Postgres + Redis
+ | |
+ | +--> Provisioner: Docker (本地) / EC2 + SSM (生产)
+ | +--> bundles · templates · secrets · KMS
|
- +-------------------- 展示 --------------------> workspaces, teams, tasks, traces, events
+ +------------------------- 展示 ------------------------> workspaces, teams, tasks, traces, events
-Workspace Runtime (Python image with adapters)
- - LangGraph / DeepAgents / Claude Code / CrewAI / AutoGen / OpenClaw
- - Agent Card + A2A server
- - heartbeat + activity + awareness-backed memory
+Workspace Runtime (Python ≥3.11,含 adapter 集合的镜像)
+ - 8 个 adapter: LangGraph / DeepAgents / Claude Code / CrewAI / AutoGen / Hermes / Gemini CLI / OpenClaw
+ - Agent Card + A2A server(typed-SSOT 响应路径,RFC #2967)
+ - heartbeat + activity + awareness-backed memory(Memory v2 —— pgvector 语义召回)
- skills + plugins + hot reload
+
+SaaS Control Plane (molecule-controlplane,私有)
+ - 每租户 EC2 + Neon (Postgres branch) + Cloudflare Tunnel
+ - WorkOS · Stripe · KMS · AWS Secrets Manager
+ - tenant_resources 审计 + 30 分钟 reconciler
```
## 快速开始
@@ -296,7 +321,11 @@ npm run dev
## 当前范围说明
-当前 `main` 已经包含核心平台、Canvas、memory model、6 个正式 adapter、skill lifecycle 和主要运维面。像 **NemoClaw** 这样的相邻 runtime 路线仍然属于分支级工作,只有合并后才会进入正式支持列表,这里会明确区分。
+当前 `main` 已经包含核心平台、Canvas v4(warm-paper 主题)、Memory v2(pgvector 语义召回)、typed-SSOT A2A 响应路径(RFC #2967)、**8 个正式 adapter**(Claude Code、Hermes、Gemini CLI、LangGraph、DeepAgents、CrewAI、AutoGen、OpenClaw)、skill lifecycle,以及主要运维面。
+
+配套的私有仓库 [`molecule-controlplane`](https://github.com/Molecule-AI/molecule-controlplane) 提供 SaaS 层 —— 多租户编排(EC2 + Neon + Cloudflare Tunnels)、KMS 信封加密、WorkOS 鉴权、Stripe 计费,以及 `tenant_resources` 审计表加 30 分钟 reconciler。
+
+像 **NemoClaw** 这样的相邻 runtime 路线仍然属于分支级工作,只有合并后才会进入正式支持列表,这里会明确区分。
## License
--
2.45.2
From 1d8c101c948e884abc325968566ef2e6ad3603e6 Mon Sep 17 00:00:00 2001
From: devops-engineer
Date: Thu, 7 May 2026 05:12:06 -0700
Subject: [PATCH 15/28] =?UTF-8?q?chore:=20drop=20github-app-auth=20+=20swa?=
=?UTF-8?q?p=20GHCR=E2=86=92ECR=20(closes=20#157,=20#161)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two coupled cleanups for the post-2026-05-06 stack:
#157 — drop molecule-ai-plugin-github-app-auth
============================================
The plugin injected GITHUB_TOKEN/GH_TOKEN via the App's
installation-access flow (~hourly rotation). Per-agent Gitea
identities replaced this approach after the 2026-05-06 suspension —
workspaces now provision with a per-persona Gitea PAT from .env
instead of an App-rotated token. The plugin code itself lived on
github.com/Molecule-AI/molecule-ai-plugin-github-app-auth which is
also unreachable post-suspension; checking it out at CI build time
was already failing.
Removed:
- workspace-server/cmd/server/main.go: githubappauth import + the
`if os.Getenv("GITHUB_APP_ID") != ""` block that called
BuildRegistry. gh-identity remains as the active mutator.
- workspace-server/Dockerfile + Dockerfile.tenant: COPY of the
sibling repo + the `replace github.com/Molecule-AI/molecule-ai-
plugin-github-app-auth => /plugin` directive injection.
- workspace-server/go.mod + go.sum: github-app-auth dep entry
(cleaned up by `go mod tidy`).
- 3 workflows: actions/checkout steps for the sibling plugin repo:
- .github/workflows/codeql.yml (Go matrix path)
- .github/workflows/harness-replays.yml
- .github/workflows/publish-workspace-server-image.yml
Verified `go build ./cmd/server` + `go vet ./...` pass post-removal.
#161 — swap GHCR→ECR for publish-workspace-server-image
=======================================================
Same workflow used to push to ghcr.io/molecule-ai/platform +
platform-tenant. ghcr.io/molecule-ai is gone post-suspension. The
operator's ECR org (153263036946.dkr.ecr.us-east-2.amazonaws.com/
molecule-ai/) already hosts platform-tenant + workspace-template-*
+ runner-base images and is the post-suspension SSOT for container
images. This PR aligns publish-workspace-server-image with that
stack.
- env.IMAGE_NAME + env.TENANT_IMAGE_NAME repointed to ECR URL.
- docker/login-action swapped for aws-actions/configure-aws-
credentials@v4 + aws-actions/amazon-ecr-login@v2 chain (the
standard ECR auth pattern; uses AWS_ACCESS_KEY_ID/SECRET secrets
bound to the molecule-cp IAM user).
The :staging- + :staging-latest tag policy is unchanged —
staging-CP's TENANT_IMAGE pin still points at :staging-latest, just
with the new registry prefix.
Refs molecule-core#157, #161; parallel to org-wide CI-green sweep.
---
.github/workflows/codeql.yml | 13 +----
.github/workflows/harness-replays.yml | 12 +----
.../publish-workspace-server-image.yml | 47 +++++++++----------
workspace-server/Dockerfile | 12 ++---
workspace-server/Dockerfile.tenant | 5 +-
workspace-server/cmd/server/main.go | 38 ++++-----------
workspace-server/go.mod | 1 -
workspace-server/go.sum | 2 -
8 files changed, 43 insertions(+), 87 deletions(-)
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 7e475a2a..3a7939e8 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -55,17 +55,8 @@ jobs:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - name: Checkout sibling plugin repo
- # Same reasoning as publish-workspace-server-image.yml — the Go
- # module's replace directive needs the plugin source so
- # CodeQL's "go build" phase can resolve.
- if: matrix.language == 'go'
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- repository: Molecule-AI/molecule-ai-plugin-github-app-auth
- path: molecule-ai-plugin-github-app-auth
- token: ${{ secrets.PLUGIN_REPO_PAT || secrets.GITHUB_TOKEN }}
-
+ # github-app-auth sibling-checkout removed 2026-05-07 (#157):
+ # plugin was dropped + the Dockerfile no longer needs it.
# jq is pre-installed on ubuntu-latest — no setup step needed.
- name: Initialize CodeQL
diff --git a/.github/workflows/harness-replays.yml b/.github/workflows/harness-replays.yml
index 5dc5d36d..dcd53f0a 100644
--- a/.github/workflows/harness-replays.yml
+++ b/.github/workflows/harness-replays.yml
@@ -95,16 +95,8 @@ jobs:
- if: needs.detect-changes.outputs.run == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - name: Checkout sibling plugin repo
- # Dockerfile.tenant copies molecule-ai-plugin-github-app-auth/
- # at the build-context root (see workspace-server/Dockerfile.tenant
- # line 19). PLUGIN_REPO_PAT pattern matches publish-workspace-server-image.yml.
- if: needs.detect-changes.outputs.run == 'true'
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- repository: Molecule-AI/molecule-ai-plugin-github-app-auth
- path: molecule-ai-plugin-github-app-auth
- token: ${{ secrets.PLUGIN_REPO_PAT || secrets.GITHUB_TOKEN }}
+ # github-app-auth sibling-checkout removed 2026-05-07 (#157):
+ # the plugin was dropped + Dockerfile.tenant no longer COPYs it.
- name: Install Python deps for replays
# peer-discovery-404 (and future replays) eval Python against the
diff --git a/.github/workflows/publish-workspace-server-image.yml b/.github/workflows/publish-workspace-server-image.yml
index a0113b4e..f9df59d4 100644
--- a/.github/workflows/publish-workspace-server-image.yml
+++ b/.github/workflows/publish-workspace-server-image.yml
@@ -60,8 +60,8 @@ permissions:
packages: write
env:
- IMAGE_NAME: ghcr.io/molecule-ai/platform
- TENANT_IMAGE_NAME: ghcr.io/molecule-ai/platform-tenant
+ IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform
+ TENANT_IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform-tenant
jobs:
build-and-push:
@@ -70,31 +70,28 @@ jobs:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - name: Checkout sibling plugin repo
- # workspace-server/Dockerfile expects
- # ./molecule-ai-plugin-github-app-auth at build-context root because
- # the Go module has a `replace` directive pointing at /plugin inside
- # the image. Pre-repo-split the plugin lived in the monorepo; the
- # 2026-04-18 restructure moved it out but didn't add this clone step
- # — which is why publish was failing after that restructure.
- #
- # Uses a fine-grained PAT (PLUGIN_REPO_PAT) because the plugin repo
- # is private and the default GITHUB_TOKEN is scoped to THIS repo.
- # The PAT needs Contents:Read on Molecule-AI/molecule-ai-plugin-
- # github-app-auth. Falls back to the default token for the (rare)
- # case where an operator made the plugin repo public.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- repository: Molecule-AI/molecule-ai-plugin-github-app-auth
- path: molecule-ai-plugin-github-app-auth
- token: ${{ secrets.PLUGIN_REPO_PAT || secrets.GITHUB_TOKEN }}
+ # github-app-auth sibling-checkout removed 2026-05-07 (#157):
+ # plugin was dropped + workspace-server/Dockerfile no longer
+ # COPYs it.
- - name: Log in to GHCR
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
+ - name: Configure AWS credentials for ECR
+ # GHCR was the pre-suspension target; the molecule-ai org on
+ # GitHub got swept 2026-05-06 and ghcr.io/molecule-ai/* is no
+ # longer reachable. Post-suspension target is the operator's
+ # ECR org (153263036946.dkr.ecr.us-east-2.amazonaws.com/
+ # molecule-ai/*), which already hosts platform-tenant +
+ # workspace-template-* + runner-base images. AWS creds come
+ # from the AWS_ACCESS_KEY_ID/SECRET secrets bound to the
+ # molecule-cp IAM user. Closes #161.
+ uses: aws-actions/configure-aws-credentials@v4
with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ aws-region: us-east-2
+
+ - name: Log in to ECR
+ id: ecr-login
+ uses: aws-actions/amazon-ecr-login@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
diff --git a/workspace-server/Dockerfile b/workspace-server/Dockerfile
index d6754312..dea2e223 100644
--- a/workspace-server/Dockerfile
+++ b/workspace-server/Dockerfile
@@ -5,15 +5,11 @@
FROM golang:1.25-alpine AS builder
WORKDIR /app
-# Plugin source for replace directive in go.mod
-COPY molecule-ai-plugin-github-app-auth/ /plugin/
COPY workspace-server/go.mod workspace-server/go.sum ./
-# Add replace directives for Docker builds:
-# 1. Platform → plugin (plugin source at /plugin/)
-# 2. Plugin → platform (plugin's go.mod has a relative replace that doesn't
-# work in Docker; fix it to point at /app where the platform source lives)
-RUN echo 'replace github.com/Molecule-AI/molecule-ai-plugin-github-app-auth => /plugin' >> go.mod
-RUN sed -i 's|replace github.com/Molecule-AI/molecule-monorepo/platform => .*|replace github.com/Molecule-AI/molecule-monorepo/platform => /app|' /plugin/go.mod
+# github-app-auth plugin removed 2026-05-07 (#157): per-agent Gitea
+# identities replaced the GitHub-App-installation token flow after the
+# 2026-05-06 suspension. Pre-removal this stage COPY'd the sibling
+# plugin repo + injected a `replace` directive; both are gone.
RUN go mod download
COPY workspace-server/ .
# GIT_SHA mirror of Dockerfile.tenant — see that file for the rationale.
diff --git a/workspace-server/Dockerfile.tenant b/workspace-server/Dockerfile.tenant
index 6ccc737e..6915365d 100644
--- a/workspace-server/Dockerfile.tenant
+++ b/workspace-server/Dockerfile.tenant
@@ -16,9 +16,10 @@
# ── Stage 1: Go platform binary ──────────────────────────────────────
FROM golang:1.25-alpine AS go-builder
WORKDIR /app
-COPY molecule-ai-plugin-github-app-auth/ /plugin/
COPY workspace-server/go.mod workspace-server/go.sum ./
-RUN echo 'replace github.com/Molecule-AI/molecule-ai-plugin-github-app-auth => /plugin' >> go.mod
+# github-app-auth plugin removed 2026-05-07 (#157): per-agent Gitea
+# identities replaced GitHub-App tokens post-suspension. The sibling
+# COPY + replace directive are gone.
RUN go mod download
COPY workspace-server/ .
diff --git a/workspace-server/cmd/server/main.go b/workspace-server/cmd/server/main.go
index cba0334c..a767c190 100644
--- a/workspace-server/cmd/server/main.go
+++ b/workspace-server/cmd/server/main.go
@@ -29,8 +29,7 @@ import (
// External plugins — each registers EnvMutator(s) that run at workspace
// provision time. Loaded via soft-dep gates in main() so self-hosters
- // without the App or without per-agent identity configured keep working.
- githubappauth "github.com/Molecule-AI/molecule-ai-plugin-github-app-auth/pluginloader"
+ // without per-agent identity configured keep working.
ghidentity "github.com/Molecule-AI/molecule-ai-plugin-gh-identity/pluginloader"
"github.com/Molecule-AI/molecule-monorepo/platform/pkg/provisionhook"
@@ -179,12 +178,15 @@ func main() {
}
// External-plugin env mutators — each plugin contributes 0+ mutators
- // onto a shared registry. Order matters: gh-identity populates
- // MOLECULE_AGENT_ROLE-derived attribution env vars that downstream
- // mutators and the workspace's install.sh can then read. Keep
- // github-app-auth last because it fails loudly on misconfig and its
- // failure mode is "no GITHUB_TOKEN" — worth surfacing after the
- // cheaper mutators already ran.
+ // onto a shared registry. gh-identity populates MOLECULE_AGENT_ROLE-
+ // derived attribution env vars that the workspace's install.sh can
+ // then read.
+ //
+ // github-app-auth was dropped 2026-05-07 (closes #157): per-agent
+ // Gitea identities (this gh-identity plugin's role-derived path)
+ // replaced GitHub-App-installation tokens after the 2026-05-06
+ // suspension. Workspaces now provision with a per-persona Gitea PAT
+ // from .env instead of an App-rotated GITHUB_TOKEN.
envReg := provisionhook.NewRegistry()
// gh-identity plugin — per-agent attribution via env injection + gh
@@ -198,26 +200,6 @@ func main() {
log.Printf("gh-identity: registered (config file=%q)", os.Getenv("MOLECULE_GH_IDENTITY_CONFIG_FILE"))
}
- // github-app-auth plugin — injects GITHUB_TOKEN + GH_TOKEN into every
- // workspace env using the App's installation access token (rotates ~hourly).
- // Soft-skip when GITHUB_APP_* env vars are absent so dev/self-hosters
- // without an App configured keep working; fail-loud only on MISCONFIG
- // (e.g. APP_ID set but key file missing), not on unset.
- if os.Getenv("GITHUB_APP_ID") != "" {
- if reg, err := githubappauth.BuildRegistry(); err != nil {
- log.Fatalf("github-app-auth plugin: %v", err)
- } else {
- // Copy the plugin's mutators onto the shared registry so the
- // TokenProvider probe (FirstTokenProvider) still finds them.
- for _, m := range reg.Mutators() {
- envReg.Register(m)
- }
- log.Printf("github-app-auth: registered, %d mutator(s) added to chain", reg.Len())
- }
- } else {
- log.Println("github-app-auth: GITHUB_APP_ID unset — skipping plugin registration (agents will use any PAT from .env)")
- }
-
wh.SetEnvMutators(envReg)
log.Printf("env-mutator chain: %v", envReg.Names())
diff --git a/workspace-server/go.mod b/workspace-server/go.mod
index 47b22a2b..85a949fa 100644
--- a/workspace-server/go.mod
+++ b/workspace-server/go.mod
@@ -5,7 +5,6 @@ go 1.25.0
require (
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/Molecule-AI/molecule-ai-plugin-gh-identity v0.0.0-20260424033845-4fd5ac7be30f
- github.com/Molecule-AI/molecule-ai-plugin-github-app-auth v0.0.0-20260421064811-7d98ae51e31d
github.com/alicebob/miniredis/v2 v2.37.0
github.com/creack/pty v1.1.24
github.com/docker/docker v28.5.2+incompatible
diff --git a/workspace-server/go.sum b/workspace-server/go.sum
index 7d9c3c3d..a31b0c4e 100644
--- a/workspace-server/go.sum
+++ b/workspace-server/go.sum
@@ -6,8 +6,6 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Molecule-AI/molecule-ai-plugin-gh-identity v0.0.0-20260424033845-4fd5ac7be30f h1:YkLRhUg+9qr9OV9N8dG1Hj0Ml7TThHlRwh5F//oUJVs=
github.com/Molecule-AI/molecule-ai-plugin-gh-identity v0.0.0-20260424033845-4fd5ac7be30f/go.mod h1:NqdtlWZDJvpXNJRHnMkPhTKHdA1LZTNH+63TB66JSOU=
-github.com/Molecule-AI/molecule-ai-plugin-github-app-auth v0.0.0-20260421064811-7d98ae51e31d h1:GpYhP6FxaJZc1Ljy5/YJ9ZIVGvfOqZBmDolNr2S5x2g=
-github.com/Molecule-AI/molecule-ai-plugin-github-app-auth v0.0.0-20260421064811-7d98ae51e31d/go.mod h1:3a6LR/zd7FjR9ZwLTbytwYlWuCBsbCOVFlEg0WnoYiM=
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
--
2.45.2
From c8110b5766d0813c7d46f13e8e0a742b90583329 Mon Sep 17 00:00:00 2001
From: devops-engineer
Date: Thu, 7 May 2026 06:48:13 -0700
Subject: [PATCH 16/28] chore(ci): retrigger staging CI on new runner image
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
All current core/staging reds ran 12:14-12:33 BEFORE the runner
image swap (cloudflared bake + GOPROXY pipe-separator at 12:55).
This empty commit forces a fresh CI run under the post-fix
runner image so we can categorize:
- REAL fails (need targeted fix)
- STALE-cleared (was a runner-image issue, now fixed)
- Genuinely unrelated (Auto-sync, CodeQL — Hongming-parked)
Per feedback_orchestrator_must_verify_before_declaring_fixed,
don't mass-mark stale — wait for fresh run, verify each context.
--
2.45.2
From 64a0bc1f7eee36a6b0e12364a892c60d4efa898a Mon Sep 17 00:00:00 2001
From: devops-engineer
Date: Thu, 7 May 2026 07:01:46 -0700
Subject: [PATCH 17/28] fix(ci): use AUTO_SYNC_TOKEN for auto-sync
main->staging (Class D)
Same shape as molecule-controlplane#29: per-job GITHUB_TOKEN
doesn't have the Gitea API permissions to open PRs / push branches
the auto-sync flow needs. AUTO_SYNC_TOKEN is the devops-engineer
persona PAT (per saved memory feedback_per_agent_gitea_identity_default).
Companion prod ops (already done):
- devops-engineer added as collaborator on molecule-core (write)
- devops-engineer added to staging branch protection push_whitelist
- AUTO_SYNC_TOKEN registered as Actions secret on molecule-core
---
.github/workflows/auto-sync-main-to-staging.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/auto-sync-main-to-staging.yml b/.github/workflows/auto-sync-main-to-staging.yml
index 76d891e3..222b2961 100644
--- a/.github/workflows/auto-sync-main-to-staging.yml
+++ b/.github/workflows/auto-sync-main-to-staging.yml
@@ -103,7 +103,7 @@ jobs:
with:
fetch-depth: 0
ref: staging
- token: ${{ secrets.GITHUB_TOKEN }}
+ token: ${{ secrets.AUTO_SYNC_TOKEN }}
- name: Configure git author
run: |
@@ -174,7 +174,7 @@ jobs:
- name: Open auto-sync PR + enable auto-merge
if: steps.check.outputs.needs_sync == 'true'
env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
BRANCH: ${{ steps.check.outputs.branch }}
MAIN_SHORT: ${{ steps.check.outputs.main_short }}
DID_FF: ${{ steps.prep.outputs.did_ff }}
--
2.45.2
From 55689e0b104d43c27976765fdce983748d66493e Mon Sep 17 00:00:00 2001
From: devops-engineer
Date: Thu, 7 May 2026 13:07:25 -0700
Subject: [PATCH 18/28] fix(post-suspension): migrate github.com/Molecule-AI
refs to git.moleculesai.app (Class G #168)
The GitHub org Molecule-AI was suspended on 2026-05-06; canonical SCM
is now Gitea at https://git.moleculesai.app/molecule-ai/. Stale
github.com/Molecule-AI/... URLs return 404 and break tooling that
clones / pip-installs / curls them.
This bundles all non-Go-module URL fixes for this repo into a single PR.
Go module path references (in *.go, go.mod, go.sum) are out of scope
here -- tracked separately under Task #140.
Token-auth clone URLs also flip ${GITHUB_TOKEN} -> ${GITEA_TOKEN} since
the GitHub token does not auth against Gitea.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.github/workflows/canary-verify.yml | 2 +-
.github/workflows/ci.yml | 8 ++++----
.github/workflows/retarget-main-to-staging.yml | 2 +-
README.md | 6 +++---
README.zh-CN.md | 6 +++---
canvas/src/app/pricing/page.tsx | 2 +-
docs/blog/2026-04-17-deploy-anywhere/index.md | 4 ++--
docs/blog/2026-04-20-secure-by-design/index.md | 8 ++++----
docs/blog/2026-04-21-discord-adapter/index.md | 6 +++---
.../postmortem-2026-04-23-boot-event-401.md | 12 ++++++------
docs/engineering/pr-hygiene.md | 2 +-
docs/engineering/testing-strategy.md | 12 ++++++------
docs/guides/external-workspace-quickstart.md | 4 ++--
docs/integrations/runtime-native-mcp-status.md | 4 ++--
docs/memory-plugins/CHANGELOG.md | 2 +-
docs/plugins/agentskills-compat.md | 10 +++++-----
docs/tutorials/chrome-devtools-mcp-quickstart.md | 2 +-
docs/tutorials/fly-machines-provisioner.md | 6 +++---
docs/tutorials/gemini-cli-runtime.md | 2 +-
docs/tutorials/google-adk-runtime.md | 2 +-
docs/tutorials/hermes-multi-provider-dispatch.md | 8 ++++----
docs/tutorials/lark-feishu-channel.md | 2 +-
scripts/build_runtime_package.py | 4 ++--
tests/e2e/STAGING_SAAS_E2E.md | 2 +-
.../internal/handlers/testdata/derive-provider.sh | 2 +-
25 files changed, 60 insertions(+), 60 deletions(-)
diff --git a/.github/workflows/canary-verify.yml b/.github/workflows/canary-verify.yml
index 6972194e..e19c1619 100644
--- a/.github/workflows/canary-verify.yml
+++ b/.github/workflows/canary-verify.yml
@@ -108,7 +108,7 @@ jobs:
echo
echo "One or more canary secrets are unset (\`CANARY_TENANT_URLS\`, \`CANARY_ADMIN_TOKENS\`, \`CANARY_CP_SHARED_SECRET\`)."
echo "Phase 2 canary fleet has not been stood up yet —"
- echo "see [canary-tenants.md](https://github.com/Molecule-AI/molecule-controlplane/blob/main/docs/canary-tenants.md)."
+ echo "see [canary-tenants.md](https://git.moleculesai.app/molecule-ai/molecule-controlplane/blob/main/docs/canary-tenants.md)."
echo
echo "**Skipped — promote-to-latest will NOT auto-fire.** Dispatch \`promote-latest.yml\` manually when ready."
} >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fffce798..2292c8b8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -87,7 +87,7 @@ jobs:
run: go mod download
- if: needs.changes.outputs.platform == 'true'
run: go build ./cmd/server
- # CLI (molecli) moved to standalone repo: github.com/Molecule-AI/molecule-cli
+ # CLI (molecli) moved to standalone repo: git.moleculesai.app/molecule-ai/molecule-cli
- if: needs.changes.outputs.platform == 'true'
run: go vet ./... || true
- if: needs.changes.outputs.platform == 'true'
@@ -243,8 +243,8 @@ jobs:
if-no-files-found: warn
# MCP Server + SDK removed from CI — now in standalone repos:
- # - github.com/Molecule-AI/molecule-mcp-server (npm CI)
- # - github.com/Molecule-AI/molecule-sdk-python (PyPI CI)
+ # - git.moleculesai.app/molecule-ai/molecule-mcp-server (npm CI)
+ # - git.moleculesai.app/molecule-ai/molecule-sdk-python (PyPI CI)
# e2e-api job moved to .github/workflows/e2e-api.yml (issue #458).
# It now has workflow-level concurrency (cancel-in-progress: false) so
@@ -434,5 +434,5 @@ jobs:
fi
# SDK + plugin validation moved to standalone repo:
- # github.com/Molecule-AI/molecule-sdk-python
+ # git.moleculesai.app/molecule-ai/molecule-sdk-python
diff --git a/.github/workflows/retarget-main-to-staging.yml b/.github/workflows/retarget-main-to-staging.yml
index 5e1ff8bc..0ffe62db 100644
--- a/.github/workflows/retarget-main-to-staging.yml
+++ b/.github/workflows/retarget-main-to-staging.yml
@@ -96,7 +96,7 @@ jobs:
--body "$(cat <<'BODY'
[retarget-bot] This PR was opened against `main` and has been retargeted to `staging` automatically.
- **Why:** per [SHARED_RULES rule 8](https://github.com/Molecule-AI/molecule-ai-org-template-molecule-dev/blob/main/SHARED_RULES.md), all feature work targets `staging` first; the CEO promotes `staging → main` separately.
+ **Why:** per [SHARED_RULES rule 8](https://git.moleculesai.app/molecule-ai/molecule-ai-org-template-molecule-dev/blob/main/SHARED_RULES.md), all feature work targets `staging` first; the CEO promotes `staging → main` separately.
**What changed:** just the base branch — no code change. CI will re-run against `staging`. If you get merge conflicts, rebase on `staging`.
diff --git a/README.md b/README.md
index 424bee6a..d455d731 100644
--- a/README.md
+++ b/README.md
@@ -225,14 +225,14 @@ The result is not just “an agent that learns.” It is **an organization that
- runtime tiers
- direct workspace inspection through terminal and files
-### SaaS (via [`molecule-controlplane`](https://github.com/Molecule-AI/molecule-controlplane))
+### SaaS (via [`molecule-controlplane`](https://git.moleculesai.app/molecule-ai/molecule-controlplane))
- multi-tenant on AWS EC2 + Neon (per-tenant Postgres branch) + Cloudflare Tunnels (per-tenant, no public ports)
- WorkOS AuthKit + Stripe Checkout + Customer Portal
- AWS KMS envelope encryption (DB / Redis connection strings); AWS Secrets Manager for tenant bootstrap
- `tenant_resources` audit table + 30-min boot-event-aware reconciler — every CF / AWS lifecycle event recorded, claim vs live state diffed
-### Bring your own Claude Code session (via [`molecule-mcp-claude-channel`](https://github.com/Molecule-AI/molecule-mcp-claude-channel))
+### Bring your own Claude Code session (via [`molecule-mcp-claude-channel`](https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel))
- Claude Code plugin that bridges Molecule A2A traffic into a local Claude Code session via MCP
- subscribe to one or more workspaces; peer messages surface as conversation turns; replies route back through Molecule's A2A
@@ -330,7 +330,7 @@ Then open `http://localhost:3000`:
The current `main` branch ships the core platform, Canvas v4 (warm-paper themed), Memory v2 (pgvector semantic recall), the typed-SSOT A2A response path (RFC #2967), **eight production adapters** (Claude Code, Hermes, Gemini CLI, LangGraph, DeepAgents, CrewAI, AutoGen, OpenClaw), skill lifecycle, and operational surfaces.
-The companion private repo [`molecule-controlplane`](https://github.com/Molecule-AI/molecule-controlplane) provides the SaaS surface — multi-tenant orchestration on EC2 + Neon + Cloudflare Tunnels, KMS envelope encryption, WorkOS auth, Stripe billing, and a `tenant_resources` audit table with a 30-min reconciler.
+The companion private repo [`molecule-controlplane`](https://git.moleculesai.app/molecule-ai/molecule-controlplane) provides the SaaS surface — multi-tenant orchestration on EC2 + Neon + Cloudflare Tunnels, KMS envelope encryption, WorkOS auth, Stripe billing, and a `tenant_resources` audit table with a 30-min reconciler.
Adjacent runtime work such as **NemoClaw** remains branch-level until merged, and this README keeps that distinction explicit on purpose.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 2b73208b..d85fe3b8 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -224,14 +224,14 @@ Molecule AI 并不是要替代下面这些 framework,而是把它们纳入更
- runtime tiers
- 终端与文件层面的 workspace 直接排障
-### SaaS(由 [`molecule-controlplane`](https://github.com/Molecule-AI/molecule-controlplane) 提供)
+### SaaS(由 [`molecule-controlplane`](https://git.moleculesai.app/molecule-ai/molecule-controlplane) 提供)
- 多租户运行在 AWS EC2 + Neon(每租户一个 Postgres branch)+ Cloudflare Tunnels(每租户一条隧道,对外不开任何端口)
- WorkOS AuthKit + Stripe Checkout + Customer Portal
- AWS KMS 信封加密(DB / Redis 连接串);AWS Secrets Manager 负责租户 bootstrap
- `tenant_resources` 审计表 + 30 分钟 boot-event-aware reconciler —— 每个 CF / AWS lifecycle 事件都有记录,每 30 分钟比对 claim 与实际状态
-### 在 Claude Code 里直接接入(由 [`molecule-mcp-claude-channel`](https://github.com/Molecule-AI/molecule-mcp-claude-channel) 提供)
+### 在 Claude Code 里直接接入(由 [`molecule-mcp-claude-channel`](https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel) 提供)
- 把 Molecule A2A 流量桥接到本地 Claude Code 会话的 MCP 插件
- 订阅一个或多个 workspace;peer 的消息会以 user-turn 出现,回复会经 Molecule A2A 路由出去
@@ -323,7 +323,7 @@ npm run dev
当前 `main` 已经包含核心平台、Canvas v4(warm-paper 主题)、Memory v2(pgvector 语义召回)、typed-SSOT A2A 响应路径(RFC #2967)、**8 个正式 adapter**(Claude Code、Hermes、Gemini CLI、LangGraph、DeepAgents、CrewAI、AutoGen、OpenClaw)、skill lifecycle,以及主要运维面。
-配套的私有仓库 [`molecule-controlplane`](https://github.com/Molecule-AI/molecule-controlplane) 提供 SaaS 层 —— 多租户编排(EC2 + Neon + Cloudflare Tunnels)、KMS 信封加密、WorkOS 鉴权、Stripe 计费,以及 `tenant_resources` 审计表加 30 分钟 reconciler。
+配套的私有仓库 [`molecule-controlplane`](https://git.moleculesai.app/molecule-ai/molecule-controlplane) 提供 SaaS 层 —— 多租户编排(EC2 + Neon + Cloudflare Tunnels)、KMS 信封加密、WorkOS 鉴权、Stripe 计费,以及 `tenant_resources` 审计表加 30 分钟 reconciler。
像 **NemoClaw** 这样的相邻 runtime 路线仍然属于分支级工作,只有合并后才会进入正式支持列表,这里会明确区分。
diff --git a/canvas/src/app/pricing/page.tsx b/canvas/src/app/pricing/page.tsx
index 3ef6f319..73748770 100644
--- a/canvas/src/app/pricing/page.tsx
+++ b/canvas/src/app/pricing/page.tsx
@@ -41,7 +41,7 @@ export default function PricingPage() {