forked from molecule-ai/molecule-core
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cb5b0a182 | |||
| fabf45216d | |||
| a50cda1a85 | |||
| a526dabf04 | |||
| 4534e922c8 | |||
| 427d5b04ed | |||
| a93c4ce177 | |||
| b3041c13d3 | |||
| e1214ca0b4 | |||
| bfefcb315b | |||
| c94ead1953 | |||
| 3de51faa19 | |||
| 6f861926bd | |||
| 15c5f32491 | |||
| 9b5e89bb42 | |||
| b91da1ab77 | |||
| aea6109602 | |||
| c3596d6271 | |||
| 2fa79ea462 | |||
| 15935143c8 | |||
| 558e4fee48 | |||
| 8e4169cfac | |||
| bce60f1b22 | |||
| c6f41198f7 | |||
| 5c0c15eb4f | |||
| 7eda8f510f | |||
| 44bb35f2a8 | |||
| 42ff6be15c | |||
| 32773fd566 | |||
| d72f21da09 | |||
| cc28cc6607 | |||
| 120b3a25aa | |||
| b7f3b270a3 | |||
| b398667fce | |||
| 5c62f172f0 | |||
| 7f86a245bf | |||
| 9c82b2a61c | |||
| e4b1248f47 | |||
| 501d07b0f2 |
@@ -0,0 +1,170 @@
|
|||||||
|
# sop-tier-check — canonical Gitea Actions workflow for §SOP-6 enforcement.
|
||||||
|
#
|
||||||
|
# Copy this file to `.gitea/workflows/sop-tier-check.yml` in any repo that
|
||||||
|
# wants the §SOP-6 PR gate enforced. Pair with branch protection on the
|
||||||
|
# protected branch:
|
||||||
|
# required_status_checks: ["sop-tier-check"]
|
||||||
|
# required_approving_reviews: 1
|
||||||
|
# approving_review_teams: ["ceo", "managers", "engineers"]
|
||||||
|
#
|
||||||
|
# What it does:
|
||||||
|
# 1. Reads the PR's `tier:*` label (low | medium | high). Fails if absent
|
||||||
|
# or ambiguous.
|
||||||
|
# 2. Reads every approving review on the PR.
|
||||||
|
# 3. For each approver, queries Gitea team membership.
|
||||||
|
# 4. Marks the check success only if at least one approver is in a team
|
||||||
|
# whose tier-tag covers the PR's tier label, AND the approver is not
|
||||||
|
# the author.
|
||||||
|
#
|
||||||
|
# Tier → eligible-team mapping (mirror of dev-sop §SOP-6):
|
||||||
|
# tier:low → engineers, managers, ceo
|
||||||
|
# tier:medium → managers, ceo
|
||||||
|
# tier:high → ceo
|
||||||
|
#
|
||||||
|
# Author identity is excluded automatically; Gitea's review system already
|
||||||
|
# rejects self-reviews, but this workflow re-checks defensively in case the
|
||||||
|
# native rule is bypassed (admin override, branch-protection edit, etc.).
|
||||||
|
#
|
||||||
|
# Force-merge: Owners-team override remains available out-of-band via the
|
||||||
|
# Gitea merge API; force-merge writes `incident.force_merge` to
|
||||||
|
# structure_events per §Persistent structured logging gate (Phase 3).
|
||||||
|
|
||||||
|
name: sop-tier-check
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, edited, synchronize, reopened, labeled, unlabeled]
|
||||||
|
pull_request_review:
|
||||||
|
types: [submitted, dismissed, edited]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
tier-check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
steps:
|
||||||
|
- name: Verify tier label + reviewer team membership
|
||||||
|
env:
|
||||||
|
# SOP_TIER_CHECK_TOKEN is the read-only `sop-tier-bot` PAT,
|
||||||
|
# provisioned with read:org scope and added to ceo/managers/
|
||||||
|
# engineers teams (a Gitea team-membership probe requires the
|
||||||
|
# caller to be a member of the team being probed). The auto-
|
||||||
|
# injected GITHUB_TOKEN's scope is repo-level only and cannot
|
||||||
|
# query org team membership, hence the dedicated secret.
|
||||||
|
# Falls back to GITHUB_TOKEN so the workflow at least starts and
|
||||||
|
# surfaces a clear error when the secret is missing.
|
||||||
|
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
|
GITEA_HOST: git.moleculesai.app
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||||
|
echo "::error::Neither GITEA_TOKEN nor GITHUB_TOKEN is available. Add a GITEA_TOKEN secret with org-membership read scope to enable team-based approval gating."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
OWNER="${REPO%%/*}"
|
||||||
|
NAME="${REPO##*/}"
|
||||||
|
API="https://${GITEA_HOST}/api/v1"
|
||||||
|
AUTH="Authorization: token ${GITEA_TOKEN}"
|
||||||
|
echo "::notice::tier-check start: repo=$OWNER/$NAME pr=$PR_NUMBER author=$PR_AUTHOR"
|
||||||
|
# Sanity-check the token resolves a user; surfaces token-scope problems
|
||||||
|
# early instead of failing on a downstream call with no context.
|
||||||
|
WHOAMI=$(curl -sS -H "$AUTH" "${API}/user" | jq -r '.login // ""')
|
||||||
|
if [ -z "$WHOAMI" ]; then
|
||||||
|
echo "::error::GITEA_TOKEN cannot resolve a user via /api/v1/user — check the token scope and that the secret is wired correctly."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "::notice::token resolves to user: $WHOAMI"
|
||||||
|
|
||||||
|
# 1. Read tier label
|
||||||
|
LABELS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/labels" | jq -r '.[].name')
|
||||||
|
TIER=""
|
||||||
|
for L in $LABELS; do
|
||||||
|
case "$L" in
|
||||||
|
tier:low|tier:medium|tier:high)
|
||||||
|
if [ -n "$TIER" ]; then
|
||||||
|
echo "::error::Multiple tier labels: $TIER + $L. Apply exactly one."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
TIER="$L"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [ -z "$TIER" ]; then
|
||||||
|
echo "::error::PR has no tier:low|tier:medium|tier:high label. Apply one before merge."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "tier=$TIER"
|
||||||
|
|
||||||
|
# 2. Tier → eligible teams
|
||||||
|
case "$TIER" in
|
||||||
|
tier:low) ELIGIBLE="engineers managers ceo" ;;
|
||||||
|
tier:medium) ELIGIBLE="managers ceo" ;;
|
||||||
|
tier:high) ELIGIBLE="ceo" ;;
|
||||||
|
esac
|
||||||
|
echo "eligible_teams=$ELIGIBLE"
|
||||||
|
|
||||||
|
# Resolve team-name → team-id once. The /orgs/{org}/teams/{slug}/...
|
||||||
|
# endpoints don't exist on Gitea 1.22; we have to use /teams/{id}.
|
||||||
|
# Fail loud on missing team rather than treating it as "user not in
|
||||||
|
# team" — that'd mask a misconfigured deployment.
|
||||||
|
ORG_TEAMS_FILE=$(mktemp)
|
||||||
|
HTTP_CODE=$(curl -sS -o "$ORG_TEAMS_FILE" -w '%{http_code}' -H "$AUTH" \
|
||||||
|
"${API}/orgs/${OWNER}/teams")
|
||||||
|
echo "teams-list HTTP=$HTTP_CODE size=$(wc -c <"$ORG_TEAMS_FILE")"
|
||||||
|
echo "teams-list body (first 300 chars):"
|
||||||
|
head -c 300 "$ORG_TEAMS_FILE"; echo
|
||||||
|
if [ "$HTTP_CODE" != "200" ]; then
|
||||||
|
echo "::error::GET /orgs/${OWNER}/teams returned HTTP $HTTP_CODE — token likely lacks read:org scope. Add a SOP_TIER_CHECK_TOKEN secret with read:organization scope."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
declare -A TEAM_ID
|
||||||
|
for T in $ELIGIBLE; do
|
||||||
|
ID=$(jq -r --arg t "$T" '.[] | select(.name==$t) | .id' <"$ORG_TEAMS_FILE" | head -1)
|
||||||
|
if [ -z "$ID" ] || [ "$ID" = "null" ]; then
|
||||||
|
VISIBLE=$(jq -r '.[]?.name? // empty' <"$ORG_TEAMS_FILE" 2>/dev/null | tr '\n' ' ')
|
||||||
|
echo "::error::Team \"$T\" not found in org $OWNER. Teams visible: $VISIBLE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
TEAM_ID[$T]="$ID"
|
||||||
|
echo "team-id: $T → $ID"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 3. Read approving reviewers
|
||||||
|
REVIEWS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/reviews")
|
||||||
|
APPROVERS=$(echo "$REVIEWS" | jq -r '[.[] | select(.state=="APPROVED") | .user.login] | unique | .[]')
|
||||||
|
if [ -z "$APPROVERS" ]; then
|
||||||
|
echo "::error::No approving reviews. Tier $TIER requires approval from {$ELIGIBLE} (non-author)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "approvers: $(echo $APPROVERS | tr '\n' ' ')"
|
||||||
|
|
||||||
|
# 4. For each approver: check non-author + team membership (by id)
|
||||||
|
OK=""
|
||||||
|
for U in $APPROVERS; do
|
||||||
|
if [ "$U" = "$PR_AUTHOR" ]; then
|
||||||
|
echo "skip self-review by $U"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
for T in $ELIGIBLE; do
|
||||||
|
ID="${TEAM_ID[$T]}"
|
||||||
|
CODE=$(curl -sS -o /dev/null -w '%{http_code}' -H "$AUTH" \
|
||||||
|
"${API}/teams/${ID}/members/${U}")
|
||||||
|
echo " probe: $U in team $T (id=$ID) → HTTP $CODE"
|
||||||
|
if [ "$CODE" = "200" ] || [ "$CODE" = "204" ]; then
|
||||||
|
echo "::notice::approver $U is in team $T (eligible for $TIER)"
|
||||||
|
OK="yes"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ -n "$OK" ] && break
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$OK" ]; then
|
||||||
|
echo "::error::Tier $TIER requires approval from a non-author member of {$ELIGIBLE}. Got approvers: $APPROVERS — none of them satisfied team membership (probe HTTP codes above)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "::notice::sop-tier-check passed: $TIER, approver in {$ELIGIBLE}"
|
||||||
@@ -20,6 +20,19 @@ on:
|
|||||||
# a few minutes under load — that's fine for a canary.
|
# a few minutes under load — that's fine for a canary.
|
||||||
- cron: '*/30 * * * *'
|
- cron: '*/30 * * * *'
|
||||||
workflow_dispatch:
|
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
|
# Serialise with the full-SaaS workflow so they don't contend for the
|
||||||
# same org-create quota on staging. Different group key from
|
# 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.
|
# is "Token Plan only" but cheap-per-token and fast.
|
||||||
E2E_MODEL_SLUG: MiniMax-M2.7-highspeed
|
E2E_MODEL_SLUG: MiniMax-M2.7-highspeed
|
||||||
E2E_RUN_ID: "canary-${{ github.run_id }}"
|
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:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
@@ -137,27 +158,28 @@ jobs:
|
|||||||
id: canary
|
id: canary
|
||||||
run: bash tests/e2e/test_staging_full_saas.sh
|
run: bash tests/e2e/test_staging_full_saas.sh
|
||||||
|
|
||||||
# Alerting: open an issue only after THREE consecutive failures so
|
# Alerting: open a sticky issue on the FIRST failure; comment on
|
||||||
# transient flakes (Cloudflare DNS hiccup, AWS API blip) don't spam
|
# subsequent failures; auto-close on next green. Comment-on-existing
|
||||||
# the issue list. If an issue is already open, we still comment on
|
# de-duplicates so a single open issue accumulates the streak —
|
||||||
# every failure so ops sees the streak. Auto-close on next green.
|
# ops sees one issue with N comments rather than N issues.
|
||||||
#
|
#
|
||||||
# Threshold rationale: canary fires every 30 min, so 3 failures =
|
# Why no consecutive-failures threshold (e.g., wait 3 runs before
|
||||||
# ~90 min of consecutive red — well past any single-run flake but
|
# filing): the prior threshold check used
|
||||||
# still tight enough that a real outage gets surfaced before the
|
# `github.rest.actions.listWorkflowRuns()` which Gitea 1.22.6 does
|
||||||
# next deploy window.
|
# 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
|
- name: Open issue on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
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:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const title = '🔴 Canary failing: staging SaaS smoke';
|
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).
|
// Find an existing open canary issue (stable title match).
|
||||||
// If one exists, this isn't a "first failure" — comment and exit.
|
// If one exists, this isn't a "first failure" — comment and exit.
|
||||||
@@ -177,32 +199,12 @@ jobs:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No open issue yet — check the last N-1 runs' conclusions.
|
// No open issue yet — file one on this first failure. The
|
||||||
// We open the issue only if the last (THRESHOLD-1) runs ALSO
|
// comment-on-existing branch above means subsequent failures
|
||||||
// failed (so this is the 3rd consecutive red).
|
// accumulate as comments on this same issue, so we don't
|
||||||
const threshold = parseInt(process.env.CONSECUTIVE_THRESHOLD, 10);
|
// spam new issues per run.
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body =
|
const body =
|
||||||
`Canary run failed at ${new Date().toISOString()}, ` +
|
`Canary run failed at ${new Date().toISOString()}.\n\n` +
|
||||||
`${threshold} consecutive runs red.\n\n` +
|
|
||||||
`Run: ${runURL}\n\n` +
|
`Run: ${runURL}\n\n` +
|
||||||
`This issue auto-closes on the next green canary run. ` +
|
`This issue auto-closes on the next green canary run. ` +
|
||||||
`Consecutive failures add a comment here rather than a new issue.`;
|
`Consecutive failures add a comment here rather than a new issue.`;
|
||||||
@@ -211,7 +213,7 @@ jobs:
|
|||||||
title, body,
|
title, body,
|
||||||
labels: ['canary-staging', 'bug'],
|
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
|
- name: Auto-close canary issue on success
|
||||||
if: success()
|
if: success()
|
||||||
|
|||||||
@@ -119,6 +119,17 @@ jobs:
|
|||||||
# symptom, different root cause: staging still has the in-image
|
# symptom, different root cause: staging still has the in-image
|
||||||
# clone path, hits the auth error directly).
|
# 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
|
# Token shape matches publish-workspace-server-image.yml: AUTO_SYNC_TOKEN
|
||||||
# is the devops-engineer persona PAT, NOT the founder PAT (per
|
# is the devops-engineer persona PAT, NOT the founder PAT (per
|
||||||
# `feedback_per_agent_gitea_identity_default`). clone-manifest.sh
|
# `feedback_per_agent_gitea_identity_default`). clone-manifest.sh
|
||||||
|
|||||||
@@ -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
@@ -1,7 +1,11 @@
|
|||||||
FROM node:22-alpine AS builder
|
FROM node:22-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json* ./
|
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 . .
|
COPY . .
|
||||||
ARG NEXT_PUBLIC_PLATFORM_URL=http://localhost:8080
|
ARG NEXT_PUBLIC_PLATFORM_URL=http://localhost:8080
|
||||||
ARG NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws
|
ARG NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
// AttachmentLightbox).
|
// AttachmentLightbox).
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { platformAuthHeaders } from "@/lib/api";
|
||||||
import type { ChatAttachment } from "./types";
|
import type { ChatAttachment } from "./types";
|
||||||
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
||||||
import { AttachmentChip } from "./AttachmentViews";
|
import { AttachmentChip } from "./AttachmentViews";
|
||||||
@@ -43,13 +44,8 @@ export function AttachmentAudio({ workspaceId, attachment, onDownload, tone }: P
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
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, {
|
const res = await fetch(href, {
|
||||||
headers,
|
headers: platformAuthHeaders(),
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
signal: AbortSignal.timeout(60_000),
|
signal: AbortSignal.timeout(60_000),
|
||||||
});
|
});
|
||||||
@@ -116,9 +112,5 @@ export function AttachmentAudio({ workspaceId, attachment, onDownload, tone }: P
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTenantSlug(): string | null {
|
// Local getTenantSlug() removed — auth-header construction now goes
|
||||||
if (typeof window === "undefined") return null;
|
// through platformAuthHeaders() from @/lib/api (#178).
|
||||||
const host = window.location.hostname;
|
|
||||||
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
|
|
||||||
return m ? m[1] : null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
// downscale via canvas, but defer that to v2.
|
// downscale via canvas, but defer that to v2.
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { platformAuthHeaders } from "@/lib/api";
|
||||||
import type { ChatAttachment } from "./types";
|
import type { ChatAttachment } from "./types";
|
||||||
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
||||||
import { AttachmentLightbox } from "./AttachmentLightbox";
|
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
|
// Platform-auth path: identical to downloadChatFile but we keep
|
||||||
// the blob (don't trigger a Save-As). Use the same headers it does
|
// the blob (don't trigger a Save-As). Auth headers come from the
|
||||||
// by going through it indirectly — no, downloadChatFile triggers a
|
// shared `platformAuthHeaders()` helper — one source of truth for
|
||||||
// Save-As. Need a separate fetch.
|
// every authenticated raw fetch in the canvas (#178).
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
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, {
|
const res = await fetch(href, {
|
||||||
headers,
|
headers: platformAuthHeaders(),
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
signal: AbortSignal.timeout(30_000),
|
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
|
// Local getTenantSlug() removed — auth-header construction now goes
|
||||||
// there). Kept local so this component doesn't reach into private
|
// through platformAuthHeaders() from @/lib/api which uses the canonical
|
||||||
// surface; if AttachmentVideo / AttachmentPDF in PR-2/PR-3 also need
|
// getTenantSlug() from @/lib/tenant. This eliminates the duplicate
|
||||||
// it, lift to an exported helper at that point (the third-caller
|
// hostname-regex + the duplicate bearer-token-attach pattern (#178).
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
// timeout, swap to chip. Implemented as a 3-second watchdog.
|
// timeout, swap to chip. Implemented as a 3-second watchdog.
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { platformAuthHeaders } from "@/lib/api";
|
||||||
import type { ChatAttachment } from "./types";
|
import type { ChatAttachment } from "./types";
|
||||||
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
||||||
import { AttachmentLightbox } from "./AttachmentLightbox";
|
import { AttachmentLightbox } from "./AttachmentLightbox";
|
||||||
@@ -69,13 +70,8 @@ export function AttachmentPDF({ workspaceId, attachment, onDownload, tone }: Pro
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
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, {
|
const res = await fetch(href, {
|
||||||
headers,
|
headers: platformAuthHeaders(),
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
signal: AbortSignal.timeout(60_000),
|
signal: AbortSignal.timeout(60_000),
|
||||||
});
|
});
|
||||||
@@ -189,9 +185,5 @@ function PdfGlyph() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTenantSlug(): string | null {
|
// Local getTenantSlug() removed — auth-header construction now goes
|
||||||
if (typeof window === "undefined") return null;
|
// through platformAuthHeaders() from @/lib/api (#178).
|
||||||
const host = window.location.hostname;
|
|
||||||
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
|
|
||||||
return m ? m[1] : null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
// to download the full file.
|
// to download the full file.
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { platformAuthHeaders } from "@/lib/api";
|
||||||
import type { ChatAttachment } from "./types";
|
import type { ChatAttachment } from "./types";
|
||||||
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
||||||
import { AttachmentChip } from "./AttachmentViews";
|
import { AttachmentChip } from "./AttachmentViews";
|
||||||
@@ -57,13 +58,13 @@ export function AttachmentTextPreview({ workspaceId, attachment, onDownload, ton
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
||||||
const headers: Record<string, string> = {};
|
// Only attach platform auth headers for in-platform URIs —
|
||||||
if (isPlatformAttachment(attachment.uri)) {
|
// off-platform URLs (HTTP/HTTPS attachments) MUST NOT receive
|
||||||
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
|
// our bearer token (it would leak the admin token to a third
|
||||||
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
|
// party). The branch is preserved with the new shared helper.
|
||||||
const slug = getTenantSlug();
|
const headers: Record<string, string> = isPlatformAttachment(attachment.uri)
|
||||||
if (slug) headers["X-Molecule-Org-Slug"] = slug;
|
? platformAuthHeaders()
|
||||||
}
|
: {};
|
||||||
const res = await fetch(href, {
|
const res = await fetch(href, {
|
||||||
headers,
|
headers,
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
@@ -182,9 +183,5 @@ export function AttachmentTextPreview({ workspaceId, attachment, onDownload, ton
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTenantSlug(): string | null {
|
// Local getTenantSlug() removed — auth-header construction now goes
|
||||||
if (typeof window === "undefined") return null;
|
// through platformAuthHeaders() from @/lib/api (#178).
|
||||||
const host = window.location.hostname;
|
|
||||||
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
|
|
||||||
return m ? m[1] : null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
// fetch via service worker. v2 if measured-needed.
|
// fetch via service worker. v2 if measured-needed.
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { platformAuthHeaders } from "@/lib/api";
|
||||||
import type { ChatAttachment } from "./types";
|
import type { ChatAttachment } from "./types";
|
||||||
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
|
||||||
import { AttachmentChip } from "./AttachmentViews";
|
import { AttachmentChip } from "./AttachmentViews";
|
||||||
@@ -61,13 +62,8 @@ export function AttachmentVideo({ workspaceId, attachment, onDownload, tone }: P
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const href = resolveAttachmentHref(workspaceId, attachment.uri);
|
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, {
|
const res = await fetch(href, {
|
||||||
headers,
|
headers: platformAuthHeaders(),
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
// Videos are larger than images on average; give the request
|
// Videos are larger than images on average; give the request
|
||||||
// more headroom. The server's per-request body cap (50MB) is
|
// 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
|
// Local getTenantSlug() removed — auth-header construction now goes
|
||||||
// shared util in PR-2.5 if a third caller needs it (PDF, audio).
|
// through platformAuthHeaders() from @/lib/api (#178).
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { PLATFORM_URL } from "@/lib/api";
|
import { PLATFORM_URL, platformAuthHeaders } from "@/lib/api";
|
||||||
import { getTenantSlug } from "@/lib/tenant";
|
|
||||||
import type { ChatAttachment } from "./types";
|
import type { ChatAttachment } from "./types";
|
||||||
|
|
||||||
/** Chat attachments are intentionally uploaded via a direct fetch()
|
/** Chat attachments are intentionally uploaded via a direct fetch()
|
||||||
* instead of the `api.post` helper — `api.post` JSON-stringifies the
|
* instead of the `api.post` helper — `api.post` JSON-stringifies the
|
||||||
* body, which would 500 on a Blob. Mirrors the header plumbing
|
* body, which would 500 on a Blob. Auth headers (tenant slug, admin
|
||||||
* (tenant slug, admin token, credentials) so SaaS + self-hosted
|
* token, credentials) come from `platformAuthHeaders()` — the same
|
||||||
* callers work the same way. */
|
* 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(
|
export async function uploadChatFiles(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
files: File[],
|
files: File[],
|
||||||
@@ -16,18 +20,12 @@ export async function uploadChatFiles(
|
|||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
for (const f of files) form.append("files", f, f.name);
|
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 +
|
// Uploads legitimately take a while on cold cache (tar write +
|
||||||
// docker cp into the container). 60s is comfortable for the 25MB/
|
// docker cp into the container). 60s is comfortable for the 25MB/
|
||||||
// 50MB caps the server enforces.
|
// 50MB caps the server enforces.
|
||||||
const res = await fetch(`${PLATFORM_URL}/workspaces/${workspaceId}/chat/uploads`, {
|
const res = await fetch(`${PLATFORM_URL}/workspaces/${workspaceId}/chat/uploads`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers: platformAuthHeaders(),
|
||||||
body: form,
|
body: form,
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
signal: AbortSignal.timeout(60_000),
|
signal: AbortSignal.timeout(60_000),
|
||||||
@@ -143,14 +141,8 @@ export async function downloadChatFile(
|
|||||||
return;
|
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, {
|
const res = await fetch(href, {
|
||||||
headers,
|
headers: platformAuthHeaders(),
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
signal: AbortSignal.timeout(60_000),
|
signal: AbortSignal.timeout(60_000),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -21,6 +21,45 @@ export interface RequestOptions {
|
|||||||
timeoutMs?: number;
|
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>(
|
async function request<T>(
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
@@ -28,17 +67,16 @@ async function request<T>(
|
|||||||
retryCount = 0,
|
retryCount = 0,
|
||||||
options?: RequestOptions,
|
options?: RequestOptions,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
// SaaS cross-origin shape:
|
// JSON-bodied request — Content-Type is JSON. Auth pair comes from
|
||||||
// - X-Molecule-Org-Slug: derived from window.location.hostname by
|
// the shared helper; see its doc comment for the SaaS-shape rationale.
|
||||||
// getTenantSlug(). Control plane uses it for fly-replay routing.
|
const headers: Record<string, string> = {
|
||||||
// Empty on localhost / non-tenant hosts — safe to omit.
|
"Content-Type": "application/json",
|
||||||
// - credentials:"include": sends the session cookie cross-origin.
|
...platformAuthHeaders(),
|
||||||
// Cookie's Domain=.moleculesai.app attribute + cp's CORS allow this.
|
};
|
||||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
// 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();
|
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}`, {
|
const res = await fetch(`${PLATFORM_URL}${path}`, {
|
||||||
method,
|
method,
|
||||||
|
|||||||
+24
-2
@@ -13,6 +13,7 @@ services:
|
|||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- molecule-monorepo-net
|
- molecule-monorepo-net
|
||||||
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dev}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dev}"]
|
||||||
interval: 2s
|
interval: 2s
|
||||||
@@ -50,6 +51,7 @@ services:
|
|||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
networks:
|
networks:
|
||||||
- molecule-monorepo-net
|
- molecule-monorepo-net
|
||||||
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
interval: 2s
|
interval: 2s
|
||||||
@@ -126,6 +128,10 @@ services:
|
|||||||
REDIS_URL: redis://redis:6379
|
REDIS_URL: redis://redis:6379
|
||||||
PORT: "${PLATFORM_PORT:-8080}"
|
PORT: "${PLATFORM_PORT:-8080}"
|
||||||
PLATFORM_URL: "http://platform:${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
|
# Default MOLECULE_ENV=development so the WorkspaceAuth / AdminAuth
|
||||||
# middleware fail-open path activates when ADMIN_TOKEN is unset —
|
# middleware fail-open path activates when ADMIN_TOKEN is unset —
|
||||||
# otherwise the canvas (which runs without a bearer in pure local
|
# 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
|
# App private key — read-only bind-mount. The host-side path is
|
||||||
# gitignored per .gitignore rules (/.secrets/ + *.pem).
|
# gitignored per .gitignore rules (/.secrets/ + *.pem).
|
||||||
- ./.secrets/github-app.pem:/secrets/github-app.pem:ro
|
- ./.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:
|
ports:
|
||||||
- "${PLATFORM_PUBLISH_PORT:-8080}:${PLATFORM_PORT:-8080}"
|
- "${PLATFORM_PUBLISH_PORT:-8080}:${PLATFORM_PORT:-8080}"
|
||||||
networks:
|
networks:
|
||||||
- molecule-monorepo-net
|
- molecule-monorepo-net
|
||||||
|
restart: unless-stopped
|
||||||
healthcheck:
|
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
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
@@ -238,7 +260,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- molecule-monorepo-net
|
- molecule-monorepo-net
|
||||||
healthcheck:
|
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
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|||||||
+1
-2
@@ -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,
|
"version": 1,
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{"name": "browser-automation", "repo": "molecule-ai/molecule-ai-plugin-browser-automation", "ref": "main"},
|
{"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": "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": "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": "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": "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"}
|
{"name": "mock-bigorg", "repo": "molecule-ai/molecule-ai-org-template-mock-bigorg", "ref": "main"}
|
||||||
]
|
]
|
||||||
|
|||||||
+14
-17
@@ -8,27 +8,24 @@
|
|||||||
# Requires: git, jq (lighter than python3 — ~2MB vs ~50MB in Alpine)
|
# Requires: git, jq (lighter than python3 — ~2MB vs ~50MB in Alpine)
|
||||||
#
|
#
|
||||||
# Auth (optional):
|
# Auth (optional):
|
||||||
# When MOLECULE_GITEA_TOKEN is set, embed it as the basic-auth password so
|
# Post-2026-05-08 (#192): every repo in manifest.json is public on
|
||||||
# private Gitea repos clone successfully. When unset, clone anonymously
|
# git.moleculesai.app. Anonymous clone works for the entire registered
|
||||||
# (works only for repos that are public on git.moleculesai.app).
|
# 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:
|
# MOLECULE_GITEA_TOKEN is therefore optional today. Kept supported for
|
||||||
# it injects AUTO_SYNC_TOKEN (devops-engineer persona PAT, repo:read on
|
# two reasons: (a) historical CI configs that still inject
|
||||||
# the molecule-ai org) so the in-CI pre-clone step succeeds for ALL
|
# AUTO_SYNC_TOKEN remain harmless, (b) reserved for the case where a
|
||||||
# manifest entries — including the 5 private workspace-template-* repos
|
# private internal-only template is later registered via a ci-readonly
|
||||||
# (codex, crewai, deepagents, gemini-cli, langgraph) and all 7
|
# team grant — review must explicitly sign off on that, since it
|
||||||
# org-template-* repos.
|
# violates the public-OSS-surface contract.
|
||||||
#
|
#
|
||||||
# The token never enters the Docker image: this script runs in the
|
# The token (when set) never enters the Docker image: this script runs
|
||||||
# trusted CI context BEFORE `docker buildx build`, populates
|
# in the trusted CI context BEFORE `docker buildx build`, populates
|
||||||
# .tenant-bundle-deps/, then `Dockerfile.tenant` COPYs from there with
|
# .tenant-bundle-deps/, then `Dockerfile.tenant` COPYs from there with
|
||||||
# the .git directories already stripped (see line ~67 below).
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,5 @@
|
|||||||
# The compiled binary, not the cmd/server package.
|
# The compiled binary, not the cmd/server package.
|
||||||
/server
|
/server
|
||||||
|
|
||||||
|
# air live-reload build cache (Dockerfile.dev + docker-compose.dev.yml).
|
||||||
|
/tmp/
|
||||||
|
|||||||
@@ -15,8 +15,14 @@
|
|||||||
|
|
||||||
FROM golang:1.25-alpine
|
FROM golang:1.25-alpine
|
||||||
|
|
||||||
# air + git (for go mod) + ca-certs (for TLS) + tzdata (for time-zone DB).
|
# air + git (for go mod) + ca-certs (for TLS) + tzdata (for time-zone DB)
|
||||||
RUN apk add --no-cache git ca-certificates tzdata wget \
|
# + 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
|
&& go install github.com/air-verse/air@latest
|
||||||
|
|
||||||
WORKDIR /app/workspace-server
|
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
|
# block) so the Dockerfile doesn't need to COPY it. air watches the
|
||||||
# bind-mounted dir for changes.
|
# bind-mounted dir for changes.
|
||||||
|
|
||||||
ENV CGO_ENABLED=1
|
ENV CGO_ENABLED=0
|
||||||
ENV GOFLAGS="-buildvcs=false"
|
ENV GOFLAGS="-buildvcs=false"
|
||||||
|
|
||||||
# Run air with the .air.toml in the bind-mounted source dir.
|
# Run air with the .air.toml in the bind-mounted source dir.
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ func TestExtended_WorkspaceDelete(t *testing.T) {
|
|||||||
WithArgs(wsDelID).
|
WithArgs(wsDelID).
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}))
|
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}))
|
||||||
|
|
||||||
|
// CascadeDelete walks descendants unconditionally (the 0-children
|
||||||
|
// optimization in the old inline path was dropped during the
|
||||||
|
// CascadeDelete extraction — descendant CTE returns 0 rows here,
|
||||||
|
// same end state, one extra cheap query).
|
||||||
|
mock.ExpectQuery("WITH RECURSIVE descendants").
|
||||||
|
WithArgs(wsDelID).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id"}))
|
||||||
|
|
||||||
// #73: batch UPDATE happens BEFORE any container teardown.
|
// #73: batch UPDATE happens BEFORE any container teardown.
|
||||||
// Uses ANY($1::uuid[]) even with a single ID for consistency.
|
// Uses ANY($1::uuid[]) even with a single ID for consistency.
|
||||||
mock.ExpectExec("UPDATE workspaces SET status =").
|
mock.ExpectExec("UPDATE workspaces SET status =").
|
||||||
|
|||||||
@@ -13,12 +13,15 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/channels"
|
"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/events"
|
||||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/lib/pq"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -422,6 +425,16 @@ type OrgWorkspace struct {
|
|||||||
Tier int `yaml:"tier" json:"tier"`
|
Tier int `yaml:"tier" json:"tier"`
|
||||||
Template string `yaml:"template" json:"template"`
|
Template string `yaml:"template" json:"template"`
|
||||||
FilesDir string `yaml:"files_dir" json:"files_dir"`
|
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
|
// 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
|
// 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.
|
// template-copy step; inline overrides that path for ad-hoc workspaces.
|
||||||
@@ -558,6 +571,19 @@ func (h *OrgHandler) Import(c *gin.Context) {
|
|||||||
var body struct {
|
var body struct {
|
||||||
Dir string `json:"dir"` // org template directory name
|
Dir string `json:"dir"` // org template directory name
|
||||||
Template OrgTemplate `json:"template"` // or inline template
|
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 {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||||
@@ -603,6 +629,19 @@ func (h *OrgHandler) Import(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit started AFTER the YAML is loaded so payload.name carries the
|
||||||
|
// resolved template name (was: empty when caller passed `dir` instead
|
||||||
|
// of inline `template`). Pre-parse error paths above return without
|
||||||
|
// emitting — semantically "we couldn't even start an import" — so
|
||||||
|
// every started event is guaranteed a paired completed/failed below
|
||||||
|
// (no orphan started rows in structure_events).
|
||||||
|
importStart := time.Now()
|
||||||
|
emitOrgEvent(c.Request.Context(), "org.import.started", map[string]any{
|
||||||
|
"name": tmpl.Name,
|
||||||
|
"dir": body.Dir,
|
||||||
|
"mode": body.Mode,
|
||||||
|
})
|
||||||
|
|
||||||
// Required-env preflight — refuses import when any required_env is
|
// Required-env preflight — refuses import when any required_env is
|
||||||
// missing from global_secrets. No bypass: the prior `force: true`
|
// missing from global_secrets. No bypass: the prior `force: true`
|
||||||
// escape hatch was removed (issue #2290) because it was the silent
|
// escape hatch was removed (issue #2290) because it was the silent
|
||||||
@@ -708,18 +747,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 {
|
||||||
|
descendantIDs, 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 + len(descendantIDs)
|
||||||
|
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
|
status := http.StatusCreated
|
||||||
resp := gin.H{
|
resp := gin.H{
|
||||||
"org": tmpl.Name,
|
"org": tmpl.Name,
|
||||||
"workspaces": results,
|
"workspaces": results,
|
||||||
"count": len(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 {
|
if createErr != nil {
|
||||||
status = http.StatusMultiStatus
|
status = http.StatusMultiStatus
|
||||||
resp["error"] = createErr.Error()
|
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)
|
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
|
// straight into the parent's child-coordinate space without doing a
|
||||||
// canvas-wide absolute-position walk.
|
// 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 {
|
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
|
// Apply defaults
|
||||||
runtime := ws.Runtime
|
runtime := ws.Runtime
|
||||||
if runtime == "" {
|
if runtime == "" {
|
||||||
@@ -453,8 +467,25 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
|
|||||||
envVars := map[string]string{}
|
envVars := map[string]string{}
|
||||||
// 0. Persona env (lowest precedence; injects the role's Gitea identity:
|
// 0. Persona env (lowest precedence; injects the role's Gitea identity:
|
||||||
// GITEA_USER, GITEA_TOKEN, GITEA_TOKEN_SCOPES, GITEA_USER_EMAIL,
|
// GITEA_USER, GITEA_TOKEN, GITEA_TOKEN_SCOPES, GITEA_USER_EMAIL,
|
||||||
// GITEA_SSH_KEY_PATH). Workspace and org .env can override.
|
// GITEA_SSH_KEY_PATH, plus MODEL_PROVIDER/MODEL and the LLM auth
|
||||||
loadPersonaEnvFile(ws.Role, envVars)
|
// 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 != "" {
|
if orgBaseDir != "" {
|
||||||
// 1. Org root .env (shared defaults)
|
// 1. Org root .env (shared defaults)
|
||||||
parseEnvFile(filepath.Join(orgBaseDir, ".env"), envVars)
|
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
|
||||||
|
}
|
||||||
@@ -323,161 +323,19 @@ func (h *WorkspaceHandler) Delete(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cascade delete: collect ALL descendants (not just direct children) via
|
// Delegate the cascade to CascadeDelete so the HTTP path and the
|
||||||
// recursive CTE, then stop each container and remove each volume.
|
// OrgImport reconcile path share one teardown sequence (#73 race
|
||||||
// Previous bug: only direct children's containers were stopped, leaving
|
// guard, container stop, volume removal, token revocation, schedule
|
||||||
// grandchildren as orphan running containers after a cascade delete.
|
// disable, broadcast). The HTTP-specific bits — direct-children 409
|
||||||
descendantIDs := []string{}
|
// gate above, ?purge=true hard-delete below, response shaping —
|
||||||
if len(children) > 0 {
|
// stay in this handler.
|
||||||
descRows, err := db.DB.QueryContext(ctx, `
|
descendantIDs, stopErrs, err := h.CascadeDelete(ctx, id)
|
||||||
WITH RECURSIVE descendants AS (
|
if err != nil {
|
||||||
SELECT id FROM workspaces WHERE parent_id = $1 AND status != 'removed'
|
log.Printf("Delete: CascadeDelete(%s) failed: %v", id, err)
|
||||||
UNION ALL
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
SELECT w.id FROM workspaces w JOIN descendants d ON w.parent_id = d.id WHERE w.status != 'removed'
|
return
|
||||||
)
|
|
||||||
SELECT id FROM descendants
|
|
||||||
`, id)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Delete: descendant query error for %s: %v", id, err)
|
|
||||||
} else {
|
|
||||||
for descRows.Next() {
|
|
||||||
var descID string
|
|
||||||
if descRows.Scan(&descID) == nil {
|
|
||||||
descendantIDs = append(descendantIDs, descID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
descRows.Close()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// #73 fix: mark rows 'removed' in the DB FIRST, BEFORE stopping containers
|
|
||||||
// or removing volumes. Previously the sequence was stop → update-status,
|
|
||||||
// which left a gap where:
|
|
||||||
// - the container's last pre-teardown heartbeat could resurrect the row
|
|
||||||
// via the register-handler UPSERT (now also guarded in #73)
|
|
||||||
// - the liveness monitor could observe 'online' status + expired Redis
|
|
||||||
// TTL and trigger RestartByID, recreating a container we're trying
|
|
||||||
// to destroy
|
|
||||||
// Marking 'removed' first makes both of those paths no-op via their
|
|
||||||
// existing `status NOT IN ('removed', ...)` guards.
|
|
||||||
allIDs := append([]string{id}, descendantIDs...)
|
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("Delete status update error 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("Delete canvas_layouts error for %s: %v", id, err)
|
|
||||||
}
|
|
||||||
// Revoke all auth tokens for the deleted workspaces. Once the workspace is
|
|
||||||
// gone its tokens are meaningless; leaving them alive would keep
|
|
||||||
// HasAnyLiveTokenGlobal = true even after the platform is otherwise empty,
|
|
||||||
// which prevents AdminAuth from returning to fail-open and breaks the E2E
|
|
||||||
// test's count-zero assertion (and local re-run cleanup).
|
|
||||||
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("Delete token revocation error for %s: %v", id, err)
|
|
||||||
}
|
|
||||||
// #1027: cascade-disable all schedules for the deleted workspaces so
|
|
||||||
// the scheduler never fires a cron into a removed container.
|
|
||||||
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("Delete schedule disable error for %s: %v", id, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now stop containers + remove volumes for all descendants (any depth).
|
|
||||||
// Any concurrent heartbeat / registration / liveness-triggered restart
|
|
||||||
// will see status='removed' and bail out early.
|
|
||||||
//
|
|
||||||
// Combines two concerns:
|
|
||||||
//
|
|
||||||
// 1. Detach cleanup from the request ctx via WithoutCancel + a 30s
|
|
||||||
// timeout, so when the canvas's `api.del` resolves on our 200
|
|
||||||
// (and gin cancels c.Request.Context()), in-flight Docker
|
|
||||||
// stop/remove calls don't get cancelled mid-operation. The
|
|
||||||
// previous shape leaked containers every time the canvas hung
|
|
||||||
// up promptly: Stop returned "context canceled", the container
|
|
||||||
// stayed up, and the next RemoveVolume failed with
|
|
||||||
// "volume in use". 30s is generous for Docker daemon round-
|
|
||||||
// trips (typical: <2s) and bounds a stuck daemon.
|
|
||||||
//
|
|
||||||
// 2. #1843: aggregate Stop() failures into stopErrs so the
|
|
||||||
// post-deletion block surfaces them as 500. On the CP/EC2
|
|
||||||
// backend, Stop() calls control plane's DELETE endpoint to
|
|
||||||
// terminate the EC2; if that errors (transient 5xx, network),
|
|
||||||
// the EC2 stays running with no DB row to track it (the
|
|
||||||
// "orphan EC2 on a 0-customer account" scenario). Loud-fail
|
|
||||||
// instead of silent-leak — clients retry, Stop's instance_id
|
|
||||||
// lookup is idempotent against status='removed'. RemoveVolume
|
|
||||||
// errors stay log-and-continue (local cleanup, not infra-leak).
|
|
||||||
cleanupCtx, cleanupCancel := context.WithTimeout(
|
|
||||||
context.WithoutCancel(ctx), 30*time.Second)
|
|
||||||
defer cleanupCancel()
|
|
||||||
|
|
||||||
var stopErrs []error
|
|
||||||
stopAndRemove := func(wsID string) {
|
|
||||||
// Stop the workload first via the backend dispatcher (CP for
|
|
||||||
// SaaS, Docker for self-hosted). Pre-2026-05-05 this gate was
|
|
||||||
// `if h.provisioner == nil { return }` — early-returning on
|
|
||||||
// every SaaS tenant left the EC2 running with no DB row to
|
|
||||||
// track it (issue #2814; the comment below claimed "loud-fail
|
|
||||||
// instead of silent-leak" but the early-return made it the
|
|
||||||
// silent path on SaaS).
|
|
||||||
//
|
|
||||||
// Check Stop's error before any volume cleanup — the previous
|
|
||||||
// code discarded it and immediately tried RemoveVolume, which
|
|
||||||
// always fails with "volume in use" when Stop didn't actually
|
|
||||||
// kill the container. The orphan sweeper
|
|
||||||
// (registry/orphan_sweeper.go) catches what we skip here on
|
|
||||||
// the next reconcile pass.
|
|
||||||
if err := h.StopWorkspaceAuto(cleanupCtx, wsID); err != nil {
|
|
||||||
log.Printf("Delete %s stop failed: %v — leaving cleanup for orphan sweeper", wsID, err)
|
|
||||||
stopErrs = append(stopErrs, fmt.Errorf("stop %s: %w", wsID, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Volume cleanup is Docker-only — CP-managed workspaces have
|
|
||||||
// no host-bind volumes to remove. Skip silently when no Docker
|
|
||||||
// provisioner is wired (the SaaS path already terminated the
|
|
||||||
// EC2 above; nothing left to do).
|
|
||||||
if h.provisioner != nil {
|
|
||||||
if err := h.provisioner.RemoveVolume(cleanupCtx, wsID); err != nil {
|
|
||||||
log.Printf("Delete %s volume removal warning: %v", wsID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, descID := range descendantIDs {
|
|
||||||
stopAndRemove(descID)
|
|
||||||
db.ClearWorkspaceKeys(cleanupCtx, descID)
|
|
||||||
// #2269: drop the per-workspace restartState entry so it
|
|
||||||
// doesn't accumulate across the platform's lifetime. The
|
|
||||||
// LoadOrStore that creates the entry (workspace_restart.go)
|
|
||||||
// has no companion remove path; without this Delete, every
|
|
||||||
// short-lived workspace leaks ~16 bytes forever.
|
|
||||||
restartStates.Delete(descID)
|
|
||||||
// Detach broadcaster ctx for the same reason as the cleanup
|
|
||||||
// above — RecordAndBroadcast does an INSERT INTO
|
|
||||||
// structure_events + Redis Publish. If the canvas hangs up,
|
|
||||||
// a request-ctx-bound INSERT can be cancelled mid-write,
|
|
||||||
// leaving other WS clients ignorant of the cascade. The DB
|
|
||||||
// row is already 'removed' so it's recoverable, but the
|
|
||||||
// inconsistency is avoidable.
|
|
||||||
h.broadcaster.RecordAndBroadcast(cleanupCtx, string(events.EventWorkspaceRemoved), descID, map[string]interface{}{})
|
|
||||||
}
|
|
||||||
|
|
||||||
stopAndRemove(id)
|
|
||||||
db.ClearWorkspaceKeys(cleanupCtx, id)
|
|
||||||
restartStates.Delete(id) // #2269: same as descendants above
|
|
||||||
|
|
||||||
h.broadcaster.RecordAndBroadcast(cleanupCtx, string(events.EventWorkspaceRemoved), id, map[string]interface{}{
|
|
||||||
"cascade_deleted": len(descendantIDs),
|
|
||||||
})
|
|
||||||
|
|
||||||
// If any Stop call failed, surface 500 so the client retries. The DB
|
// If any Stop call failed, surface 500 so the client retries. The DB
|
||||||
// row is already 'removed' (idempotent), and Stop's instance_id
|
// row is already 'removed' (idempotent), and Stop's instance_id
|
||||||
@@ -543,6 +401,104 @@ func (h *WorkspaceHandler) Delete(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"status": "removed", "cascade_deleted": len(descendantIDs)})
|
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 descendant id list so the HTTP
|
||||||
|
// caller can drive the optional `?purge=true` hard-delete path against the
|
||||||
|
// same set the cascade just touched, plus 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) ([]string, []error, error) {
|
||||||
|
if err := validateWorkspaceID(id); err != nil {
|
||||||
|
return nil, 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 nil, 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 descendantIDs, stopErrs, nil
|
||||||
|
}
|
||||||
|
|
||||||
// validateWorkspaceID returns an error when id is not a valid UUID.
|
// validateWorkspaceID returns an error when id is not a valid UUID.
|
||||||
// #687: prevents 500s from Postgres when a garbage string (e.g. ../../etc/passwd)
|
// #687: prevents 500s from Postgres when a garbage string (e.g. ../../etc/passwd)
|
||||||
// is passed as the :id path parameter.
|
// 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
|
// payload.Model at boot), this is a no-op — no harm in the switch
|
||||||
// being empty for those cases.
|
// being empty for those cases.
|
||||||
func applyRuntimeModelEnv(envVars map[string]string, runtime, model string) {
|
func applyRuntimeModelEnv(envVars map[string]string, runtime, model string) {
|
||||||
// Fall back to the MODEL_PROVIDER workspace secret when the caller
|
// Resolution order (priority high → low):
|
||||||
// didn't pass one explicitly. This is the path that "Save+Restart"
|
// 1. payload.Model (caller passed the canvas-picked model id verbatim)
|
||||||
// hits — Restart builds its payload from the workspaces row (no model
|
// 2. envVars["MODEL"] (workspace_secret persisted by /org/import via
|
||||||
// column there) so payload.Model is always empty, but the user's
|
// the persona env file — MODEL=MiniMax-M2.7-highspeed etc.)
|
||||||
// canvas selection was stored as MODEL_PROVIDER via PUT /model and
|
// 3. envVars["MODEL_PROVIDER"] (legacy: this secret was historically a
|
||||||
// is already loaded into envVars here. Without this fallback hermes
|
// *model id* set by canvas Save+Restart's PUT /model; on the
|
||||||
// silently boots with the template default and errors "No LLM
|
// post-2026-05-08 persona-env convention it's a *provider slug*
|
||||||
// provider configured" even though the user picked a valid model.
|
// (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 == "" {
|
if model == "" {
|
||||||
model = envVars["MODEL_PROVIDER"]
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -813,6 +813,12 @@ func TestWorkspaceDelete_DisablesSchedules(t *testing.T) {
|
|||||||
WithArgs(wsID).
|
WithArgs(wsID).
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}))
|
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}))
|
||||||
|
|
||||||
|
// CascadeDelete walks descendants unconditionally — 0-children case
|
||||||
|
// returns 0 rows here.
|
||||||
|
mock.ExpectQuery("WITH RECURSIVE descendants").
|
||||||
|
WithArgs(wsID).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id"}))
|
||||||
|
|
||||||
// Mark workspace as removed
|
// Mark workspace as removed
|
||||||
mock.ExpectExec("UPDATE workspaces SET status =").
|
mock.ExpectExec("UPDATE workspaces SET status =").
|
||||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
@@ -935,6 +941,12 @@ func TestWorkspaceDelete_ScheduleDisableOnlyTargetsDeletedWorkspace(t *testing.T
|
|||||||
WithArgs(wsA).
|
WithArgs(wsA).
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}))
|
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}))
|
||||||
|
|
||||||
|
// CascadeDelete walks descendants unconditionally — 0-children case
|
||||||
|
// returns 0 rows here.
|
||||||
|
mock.ExpectQuery("WITH RECURSIVE descendants").
|
||||||
|
WithArgs(wsA).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id"}))
|
||||||
|
|
||||||
// Mark only workspace A as removed
|
// Mark only workspace A as removed
|
||||||
mock.ExpectExec("UPDATE workspaces SET status =").
|
mock.ExpectExec("UPDATE workspaces SET status =").
|
||||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -46,7 +46,11 @@
|
|||||||
# 2. Fetch fresh token from platform API.
|
# 2. Fetch fresh token from platform API.
|
||||||
# 3. If platform is unreachable, fall back to GITHUB_TOKEN / GH_TOKEN
|
# 3. If platform is unreachable, fall back to GITHUB_TOKEN / GH_TOKEN
|
||||||
# env var (set at container start, valid for up to 60 min).
|
# env var (set at container start, valid for up to 60 min).
|
||||||
# 4. If all fail, exit 1 so git falls through to the next credential
|
# 4. If env var is unset, read static-token file at
|
||||||
|
# ${CONFIGS_DIR}/.github-token. Operator escape hatch for incidents
|
||||||
|
# when the platform endpoint is broken; not managed by the platform.
|
||||||
|
# Never auto-cached, so API recovery is detected immediately.
|
||||||
|
# 5. If all fail, exit 1 so git falls through to the next credential
|
||||||
# helper in the chain (if any).
|
# helper in the chain (if any).
|
||||||
#
|
#
|
||||||
# # gh CLI integration
|
# # gh CLI integration
|
||||||
@@ -197,7 +201,25 @@ _fetch_token_from_api() {
|
|||||||
echo "${token}"
|
echo "${token}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# _fetch_token — return a fresh token using cache > API > env fallback chain.
|
# _read_static_token — output static-token-file contents if present and
|
||||||
|
# non-empty. Returns 1 if file missing or empty. Never writes to cache —
|
||||||
|
# operator escape hatch; we want API recovery to be detected on the very
|
||||||
|
# next call without 50-min stale-cache stickiness on the workaround.
|
||||||
|
_read_static_token() {
|
||||||
|
local static_file="${CONFIGS_DIR}/.github-token"
|
||||||
|
if [ ! -f "${static_file}" ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
local static_token
|
||||||
|
static_token=$(cat "${static_file}" 2>/dev/null | tr -d '[:space:]')
|
||||||
|
if [ -z "${static_token}" ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "${static_token}"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# _fetch_token — return a fresh token using cache > API > env > static fallback chain.
|
||||||
# Outputs the raw token string on success; exits non-zero if all sources fail.
|
# Outputs the raw token string on success; exits non-zero if all sources fail.
|
||||||
_fetch_token() {
|
_fetch_token() {
|
||||||
# 1. Try cache first.
|
# 1. Try cache first.
|
||||||
@@ -222,6 +244,16 @@ _fetch_token() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 4. Static-token file fallback — operator escape hatch for when
|
||||||
|
# the platform API is broken AND no env var is set.
|
||||||
|
# Manually written by infra; never auto-cached so API recovery
|
||||||
|
# is detected on the very next call.
|
||||||
|
static_token=$(_read_static_token 2>/dev/null) && {
|
||||||
|
echo "[molecule-git-token-helper] API + env exhausted, using static-token file" >&2
|
||||||
|
echo "${static_token}"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
echo "[molecule-git-token-helper] all token sources exhausted" >&2
|
echo "[molecule-git-token-helper] all token sources exhausted" >&2
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
@@ -240,20 +272,38 @@ case "${ACTION}" in
|
|||||||
# No-op — the platform manages token lifecycle.
|
# No-op — the platform manages token lifecycle.
|
||||||
;;
|
;;
|
||||||
_fetch_token)
|
_fetch_token)
|
||||||
# Return raw token (cache > API > env fallback).
|
# Return raw token (cache > API > env > static fallback).
|
||||||
_fetch_token
|
_fetch_token
|
||||||
;;
|
;;
|
||||||
_refresh_gh)
|
_refresh_gh)
|
||||||
# Refresh cache AND update gh CLI auth in one shot.
|
# Refresh cache AND update gh CLI auth in one shot.
|
||||||
# Called by molecule-gh-token-refresh.sh background daemon.
|
# Called by molecule-gh-token-refresh.sh background daemon.
|
||||||
# Force-bypass cache to get a definitely fresh token.
|
# Force-bypass cache to get a definitely fresh token.
|
||||||
api_token=$(_fetch_token_from_api) || {
|
# On API failure, fall through env → static-file like _fetch_token does,
|
||||||
echo "[molecule-git-token-helper] _refresh_gh: API fetch failed" >&2
|
# but do NOT write the cache (those aren't API-issued tokens).
|
||||||
exit 1
|
api_token=$(_fetch_token_from_api) || api_token=""
|
||||||
}
|
chosen_token=""
|
||||||
_write_cache "${api_token}"
|
if [ -n "${api_token}" ]; then
|
||||||
|
_write_cache "${api_token}"
|
||||||
|
chosen_token="${api_token}"
|
||||||
|
else
|
||||||
|
env_token="${GITHUB_TOKEN:-${GH_TOKEN:-}}"
|
||||||
|
if [ -n "${env_token}" ]; then
|
||||||
|
chosen_token="${env_token}"
|
||||||
|
echo "[molecule-git-token-helper] _refresh_gh: API failed, using env GITHUB_TOKEN" >&2
|
||||||
|
else
|
||||||
|
static_token=$(_read_static_token 2>/dev/null) && {
|
||||||
|
chosen_token="${static_token}"
|
||||||
|
echo "[molecule-git-token-helper] _refresh_gh: API failed + env unset, using static-token file" >&2
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
if [ -z "${chosen_token}" ]; then
|
||||||
|
echo "[molecule-git-token-helper] _refresh_gh: API fetch failed and no fallback available" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
# Update gh CLI auth — gh auth login reads token from stdin.
|
# Update gh CLI auth — gh auth login reads token from stdin.
|
||||||
echo "${api_token}" | gh auth login --hostname github.com --with-token 2>/dev/null || {
|
echo "${chosen_token}" | gh auth login --hostname github.com --with-token 2>/dev/null || {
|
||||||
echo "[molecule-git-token-helper] _refresh_gh: gh auth login failed (non-fatal)" >&2
|
echo "[molecule-git-token-helper] _refresh_gh: gh auth login failed (non-fatal)" >&2
|
||||||
}
|
}
|
||||||
# Also update GH_TOKEN file for scripts that source it.
|
# Also update GH_TOKEN file for scripts that source it.
|
||||||
@@ -265,7 +315,7 @@ case "${ACTION}" in
|
|||||||
# function); shadow with a uniquely-named global instead.
|
# function); shadow with a uniquely-named global instead.
|
||||||
_gh_prev_umask=$(umask)
|
_gh_prev_umask=$(umask)
|
||||||
umask 077
|
umask 077
|
||||||
printf '%s' "${api_token}" > "${gh_token_file}.tmp"
|
printf '%s' "${chosen_token}" > "${gh_token_file}.tmp"
|
||||||
mv -f "${gh_token_file}.tmp" "${gh_token_file}"
|
mv -f "${gh_token_file}.tmp" "${gh_token_file}"
|
||||||
umask "${_gh_prev_umask}"
|
umask "${_gh_prev_umask}"
|
||||||
unset _gh_prev_umask
|
unset _gh_prev_umask
|
||||||
|
|||||||
Reference in New Issue
Block a user