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/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 */} +
+ +