diff --git a/.github/workflows/branch-protection-drift.yml b/.github/workflows/branch-protection-drift.yml new file mode 100644 index 00000000..889d3d5b --- /dev/null +++ b/.github/workflows/branch-protection-drift.yml @@ -0,0 +1,81 @@ +name: branch-protection drift check + +# Catches out-of-band edits to branch protection (UI clicks, manual gh +# api PATCH from a one-off ops session) by comparing live state against +# tools/branch-protection/apply.sh's desired state every day. Fails the +# workflow when they drift; the failure is the signal. +# +# When it fails: re-run apply.sh to put the live state back to the +# script's intent, OR update apply.sh to encode the new intent and +# commit. Either way the script is the source of truth. + +on: + schedule: + # 14:00 UTC daily. Off-hours for most teams; gives a fresh signal + # at the start of every working day. + - cron: '0 14 * * *' + workflow_dispatch: + pull_request: + branches: [staging, main] + paths: + - 'tools/branch-protection/**' + - '.github/workflows/branch-protection-drift.yml' + +permissions: + contents: read + +jobs: + drift: + name: Branch protection drift + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + # Token strategy by trigger: + # + # - schedule (daily canary): hard-fail when the admin token is + # missing. This is the *only* trigger where silent soft-skip is + # dangerous — a missing secret on the cron run means the drift + # gate has effectively disappeared with no human in the loop to + # notice. Per feedback_schedule_vs_dispatch_secrets_hardening.md + # the rule is "schedule/automated triggers must hard-fail". + # + # - pull_request (touching tools/branch-protection/**): soft-skip + # with a prominent warning. A PR cannot retroactively drift the + # live state — drift happens *between* PRs (UI clicks, manual + # gh api PATCH) and is the schedule's job to catch. The PR-time + # gate would only catch typos in apply.sh, which the apply.sh + # *_payload unit tests catch better. A human is reviewing the + # PR and will see the warning in the workflow log. + # + # - workflow_dispatch (operator one-off): soft-skip with warning, + # so an operator can run a diagnostic without configuring the + # secret first. + - name: Verify admin token present (hard-fail on schedule only) + env: + GH_TOKEN_FOR_ADMIN_API: ${{ secrets.GH_TOKEN_FOR_ADMIN_API }} + run: | + if [[ -n "$GH_TOKEN_FOR_ADMIN_API" ]]; then + echo "GH_TOKEN_FOR_ADMIN_API present — drift_check will run with admin scope." + exit 0 + fi + if [[ "${{ github.event_name }}" == "schedule" ]]; then + echo "::error::GH_TOKEN_FOR_ADMIN_API secret missing on the daily canary." >&2 + echo "" >&2 + echo "The schedule run is the SoT for branch-protection drift detection." >&2 + echo "Without admin scope it silently passes, hiding any out-of-band edits." >&2 + echo "Set GH_TOKEN_FOR_ADMIN_API at Settings → Secrets and variables → Actions." >&2 + exit 1 + fi + echo "::warning::GH_TOKEN_FOR_ADMIN_API secret missing — drift_check will be SKIPPED." + echo "::warning::PR drift checks need repo-admin scope to read /branches/:b/protection." + echo "::warning::This is non-fatal: the daily schedule run is the canonical drift gate." + echo "SKIP_DRIFT_CHECK=1" >> "$GITHUB_ENV" + + - name: Run drift check + if: env.SKIP_DRIFT_CHECK != '1' + env: + # Repo-admin scope, needed for /branches/:b/protection. + GH_TOKEN: ${{ secrets.GH_TOKEN_FOR_ADMIN_API }} + run: bash tools/branch-protection/drift_check.sh diff --git a/.github/workflows/handlers-postgres-integration.yml b/.github/workflows/handlers-postgres-integration.yml new file mode 100644 index 00000000..78bcaf7c --- /dev/null +++ b/.github/workflows/handlers-postgres-integration.yml @@ -0,0 +1,160 @@ +name: Handlers Postgres Integration + +# Real-Postgres integration tests for workspace-server/internal/handlers/. +# Triggered on every PR/push that touches the handlers package. +# +# Why this workflow exists +# ------------------------ +# Strict-sqlmock unit tests pin which SQL statements fire — they're fast +# and let us iterate without a DB. But sqlmock CANNOT detect bugs that +# depend on the row state AFTER the SQL runs. The result_preview-lost +# bug shipped to staging in PR #2854 because every unit test was +# satisfied with "an UPDATE statement fired" — none verified the row's +# preview field actually landed. The local-postgres E2E that retrofit +# self-review caught it took 2 minutes to set up and would have caught +# the bug at PR-time. +# +# This job spins a Postgres service container, applies the migration, +# and runs `go test -tags=integration` against a live DB. Required +# check on staging branch protection — backend handler PRs cannot +# merge without a real-DB regression gate. +# +# Cost: ~30s job (postgres pull from GH cache + go build + 4 tests). + +on: + push: + branches: [main, staging] + pull_request: + branches: [main, staging] + merge_group: + types: [checks_requested] + workflow_dispatch: + +concurrency: + group: handlers-pg-integ-${{ github.event.pull_request.head.sha || github.sha }} + cancel-in-progress: false + +jobs: + detect-changes: + name: detect-changes + runs-on: ubuntu-latest + outputs: + handlers: ${{ steps.filter.outputs.handlers }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + handlers: + - 'workspace-server/internal/handlers/**' + - 'workspace-server/internal/wsauth/**' + - 'workspace-server/migrations/**' + - '.github/workflows/handlers-postgres-integration.yml' + + # Single-job-with-per-step-if pattern: always runs to satisfy the + # required-check name on branch protection; real work gates on the + # paths filter. See ci.yml's Platform (Go) for the same shape. + integration: + name: Handlers Postgres Integration + needs: detect-changes + runs-on: ubuntu-latest + services: + postgres: + image: postgres:15-alpine + env: + POSTGRES_PASSWORD: test + POSTGRES_DB: molecule + ports: + - 5432:5432 + # GHA spins this with --health-cmd built in for postgres images. + options: >- + --health-cmd pg_isready + --health-interval 5s + --health-timeout 5s + --health-retries 10 + defaults: + run: + working-directory: workspace-server + steps: + - if: needs.detect-changes.outputs.handlers != 'true' + working-directory: . + run: echo "No handlers/migrations changes — skipping; this job always runs to satisfy the required-check name." + + - if: needs.detect-changes.outputs.handlers == 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - if: needs.detect-changes.outputs.handlers == 'true' + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: 'stable' + + - if: needs.detect-changes.outputs.handlers == 'true' + name: Apply migrations to Postgres service + env: + PGPASSWORD: test + run: | + # Wait for postgres to actually accept connections (the + # GHA --health-cmd is best-effort but psql can still race). + for i in {1..15}; do + if pg_isready -h localhost -p 5432 -U postgres -q; then break; fi + echo "waiting for postgres..."; sleep 2 + done + + # Apply every .up.sql in lexicographic order with + # ON_ERROR_STOP=0 — failing migrations are SKIPPED rather than + # blocking the suite. This handles the current schema state + # where a few historical migrations (e.g. 017_memories_fts_*) + # depend on tables that were later renamed/dropped and so + # cannot replay from scratch. The migrations that DO succeed + # land their tables, which is sufficient for the integration + # tests in handlers/. + # + # Why not maintain a curated allowlist: every new migration + # touching a handlers/-tested table would have to update this + # workflow. With apply-all-or-skip, a future migration that + # adds a column to delegations runs automatically (its base + # table 049_delegations.up.sql already succeeded above it in + # the order). Operators only need to revisit this if the + # migration chain becomes legitimately replayable end-to-end. + # + # Per-migration result is logged so a failed migration that + # SHOULD have been replayable surfaces in the CI log instead + # of silently failing. + set +e + for migration in migrations/*.up.sql; do + if psql -h localhost -U postgres -d molecule -v ON_ERROR_STOP=1 \ + -f "$migration" >/dev/null 2>&1; then + echo "✓ $(basename "$migration")" + else + echo "⊘ $(basename "$migration") (skipped — see comment in workflow)" + fi + done + set -e + + # Sanity: the delegations table MUST exist for the integration + # tests to be meaningful. Hard-fail if 049 didn't land — that + # would be a real regression we want loud. + if ! psql -h localhost -U postgres -d molecule -tA \ + -c "SELECT 1 FROM information_schema.tables WHERE table_name = 'delegations'" \ + | grep -q 1; then + echo "::error::delegations table missing after migration replay — handler integration tests would be meaningless" + exit 1 + fi + echo "✓ delegations table present" + + - if: needs.detect-changes.outputs.handlers == 'true' + name: Run integration tests + env: + INTEGRATION_DB_URL: postgres://postgres:test@localhost:5432/molecule?sslmode=disable + run: | + go test -tags=integration -timeout 5m -v ./internal/handlers/ -run "^TestIntegration_" + + - if: needs.detect-changes.outputs.handlers == 'true' && failure() + name: Diagnostic dump on failure + env: + PGPASSWORD: test + run: | + echo "::group::delegations table state" + psql -h localhost -U postgres -d molecule -c "SELECT * FROM delegations LIMIT 50;" || true + echo "::endgroup::" diff --git a/.github/workflows/runtime-prbuild-compat.yml b/.github/workflows/runtime-prbuild-compat.yml index 4033a11c..05b1d37c 100644 --- a/.github/workflows/runtime-prbuild-compat.yml +++ b/.github/workflows/runtime-prbuild-compat.yml @@ -43,7 +43,20 @@ on: types: [checks_requested] concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.head.sha || github.sha }} + # Include event_name so a PR sync (event=pull_request) and the + # subsequent staging push (event=push) on the SAME merge SHA don't + # collide in one group. Without event_name, both runs hashed to + # the same key and cancel-in-progress=true cancelled whichever + # arrived second — usually the push run, which staging branch- + # protection then sees as a CANCELLED required check and refuses + # to mark merged. Caught 2026-05-05 across PR #2869's runs (run + # ids 25371863455 / 25371811486 / 25371078157 / 25370403142 — every + # staging push run cancelled, every matching PR run green). + # + # Per memory `feedback_concurrency_group_per_sha.md` — same drift + # class that broke auto-promote-staging on 2026-04-28. Pin invariant: + # event_name + sha is the minimum unique key for these workflows. + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.head.sha || github.sha }} cancel-in-progress: true jobs: diff --git a/canvas/src/components/ContextMenu.tsx b/canvas/src/components/ContextMenu.tsx index 1247c81c..66ac3b82 100644 --- a/canvas/src/components/ContextMenu.tsx +++ b/canvas/src/components/ContextMenu.tsx @@ -215,16 +215,6 @@ export function ContextMenu() { closeContextMenu(); }, [contextMenu, selectNode, setPanelTab, closeContextMenu]); - const handleExpand = useCallback(async () => { - if (!contextMenu) return; - try { - await api.post(`/workspaces/${contextMenu.nodeId}/expand`, {}); - } catch (e) { - showToast("Expand failed", "error"); - } - closeContextMenu(); - }, [contextMenu, closeContextMenu]); - const setCollapsed = useCanvasStore((s) => s.setCollapsed); const handleCollapse = useCallback(async () => { if (!contextMenu) return; @@ -295,7 +285,7 @@ export function ContextMenu() { }, { label: "Zoom to Team", icon: "⊕", action: handleZoomToTeam }, ] - : [{ label: "Expand to Team", icon: "▷", action: handleExpand }]), + : []), { label: "", icon: "", action: () => {}, divider: true }, ...(isPaused ? [{ label: "Resume", icon: "▶", action: handleResume }] diff --git a/canvas/src/components/MemoryEditorDialog.tsx b/canvas/src/components/MemoryEditorDialog.tsx new file mode 100644 index 00000000..6625412a --- /dev/null +++ b/canvas/src/components/MemoryEditorDialog.tsx @@ -0,0 +1,261 @@ +'use client'; + +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { api } from "@/lib/api"; +import type { MemoryEntry } from "@/components/MemoryInspectorPanel"; + +type Scope = "LOCAL" | "TEAM" | "GLOBAL"; +const SCOPES: Scope[] = ["LOCAL", "TEAM", "GLOBAL"]; + +interface AddProps { + open: boolean; + mode: "add"; + workspaceId: string; + defaultScope: Scope; + defaultNamespace?: string; + entry?: undefined; + onClose: () => void; + onSaved: () => void; +} + +interface EditProps { + open: boolean; + mode: "edit"; + workspaceId: string; + entry: MemoryEntry; + defaultScope?: undefined; + defaultNamespace?: undefined; + onClose: () => void; + onSaved: () => void; +} + +type Props = AddProps | EditProps; + +export function MemoryEditorDialog(props: Props) { + const { open, mode, workspaceId, onClose, onSaved } = props; + const dialogRef = useRef(null); + const [mounted, setMounted] = useState(false); + const [scope, setScope] = useState("LOCAL"); + const [namespace, setNamespace] = useState("general"); + const [content, setContent] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + setMounted(true); + }, []); + + // Reset form whenever the dialog opens. + useEffect(() => { + if (!open) return; + setError(null); + setSaving(false); + if (mode === "edit" && props.entry) { + setScope(props.entry.scope); + setNamespace(props.entry.namespace || "general"); + setContent(props.entry.content); + } else if (mode === "add") { + setScope(props.defaultScope); + setNamespace(props.defaultNamespace || "general"); + setContent(""); + } + // mode/props are stable per-open; intentional shallow deps. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + // Move focus into the dialog when it opens (WCAG SC 2.4.3). + useEffect(() => { + if (!open || !mounted) return; + const raf = requestAnimationFrame(() => { + dialogRef.current?.querySelector("textarea, input, select")?.focus(); + }); + return () => cancelAnimationFrame(raf); + }, [open, mounted]); + + // Escape closes; Cmd/Ctrl-Enter saves. + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + const handleSaveRef = useRef<() => void>(() => {}); + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onCloseRef.current(); + } else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + handleSaveRef.current(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open]); + + const handleSave = async () => { + if (saving) return; + const trimmed = content.trim(); + if (!trimmed) { + setError("Content cannot be empty"); + return; + } + setError(null); + setSaving(true); + try { + if (mode === "add") { + await api.post(`/workspaces/${workspaceId}/memories`, { + content: trimmed, + scope, + namespace: namespace.trim() || "general", + }); + } else { + // PATCH only sends fields that changed. Content always changeable; + // namespace only sent if it differs from the original (saves a + // no-op write through redactSecrets + re-embed). + const original = props.entry; + const body: Record = {}; + if (trimmed !== original.content) body.content = trimmed; + const ns = namespace.trim() || "general"; + if (ns !== original.namespace) body.namespace = ns; + if (Object.keys(body).length === 0) { + // No-op edit — close without an HTTP round-trip. + onSaved(); + onClose(); + return; + } + await api.patch( + `/workspaces/${workspaceId}/memories/${encodeURIComponent(original.id)}`, + body, + ); + } + onSaved(); + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : "Save failed"); + } finally { + setSaving(false); + } + }; + handleSaveRef.current = handleSave; + + if (!open || !mounted) return null; + + const titleId = "memory-editor-title"; + const isEdit = mode === "edit"; + + return createPortal( +
+
+ +
+
+

+ {isEdit ? "Edit memory" : "Add memory"} +

+ + {/* Scope */} +
+ + {isEdit ? ( +
+ {scope} +
+ ) : ( +
+ {SCOPES.map((s) => ( + + ))} +
+ )} +
+ + {/* Namespace */} +
+ + setNamespace(e.target.value)} + placeholder="general" + className="w-full bg-surface border border-line/60 focus:border-accent/60 rounded px-2 py-1.5 text-[12px] text-ink placeholder-zinc-600 focus:outline-none transition-colors" + /> +
+ + {/* Content */} +
+ +