Compare commits

...

30 Commits

Author SHA1 Message Date
fullstack-engineer 1da8c1bb2f fix(canvas): boot-time matched-pair guard for ADMIN_TOKEN env vars (#53)
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 0s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
CI / Detect changes (pull_request) Successful in 8s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 8s
E2E API Smoke Test / detect-changes (pull_request) Successful in 8s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
Harness Replays / detect-changes (pull_request) Successful in 8s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 7s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 3s
CI / Python Lint & Test (pull_request) Successful in 32s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 32s
Harness Replays / Harness Replays (pull_request) Successful in 55s
CI / Canvas (Next.js) (pull_request) Successful in 2m22s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Successful in 2m48s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4m19s
Adds checkAdminTokenPair() to canvas/next.config.ts to warn at boot
when ADMIN_TOKEN and NEXT_PUBLIC_ADMIN_TOKEN are not both set or both
unset. Warns via console.error (recoverable — does not process.exit)
so the message surfaces in next dev console, standalone server stdout,
and Docker container logs. Fixes the post-PR-#174 self-review gap where
an asymmetric configuration silently 401s against workspace-server.

Includes 8-unit test suite covering all 4 asymmetry combinations,
empty-string-as-unset semantics, and warning message content.

Closes #53

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 00:16:35 +00:00
claude-ceo-assistant c94ead1953 Merge pull request 'fix(org-import): reconcile mode + audit-event emission' (#137) from fix/org-import-reconcile-and-audit into main
CodeQL / Analyze (${{ matrix.language }}) (go) (push) Successful in 0s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (push) Successful in 0s
CodeQL / Analyze (${{ matrix.language }}) (python) (push) Successful in 0s
Block internal-flavored paths / Block forbidden paths (push) Successful in 5s
E2E API Smoke Test / detect-changes (push) Successful in 6s
CI / Detect changes (push) Successful in 8s
Handlers Postgres Integration / detect-changes (push) Successful in 8s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 8s
Harness Replays / detect-changes (push) Successful in 8s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 6s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 8s
CI / Shellcheck (E2E scripts) (push) Successful in 3s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 3s
CI / Python Lint & Test (push) Successful in 36s
CI / Canvas (Next.js) (push) Successful in 58s
CI / Canvas Deploy Reminder (push) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 59s
Harness Replays / Harness Replays (push) Successful in 1m10s
publish-workspace-server-image / build-and-push (push) Successful in 2m11s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2m23s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m25s
CI / Platform (Go) (push) Successful in 3m26s
2026-05-08 22:13:20 +00:00
claude-ceo-assistant 3de51faa19 fix(org-import): reconcile mode + audit-event emission
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 2s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 1s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
CI / Detect changes (pull_request) Successful in 8s
E2E API Smoke Test / detect-changes (pull_request) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 8s
Harness Replays / detect-changes (pull_request) Successful in 7s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 7s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 10s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
CI / Python Lint & Test (pull_request) Successful in 4s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 34s
CI / Canvas (Next.js) (pull_request) Successful in 57s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 56s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m1s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m22s
Harness Replays / Harness Replays (pull_request) Successful in 2m59s
CI / Platform (Go) (pull_request) Successful in 3m20s
Closes the additive-import zombie bug — re-running /org/import with a
tree shape that reparents same-named roles left the prior workspace
online because lookupExistingChild's dedupe is parent-scoped (different
parent_id → "different" workspace). Caught 2026-05-08 after a dev-tree
re-import left 8 orphans co-existing with the new tree on canvas until
manual cascade-delete.

Three layers in this PR:

- mode="reconcile" on /org/import — after the import loop, online
  workspaces whose name matches an imported name but whose id isn't in
  the result set are cascade-deleted. Default mode "" / "merge"
  preserves existing additive behavior. Empty-set guards prevent
  accidental "delete everything" if either array comes up empty.

- WorkspaceHandler.CascadeDelete extracted as a callable helper from
  the existing Delete HTTP handler so OrgImport's reconcile path shares
  the same teardown sequence (#73 race guard, container stop, volume
  removal, token revocation, schedule disable, event broadcast). The
  HTTP Delete handler still inlines the same logic; deduplication
  tracked as tech-debt follow-up.

- emitOrgEvent(structure_events) records org.import.started +
  org.import.completed with mode, created/skipped/reconcile_removed
  counts, duration_ms, error. Replaces the lost-on-restart stdout-only
  log shape for an audit-trail surface that's queryable by SQL. Closes
  the "what happened at 20:13?" debugging gap that motivated this fix.

Verified live against the local platform: cascade-delete on an old
tree's removed root cleared 8 surviving orphans; mode="reconcile" with
a freshly-INSERTed fake orphan removed exactly the fake; idempotent
re-run of reconcile is a no-op (0 removed, no errors); structure_events
captures every started+completed pair with full payload.

7 new unit tests (walkOrgWorkspaceNames flat/nested/spawning:false/
empty-name; emitOrgEvent success + DB-error-swallow; errString). Full
handler suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:04:47 -07:00
claude-ceo-assistant 6f861926bd Merge pull request 'fix(workspace_provision): preserve MODEL secret over MODEL_PROVIDER slug on restart' (#136) from fix/preserve-model-secret-on-restart into main
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (push) Successful in 7s
CodeQL / Analyze (${{ matrix.language }}) (go) (push) Successful in 7s
CodeQL / Analyze (${{ matrix.language }}) (python) (push) Successful in 5s
Block internal-flavored paths / Block forbidden paths (push) Successful in 22s
CI / Detect changes (push) Successful in 29s
Handlers Postgres Integration / detect-changes (push) Successful in 22s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 24s
Harness Replays / detect-changes (push) Successful in 21s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 18s
CI / Shellcheck (E2E scripts) (push) Successful in 11s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 30s
CI / Python Lint & Test (push) Successful in 10s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 1m5s
CI / Canvas (Next.js) (push) Successful in 1m47s
CI / Canvas Deploy Reminder (push) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 1m53s
Harness Replays / Harness Replays (push) Successful in 2m27s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 7m31s
publish-workspace-server-image / build-and-push (push) Failing after 9m49s
CI / Platform (Go) (push) Successful in 10m11s
E2E API Smoke Test / detect-changes (push) Failing after 11m16s
2026-05-08 21:31:50 +00:00
claude-ceo-assistant 15c5f32491 fix(workspace_provision): preserve MODEL secret over MODEL_PROVIDER slug on restart
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 4s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 5s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 5s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 12s
Check merge_group trigger on required workflows / Required workflows have merge_group trigger (pull_request) Successful in 13s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 26s
cascade-list-drift-gate / check (pull_request) Successful in 30s
CI / Detect changes (pull_request) Successful in 35s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 32s
Harness Replays / detect-changes (pull_request) Successful in 34s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 36s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 40s
branch-protection drift check / Branch protection drift (pull_request) Successful in 42s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 38s
E2E API Smoke Test / detect-changes (pull_request) Successful in 42s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 37s
Harness Replays / Harness Replays (pull_request) Failing after 40s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m46s
CI / Python Lint & Test (pull_request) Successful in 1m10s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 1m7s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 1m39s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 7m39s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 7m51s
CI / Canvas (Next.js) (pull_request) Successful in 9m16s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Successful in 10m17s
Phase 4 follow-up to template-claude-code PR #9 (2026-05-08 dev-tree wedge).

Pre-fix: applyRuntimeModelEnv unconditionally overwrote envVars["MODEL"]
with the MODEL_PROVIDER slug whenever payload.Model was empty (the restart
path). This silently wiped the operator'\''s explicit per-persona MODEL
secret on every restart.

Symptom: dev-tree workspaces booted correctly on first /org/import (the
envVars map was populated direct from the persona env file with both
MODEL=MiniMax-M2.7-highspeed and MODEL_PROVIDER=minimax), then on the
next Restart the MODEL secret got clobbered to literal "minimax" — a
provider slug, not a valid model id — and the workspace template'\''s
adapter failed to match any registry prefix, fell through to providers[0]
(anthropic-oauth), and wedged at SDK initialize.

Fix: resolution order in applyRuntimeModelEnv is now:
  1. payload.Model (caller passed the canvas-picked model id verbatim)
  2. envVars["MODEL"] (workspace_secret persisted from persona env)
  3. envVars["MODEL_PROVIDER"] (legacy canvas Save+Restart shape)

Tests
-----
TestApplyRuntimeModelEnv_PersonaEnvMODELSecretPreserved — locks in
the new resolution order with four cases:
  - MODEL secret wins over MODEL_PROVIDER slug (persona-env shape)
  - MODEL secret wins even when same as MODEL_PROVIDER
  - MODEL absent → fall back to MODEL_PROVIDER (legacy shape)
  - Both absent → no MODEL set (no-op)

Existing TestApplyRuntimeModelEnv_SetsUniversalMODELForAllRuntimes
continues to pass — fix is strictly additive on the precedence chain.
2026-05-08 14:31:14 -07:00
claude-ceo-assistant 9b5e89bb42 Merge pull request 'feat(org-import): add spawning:false field to skip workspace + descendants' (#135) from feat/org-import-spawning-false into main
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
publish-workspace-server-image / build-and-push (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (push) Successful in 21s
CodeQL / Analyze (${{ matrix.language }}) (go) (push) Successful in 23s
CodeQL / Analyze (${{ matrix.language }}) (python) (push) Successful in 21s
CI / Detect changes (push) Successful in 28s
Block internal-flavored paths / Block forbidden paths (push) Successful in 35s
Handlers Postgres Integration / detect-changes (push) Successful in 29s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 33s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 31s
E2E API Smoke Test / detect-changes (push) Successful in 1m5s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 1m1s
Harness Replays / detect-changes (push) Successful in 1m4s
CI / Shellcheck (E2E scripts) (push) Successful in 11s
CI / Canvas (Next.js) (push) Successful in 17s
CI / Canvas Deploy Reminder (push) Has been skipped
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 1m15s
CI / Python Lint & Test (push) Successful in 1m56s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 2m27s
Harness Replays / Harness Replays (push) Successful in 3m0s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 5m46s
CI / Platform (Go) (push) Successful in 8m23s
2026-05-08 21:20:56 +00:00
claude-ceo-assistant b91da1ab77 feat(org-import): add spawning:false field to skip workspace + descendants
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 11s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 11s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 11s
Check merge_group trigger on required workflows / Required workflows have merge_group trigger (pull_request) Successful in 24s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 36s
cascade-list-drift-gate / check (pull_request) Successful in 35s
E2E API Smoke Test / detect-changes (pull_request) Successful in 36s
CI / Detect changes (pull_request) Successful in 39s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 27s
branch-protection drift check / Branch protection drift (pull_request) Successful in 45s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 47s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 37s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 58s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 57s
Harness Replays / detect-changes (pull_request) Successful in 50s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 29s
CI / Python Lint & Test (pull_request) Successful in 33s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 56s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 30s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 2m5s
Harness Replays / Harness Replays (pull_request) Failing after 1m37s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 4m54s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6m49s
CI / Platform (Go) (pull_request) Successful in 9m13s
CI / Canvas (Next.js) (pull_request) Failing after 11m30s
CI / Canvas Deploy Reminder (pull_request) Has been cancelled
Lets a workspace declare it (and its entire subtree) should be skipped
during /org/import. Pointer-typed `*bool` so we distinguish "explicitly
false" from "unset" (default = spawn).

## Use case

The dev-tree org template ships the full role taxonomy (Dev Lead with
Core Platform / Controlplane / App & Docs / Infra / SDK Leads, each with
their own engineering / QA / security / UI-UX children — 27 personas
total in a single import). Some setups need a smaller set:

- Local dev on a memory-constrained machine
- Demo / smoke runs that don't need the full org breathing
- Customer trials starting with leadership-only before fan-out

Pre-fix the only options were:
- Edit the canonical template (mutates shared state)
- Author a parallel slimmer template (duplicates structure)
- Manual workspace deprovision after full import (wasteful — already paid
  the docker pull / build cost)

`spawning: false` is the per-workspace knob that solves this without
touching the canonical template structure.

## Semantics

- Unset: workspace spawns (current behaviour, no migration)
- `spawning: true`: explicitly spawns (same as unset)
- `spawning: false`: workspace is skipped AND every descendant is
  skipped. The guard sits BEFORE any side effect in
  createWorkspaceTree — no DB row, no docker provision, no children
  recursion. A false-spawning subtree is genuinely a no-op except for
  the log line. countWorkspaces still counts the subtree (so /org/templates
  numbers reflect the full structure).

## Stage A — verified

Local dev-only template that wraps teams/dev.yaml (Dev Lead) with
children:[] cleared on the 5 sub-team yaml files, plus 3 floater
personas (Release Manager / Integration Tester / Fullstack Engineer).
/org/import returned 9 workspaces. Drop-in: same result via
`spawning: false` on each sub-tree root in the future.

## Stage B — N/A

Pure additive feature on the org-template handler. No SaaS deploy chain
implications.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:20:14 -07:00
claude-ceo-assistant aea6109602 Merge pull request 'fix(org-import): use ws.FilesDir as persona-dir lookup + docker-cli-buildx in dev image' (#134) from fix/org-import-persona-env-files-dir into main
CodeQL / Analyze (${{ matrix.language }}) (go) (push) Successful in 6s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (push) Successful in 5s
CodeQL / Analyze (${{ matrix.language }}) (python) (push) Successful in 4s
Block internal-flavored paths / Block forbidden paths (push) Successful in 29s
E2E API Smoke Test / detect-changes (push) Successful in 21s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 22s
Handlers Postgres Integration / detect-changes (push) Successful in 22s
Harness Replays / detect-changes (push) Successful in 27s
CI / Detect changes (push) Successful in 48s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 24s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 13s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 27s
CI / Canvas (Next.js) (push) Successful in 15s
CI / Shellcheck (E2E scripts) (push) Successful in 12s
CI / Canvas Deploy Reminder (push) Has been skipped
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 1m12s
Harness Replays / Harness Replays (push) Failing after 36s
CI / Platform (Go) (push) Failing after 1m3s
CI / Python Lint & Test (push) Successful in 10s
E2E API Smoke Test / E2E API Smoke Test (push) Failing after 1m24s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 7s
publish-workspace-server-image / build-and-push (push) Successful in 4m22s
2026-05-08 20:51:47 +00:00
claude-ceo-assistant c3596d6271 fix(org-import): use ws.FilesDir as persona-dir lookup, add docker-cli-buildx to dev image
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 7s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 8s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 8s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 20s
branch-protection drift check / Branch protection drift (pull_request) Successful in 23s
Check merge_group trigger on required workflows / Required workflows have merge_group trigger (pull_request) Successful in 23s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 28s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 28s
E2E API Smoke Test / detect-changes (pull_request) Successful in 30s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 24s
Harness Replays / detect-changes (pull_request) Successful in 25s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 27s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 21s
Harness Replays / Harness Replays (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 13s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 11s
CI / Detect changes (pull_request) Successful in 52s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 7s
CI / Python Lint & Test (pull_request) Successful in 13s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 2m5s
CI / Platform (Go) (pull_request) Failing after 1m46s
CI / Canvas (Next.js) (pull_request) Failing after 1m49s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 2m16s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
## org_import.go — persona env injection root-cause fix

The Phase-3 fix from earlier today (`feedback/per-agent-gitea-identity-default`)
introduced loadPersonaEnvFile to inject persona-specific creds into
workspace_secrets on /org/import. It passed `ws.Role` as the persona-dir
lookup key, but in our dev-tree org.yaml shape `role:` carries the
multi-line descriptive text the agent reads from its prompt
("Engineering planning and team coordination — leads Core Platform,
Controlplane, ..."), while `files_dir:` holds the short slug
(`core-lead`, `dev-lead`, etc.) matching
`~/.molecule-ai/personas/<files_dir>/env`.

isSafeRoleName silently rejected the multi-word role text → no persona
env loaded → every imported workspace booted with zero
workspace_secrets rows → no ANTHROPIC / CLAUDE_CODE / MINIMAX auth in
the container env → claude_agent_sdk wedged on `query.initialize()`
with a 60s control-request timeout.

After the fix, /org/import on the dev tree (27 personas) populates
8 workspace_secrets per workspace (Gitea identity + MODEL/MODEL_PROVIDER
+ provider-specific token), 5 of 6 leads boot online, and the
remaining wedges trace to a separate runtime-template-repo bug
(workspace-template-claude-code's claude_sdk_executor.py doesn't
dispatch on MODEL_PROVIDER=minimax — filed separately).

## Dockerfile.dev — docker-cli + docker-cli-buildx

Without these, every claude-code/tier-2 workspace POST fails-fast:
- docker-cli alone produces `exec: "docker": executable file not found`
- docker-cli alone (no buildx) fails on `docker build` with
  `ERROR: BuildKit is enabled but the buildx component is missing or broken`

Both packages are now installed in the dev image; verified with
`docker exec molecule-core-platform-1 docker buildx version`.

## Stage A verified

Local /org/import dev-only path: 27 workspaces created, all 27 receive
persona env injection (8 secrets each — Gitea identity + provider creds).
Lead workspaces (claude-code-OAuth tier) boot online.

## Stage B — N/A

Local-dev-only path (docker-compose.dev.yml + dev image). Tenant EC2
provisioning uses Dockerfile.tenant (untouched).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 13:50:46 -07:00
claude-ceo-assistant 2fa79ea462 Merge pull request 'chore(ci): document #192 root cause — workspace-template repos public per OSS-first' (#133) from chore/192-retrigger-harness-replays-after-public-flip into main
CodeQL / Analyze (${{ matrix.language }}) (go) (push) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (push) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (python) (push) Successful in 1s
Block internal-flavored paths / Block forbidden paths (push) Successful in 5s
Check merge_group trigger on required workflows / Required workflows have merge_group trigger (push) Successful in 5s
CI / Detect changes (push) Successful in 9s
E2E API Smoke Test / detect-changes (push) Successful in 9s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 9s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 8s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 10s
Handlers Postgres Integration / detect-changes (push) Successful in 10s
Harness Replays / detect-changes (push) Successful in 10s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 10s
CI / Platform (Go) (push) Successful in 4s
CI / Python Lint & Test (push) Successful in 4s
CI / Canvas (Next.js) (push) Successful in 5s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 5s
CI / Canvas Deploy Reminder (push) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 5s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 4s
CI / Shellcheck (E2E scripts) (push) Successful in 8s
Ops Scripts Tests / Ops scripts (unittest) (push) Successful in 36s
Harness Replays / Harness Replays (push) Successful in 52s
publish-workspace-server-image / build-and-push (push) Successful in 2m2s
2026-05-08 19:12:54 +00:00
claude-ceo-assistant 15935143c8 chore(manifest): drop reno-stars + 5 org-templates flipped public; document OSS-surface contract
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 1s
pr-guards / disable-auto-merge-on-push (pull_request) Successful in 4s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 6s
cascade-list-drift-gate / check (pull_request) Successful in 7s
Check merge_group trigger on required workflows / Required workflows have merge_group trigger (pull_request) Successful in 7s
branch-protection drift check / Branch protection drift (pull_request) Successful in 11s
E2E API Smoke Test / detect-changes (pull_request) Successful in 10s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 10s
CI / Detect changes (pull_request) Successful in 11s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 10s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 10s
Harness Replays / detect-changes (pull_request) Successful in 10s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 10s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 10s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3s
CI / Platform (Go) (pull_request) Successful in 2s
CI / Python Lint & Test (pull_request) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 3s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 5s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 8s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 36s
Harness Replays / Harness Replays (pull_request) Successful in 49s
CI / Canvas (Next.js) (pull_request) Successful in 1m31s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Follow-up to the workspace-template visibility flip in 558e4fee. After
flipping the 5 private workspace-templates public (#192 root cause),
the harness-replays clone moved one step deeper to the org-templates
list, where 6 of 7 were also private. Hongming-confirmed flip plan:

- 5 of 6 (molecule-dev, free-beats-all, medo-smoke, molecule-worker-gemini,
  ux-ab-lab) — flipped public per `feedback_oss_first_repo_visibility_default`.
  These are unambiguously OSS-template-shape: generic README, no
  customer-shaped names, no creds in content.
- 1 of 6 (reno-stars) — name itself is customer-shaped (would expose
  customer/tenant identity). Kept private; removed from manifest.json
  per Hongming. Will be handled at provision-time via the per-tenant
  credential resolver designed in internal#102 (Layer-3 RFC).

Documents the OSS-surface contract in two places:
- manifest.json _comment: every entry MUST be public; Layer-3 lives elsewhere
- clone-manifest.sh comment block: rationale + the explicit ci-readonly
  team-grant escape hatch (review-gated, not default).

Closes the second clone-fail layer of #192. Combined with 558e4fee +
the workspace-template visibility flips, the Pre-clone manifest deps
step should now succeed anonymously for the full registered set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:58:09 -07:00
claude-ceo-assistant 558e4fee48 chore(ci): document #192 root cause — workspace-template repos public per OSS-first
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 2s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 2s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 1s
Check merge_group trigger on required workflows / Required workflows have merge_group trigger (pull_request) Successful in 8s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 9s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 8s
E2E API Smoke Test / detect-changes (pull_request) Successful in 10s
branch-protection drift check / Branch protection drift (pull_request) Successful in 12s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 10s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 10s
CI / Detect changes (pull_request) Successful in 11s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 10s
Harness Replays / detect-changes (pull_request) Successful in 10s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 10s
CI / Platform (Go) (pull_request) Successful in 4s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 3s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 4s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 5s
CI / Python Lint & Test (pull_request) Successful in 5s
Harness Replays / Harness Replays (pull_request) Failing after 7s
CI / Canvas (Next.js) (pull_request) Successful in 1m37s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
5 of 9 workspace-template repos (openclaw, codex, crewai, deepagents,
gemini-cli) had been marked private with no team grant for AUTO_SYNC_TOKEN
bearer (devops-engineer persona). Pre-clone manifest deps step 404'd on
the first private repo encountered, failing every Harness Replays run.

Resolution path taken:
1. Flipped the 5 to public per `feedback_oss_first_repo_visibility_default`
   — runtime/template/plugin repos default public; that's what makes them
   OSS surface.
2. Scoped existing `ci-readonly` org team to legitimately-internal repos
   only (compliance docs, RFCs-in-flight). Workspace templates removed
   from it.
3. Filed internal#102 RFC for Layer-3 (customer-owned + marketplace
   third-party private repos) — that's a different shape entirely;
   needs per-tenant credential-resolver, not org-team grants.

This commit is a documentation-only touch on the workflow file to (a)
record the root cause inline next to the existing pre-clone-fail
narrative, (b) trigger a fresh Harness Replays run that should now pass
the clone step.

Closes #192.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:50:55 -07:00
claude-ceo-assistant 8e4169cfac Merge pull request 'feat(local-dev): containerize platform + canvas stack via docker-compose' (#131) from feat/126-containerize-local-platform-stack into main
CodeQL / Analyze (${{ matrix.language }}) (go) (push) Successful in 3s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (push) Successful in 2s
CodeQL / Analyze (${{ matrix.language }}) (python) (push) Successful in 2s
Block internal-flavored paths / Block forbidden paths (push) Successful in 5s
CI / Detect changes (push) Successful in 8s
E2E API Smoke Test / detect-changes (push) Successful in 8s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 7s
publish-workspace-server-image / build-and-push (push) Failing after 8s
Handlers Postgres Integration / detect-changes (push) Successful in 8s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 10s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 8s
Harness Replays / detect-changes (push) Successful in 9s
CI / Shellcheck (E2E scripts) (push) Successful in 2s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 3s
CI / Python Lint & Test (push) Successful in 4s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 4s
Harness Replays / Harness Replays (push) Failing after 7s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m35s
CI / Canvas (Next.js) (push) Successful in 2m21s
CI / Canvas Deploy Reminder (push) Failing after 1s
CI / Platform (Go) (push) Successful in 2m50s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 4m24s
2026-05-08 18:38:32 +00:00
claude-ceo-assistant bce60f1b22 Merge pull request 'fix(canvas): consolidate platform-auth headers via shared helper (#178)' (#54) from fix/178-canvas-shared-auth-headers into main
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (push) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (go) (push) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (python) (push) Successful in 0s
Block internal-flavored paths / Block forbidden paths (push) Successful in 5s
CI / Detect changes (push) Successful in 8s
E2E API Smoke Test / detect-changes (push) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 8s
Handlers Postgres Integration / detect-changes (push) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 7s
publish-workspace-server-image / build-and-push (push) Failing after 7s
Harness Replays / detect-changes (push) Successful in 8s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 8s
CI / Platform (Go) (push) Successful in 4s
CI / Shellcheck (E2E scripts) (push) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 3s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 4s
CI / Python Lint & Test (push) Successful in 4s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2s
Harness Replays / Harness Replays (push) Failing after 5s
CI / Canvas (Next.js) (push) Successful in 1m32s
CI / Canvas Deploy Reminder (push) Failing after 1s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Has been cancelled
2026-05-08 18:35:58 +00:00
claude-ceo-assistant c6f41198f7 Merge pull request 'chore(canary): workflow_dispatch input keep_on_failure for log capture' (#132) from chore/canary-keep-on-failure-input into main
CodeQL / Analyze (${{ matrix.language }}) (go) (push) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (push) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (python) (push) Successful in 1s
Block internal-flavored paths / Block forbidden paths (push) Successful in 4s
Check merge_group trigger on required workflows / Required workflows have merge_group trigger (push) Successful in 4s
CI / Detect changes (push) Successful in 7s
Handlers Postgres Integration / detect-changes (push) Successful in 6s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 6s
E2E API Smoke Test / detect-changes (push) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 7s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 7s
CI / Shellcheck (E2E scripts) (push) Successful in 3s
CI / Platform (Go) (push) Successful in 3s
CI / Python Lint & Test (push) Successful in 3s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 3s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 3s
CI / Canvas (Next.js) (push) Successful in 4s
CI / Canvas Deploy Reminder (push) Has been skipped
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 4s
2026-05-08 17:59:10 +00:00
dev-lead 5c0c15eb4f chore(canary): workflow_dispatch input keep_on_failure for log capture
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 4s
Check merge_group trigger on required workflows / Required workflows have merge_group trigger (pull_request) Successful in 10s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 3s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 12s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 2s
pr-guards / disable-auto-merge-on-push (pull_request) Successful in 2s
CI / Detect changes (pull_request) Successful in 11s
branch-protection drift check / Branch protection drift (pull_request) Successful in 14s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 8s
E2E API Smoke Test / detect-changes (pull_request) Successful in 9s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 9s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 8s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 10s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 4s
CI / Platform (Go) (pull_request) Successful in 5s
CI / Python Lint & Test (pull_request) Successful in 6s
CI / Canvas (Next.js) (pull_request) Successful in 7s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 5s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 6s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Investigating molecule-core#129 failure mode #1 (claude-code "Agent
error (Exception)") needs the workspace's docker logs to find the
actual exception. The canary tears down the tenant on every failure,
so the workspace container is destroyed before anyone can SSM in.

Add a workflow_dispatch input `keep_on_failure: bool` (default false).
When true, sets `E2E_KEEP_ORG=1` for the canary script — its existing
debug path skips teardown, leaving the tenant + EC2 + CF tunnel + DNS
alive. Operator can then SSM into the workspace EC2 (via the same
flow as recover-tunnels.py) and capture `docker logs` from the
claude-code container.

Cron-triggered runs never set the input (it only exists on dispatch),
so unattended scheduled canaries always tear down — no risk of
unattended cost leak.

Operator workflow:
  1. Dispatch canary-staging.yml with keep_on_failure=true
  2. Watch CI; on failure (likely, given the 38h chronic red),
     note the SLUG / TENANT_URL printed at step 1/11
  3. SSM exec into the workspace EC2 (us-east-2) and run
     `docker logs <claude-code-container>` to find the actual
     exception traceback
  4. Manually delete via DELETE /cp/admin/tenants/<slug> when done
     (the script logs this reminder on E2E_KEEP_ORG=1 path)

Refs: molecule-core#129 (canary investigation)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:58:19 -07:00
claude-ceo-assistant 7eda8f510f feat(local-dev): containerize platform + canvas stack via docker-compose (closes #126)
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 0s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
CI / Detect changes (pull_request) Successful in 7s
E2E API Smoke Test / detect-changes (pull_request) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 8s
Harness Replays / detect-changes (pull_request) Successful in 8s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 8s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 3s
CI / Python Lint & Test (pull_request) Successful in 3s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 4s
Harness Replays / Harness Replays (pull_request) Failing after 5s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 51s
CI / Canvas (Next.js) (pull_request) Successful in 2m5s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Successful in 2m31s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4m22s
Replaces the legacy nohup `go run ./cmd/server` setup with a fully
containerized local stack: postgres + redis + platform + canvas, all
with `restart: unless-stopped` so they survive Mac sleep/wake and
Docker Desktop daemon restarts.

## Changes

- **docker-compose.yml**
  - `restart: unless-stopped` on platform/postgres/redis
  - `BIND_ADDR=0.0.0.0` for platform — the dev-mode-fail-open default
    of 127.0.0.1 (PR #7) made the host unable to reach the container
    even with port mapping. Container netns is already isolated, so
    binding all interfaces inside is safe.
  - Healthchecks switched from `wget --spider` (HEAD → 404 forever
    because /health is GET-only) to `wget -qO /dev/null` (GET).
    Same regression existed on canvas; fixed both.

- **workspace-server/Dockerfile.dev**
  - `CGO_ENABLED=1` → `0` to match prod Dockerfile + Dockerfile.tenant.
    Without this, the alpine dev image fails with "gcc: not found"
    because workspace-server has no actual cgo deps but the env was
    forcing the cgo build path. Closes a divergence introduced in
    9d50a6da (today's air hot-reload PR).

- **canvas/Dockerfile**
  - `npm install` → `npm ci --include=optional` for lockfile-exact
    installs that include platform-specific @tailwindcss/oxide native
    binaries. Without these, `next build` fails with "Cannot read
    properties of undefined (reading 'All')" on the
    `@import "tailwindcss"` directive.

- **canvas/.dockerignore** (new)
  - Excludes `node_modules` and `.next` so the Dockerfile's
    `COPY . .` step doesn't clobber the freshly-installed container
    node_modules with the host's (potentially stale or wrong-arch)
    copy. This was the actual root cause of the canvas build break.

- **workspace-server/.gitignore**
  - Adds `/tmp/` for air's live-reload build cache.

## Stage A verified

```
container          status                    restart
postgres-1         Up (healthy)              unless-stopped
redis-1            Up (healthy)              unless-stopped
platform-1         Up (healthy, air-mode)    unless-stopped
canvas-1           Up (healthy)              unless-stopped

GET :8080/health  → 200
GET :3000/        → 200
DB preserved:     407 workspace rows + 5 named personas
Persona mount:    28 dirs at /etc/molecule-bootstrap/personas
```

## Stage B — N/A

This is local-dev infrastructure only. None of these files ship to
SaaS tenants — production EC2s use `Dockerfile.tenant` + `ec2.go`
user-data, not docker-compose.

## Out of scope

- The decorative-but-broken `wget --spider` healthcheck has presumably
  also been silently 404'ing on prod tenants. Ship a follow-up to
  audit + fix the prod path; not done here to keep the PR scoped.
- Docker Desktop "Start at login" is a per-machine GUI setting that
  must be toggled manually (Settings → General).
- The legacy heartbeat-all.sh that pinged 5 persona workspaces from
  the host has been deleted (~/.molecule-ai/heartbeat-all.sh).
  Per Hongming: each workspace is responsible for its own heartbeat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:53:39 -07:00
claude-ceo-assistant 44bb35f2a8 Merge pull request 'fix(ci): canary alerting — drop Gitea-incompatible actions API call' (#130) from fix/canary-staging-gitea-compat-alerting into main
CodeQL / Analyze (${{ matrix.language }}) (go) (push) Successful in 0s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (push) Successful in 0s
CodeQL / Analyze (${{ matrix.language }}) (python) (push) Successful in 2s
Block internal-flavored paths / Block forbidden paths (push) Successful in 5s
Check merge_group trigger on required workflows / Required workflows have merge_group trigger (push) Successful in 5s
CI / Detect changes (push) Successful in 7s
E2E API Smoke Test / detect-changes (push) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 6s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 7s
Handlers Postgres Integration / detect-changes (push) Successful in 7s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 8s
CI / Shellcheck (E2E scripts) (push) Successful in 2s
CI / Platform (Go) (push) Successful in 4s
CI / Canvas (Next.js) (push) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 3s
CI / Python Lint & Test (push) Successful in 3s
CI / Canvas Deploy Reminder (push) Has been skipped
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 5s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 26s
2026-05-08 17:52:48 +00:00
dev-lead 42ff6be15c fix(ci): canary alerting — drop Gitea-incompatible actions API call
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 1s
Check merge_group trigger on required workflows / Required workflows have merge_group trigger (pull_request) Successful in 5s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
branch-protection drift check / Branch protection drift (pull_request) Successful in 8s
CI / Detect changes (pull_request) Successful in 8s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 7s
E2E API Smoke Test / detect-changes (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 8s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 8s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 8s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 8s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 3s
CI / Python Lint & Test (pull_request) Successful in 4s
CI / Canvas (Next.js) (pull_request) Successful in 4s
CI / Platform (Go) (pull_request) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 3s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4s
The "Open issue on failure" step was failing on every canary run
because Gitea 1.22.6 doesn't expose /api/v1/actions endpoints
(per memory reference_gitea_actions_log_fetch). The threshold check
called github.rest.actions.listWorkflowRuns() to count consecutive
prior failures and gate issue creation behind 3 reds — that call
ALWAYS 404'd on Gitea, breaking the entire alerting step.

Net effect: the canary's own self-alerting was broken, so the
underlying staging regression went unflagged for 38h+
(2026-05-07 02:30 UTC → 2026-05-08 17:34 UTC, every cron tick red,
zero issues filed).

Fix: drop the consecutive-failures threshold entirely. File a
sticky issue on the FIRST failure; comment-on-existing handles
deduplication for subsequent failures. The auto-close-on-success
step is unchanged.

Why not a Gitea-compatible threshold (e.g., walk recent commit
statuses): comment-on-existing already gives ops a single
accumulating issue per regression streak. The threshold's purpose
was to avoid spamming on transient flakes — but with sticky issue
+ auto-close-on-green, transient flakes get one issue + one quick
close, which is fine signal. Filing on first failure is also
better UX: catches the regression in 30 min instead of 90 min.

Also: rewrote runURL from hardcoded https://github.com/... to
context.serverUrl so the link actually points at Gitea
(https://git.moleculesai.app) — was always broken on Gitea but
nobody noticed because the issue-filing step itself was broken.

Net: 21 insertions, 40 deletions. Removes WORKFLOW_PATH +
CONSECUTIVE_THRESHOLD env vars (no longer needed).

Tracked in: molecule-core#129 (failure mode 3 of 3)
Verification: yaml syntax-valid; no remaining github.rest.actions.*
calls; only github.rest.issues.* (all Gitea-supported per
memory feedback_persona_token_v2_scope).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:52:09 -07:00
claude-ceo-assistant 32773fd566 Merge pull request 'feat(local-dev): bind-mount ~/.molecule-ai/personas into platform container' (#127) from feat/persona-bind-mount-local-dev into main
CodeQL / Analyze (${{ matrix.language }}) (go) (push) Successful in 2s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (push) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (python) (push) Successful in 3s
Block internal-flavored paths / Block forbidden paths (push) Successful in 10s
CI / Detect changes (push) Successful in 12s
E2E API Smoke Test / detect-changes (push) Successful in 11s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 10s
Handlers Postgres Integration / detect-changes (push) Successful in 11s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 12s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 12s
CI / Shellcheck (E2E scripts) (push) Successful in 3s
CI / Canvas (Next.js) (push) Successful in 5s
CI / Platform (Go) (push) Successful in 6s
CI / Python Lint & Test (push) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 4s
CI / Canvas Deploy Reminder (push) Has been skipped
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 6s
Canary — staging SaaS smoke (every 30 min) / Canary smoke (push) Failing after 4m49s
2026-05-08 16:53:05 +00:00
claude-ceo-assistant d72f21da09 feat(local-dev): bind-mount ~/.molecule-ai/personas into platform container
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 1s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 1s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 7s
CI / Detect changes (pull_request) Successful in 9s
E2E API Smoke Test / detect-changes (pull_request) Successful in 9s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 9s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 11s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 11s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 12s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 5s
CI / Platform (Go) (pull_request) Successful in 5s
CI / Canvas (Next.js) (pull_request) Successful in 7s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 5s
CI / Python Lint & Test (pull_request) Successful in 6s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 6s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 5s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 8s
Closes core#242 LOCAL surface. The PROD surface (CP user-data fetching
persona env files into tenant EC2's /etc/molecule-bootstrap/personas
via Secrets Manager) is filed as a follow-up.

WHAT THIS ADDS
  Bind-mount on the platform service in docker-compose.yml:
    ${MOLECULE_PERSONA_ROOT_HOST:-${HOME}/.molecule-ai/personas}
      → /etc/molecule-bootstrap/personas (read-only)

  Default source = ${HOME}/.molecule-ai/personas (the operator-host-mirrored
  local dir populated by today's persona rotation work). Override via
  MOLECULE_PERSONA_ROOT_HOST when running on a machine with a different
  layout (CI runners, etc.).

WHY READ-ONLY
  workspace-server only reads persona env files; never writes back. The
  read-only mount enforces that contract — a hostile plugin install path
  can't tamper with the persona credentials it's about to consume.

WHY THIS PATH MATCHES PROD
  /etc/molecule-bootstrap/personas is the same in-container path the
  prod tenant EC2 will use. Same code path (org_import.go::loadPersonaEnvFile)
  reads the same file regardless of mode — local-dev parity with prod
  per feedback_local_must_mimic_production.

STAGE A VERIFICATION
  - docker compose config: resolves to /Users/hongming/.molecule-ai/personas
    correctly (28 persona dirs visible at source path)
  - Persona env file shape verified: dev-lead's env contains GITEA_USER,
    GITEA_USER_EMAIL, GITEA_TOKEN_SCOPES, GITEA_SSH_KEY_PATH,
    MODEL_PROVIDER=claude-code, MODEL=opus (lead tier matches Hongming's
    2026-05-08 mapping)
  - Full handler test suite green (TestLoadPersonaEnvFile_HappyPath +
    7 sibling tests pass; rejection tests still catch path traversal)
  - Build clean

STAGE B SKIPPED (with justification per § Skip conditions)
  This change is config-only (docker-compose.yml volume addition). The
  prod tenant EC2s do NOT use docker-compose.yml — they use CP user-data
  + ec2.go's docker run script. So this PR has no prod blast radius.
  Stage B (staging tenant probe) would be checking 'is the platform
  using the new compose mount' on a SaaS tenant — and SaaS tenants
  don't run docker compose. The actual prod-surface change is the
  follow-up issue.

PROD SURFACE — FOLLOW-UP FILED
  Tenant EC2 user-data needs to fetch persona env files from operator
  host (or AWS Secrets Manager per the established
  feedback_unified_credentials_file pattern) and stage them at
  /etc/molecule-bootstrap/personas inside the workspace-server container.
  Touches molecule-controlplane/internal/provisioner/ec2.go user-data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:52:43 -07:00
claude-ceo-assistant cc28cc6607 Merge pull request 'feat(workspaces): update_tier column for canary vs production fan-out' (#124) from feat/canary-tier-filter into main
CodeQL / Analyze (${{ matrix.language }}) (go) (push) Successful in 5s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (push) Successful in 6s
CodeQL / Analyze (${{ matrix.language }}) (python) (push) Successful in 19s
Block internal-flavored paths / Block forbidden paths (push) Successful in 35s
CI / Detect changes (push) Successful in 34s
E2E API Smoke Test / detect-changes (push) Successful in 17s
Handlers Postgres Integration / detect-changes (push) Successful in 25s
publish-workspace-server-image / build-and-push (push) Failing after 25s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 26s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 29s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 28s
Harness Replays / detect-changes (push) Successful in 29s
CI / Canvas (Next.js) (push) Successful in 10s
CI / Shellcheck (E2E scripts) (push) Successful in 8s
CI / Python Lint & Test (push) Successful in 23s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 12s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 8s
Harness Replays / Harness Replays (push) Failing after 22s
CI / Canvas Deploy Reminder (push) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 2m35s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 7m31s
CI / Platform (Go) (push) Successful in 13m59s
Canary — staging SaaS smoke (every 30 min) / Canary smoke (push) Failing after 3m56s
2026-05-08 15:55:42 +00:00
claude-ceo-assistant 120b3a25aa feat(workspaces): update_tier column for canary vs production fan-out
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 19s
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 4s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 4s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 4s
Check migration collisions / Migration version collision check (pull_request) Successful in 29s
CI / Detect changes (pull_request) Successful in 1m3s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 39s
E2E API Smoke Test / detect-changes (pull_request) Successful in 47s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 23s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 34s
Harness Replays / detect-changes (pull_request) Successful in 38s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 38s
CI / Canvas (Next.js) (pull_request) Successful in 11s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 11s
CI / Python Lint & Test (pull_request) Successful in 10s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 15s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Harness Replays / Harness Replays (pull_request) Failing after 35s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 21s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 6m49s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 7m55s
CI / Platform (Go) (pull_request) Successful in 14m6s
Closes core#115 partial. Schema-only change; the apply-endpoint filter
logic that reads this column lands with core#123 (drift detector +
queue + apply endpoint, the deferred follow-up of core#113).

Default 'production' so existing customers (Reno-Stars + any future
tenant) are default-safe. Synthetic dogfooding workspaces opt INTO
'canary' explicitly.

CHECK constraint pins the closed value set ('canary' | 'production') —
the apply endpoint's filter relies on the database to reject anything
else, so a future operator typo in PATCH /workspaces/:id ({update_tier:
'canery'}) returns a constraint violation, not silent fan-out to
nobody.

Partial index on canary rows since the apply-endpoint query path
('apply this update only to canary tier first') hits canary much more
often than production, and the production set is the much larger
default.

WHAT THIS DOES NOT DO (lands with core#123)
  - PATCH endpoint to flip a workspace to canary
  - The apply endpoint that consults the column
  - Tests that exercise canary-vs-production fan-out

Schema-only foundation; same pattern as core#113 (workspace_plugins).

PHASE 4 SELF-REVIEW
  Correctness: No finding — IF NOT EXISTS guards, DEFAULT clause means
    existing rows get 'production' on migration apply.
  Readability: No finding — comment block documents the tier semantics
    + the deferral to core#123.
  Architecture: No finding — additive ALTER, partial index for the
    expected access pattern.
  Security: No finding — no code path; column constraint reduces blast
    radius of bad PATCH input.
  Performance: No finding — partial index minimizes write amplification
    on the production-default rows.

REFS
  core#115 — this issue
  core#123 — apply endpoint follow-up (will exercise this column)
  core#113 — version subscription DB foundation (sibling pattern)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:55:19 -07:00
claude-ceo-assistant b7f3b270a3 Merge pull request 'feat(plugins): workspace_plugins tracking table (version-subscription foundation)' (#122) from feat/plugin-version-subscription into main
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Harness Replays / Harness Replays (push) Blocked by required conditions
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
CodeQL / Analyze (${{ matrix.language }}) (go) (push) Successful in 8s
Block internal-flavored paths / Block forbidden paths (push) Successful in 28s
CodeQL / Analyze (${{ matrix.language }}) (python) (push) Successful in 15s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (push) Successful in 16s
CI / Detect changes (push) Successful in 27s
E2E API Smoke Test / detect-changes (push) Successful in 20s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 20s
Handlers Postgres Integration / detect-changes (push) Successful in 19s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 31s
publish-workspace-server-image / build-and-push (push) Failing after 31s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 33s
Harness Replays / detect-changes (push) Successful in 33s
CI / Python Lint & Test (push) Successful in 12s
CI / Canvas (Next.js) (push) Has been cancelled
CI / Shellcheck (E2E scripts) (push) Has been cancelled
CI / Canvas Deploy Reminder (push) Has been skipped
CI / Platform (Go) (push) Failing after 14m35s
2026-05-08 15:53:42 +00:00
claude-ceo-assistant b398667fce Merge branch 'main' into fix/178-canvas-shared-auth-headers
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 6s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 7s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 8s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 14s
CI / Detect changes (pull_request) Successful in 16s
pr-guards / disable-auto-merge-on-push (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 15s
E2E API Smoke Test / detect-changes (pull_request) Successful in 17s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 16s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 15s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 18s
Harness Replays / detect-changes (pull_request) Successful in 19s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 8s
CI / Platform (Go) (pull_request) Successful in 10s
CI / Python Lint & Test (pull_request) Successful in 8s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 11s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 9s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 11s
Harness Replays / Harness Replays (pull_request) Successful in 2m8s
CI / Canvas (Next.js) (pull_request) Successful in 5m49s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 6m19s
2026-05-08 02:46:41 +00:00
claude-ceo-assistant 5c62f172f0 Merge branch 'main' into fix/178-canvas-shared-auth-headers
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 9s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 9s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 9s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 14s
pr-guards / disable-auto-merge-on-push (pull_request) Failing after 7s
CI / Detect changes (pull_request) Successful in 17s
E2E API Smoke Test / detect-changes (pull_request) Successful in 16s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 16s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 16s
Harness Replays / detect-changes (pull_request) Successful in 16s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 16s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 16s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 7s
CI / Platform (Go) (pull_request) Successful in 8s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 9s
CI / Python Lint & Test (pull_request) Successful in 10s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 11s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 8s
Harness Replays / Harness Replays (pull_request) Failing after 1m59s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 5m54s
CI / Canvas (Next.js) (pull_request) Failing after 8m34s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
2026-05-08 01:27:46 +00:00
claude-ceo-assistant 7f86a245bf Merge branch 'main' into fix/178-canvas-shared-auth-headers
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 6s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 6s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 17s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 6s
CI / Detect changes (pull_request) Successful in 17s
pr-guards / disable-auto-merge-on-push (pull_request) Failing after 7s
E2E API Smoke Test / detect-changes (pull_request) Successful in 14s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 14s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 16s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 14s
Harness Replays / detect-changes (pull_request) Successful in 16s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 15s
CI / Platform (Go) (pull_request) Successful in 10s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 9s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 11s
CI / Python Lint & Test (pull_request) Successful in 12s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 11s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 16s
Harness Replays / Harness Replays (pull_request) Failing after 1m13s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 6m40s
CI / Canvas (Next.js) (pull_request) Failing after 8m13s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
2026-05-08 00:54:16 +00:00
claude-ceo-assistant 9c82b2a61c Merge branch 'main' into fix/178-canvas-shared-auth-headers
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 8s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 8s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 8s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 16s
pr-guards / disable-auto-merge-on-push (pull_request) Failing after 8s
CI / Detect changes (pull_request) Successful in 19s
E2E API Smoke Test / detect-changes (pull_request) Successful in 17s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 17s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 16s
Harness Replays / detect-changes (pull_request) Successful in 16s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 15s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 16s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 7s
CI / Platform (Go) (pull_request) Successful in 9s
CI / Python Lint & Test (pull_request) Successful in 8s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 11s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 12s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 9s
Harness Replays / Harness Replays (pull_request) Failing after 1m20s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 6m26s
CI / Canvas (Next.js) (pull_request) Failing after 8m35s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
2026-05-08 00:20:46 +00:00
claude-ceo-assistant e4b1248f47 Merge branch 'main' into fix/178-canvas-shared-auth-headers
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 5s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 5s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 5s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 12s
pr-guards / disable-auto-merge-on-push (pull_request) Failing after 4s
CI / Detect changes (pull_request) Successful in 12s
E2E API Smoke Test / detect-changes (pull_request) Successful in 12s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 13s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 13s
Harness Replays / detect-changes (pull_request) Successful in 14s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 13s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 13s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 4s
CI / Platform (Go) (pull_request) Successful in 6s
CI / Python Lint & Test (pull_request) Successful in 6s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 7s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 8s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 11s
Harness Replays / Harness Replays (pull_request) Failing after 37s
CI / Canvas (Next.js) (pull_request) Failing after 2m54s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4m25s
2026-05-07 22:24:44 +00:00
Hongming Wang 501d07b0f2 fix(canvas): consolidate platform-auth headers via shared helper (#178)
Retarget main PRs to staging / Retarget to staging (pull_request) Has been skipped
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 6s
CI / Detect changes (pull_request) Successful in 8s
E2E API Smoke Test / detect-changes (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 7s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 7s
Harness Replays / detect-changes (pull_request) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 7s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 3s
CI / Platform (Go) (pull_request) Successful in 4s
CI / Python Lint & Test (pull_request) Successful in 4s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 5s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 5s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 5s
Harness Replays / Harness Replays (pull_request) Failing after 36s
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Failing after 1m22s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Failing after 1m22s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Failing after 1m23s
CI / Canvas (Next.js) (pull_request) Failing after 1m39s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 3m55s
Closes the post-Task-#176 self-review gap: the bearer-token + tenant-
slug header construction was duplicated across 7 raw-fetch callsites
in the canvas (lib/api.ts request(), uploads.ts × 2, and 5 Attachment*
components). Each callsite read NEXT_PUBLIC_ADMIN_TOKEN, attached
Authorization: Bearer manually, computed getTenantSlug locally
(three of them inline-redefined it from /lib/tenant!), and attached
X-Molecule-Org-Slug. A new poller / raw-fetch added without going
through this exact recipe silently 401s against workspace-server when
ADMIN_TOKEN is set on the server side — the bug shape called out in
the original task.

Adds platformAuthHeaders() to lib/api.ts as the single source of truth
and routes all 7 raw-fetch callsites through it. Removes 4 duplicate
local getTenantSlug() copies (Image, Video, Audio, PDF, TextPreview)
that were inline-redefining what /lib/tenant.ts already exports.

Also preserves the AttachmentTextPreview off-platform branch — when
isPlatformAttachment() is false, headers is {} (no bearer leakage to
third-party URLs).

Tests:
- 6 unit tests in platform-auth-headers.test.ts covering: empty,
  bearer-only, slug-only, both, empty-string-as-unset, fresh-object-
  per-call. Mutation-tested: removing the bearer attach inside the
  helper fails 2 of 6 tests immediately.
- All 1389 existing canvas vitest tests pass unchanged.
- npx tsc --noEmit clean.
- npm run build succeeds (canvas Next.js build).

Per feedback_assert_exact_not_substring: tests use exact toEqual()
equality, not substring/contains, so an extra-header bug also fails
the assertion. Per feedback_oss_design_philosophy: this is the
"plugin/abstract/modular/SSOT" move applied to the auth-header
construction surface — one helper, six call sites, no duplication.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:36:02 -07:00
27 changed files with 1037 additions and 180 deletions
+42 -40
View File
@@ -20,6 +20,19 @@ on:
# a few minutes under load — that's fine for a canary.
- cron: '*/30 * * * *'
workflow_dispatch:
inputs:
keep_on_failure:
description: >-
Skip teardown when the canary fails (debugging only). The
tenant org + EC2 + CF tunnel + DNS stay alive so an operator
can SSM into the workspace EC2 and capture docker logs of the
failing claude-code container. REMEMBER to manually delete
via DELETE /cp/admin/tenants/<slug> when done so the org
doesn't accumulate cost. Only honored on workflow_dispatch;
cron runs always tear down (we don't want unattended cron
to leak resources).
type: boolean
default: false
# Serialise with the full-SaaS workflow so they don't contend for the
# same org-create quota on staging. Different group key from
@@ -80,6 +93,14 @@ jobs:
# is "Token Plan only" but cheap-per-token and fast.
E2E_MODEL_SLUG: MiniMax-M2.7-highspeed
E2E_RUN_ID: "canary-${{ github.run_id }}"
# Debug-only: when an operator dispatches with keep_on_failure=true,
# the canary script's E2E_KEEP_ORG=1 path skips teardown so the
# tenant org + EC2 stay alive for SSM-based log capture. Cron runs
# never set this (the input only exists on workflow_dispatch) so
# unattended cron always tears down. See molecule-core#129
# failure mode #1 — capturing the actual exception requires
# docker logs from the live container.
E2E_KEEP_ORG: ${{ github.event.inputs.keep_on_failure == 'true' && '1' || '0' }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -137,27 +158,28 @@ jobs:
id: canary
run: bash tests/e2e/test_staging_full_saas.sh
# Alerting: open an issue only after THREE consecutive failures so
# transient flakes (Cloudflare DNS hiccup, AWS API blip) don't spam
# the issue list. If an issue is already open, we still comment on
# every failure so ops sees the streak. Auto-close on next green.
# Alerting: open a sticky issue on the FIRST failure; comment on
# subsequent failures; auto-close on next green. Comment-on-existing
# de-duplicates so a single open issue accumulates the streak —
# ops sees one issue with N comments rather than N issues.
#
# Threshold rationale: canary fires every 30 min, so 3 failures =
# ~90 min of consecutive red — well past any single-run flake but
# still tight enough that a real outage gets surfaced before the
# next deploy window.
# Why no consecutive-failures threshold (e.g., wait 3 runs before
# filing): the prior threshold check used
# `github.rest.actions.listWorkflowRuns()` which Gitea 1.22.6 does
# not expose (returns 404). On Gitea Actions the threshold call
# ALWAYS failed, breaking the entire alerting step and going days
# silent on real regressions (38h+ chronic red on 2026-05-07/08
# before this fix; tracked in molecule-core#129). Filing on first
# failure is also better UX — we want to know about the first red,
# not wait 90 min for it to "count." Real flakes get one issue +
# a quick close-on-green; persistent reds accumulate comments.
- name: Open issue on failure
if: failure()
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
# Inject the workflow path explicitly — context.workflow is
# the *name*, not the file path the actions API needs.
WORKFLOW_PATH: '.github/workflows/canary-staging.yml'
CONSECUTIVE_THRESHOLD: '3'
with:
script: |
const title = '🔴 Canary failing: staging SaaS smoke';
const runURL = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const runURL = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
// Find an existing open canary issue (stable title match).
// If one exists, this isn't a "first failure" — comment and exit.
@@ -177,32 +199,12 @@ jobs:
return;
}
// No open issue yet — check the last N-1 runs' conclusions.
// We open the issue only if the last (THRESHOLD-1) runs ALSO
// failed (so this is the 3rd consecutive red).
const threshold = parseInt(process.env.CONSECUTIVE_THRESHOLD, 10);
const { data: runs } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner, repo: context.repo.repo,
workflow_id: process.env.WORKFLOW_PATH,
status: 'completed',
per_page: threshold,
// Skip the current in-progress run; it isn't 'completed' yet.
});
// listWorkflowRuns returns recent first. We need (threshold-1)
// prior failures (current run is the threshold-th).
const priorFailures = (runs.workflow_runs || [])
.slice(0, threshold - 1)
.filter(r => r.id !== context.runId)
.filter(r => r.conclusion === 'failure')
.length;
if (priorFailures < threshold - 1) {
core.info(`Below threshold: ${priorFailures + 1}/${threshold} consecutive failures — not filing yet`);
return;
}
// No open issue yet — file one on this first failure. The
// comment-on-existing branch above means subsequent failures
// accumulate as comments on this same issue, so we don't
// spam new issues per run.
const body =
`Canary run failed at ${new Date().toISOString()}, ` +
`${threshold} consecutive runs red.\n\n` +
`Canary run failed at ${new Date().toISOString()}.\n\n` +
`Run: ${runURL}\n\n` +
`This issue auto-closes on the next green canary run. ` +
`Consecutive failures add a comment here rather than a new issue.`;
@@ -211,7 +213,7 @@ jobs:
title, body,
labels: ['canary-staging', 'bug'],
});
core.info(`Opened canary failure issue (${threshold} consecutive reds)`);
core.info('Opened canary failure issue (first red)');
- name: Auto-close canary issue on success
if: success()
+11
View File
@@ -119,6 +119,17 @@ jobs:
# symptom, different root cause: staging still has the in-image
# clone path, hits the auth error directly).
#
# 2026-05-08 sub-finding (#192): the clone step ALSO fails when
# any referenced workspace-template repo is private and the
# AUTO_SYNC_TOKEN bearer (devops-engineer persona) lacks read
# access. Root cause: 5 of 9 workspace-template repos
# (openclaw, codex, crewai, deepagents, gemini-cli) had been
# marked private with no team grant. Resolution: flipped them
# to public per `feedback_oss_first_repo_visibility_default`
# (the OSS surface should be public). Layer-3 (customer-private +
# marketplace third-party repos) tracked separately in
# internal#102.
#
# Token shape matches publish-workspace-server-image.yml: AUTO_SYNC_TOKEN
# is the devops-engineer persona PAT, NOT the founder PAT (per
# `feedback_per_agent_gitea_identity_default`). clone-manifest.sh
+10
View File
@@ -0,0 +1,10 @@
# Excluded from `docker build` context. Without this, the COPY . . step in
# canvas/Dockerfile clobbers the freshly-installed node_modules with the
# host's (potentially broken / wrong-arch) copy — the @tailwindcss/oxide
# native binary disagreed and broke `next build`.
node_modules
.next
.git
*.log
.env*
!.env.example
+5 -1
View File
@@ -1,7 +1,11 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
# `npm ci` (not `install`) for lockfile-exact reproducibility.
# `--include=optional` ensures the platform-specific @tailwindcss/oxide
# native binary lands — without it, postcss fails with "Cannot read
# properties of undefined (reading 'All')" at build time.
RUN npm ci --include=optional
COPY . .
ARG NEXT_PUBLIC_PLATFORM_URL=http://localhost:8080
ARG NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws
+24
View File
@@ -17,6 +17,7 @@ import { dirname, join } from "node:path";
// update one heuristic. Production is unaffected: `output: "standalone"`
// bakes resolved env into the build, and the marker file isn't shipped.
loadMonorepoEnv();
checkAdminTokenPair();
const nextConfig: NextConfig = {
output: "standalone",
@@ -24,6 +25,29 @@ const nextConfig: NextConfig = {
export default nextConfig;
// checkAdminTokenPair validates the matched-pair contract for ADMIN_TOKEN
// (server-side) and NEXT_PUBLIC_ADMIN_TOKEN (client-side). Both must be set
// or both must be unset — asymmetric configuration silently breaks canvas
// auth against workspace-server in production. Warns via console.error so
// the message appears in next dev console, standalone server stdout, and
// Docker container logs. Does not process.exit — Docker images bake env at
// build time and a hard exit would crash the image; the warn is recoverable.
function checkAdminTokenPair(): void {
// Treat empty-string as unset, matching platform-auth-headers.test.ts.
// An explicitly-set empty string (KEY= in .env) counts as unset.
const serverSet = (process.env.ADMIN_TOKEN ?? "") !== "";
const clientSet = (process.env.NEXT_PUBLIC_ADMIN_TOKEN ?? "") !== "";
if (serverSet === clientSet) return;
// eslint-disable-next-line no-console
console.error(
"[next.config] ADMIN_TOKEN mismatch — " +
`server=${serverSet} client=${clientSet}. ` +
"Both ADMIN_TOKEN and NEXT_PUBLIC_ADMIN_TOKEN must be set together, " +
"or both must be unset (self-hosted / dev mode). " +
"Canvas requests to workspace-server will 401 until resolved.",
);
}
function loadMonorepoEnv() {
const root = findMonorepoRoot(__dirname);
if (!root) return;
@@ -9,6 +9,7 @@
// AttachmentLightbox).
import { useState, useEffect, useRef } from "react";
import { platformAuthHeaders } from "@/lib/api";
import type { ChatAttachment } from "./types";
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
import { AttachmentChip } from "./AttachmentViews";
@@ -43,13 +44,8 @@ export function AttachmentAudio({ workspaceId, attachment, onDownload, tone }: P
void (async () => {
try {
const href = resolveAttachmentHref(workspaceId, attachment.uri);
const headers: Record<string, string> = {};
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
const res = await fetch(href, {
headers,
headers: platformAuthHeaders(),
credentials: "include",
signal: AbortSignal.timeout(60_000),
});
@@ -116,9 +112,5 @@ export function AttachmentAudio({ workspaceId, attachment, onDownload, tone }: P
);
}
function getTenantSlug(): string | null {
if (typeof window === "undefined") return null;
const host = window.location.hostname;
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
return m ? m[1] : null;
}
// Local getTenantSlug() removed — auth-header construction now goes
// through platformAuthHeaders() from @/lib/api (#178).
@@ -35,6 +35,7 @@
// downscale via canvas, but defer that to v2.
import { useState, useEffect, useRef } from "react";
import { platformAuthHeaders } from "@/lib/api";
import type { ChatAttachment } from "./types";
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
import { AttachmentLightbox } from "./AttachmentLightbox";
@@ -75,22 +76,14 @@ export function AttachmentImage({ workspaceId, attachment, onDownload, tone }: P
}
// Platform-auth path: identical to downloadChatFile but we keep
// the blob (don't trigger a Save-As). Use the same headers it does
// by going through it indirectly — no, downloadChatFile triggers a
// Save-As. Need a separate fetch.
// the blob (don't trigger a Save-As). Auth headers come from the
// shared `platformAuthHeaders()` helper — one source of truth for
// every authenticated raw fetch in the canvas (#178).
void (async () => {
try {
const href = resolveAttachmentHref(workspaceId, attachment.uri);
const headers: Record<string, string> = {};
// Read the same env var downloadChatFile reads — single source
// of truth would be cleaner; refactor opportunity for PR-2 if
// we add the same path to AttachmentVideo.
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
const res = await fetch(href, {
headers,
headers: platformAuthHeaders(),
credentials: "include",
signal: AbortSignal.timeout(30_000),
});
@@ -184,15 +177,7 @@ export function AttachmentImage({ workspaceId, attachment, onDownload, tone }: P
);
}
// Internal helper — duplicated from uploads.ts (it's not exported
// there). Kept local so this component doesn't reach into private
// surface; if AttachmentVideo / AttachmentPDF in PR-2/PR-3 also need
// it, lift to an exported helper at that point (the third-caller
// rule).
function getTenantSlug(): string | null {
if (typeof window === "undefined") return null;
const host = window.location.hostname;
// Tenant subdomain shape: <slug>.moleculesai.app
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
return m ? m[1] : null;
}
// Local getTenantSlug() removed — auth-header construction now goes
// through platformAuthHeaders() from @/lib/api which uses the canonical
// getTenantSlug() from @/lib/tenant. This eliminates the duplicate
// hostname-regex + the duplicate bearer-token-attach pattern (#178).
@@ -33,6 +33,7 @@
// timeout, swap to chip. Implemented as a 3-second watchdog.
import { useState, useEffect, useRef } from "react";
import { platformAuthHeaders } from "@/lib/api";
import type { ChatAttachment } from "./types";
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
import { AttachmentLightbox } from "./AttachmentLightbox";
@@ -69,13 +70,8 @@ export function AttachmentPDF({ workspaceId, attachment, onDownload, tone }: Pro
void (async () => {
try {
const href = resolveAttachmentHref(workspaceId, attachment.uri);
const headers: Record<string, string> = {};
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
const res = await fetch(href, {
headers,
headers: platformAuthHeaders(),
credentials: "include",
signal: AbortSignal.timeout(60_000),
});
@@ -189,9 +185,5 @@ function PdfGlyph() {
);
}
function getTenantSlug(): string | null {
if (typeof window === "undefined") return null;
const host = window.location.hostname;
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
return m ? m[1] : null;
}
// Local getTenantSlug() removed — auth-header construction now goes
// through platformAuthHeaders() from @/lib/api (#178).
@@ -26,6 +26,7 @@
// to download the full file.
import { useState, useEffect } from "react";
import { platformAuthHeaders } from "@/lib/api";
import type { ChatAttachment } from "./types";
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
import { AttachmentChip } from "./AttachmentViews";
@@ -57,13 +58,13 @@ export function AttachmentTextPreview({ workspaceId, attachment, onDownload, ton
void (async () => {
try {
const href = resolveAttachmentHref(workspaceId, attachment.uri);
const headers: Record<string, string> = {};
if (isPlatformAttachment(attachment.uri)) {
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
}
// Only attach platform auth headers for in-platform URIs —
// off-platform URLs (HTTP/HTTPS attachments) MUST NOT receive
// our bearer token (it would leak the admin token to a third
// party). The branch is preserved with the new shared helper.
const headers: Record<string, string> = isPlatformAttachment(attachment.uri)
? platformAuthHeaders()
: {};
const res = await fetch(href, {
headers,
credentials: "include",
@@ -182,9 +183,5 @@ export function AttachmentTextPreview({ workspaceId, attachment, onDownload, ton
);
}
function getTenantSlug(): string | null {
if (typeof window === "undefined") return null;
const host = window.location.hostname;
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
return m ? m[1] : null;
}
// Local getTenantSlug() removed — auth-header construction now goes
// through platformAuthHeaders() from @/lib/api (#178).
@@ -25,6 +25,7 @@
// fetch via service worker. v2 if measured-needed.
import { useState, useEffect, useRef } from "react";
import { platformAuthHeaders } from "@/lib/api";
import type { ChatAttachment } from "./types";
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
import { AttachmentChip } from "./AttachmentViews";
@@ -61,13 +62,8 @@ export function AttachmentVideo({ workspaceId, attachment, onDownload, tone }: P
void (async () => {
try {
const href = resolveAttachmentHref(workspaceId, attachment.uri);
const headers: Record<string, string> = {};
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
const res = await fetch(href, {
headers,
headers: platformAuthHeaders(),
credentials: "include",
// Videos are larger than images on average; give the request
// more headroom. The server's per-request body cap (50MB) is
@@ -147,11 +143,5 @@ export function AttachmentVideo({ workspaceId, attachment, onDownload, tone }: P
);
}
// Internal helper — same shape as AttachmentImage's. Lifted to a
// shared util in PR-2.5 if a third caller needs it (PDF, audio).
function getTenantSlug(): string | null {
if (typeof window === "undefined") return null;
const host = window.location.hostname;
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
return m ? m[1] : null;
}
// Local getTenantSlug() removed — auth-header construction now goes
// through platformAuthHeaders() from @/lib/api (#178).
+11 -19
View File
@@ -1,12 +1,16 @@
import { PLATFORM_URL } from "@/lib/api";
import { getTenantSlug } from "@/lib/tenant";
import { PLATFORM_URL, platformAuthHeaders } from "@/lib/api";
import type { ChatAttachment } from "./types";
/** Chat attachments are intentionally uploaded via a direct fetch()
* instead of the `api.post` helper — `api.post` JSON-stringifies the
* body, which would 500 on a Blob. Mirrors the header plumbing
* (tenant slug, admin token, credentials) so SaaS + self-hosted
* callers work the same way. */
* body, which would 500 on a Blob. Auth headers (tenant slug, admin
* token, credentials) come from `platformAuthHeaders()` — the same
* helper `request()` uses, so a missing bearer surfaces as a single
* fix site instead of N copies. We deliberately do NOT set
* Content-Type so the browser writes the multipart boundary into the
* header; setting it manually would yield a multipart body the server
* can't parse. See lib/api.ts platformAuthHeaders() for the full
* rationale on why this pair must stay matched. */
export async function uploadChatFiles(
workspaceId: string,
files: File[],
@@ -16,18 +20,12 @@ export async function uploadChatFiles(
const form = new FormData();
for (const f of files) form.append("files", f, f.name);
const headers: Record<string, string> = {};
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
// Uploads legitimately take a while on cold cache (tar write +
// docker cp into the container). 60s is comfortable for the 25MB/
// 50MB caps the server enforces.
const res = await fetch(`${PLATFORM_URL}/workspaces/${workspaceId}/chat/uploads`, {
method: "POST",
headers,
headers: platformAuthHeaders(),
body: form,
credentials: "include",
signal: AbortSignal.timeout(60_000),
@@ -143,14 +141,8 @@ export async function downloadChatFile(
return;
}
const headers: Record<string, string> = {};
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
const res = await fetch(href, {
headers,
headers: platformAuthHeaders(),
credentials: "include",
signal: AbortSignal.timeout(60_000),
});
@@ -0,0 +1,116 @@
// @vitest-environment node
//
// Tests for the ADMIN_TOKEN / NEXT_PUBLIC_ADMIN_TOKEN matched-pair guard
// in next.config.ts.
//
// Duplicated here (not imported) because importing next.config.ts would
// run loadMonorepoEnv() as a side effect on module load, polluting the
// test env before we can set up our scenarios. If checkAdminTokenPair in
// next.config.ts changes, this duplicate must be kept in sync.
//
// Pin invariant: empty-string is treated as unset.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// Duplicate of next.config.ts checkAdminTokenPair — must stay in sync.
// If you change this, update the original in next.config.ts too.
function checkAdminTokenPair(): void {
const serverSet = (process.env.ADMIN_TOKEN ?? "") !== "";
const clientSet = (process.env.NEXT_PUBLIC_ADMIN_TOKEN ?? "") !== "";
if (serverSet === clientSet) return;
// eslint-disable-next-line no-console
console.error(
"[next.config] ADMIN_TOKEN mismatch — " +
`server=${serverSet} client=${clientSet}. ` +
"Both ADMIN_TOKEN and NEXT_PUBLIC_ADMIN_TOKEN must be set together, " +
"or both must be unset (self-hosted / dev mode). " +
"Canvas requests to workspace-server will 401 until resolved.",
);
}
describe("checkAdminTokenPair", () => {
let origAdminToken: string | undefined;
let origPublicToken: string | undefined;
beforeEach(() => {
origAdminToken = process.env.ADMIN_TOKEN;
origPublicToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
delete process.env.ADMIN_TOKEN;
delete process.env.NEXT_PUBLIC_ADMIN_TOKEN;
// Mock console.error to capture warnings
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
if (origAdminToken === undefined) delete process.env.ADMIN_TOKEN;
else process.env.ADMIN_TOKEN = origAdminToken;
if (origPublicToken === undefined) delete process.env.NEXT_PUBLIC_ADMIN_TOKEN;
else process.env.NEXT_PUBLIC_ADMIN_TOKEN = origPublicToken;
vi.restoreAllMocks();
});
it("passes silently when both are unset", () => {
checkAdminTokenPair();
expect(console.error).not.toHaveBeenCalled();
});
it("passes silently when both are set", () => {
process.env.ADMIN_TOKEN = "server-secret";
process.env.NEXT_PUBLIC_ADMIN_TOKEN = "client-secret";
checkAdminTokenPair();
expect(console.error).not.toHaveBeenCalled();
});
it("warns when ADMIN_TOKEN is set but NEXT_PUBLIC_ADMIN_TOKEN is unset", () => {
process.env.ADMIN_TOKEN = "server-secret";
checkAdminTokenPair();
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining("ADMIN_TOKEN mismatch"),
);
});
it("warns when NEXT_PUBLIC_ADMIN_TOKEN is set but ADMIN_TOKEN is unset", () => {
process.env.NEXT_PUBLIC_ADMIN_TOKEN = "client-secret";
checkAdminTokenPair();
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining("ADMIN_TOKEN mismatch"),
);
});
it("treats empty string as unset (both empty = both unset = pass)", () => {
process.env.ADMIN_TOKEN = "";
process.env.NEXT_PUBLIC_ADMIN_TOKEN = "";
checkAdminTokenPair();
expect(console.error).not.toHaveBeenCalled();
});
it("warns when ADMIN_TOKEN=empty but NEXT_PUBLIC_ADMIN_TOKEN=set", () => {
process.env.ADMIN_TOKEN = "";
process.env.NEXT_PUBLIC_ADMIN_TOKEN = "client-secret";
checkAdminTokenPair();
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining("ADMIN_TOKEN mismatch"),
);
});
it("warns when ADMIN_TOKEN=set but NEXT_PUBLIC_ADMIN_TOKEN=empty", () => {
process.env.ADMIN_TOKEN = "server-secret";
process.env.NEXT_PUBLIC_ADMIN_TOKEN = "";
checkAdminTokenPair();
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining("ADMIN_TOKEN mismatch"),
);
});
it("warning message includes both server and client state", () => {
process.env.ADMIN_TOKEN = "server-secret";
checkAdminTokenPair();
const msg = (console.error as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(msg).toContain("server=true");
expect(msg).toContain("client=false");
});
});
@@ -0,0 +1,97 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// Tests for platformAuthHeaders — the shared helper extracted in #178
// to consolidate the bearer-token-attach + tenant-slug-attach pattern
// that was previously duplicated across 7 raw-fetch callsites in the
// canvas (uploads + 5 Attachment* components + the api.ts request()
// function).
//
// What we pin here:
// - Returns a fresh object each call (so callers can mutate without
// leaking into each other).
// - Empty result on a non-tenant host with no admin token (the
// localhost / self-hosted shape).
// - Bearer attached when NEXT_PUBLIC_ADMIN_TOKEN is set.
// - X-Molecule-Org-Slug attached when window.location.hostname is a
// tenant subdomain (<slug>.moleculesai.app).
// - Both attached when both apply (the production SaaS shape).
//
// Why jsdom: getTenantSlug() reads window.location.hostname. Node-only
// environment yields no window and getTenantSlug returns null
// unconditionally — wouldn't exercise the slug branch.
import { platformAuthHeaders } from "../api";
describe("platformAuthHeaders", () => {
let originalAdminToken: string | undefined;
beforeEach(() => {
originalAdminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
delete process.env.NEXT_PUBLIC_ADMIN_TOKEN;
});
afterEach(() => {
if (originalAdminToken === undefined) delete process.env.NEXT_PUBLIC_ADMIN_TOKEN;
else process.env.NEXT_PUBLIC_ADMIN_TOKEN = originalAdminToken;
// jsdom resets hostname between tests via the @vitest-environment
// pragma's per-test isolation. No explicit reset needed.
});
it("returns an empty object on a non-tenant host with no admin token", () => {
// jsdom default hostname is "localhost" — not a tenant slug, so
// getTenantSlug() returns null and no X-Molecule-Org-Slug is added.
const headers = platformAuthHeaders();
expect(headers).toEqual({});
});
it("attaches Authorization when NEXT_PUBLIC_ADMIN_TOKEN is set", () => {
process.env.NEXT_PUBLIC_ADMIN_TOKEN = "local-dev-admin";
const headers = platformAuthHeaders();
expect(headers).toEqual({ Authorization: "Bearer local-dev-admin" });
});
it("does NOT attach Authorization when NEXT_PUBLIC_ADMIN_TOKEN is empty string", () => {
// Empty-string env is the JS-side shape of `KEY=` in .env.
// Treating it as unset matches the matched-pair guard in
// next.config.ts (admin-token-pair.test.ts) — symmetric semantics.
process.env.NEXT_PUBLIC_ADMIN_TOKEN = "";
const headers = platformAuthHeaders();
expect(headers).toEqual({});
});
it("attaches X-Molecule-Org-Slug on a tenant subdomain", () => {
Object.defineProperty(window, "location", {
value: { hostname: "reno-stars.moleculesai.app" },
writable: true,
});
const headers = platformAuthHeaders();
expect(headers).toEqual({ "X-Molecule-Org-Slug": "reno-stars" });
});
it("attaches both when both apply (production SaaS shape)", () => {
Object.defineProperty(window, "location", {
value: { hostname: "reno-stars.moleculesai.app" },
writable: true,
});
process.env.NEXT_PUBLIC_ADMIN_TOKEN = "tenant-bearer";
const headers = platformAuthHeaders();
// Pin exact-equality on the full shape — substring/contains
// assertions would also pass for an extra-header bug.
expect(headers).toEqual({
"X-Molecule-Org-Slug": "reno-stars",
Authorization: "Bearer tenant-bearer",
});
});
it("returns a fresh object each call (callers can mutate safely)", () => {
process.env.NEXT_PUBLIC_ADMIN_TOKEN = "tok";
const a = platformAuthHeaders();
const b = platformAuthHeaders();
expect(a).not.toBe(b); // distinct refs
expect(a).toEqual(b); // same content
a["Content-Type"] = "application/json";
// Mutation on `a` does not leak into `b`.
expect(b["Content-Type"]).toBeUndefined();
});
});
+48 -10
View File
@@ -21,6 +21,45 @@ export interface RequestOptions {
timeoutMs?: number;
}
/**
* Build the platform auth header set used by every authenticated fetch
* from the canvas. Returns a fresh object so callers can mutate (e.g.
* append `Content-Type` for JSON requests, omit it for FormData).
*
* SaaS cross-origin shape:
* - `X-Molecule-Org-Slug` — derived from `window.location.hostname`
* by `getTenantSlug()`. Control plane uses it for fly-replay
* routing. Empty on localhost / non-tenant hosts — safe to omit.
* - `Authorization: Bearer <token>` — `NEXT_PUBLIC_ADMIN_TOKEN` baked
* into the canvas build (see canvas/Dockerfile L8/L11). Required by
* the workspace-server when `ADMIN_TOKEN` is set on the server side
* (Tier-2b AdminAuth gate, wsauth_middleware.go ~L245). Empty when
* no admin token was provisioned — the Tier-1 session-cookie path
* handles that case via `credentials:"include"`.
*
* Why a shared helper: the two-line "read env, attach bearer; read
* slug, attach header" pattern was duplicated across `request()` and
* 7 raw-fetch callsites (chat uploads/download + 5 Attachment*
* components) before this consolidation. A new poller or raw fetch
* that forgets one of the two headers silently 401s against
* workspace-server when ADMIN_TOKEN is set — the exact bug shape
* called out in #178 / closes the post-#176 self-review gap.
*
* Callers that want JSON Content-Type should spread this and add it
* themselves; FormData callers should NOT add Content-Type (the
* browser sets the multipart boundary). Centralizing the auth pair
* but leaving Content-Type up to the caller is the minimum viable
* shared shape.
*/
export function platformAuthHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
return headers;
}
async function request<T>(
method: string,
path: string,
@@ -28,17 +67,16 @@ async function request<T>(
retryCount = 0,
options?: RequestOptions,
): Promise<T> {
// SaaS cross-origin shape:
// - X-Molecule-Org-Slug: derived from window.location.hostname by
// getTenantSlug(). Control plane uses it for fly-replay routing.
// Empty on localhost / non-tenant hosts — safe to omit.
// - credentials:"include": sends the session cookie cross-origin.
// Cookie's Domain=.moleculesai.app attribute + cp's CORS allow this.
const headers: Record<string, string> = { "Content-Type": "application/json" };
// JSON-bodied request — Content-Type is JSON. Auth pair comes from
// the shared helper; see its doc comment for the SaaS-shape rationale.
const headers: Record<string, string> = {
"Content-Type": "application/json",
...platformAuthHeaders(),
};
// Re-read slug locally for the 401 handler below — `headers` already
// has it, but the 401 branch needs the bare value to gate the
// session-probe + redirect logic on tenant context.
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
const res = await fetch(`${PLATFORM_URL}${path}`, {
method,
+24 -2
View File
@@ -13,6 +13,7 @@ services:
- pgdata:/var/lib/postgresql/data
networks:
- molecule-monorepo-net
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dev}"]
interval: 2s
@@ -50,6 +51,7 @@ services:
- redisdata:/data
networks:
- molecule-monorepo-net
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
@@ -126,6 +128,10 @@ services:
REDIS_URL: redis://redis:6379
PORT: "${PLATFORM_PORT:-8080}"
PLATFORM_URL: "http://platform:${PLATFORM_PORT:-8080}"
# Container network namespace is already isolated; "all interfaces"
# inside the container = the bridge interface only. The fail-open
# default (127.0.0.1) would block host-to-container access.
BIND_ADDR: "${BIND_ADDR:-0.0.0.0}"
# Default MOLECULE_ENV=development so the WorkspaceAuth / AdminAuth
# middleware fail-open path activates when ADMIN_TOKEN is unset —
# otherwise the canvas (which runs without a bearer in pure local
@@ -195,12 +201,28 @@ services:
# App private key — read-only bind-mount. The host-side path is
# gitignored per .gitignore rules (/.secrets/ + *.pem).
- ./.secrets/github-app.pem:/secrets/github-app.pem:ro
# Per-role persona credentials (molecule-core#242 local surface).
# Sourced at workspace creation time by org_import.go::loadPersonaEnvFile
# when a workspace.yaml carries `role: <name>`. The host-side dir is
# populated by the operator-host bootstrap kit (28 dev-tree personas);
# /etc/molecule-bootstrap/personas is the in-container path the
# platform expects (matches the prod tenant-EC2 path so the same code
# works in both modes).
#
# Read-only mount — workspace-server only reads, never writes here.
# If the host dir is empty/missing the platform's loadPersonaEnvFile
# silently no-ops per its existing semantics, so this mount is safe
# even on a fresh machine that hasn't run the bootstrap kit yet.
- ${MOLECULE_PERSONA_ROOT_HOST:-${HOME}/.molecule-ai/personas}:/etc/molecule-bootstrap/personas:ro
ports:
- "${PLATFORM_PUBLISH_PORT:-8080}:${PLATFORM_PORT:-8080}"
networks:
- molecule-monorepo-net
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:${PLATFORM_PORT:-8080}/health || exit 1"]
# Plain GET — `--spider` would issue HEAD, which returns 404 because
# /health is registered as GET only.
test: ["CMD-SHELL", "wget -qO /dev/null --tries=1 http://localhost:${PLATFORM_PORT:-8080}/health || exit 1"]
interval: 5s
timeout: 5s
retries: 10
@@ -238,7 +260,7 @@ services:
networks:
- molecule-monorepo-net
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:${CANVAS_PORT:-3000} || exit 1"]
test: ["CMD-SHELL", "wget -qO /dev/null --tries=1 http://127.0.0.1:${CANVAS_PORT:-3000} || exit 1"]
interval: 10s
timeout: 5s
retries: 10
+1 -2
View File
@@ -1,5 +1,5 @@
{
"_comment": "Pin refs to release tags for reproducible builds. 'main' is OK while all repos are internal.",
"_comment": "OSS surface registry — every repo listed here MUST be public on git.moleculesai.app. Layer-3 customer/private templates are NOT registered here; they are handled at provision-time via the per-tenant credential resolver (see internal#102 RFC). 'main' refs are pinned to tags before broad rollout.",
"version": 1,
"plugins": [
{"name": "browser-automation", "repo": "molecule-ai/molecule-ai-plugin-browser-automation", "ref": "main"},
@@ -40,7 +40,6 @@
{"name": "free-beats-all", "repo": "molecule-ai/molecule-ai-org-template-free-beats-all", "ref": "main"},
{"name": "medo-smoke", "repo": "molecule-ai/molecule-ai-org-template-medo-smoke", "ref": "main"},
{"name": "molecule-worker-gemini", "repo": "molecule-ai/molecule-ai-org-template-molecule-worker-gemini", "ref": "main"},
{"name": "reno-stars", "repo": "molecule-ai/molecule-ai-org-template-reno-stars", "ref": "main"},
{"name": "ux-ab-lab", "repo": "molecule-ai/molecule-ai-org-template-ux-ab-lab", "ref": "main"},
{"name": "mock-bigorg", "repo": "molecule-ai/molecule-ai-org-template-mock-bigorg", "ref": "main"}
]
+14 -17
View File
@@ -8,27 +8,24 @@
# Requires: git, jq (lighter than python3 — ~2MB vs ~50MB in Alpine)
#
# Auth (optional):
# When MOLECULE_GITEA_TOKEN is set, embed it as the basic-auth password so
# private Gitea repos clone successfully. When unset, clone anonymously
# (works only for repos that are public on git.moleculesai.app).
# Post-2026-05-08 (#192): every repo in manifest.json is public on
# git.moleculesai.app. Anonymous clone works for the entire registered
# set. The OSS-surface contract is recorded in manifest.json's _comment
# — Layer-3 customer/private templates (e.g. reno-stars) are NOT in the
# manifest; they are handled at provision-time via the per-tenant
# credential resolver (internal#102 RFC).
#
# This is the path the publish-workspace-server-image.yml workflow uses:
# it injects AUTO_SYNC_TOKEN (devops-engineer persona PAT, repo:read on
# the molecule-ai org) so the in-CI pre-clone step succeeds for ALL
# manifest entries — including the 5 private workspace-template-* repos
# (codex, crewai, deepagents, gemini-cli, langgraph) and all 7
# org-template-* repos.
# MOLECULE_GITEA_TOKEN is therefore optional today. Kept supported for
# two reasons: (a) historical CI configs that still inject
# AUTO_SYNC_TOKEN remain harmless, (b) reserved for the case where a
# private internal-only template is later registered via a ci-readonly
# team grant — review must explicitly sign off on that, since it
# violates the public-OSS-surface contract.
#
# The token never enters the Docker image: this script runs in the
# trusted CI context BEFORE `docker buildx build`, populates
# The token (when set) never enters the Docker image: this script runs
# in the trusted CI context BEFORE `docker buildx build`, populates
# .tenant-bundle-deps/, then `Dockerfile.tenant` COPYs from there with
# the .git directories already stripped (see line ~67 below).
#
# For backward compatibility — and so a fresh clone works without
# secrets when (eventually) the workspace-template-* repos flip public —
# the unset path remains a plain anonymous HTTPS clone. That path will
# FAIL with "could not read Username" on private repos today; CI MUST
# set MOLECULE_GITEA_TOKEN.
set -euo pipefail
+3
View File
@@ -1,2 +1,5 @@
# The compiled binary, not the cmd/server package.
/server
# air live-reload build cache (Dockerfile.dev + docker-compose.dev.yml).
/tmp/
+9 -3
View File
@@ -15,8 +15,14 @@
FROM golang:1.25-alpine
# air + git (for go mod) + ca-certs (for TLS) + tzdata (for time-zone DB).
RUN apk add --no-cache git ca-certificates tzdata wget \
# air + git (for go mod) + ca-certs (for TLS) + tzdata (for time-zone DB)
# + docker-cli + docker-cli-buildx so the platform binary can shell out to
# /var/run/docker.sock (bind-mounted from host) for local-build provisioning.
# docker-cli alone is insufficient: alpine's docker-cli enables BuildKit by
# default but ships without buildx, producing
# `ERROR: BuildKit is enabled but the buildx component is missing or broken`
# on every `docker build`. docker-cli-buildx provides the buildx subcommand.
RUN apk add --no-cache git ca-certificates tzdata wget docker-cli docker-cli-buildx \
&& go install github.com/air-verse/air@latest
WORKDIR /app/workspace-server
@@ -31,7 +37,7 @@ RUN go mod download
# block) so the Dockerfile doesn't need to COPY it. air watches the
# bind-mounted dir for changes.
ENV CGO_ENABLED=1
ENV CGO_ENABLED=0
ENV GOFLAGS="-buildvcs=false"
# Run air with the .air.toml in the bind-mounted source dir.
+186 -1
View File
@@ -13,12 +13,15 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/channels"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
"github.com/gin-gonic/gin"
"github.com/lib/pq"
"gopkg.in/yaml.v3"
)
@@ -422,6 +425,16 @@ type OrgWorkspace struct {
Tier int `yaml:"tier" json:"tier"`
Template string `yaml:"template" json:"template"`
FilesDir string `yaml:"files_dir" json:"files_dir"`
// Spawning gates whether this workspace (AND its descendants) gets
// provisioned during /org/import. Pointer so we can distinguish
// "explicitly set to false" from "unset" (default = spawn). Use case:
// the dev-tree org template declares the full team structure but a
// developer's local machine only has RAM for a subset; setting
// spawning: false on a leaf or a sub-tree root skips that branch
// entirely without editing the canonical template structure.
// Counted in countWorkspaces same as actual; subtree-skip happens
// at provision time in createWorkspaceTree.
Spawning *bool `yaml:"spawning,omitempty" json:"spawning,omitempty"`
// SystemPrompt is an inline override. Normally each role's system-prompt.md
// lives at `<files_dir>/system-prompt.md` and is copied via the files_dir
// template-copy step; inline overrides that path for ad-hoc workspaces.
@@ -558,11 +571,30 @@ func (h *OrgHandler) Import(c *gin.Context) {
var body struct {
Dir string `json:"dir"` // org template directory name
Template OrgTemplate `json:"template"` // or inline template
// Mode controls cleanup behavior of pre-existing workspaces:
// "" / "merge" — additive (default; current behavior).
// Existing workspaces matched by
// (parent_id, name) are skipped; nothing
// outside the new tree is touched.
// "reconcile" — additive + cleanup. After import, any
// online workspace whose name matches an
// imported workspace's name but whose id
// isn't in the import result set is
// cascade-deleted. Catches "previous
// import survived a re-import" zombies
// (the 20:13→21:17 dev-tree case).
Mode string `json:"mode"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
importStart := time.Now()
emitOrgEvent(c.Request.Context(), "org.import.started", map[string]any{
"name": body.Template.Name,
"dir": body.Dir,
"mode": body.Mode,
})
var tmpl OrgTemplate
var orgBaseDir string // base directory for files_dir resolution
@@ -708,18 +740,171 @@ func (h *OrgHandler) Import(c *gin.Context) {
}
}
// Reconcile mode: prune workspaces present from a previous import that
// share a name with the new tree but are NOT in the new result set.
// Catches the additive-import bug where re-running /org/import with a
// changed tree shape (different parent_id for the same role name) leaves
// the prior workspace online — visible to the canvas, consuming
// containers, and looking like a duplicate. Default mode "" / "merge"
// preserves the old additive behavior.
reconcileRemovedCount := 0
reconcileSkipped := 0
reconcileErrs := []string{}
if body.Mode == "reconcile" && createErr == nil {
ctx := c.Request.Context()
importedNames := []string{}
walkOrgWorkspaceNames(tmpl.Workspaces, &importedNames)
importedIDs := make([]string, 0, len(results))
for _, r := range results {
if id, ok := r["id"].(string); ok && id != "" {
importedIDs = append(importedIDs, id)
}
}
// Empty-set guards: if the import didn't produce any names or any
// IDs, skip — querying with empty arrays would either match
// nothing (harmless) or, worse, match every workspace if a future
// query rewrite drops the IN clause. Belt-and-suspenders.
if len(importedNames) > 0 && len(importedIDs) > 0 {
rows, err := db.DB.QueryContext(ctx, `
SELECT id FROM workspaces
WHERE name = ANY($1::text[])
AND id != ALL($2::uuid[])
AND status != 'removed'
`, pq.Array(importedNames), pq.Array(importedIDs))
if err != nil {
log.Printf("Org import reconcile: orphan query failed: %v", err)
reconcileErrs = append(reconcileErrs, fmt.Sprintf("orphan query: %v", err))
} else {
orphanIDs := []string{}
for rows.Next() {
var orphanID string
if rows.Scan(&orphanID) == nil {
orphanIDs = append(orphanIDs, orphanID)
}
}
rows.Close()
for _, oid := range orphanIDs {
cascadeCount, stopErrs, err := h.workspace.CascadeDelete(ctx, oid)
if err != nil {
log.Printf("Org import reconcile: CascadeDelete(%s) failed: %v", oid, err)
reconcileErrs = append(reconcileErrs, fmt.Sprintf("delete %s: %v", oid, err))
reconcileSkipped++
continue
}
reconcileRemovedCount += 1 + cascadeCount
if len(stopErrs) > 0 {
log.Printf("Org import reconcile: %s had %d stop errors (orphan sweeper will retry)", oid, len(stopErrs))
}
}
log.Printf("Org import reconcile: %d orphans removed (%d cascade descendants), %d skipped", len(orphanIDs), reconcileRemovedCount-len(orphanIDs), reconcileSkipped)
}
}
}
status := http.StatusCreated
resp := gin.H{
"org": tmpl.Name,
"workspaces": results,
"count": len(results),
}
if body.Mode == "reconcile" {
resp["mode"] = "reconcile"
resp["reconcile_removed_count"] = reconcileRemovedCount
if len(reconcileErrs) > 0 {
resp["reconcile_errors"] = reconcileErrs
}
}
if createErr != nil {
status = http.StatusMultiStatus
resp["error"] = createErr.Error()
}
log.Printf("Org import: %s — %d workspaces created", tmpl.Name, len(results))
// results contains both freshly-created AND lookupExistingChild skips
// (entries with "skipped":true). Splitting the count here so the audit
// row reflects "what changed" vs "what was already there" — telemetry
// readers shouldn't need to grep stdout to tell an idempotent re-run
// apart from a fresh-create.
createdCount, skippedCount := 0, 0
for _, r := range results {
if skipped, _ := r["skipped"].(bool); skipped {
skippedCount++
} else {
createdCount++
}
}
log.Printf("Org import: %s — %d created, %d skipped, %d reconciled",
tmpl.Name, createdCount, skippedCount, reconcileRemovedCount)
emitOrgEvent(c.Request.Context(), "org.import.completed", map[string]any{
"name": tmpl.Name,
"dir": body.Dir,
"mode": body.Mode,
"created_count": createdCount,
"skipped_count": skippedCount,
"reconcile_removed_count": reconcileRemovedCount,
"reconcile_errors": len(reconcileErrs),
"duration_ms": time.Since(importStart).Milliseconds(),
"create_error": errString(createErr),
})
c.JSON(status, resp)
}
// walkOrgWorkspaceNames collects every Name in the tree (in any order) into
// names. Used by reconcile to detect orphan workspaces — workspaces with the
// same role name as a freshly-imported one but a different id, surviving from
// a prior import.
func walkOrgWorkspaceNames(workspaces []OrgWorkspace, names *[]string) {
for _, w := range workspaces {
// spawning:false subtrees are still part of the imported tree
// from a logical-tree perspective — DON'T skip the recursion,
// or reconcile would orphan the rest of the subtree on every
// re-import where spawning is toggled. Names of skipped
// workspaces remain registered so reconcile won't double-create
// them when spawning flips back to true.
if w.Name != "" {
*names = append(*names, w.Name)
}
walkOrgWorkspaceNames(w.Children, names)
}
}
// emitOrgEvent records an org-lifecycle event in structure_events so the
// import history is queryable independent of stdout log retention. Errors
// are logged and swallowed — never block the request path on telemetry.
//
// Event-type taxonomy (extend by appending; never rename):
//
// org.import.started — handler entered, request body parsed
// org.import.completed — handler exiting (success or partial)
// org.import.failed — handler exiting with an unrecoverable error
//
// payload fields are documented at each call site.
func emitOrgEvent(ctx context.Context, eventType string, payload map[string]any) {
if payload == nil {
payload = map[string]any{}
}
payloadJSON, err := json.Marshal(payload)
if err != nil {
log.Printf("emitOrgEvent: marshal %s payload failed: %v", eventType, err)
return
}
if _, err := db.DB.ExecContext(ctx, `
INSERT INTO structure_events (event_type, payload, created_at)
VALUES ($1, $2, now())
`, eventType, payloadJSON); err != nil {
log.Printf("emitOrgEvent: insert %s failed: %v", eventType, err)
}
}
// errString returns "" for a nil error, err.Error() otherwise. Lets us put
// nullable error strings in event payloads without checking for nil at every
// call site.
func errString(err error) string {
if err == nil {
return ""
}
return err.Error()
}
@@ -42,6 +42,20 @@ import (
// straight into the parent's child-coordinate space without doing a
// canvas-wide absolute-position walk.
func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX, absY, relX, relY float64, defaults OrgDefaults, orgBaseDir string, results *[]map[string]interface{}, provisionSem chan struct{}) error {
// spawning: false guard — skip this workspace AND all descendants.
// Pointer-typed so we distinguish "explicitly false" from "unset"
// (unset = default to spawn). The guard sits BEFORE any side effect
// (no DB row, no docker provision, no children recursion) so a
// false-spawning subtree is genuinely a no-op except for the log line.
// Use case: dev-tree org template ships the full role taxonomy but a
// developer's machine only has RAM for a subset; a per-workspace
// `spawning: false` lets them narrow without editing the parent
// template's structure.
if ws.Spawning != nil && !*ws.Spawning {
log.Printf("Org import: skipping workspace %q (spawning=false; %d descendant workspace(s) in subtree also skipped)", ws.Name, countWorkspaces(ws.Children))
return nil
}
// Apply defaults
runtime := ws.Runtime
if runtime == "" {
@@ -453,8 +467,25 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
envVars := map[string]string{}
// 0. Persona env (lowest precedence; injects the role's Gitea identity:
// GITEA_USER, GITEA_TOKEN, GITEA_TOKEN_SCOPES, GITEA_USER_EMAIL,
// GITEA_SSH_KEY_PATH). Workspace and org .env can override.
loadPersonaEnvFile(ws.Role, envVars)
// GITEA_SSH_KEY_PATH, plus MODEL_PROVIDER/MODEL and the LLM auth
// token like CLAUDE_CODE_OAUTH_TOKEN or MINIMAX_API_KEY).
// Workspace and org .env can override.
//
// Use ws.FilesDir as the persona-dir lookup key, NOT ws.Role. In the
// dev-tree org.yaml shape, `role:` carries the multi-line descriptive
// text the agent reads from its prompt ("Engineering planning and
// team coordination — leads Core Platform, Controlplane, ..."), while
// `files_dir:` holds the short slug (`core-lead`, `dev-lead`, etc.)
// matching `~/.molecule-ai/personas/<files_dir>/env`
// (bind-mounted to `/etc/molecule-bootstrap/personas/<files_dir>/env`).
//
// Pre-fix, this passed `ws.Role` whose multi-word content failed
// isSafeRoleName silently, so every imported workspace booted with
// zero persona-env rows in workspace_secrets — no ANTHROPIC /
// CLAUDE_CODE auth in the container env. The claude_agent_sdk
// then wedged on `query.initialize()` with a 60s control-request
// timeout (caught 2026-05-08 right after dev-only org/import).
loadPersonaEnvFile(ws.FilesDir, envVars)
if orgBaseDir != "" {
// 1. Org root .env (shared defaults)
parseEnvFile(filepath.Join(orgBaseDir, ".env"), envVars)
@@ -0,0 +1,158 @@
package handlers
import (
"context"
"sort"
"testing"
"github.com/DATA-DOG/go-sqlmock"
)
// Tests for the reconcile-mode + audit-event additions to OrgHandler.Import.
//
// Background: /org/import was purely additive — re-running with a tree that
// renamed/reparented a role left the prior workspace online (different
// parent_id from the new one, so lookupExistingChild's parent-scoped dedupe
// missed it). The 2026-05-08 dev-tree case left 8 orphans surviving a
// re-import. mode="reconcile" closes the gap; emitOrgEvent makes "what
// happened at 20:13?" queryable instead of stdout-grep archaeology.
func TestWalkOrgWorkspaceNames_FlatTree(t *testing.T) {
tree := []OrgWorkspace{
{Name: "Dev Lead"},
{Name: "Release Manager"},
}
var names []string
walkOrgWorkspaceNames(tree, &names)
sort.Strings(names)
want := []string{"Dev Lead", "Release Manager"}
if !equalStrings(names, want) {
t.Errorf("flat tree: got %v, want %v", names, want)
}
}
func TestWalkOrgWorkspaceNames_NestedTree(t *testing.T) {
tree := []OrgWorkspace{
{
Name: "Dev Lead",
Children: []OrgWorkspace{
{Name: "Core Platform Lead", Children: []OrgWorkspace{{Name: "Core-BE"}}},
{Name: "SDK Lead"},
},
},
}
var names []string
walkOrgWorkspaceNames(tree, &names)
sort.Strings(names)
want := []string{"Core Platform Lead", "Core-BE", "Dev Lead", "SDK Lead"}
if !equalStrings(names, want) {
t.Errorf("nested tree: got %v, want %v", names, want)
}
}
// Pins the contract that spawning:false subtrees still contribute their names
// to the reconcile working set. If the walker started skipping them, a
// re-import that toggled spawning would orphan whichever workspaces had been
// previously imported with spawning:true — the inverse of the bug being
// fixed. Spawning gates *provisioning*, not *reconcile membership*.
func TestWalkOrgWorkspaceNames_SpawningFalseStillCounted(t *testing.T) {
f := false
tree := []OrgWorkspace{
{Name: "Dev Lead", Children: []OrgWorkspace{
{Name: "Skipped Lead", Spawning: &f, Children: []OrgWorkspace{
{Name: "Skipped Child"},
}},
}},
}
var names []string
walkOrgWorkspaceNames(tree, &names)
sort.Strings(names)
want := []string{"Dev Lead", "Skipped Child", "Skipped Lead"}
if !equalStrings(names, want) {
t.Errorf("spawning:false subtree: got %v, want %v", names, want)
}
}
func TestWalkOrgWorkspaceNames_EmptyNamesSkipped(t *testing.T) {
tree := []OrgWorkspace{
{Name: "Dev Lead"},
{Name: ""}, // YAML default / placeholder
{Name: "Release Manager"},
}
var names []string
walkOrgWorkspaceNames(tree, &names)
sort.Strings(names)
want := []string{"Dev Lead", "Release Manager"}
if !equalStrings(names, want) {
t.Errorf("empty-name skip: got %v, want %v", names, want)
}
}
// emitOrgEvent must INSERT into structure_events with event_type + JSON
// payload. Verifies the SQL shape pinning so a future schema rename
// (e.g., switching to audit_events) breaks the test loudly instead of
// silently dropping telemetry.
func TestEmitOrgEvent_InsertsToStructureEvents(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectExec(`INSERT INTO structure_events`).
WithArgs("org.import.started", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
emitOrgEvent(context.Background(), "org.import.started", map[string]any{
"name": "test-org",
"mode": "reconcile",
})
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations: %v", err)
}
}
// Insert failures are log-and-swallow — telemetry MUST NOT block the
// caller path. If this regresses (e.g., a future patch returns the err),
// org-import requests would fail with HTTP 500 every time a structure_events
// INSERT hiccups, which is strictly worse than losing the row.
func TestEmitOrgEvent_DBErrorIsSwallowed(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectExec(`INSERT INTO structure_events`).
WithArgs("org.import.failed", sqlmock.AnyArg()).
WillReturnError(errSentinelTest)
// Must not panic; must not propagate. The function returns nothing,
// so the contract is "doesn't crash."
emitOrgEvent(context.Background(), "org.import.failed", map[string]any{
"err": "preflight failed",
})
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations: %v", err)
}
}
func TestErrString(t *testing.T) {
if got := errString(nil); got != "" {
t.Errorf("nil error: got %q, want empty", got)
}
if got := errString(errSentinelTest); got != "sentinel" {
t.Errorf("sentinel error: got %q, want \"sentinel\"", got)
}
}
// errSentinelTest is a marker error used for swallow-error assertions.
var errSentinelTest = sentinelErrTest{}
type sentinelErrTest struct{}
func (sentinelErrTest) Error() string { return "sentinel" }
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
@@ -543,6 +543,103 @@ func (h *WorkspaceHandler) Delete(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "removed", "cascade_deleted": len(descendantIDs)})
}
// CascadeDelete performs the cascade-removal sequence used by the HTTP
// DELETE handler and by OrgImport's reconcile mode: walk descendants, mark
// self+descendants 'removed' first (#73 race guard), stop containers / EC2s,
// remove volumes, revoke tokens, disable schedules, broadcast events.
//
// Idempotent against already-removed rows (the descendant CTE and all UPDATE
// guards skip status='removed'). Returns the number of cascaded descendants
// (not including id itself) and any per-workspace stop errors so callers can
// surface a retryable failure instead of a silent-leak.
//
// Caller is responsible for the children-confirmation gate (the HTTP handler
// returns 409 when children exist + ?confirm=true is missing); this helper
// always cascades.
func (h *WorkspaceHandler) CascadeDelete(ctx context.Context, id string) (int, []error, error) {
if err := validateWorkspaceID(id); err != nil {
return 0, nil, err
}
descendantIDs := []string{}
descRows, err := db.DB.QueryContext(ctx, `
WITH RECURSIVE descendants AS (
SELECT id FROM workspaces WHERE parent_id = $1 AND status != 'removed'
UNION ALL
SELECT w.id FROM workspaces w JOIN descendants d ON w.parent_id = d.id WHERE w.status != 'removed'
)
SELECT id FROM descendants
`, id)
if err != nil {
return 0, nil, fmt.Errorf("descendant query: %w", err)
}
for descRows.Next() {
var descID string
if descRows.Scan(&descID) == nil {
descendantIDs = append(descendantIDs, descID)
}
}
descRows.Close()
allIDs := append([]string{id}, descendantIDs...)
if _, err := db.DB.ExecContext(ctx,
`UPDATE workspaces SET status = $1, updated_at = now() WHERE id = ANY($2::uuid[])`,
models.StatusRemoved, pq.Array(allIDs)); err != nil {
log.Printf("CascadeDelete status update for %s: %v", id, err)
}
if _, err := db.DB.ExecContext(ctx,
`DELETE FROM canvas_layouts WHERE workspace_id = ANY($1::uuid[])`,
pq.Array(allIDs)); err != nil {
log.Printf("CascadeDelete canvas_layouts for %s: %v", id, err)
}
if _, err := db.DB.ExecContext(ctx,
`UPDATE workspace_auth_tokens SET revoked_at = now()
WHERE workspace_id = ANY($1::uuid[]) AND revoked_at IS NULL`,
pq.Array(allIDs)); err != nil {
log.Printf("CascadeDelete token revocation for %s: %v", id, err)
}
if _, err := db.DB.ExecContext(ctx,
`UPDATE workspace_schedules SET enabled = false, updated_at = now()
WHERE workspace_id = ANY($1::uuid[]) AND enabled = true`,
pq.Array(allIDs)); err != nil {
log.Printf("CascadeDelete schedule disable for %s: %v", id, err)
}
cleanupCtx, cleanupCancel := context.WithTimeout(
context.WithoutCancel(ctx), 30*time.Second)
defer cleanupCancel()
var stopErrs []error
stopAndRemove := func(wsID string) {
if err := h.StopWorkspaceAuto(cleanupCtx, wsID); err != nil {
log.Printf("CascadeDelete %s stop failed: %v — leaving cleanup for orphan sweeper", wsID, err)
stopErrs = append(stopErrs, fmt.Errorf("stop %s: %w", wsID, err))
return
}
if h.provisioner != nil {
if err := h.provisioner.RemoveVolume(cleanupCtx, wsID); err != nil {
log.Printf("CascadeDelete %s volume removal warning: %v", wsID, err)
}
}
}
for _, descID := range descendantIDs {
stopAndRemove(descID)
db.ClearWorkspaceKeys(cleanupCtx, descID)
restartStates.Delete(descID)
h.broadcaster.RecordAndBroadcast(cleanupCtx, string(events.EventWorkspaceRemoved), descID, map[string]interface{}{})
}
stopAndRemove(id)
db.ClearWorkspaceKeys(cleanupCtx, id)
restartStates.Delete(id)
h.broadcaster.RecordAndBroadcast(cleanupCtx, string(events.EventWorkspaceRemoved), id, map[string]interface{}{
"cascade_deleted": len(descendantIDs),
})
return len(descendantIDs), stopErrs, nil
}
// validateWorkspaceID returns an error when id is not a valid UUID.
// #687: prevents 500s from Postgres when a garbage string (e.g. ../../etc/passwd)
// is passed as the :id path parameter.
@@ -715,14 +715,30 @@ func deriveProviderFromModelSlug(model string) string {
// payload.Model at boot), this is a no-op — no harm in the switch
// being empty for those cases.
func applyRuntimeModelEnv(envVars map[string]string, runtime, model string) {
// Fall back to the MODEL_PROVIDER workspace secret when the caller
// didn't pass one explicitly. This is the path that "Save+Restart"
// hits — Restart builds its payload from the workspaces row (no model
// column there) so payload.Model is always empty, but the user's
// canvas selection was stored as MODEL_PROVIDER via PUT /model and
// is already loaded into envVars here. Without this fallback hermes
// silently boots with the template default and errors "No LLM
// provider configured" even though the user picked a valid model.
// Resolution order (priority high → low):
// 1. payload.Model (caller passed the canvas-picked model id verbatim)
// 2. envVars["MODEL"] (workspace_secret persisted by /org/import via
// the persona env file — MODEL=MiniMax-M2.7-highspeed etc.)
// 3. envVars["MODEL_PROVIDER"] (legacy: this secret was historically a
// *model id* set by canvas Save+Restart's PUT /model; on the
// post-2026-05-08 persona-env convention it's a *provider slug*
// (e.g. "minimax") which is NOT a valid model id, so this fallback
// only fires when MODEL is absent.)
//
// Pre-fix bug: this function unconditionally OVERWROTE envVars["MODEL"]
// with the MODEL_PROVIDER slug (when payload.Model was empty), wiping
// the operator's explicit per-persona MODEL secret on every restart.
// Symptom: a workspace whose persona env said
// MODEL=MiniMax-M2.7-highspeed booted fine on first /org/import (the
// envVars map was populated direct from the env file), then on the
// next Restart the workspace_secrets-derived MODEL got clobbered by
// MODEL_PROVIDER="minimax" — the literal slug, not a valid model id —
// and the workspace template's adapter routed to providers[0]
// (anthropic-oauth) and wedged at SDK initialize. Caught 2026-05-08
// during Phase 4 verification of template-claude-code PR #9.
if model == "" {
model = envVars["MODEL"]
}
if model == "" {
model = envVars["MODEL_PROVIDER"]
}
@@ -724,3 +724,68 @@ func TestApplyRuntimeModelEnv_SetsUniversalMODELForAllRuntimes(t *testing.T) {
})
}
}
// TestApplyRuntimeModelEnv_PersonaEnvMODELSecretPreserved locks in the
// 2026-05-08 fix that prevents the MODEL_PROVIDER-as-slug fallback from
// silently overwriting a per-persona MODEL workspace_secret on restart.
//
// Pre-fix bug recurrence guard: when the persona env file (loaded into
// workspace_secrets at /org/import time) declares both MODEL=<id> and
// MODEL_PROVIDER=<slug>, the restart path used to overwrite envVars["MODEL"]
// with the MODEL_PROVIDER slug because applyRuntimeModelEnv'\''s
// payload.Model fallback consulted MODEL_PROVIDER first. Symptom: dev-tree
// workspaces booted fine on first /org/import, then on next restart the
// model id became literal "minimax" and the workspace template'\''s adapter
// failed to match any registry prefix, fell through to anthropic-oauth,
// and wedged at SDK initialize. Caught during Phase 4 verification of
// template-claude-code PR #9.
func TestApplyRuntimeModelEnv_PersonaEnvMODELSecretPreserved(t *testing.T) {
cases := []struct {
name string
envMODEL string
envMP string
wantMODEL string
}{
{
name: "MODEL secret wins over MODEL_PROVIDER slug (persona-env shape on restart)",
envMODEL: "MiniMax-M2.7-highspeed",
envMP: "minimax",
wantMODEL: "MiniMax-M2.7-highspeed",
},
{
name: "MODEL secret wins even when same as MODEL_PROVIDER",
envMODEL: "opus",
envMP: "claude-code",
wantMODEL: "opus",
},
{
name: "MODEL absent → fall back to MODEL_PROVIDER (legacy canvas Save+Restart shape)",
envMODEL: "",
envMP: "MiniMax-M2.7",
wantMODEL: "MiniMax-M2.7",
},
{
name: "Both absent → no MODEL set",
envMODEL: "",
envMP: "",
wantMODEL: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
envVars := map[string]string{}
if tc.envMODEL != "" {
envVars["MODEL"] = tc.envMODEL
}
if tc.envMP != "" {
envVars["MODEL_PROVIDER"] = tc.envMP
}
// payload.Model is empty (the restart case)
applyRuntimeModelEnv(envVars, "claude-code", "")
if got := envVars["MODEL"]; got != tc.wantMODEL {
t.Errorf("MODEL = %q, want %q (envMODEL=%q envMP=%q)",
got, tc.wantMODEL, tc.envMODEL, tc.envMP)
}
})
}
}
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS workspaces_update_tier_canary;
ALTER TABLE workspaces DROP COLUMN IF EXISTS update_tier;
@@ -0,0 +1,26 @@
-- workspaces.update_tier — canary vs production filter for plugin updates
-- (core#115). Composes with the version-subscription DB foundation
-- (core#113, merged) and the upcoming drift+queue+apply endpoint
-- (core#123).
--
-- Tiers:
-- 'production' (default) — fan-out target; only updated AFTER canary soak
-- 'canary' — early-adopter target; updates land here first
--
-- Default 'production' so existing customers (Reno-Stars + any future
-- live tenant) are default-safe. Synthetic dogfooding workspaces opt
-- INTO 'canary' explicitly.
--
-- The column is just metadata at this layer; the actual filter logic
-- ('apply this update only to canary tier first') lives in the future
-- POST /admin/plugin-updates/:id/apply endpoint (core#123).
ALTER TABLE workspaces
ADD COLUMN IF NOT EXISTS update_tier TEXT NOT NULL DEFAULT 'production'
CHECK (update_tier IN ('canary', 'production'));
-- Partial index for the apply endpoint's canary-tier scan: only
-- index canary rows since the apply path queries them most often
-- and the production set is the much larger default.
CREATE INDEX IF NOT EXISTS workspaces_update_tier_canary
ON workspaces(update_tier) WHERE update_tier = 'canary';