From 7140ffa47ca20e37e7aa3057ba721a6d6c505064 Mon Sep 17 00:00:00 2001
From: benjsmith
Date: Fri, 18 Sep 2026 22:08:27 +0200
Subject: [PATCH 1/4] fix: harden desks, comms, and ingestion
Bound live workers and preserve productive Curate results. Require
metadata review before message bodies and per-call approval for Web.
Repair runtime discovery, PDF export, Chat layout, and document
extraction. Retry iCloud arrivals without losing sources or receipts.
Signed-off-by: benjsmith
---
.github/workflows/ci.yml | 185 +-
docs/concepts-and-data-flow.md | 50 +-
docs/enterprise.md | 110 +-
docs/known-issues.md | 1 +
docs/vscode.md | 2 +-
frontend/scripts/render-slideshow-pdf.mjs | 22 +-
frontend/src/App.tsx | 23 +
frontend/src/center/builtinTabs.tsx | 2 +
frontend/src/index.css | 20 +-
frontend/src/layout/HelpModal.tsx | 2 +-
frontend/src/layout/SettingsModal.tsx | 279 +--
frontend/src/layout/WorkspaceSwitcher.tsx | 2 +-
frontend/src/rail/Rail.tsx | 34 +-
frontend/src/rail/ReasoningPicker.tsx | 2 +-
frontend/src/sidebar/FileBrowser.tsx | 2 +-
frontend/src/sidebar/SourceBrowser.tsx | 10 +-
.../src/widgets/agents/AgentDashboardTab.tsx | 15 +
frontend/src/widgets/comms/CommsTab.tsx | 276 +++
frontend/src/widgets/graph/static/sidebar.js | 2 +-
frontend/src/ws.ts | 12 +
frontend/src/zen/ZenChatBox.tsx | 4 +-
frontend/src/zen/ZenShell.tsx | 29 +-
frontend/tests/e2e/comms-desks.spec.ts | 135 ++
frontend/tests/e2e/mock_backend.py | 510 +++++
frontend/tests/e2e/zen-web-policy.spec.ts | 296 +++
src/switchbay/admin_policy.py | 58 +
src/switchbay/agents/ce_workers.py | 70 +-
src/switchbay/agents/desk_admission.py | 355 ++++
src/switchbay/agents/fast_lookup.py | 5 +-
src/switchbay/agents/orchestration.py | 1222 +++++++++---
src/switchbay/agents/orchestration_health.py | 40 +-
src/switchbay/agents/orchestration_policy.py | 30 +-
src/switchbay/app_settings.py | 27 +
src/switchbay/ce_host.py | 200 +-
src/switchbay/ce_protocol.py | 2 +
src/switchbay/ce_tools.py | 433 ++++-
src/switchbay/cebridge.py | 199 +-
src/switchbay/command_palettes.py | 9 +
src/switchbay/comms_review.py | 764 ++++++++
src/switchbay/daemon.py | 1666 +++++++++++++----
src/switchbay/helpers/icloud_download.js | 104 +
src/switchbay/icloud_download.py | 245 +++
src/switchbay/kernel/__init__.py | 4 +
src/switchbay/kernel/desk.py | 14 +-
src/switchbay/kernel/harness_pi.py | 46 +-
src/switchbay/kernel/hire.py | 45 +-
src/switchbay/kernel/packages.py | 1 +
.../llmgateway/claude_code_settings.py | 36 +-
src/switchbay/llmgateway/openai_codex.py | 23 +-
src/switchbay/mcp_server.py | 28 +
src/switchbay/permissions.py | 299 ++-
src/switchbay/protocol.py | 2 +
src/switchbay/research.py | 21 +-
src/switchbay/runtime.py | 514 +++++
src/switchbay/service.py | 23 +-
src/switchbay/streams.py | 1169 ++++++++++--
src/switchbay/tabstore.py | 52 +
src/switchbay/tools.py | 36 +-
src/switchbay/updater.py | 14 +-
src/switchbay/verbs.py | 12 +
src/switchbay/watchfolders.py | 1182 +++++++++++-
tests/unit/conftest.py | 34 +
tests/unit/test_admin_policy.py | 76 +
tests/unit/test_ce_global_fallback.py | 55 +
tests/unit/test_ce_host.py | 49 +
tests/unit/test_ce_workers.py | 18 +-
tests/unit/test_comms_desks_review.py | 177 ++
tests/unit/test_comms_gate.py | 1022 ++++++++++
tests/unit/test_curate_content_receipts.py | 56 +
tests/unit/test_curate_evidence.py | 44 +
tests/unit/test_curate_lifecycle.py | 579 ++++++
tests/unit/test_curate_scheduler.py | 483 +++++
tests/unit/test_desk_admission.py | 228 +++
tests/unit/test_icloud_download.py | 89 +
tests/unit/test_ingest_prep.py | 4 +-
tests/unit/test_kernel_desk.py | 150 ++
tests/unit/test_kernel_harness.py | 5 +-
tests/unit/test_orchestration.py | 12 +-
tests/unit/test_permission_scoping.py | 8 +
tests/unit/test_pptx_ingest.py | 344 ++++
tests/unit/test_release_acceptance.py | 315 ++++
tests/unit/test_research.py | 19 +
tests/unit/test_research_desk.py | 131 ++
tests/unit/test_runtime.py | 216 +++
tests/unit/test_service_stop.py | 69 +
tests/unit/test_slideshow_pdf.py | 5 +-
tests/unit/test_watch_review.py | 369 ++++
tests/unit/test_watchfolders.py | 365 ++++
tests/unit/test_web_consent.py | 212 +++
tests/unit/test_web_egress_policy.py | 196 ++
tests/unit/test_web_search_approval.py | 13 +-
91 files changed, 15181 insertions(+), 1137 deletions(-)
create mode 100644 frontend/src/widgets/comms/CommsTab.tsx
create mode 100644 frontend/tests/e2e/comms-desks.spec.ts
create mode 100644 frontend/tests/e2e/mock_backend.py
create mode 100644 frontend/tests/e2e/zen-web-policy.spec.ts
create mode 100644 src/switchbay/agents/desk_admission.py
create mode 100644 src/switchbay/comms_review.py
create mode 100644 src/switchbay/helpers/icloud_download.js
create mode 100644 src/switchbay/icloud_download.py
create mode 100644 src/switchbay/runtime.py
create mode 100644 tests/unit/test_ce_global_fallback.py
create mode 100644 tests/unit/test_comms_desks_review.py
create mode 100644 tests/unit/test_comms_gate.py
create mode 100644 tests/unit/test_curate_content_receipts.py
create mode 100644 tests/unit/test_curate_evidence.py
create mode 100644 tests/unit/test_curate_lifecycle.py
create mode 100644 tests/unit/test_curate_scheduler.py
create mode 100644 tests/unit/test_desk_admission.py
create mode 100644 tests/unit/test_icloud_download.py
create mode 100644 tests/unit/test_pptx_ingest.py
create mode 100644 tests/unit/test_release_acceptance.py
create mode 100644 tests/unit/test_research_desk.py
create mode 100644 tests/unit/test_runtime.py
create mode 100644 tests/unit/test_watch_review.py
create mode 100644 tests/unit/test_watchfolders.py
create mode 100644 tests/unit/test_web_consent.py
create mode 100644 tests/unit/test_web_egress_policy.py
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2691ca9..9daa19d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -42,7 +42,7 @@ jobs:
run: uv run --no-sync python -c "import switchbay.daemon"
frontend:
- name: Frontend typecheck + build
+ name: Frontend Node tests + typecheck + builds
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -54,5 +54,188 @@ jobs:
install: false
- name: Install
run: pnpm --dir frontend install --frozen-lockfile
+ - name: HTML extraction Node tests (6)
+ working-directory: frontend
+ run: |
+ set -euo pipefail
+ node --version
+ node --experimental-strip-types --test --test-reporter tap \
+ src/lib/htmlExtraction.test.ts > "${RUNNER_TEMP}/html-extract.tap"
+ cat "${RUNNER_TEMP}/html-extract.tap"
+ python3 - "${RUNNER_TEMP}/html-extract.tap" <<'PY'
+ from pathlib import Path
+ import sys
+ text = Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace")
+ lines = [ln.strip() for ln in text.splitlines()]
+ oks = [ln for ln in lines if ln.startswith("ok ")]
+ notoks = [ln for ln in lines if ln.startswith("not ok ")]
+ meta = {}
+ for ln in lines:
+ for key in ("pass", "fail", "skipped", "todo", "cancelled"):
+ prefix = f"# {key} "
+ if ln.startswith(prefix):
+ meta[key] = int(ln.split()[-1])
+ plan = [ln for ln in lines if ln.startswith("1..")]
+ if notoks or meta.get("fail", 0) != 0 or meta.get("skipped", 0) != 0 \
+ or meta.get("todo", 0) != 0 or meta.get("cancelled", 0) != 0:
+ raise SystemExit(
+ f"html extraction tests must all run and pass with no skips: "
+ f"{meta} notoks={notoks}"
+ )
+ pass_n = meta.get("pass", len(oks))
+ if pass_n != 6 or len(oks) != 6 or "1..6" not in plan:
+ raise SystemExit(
+ f"expected 6 passing html extraction tests, got pass={pass_n} "
+ f"oks={len(oks)} plan={plan} meta={meta}"
+ )
+ print("htmlExtraction: 6/6 (no skips)")
+ PY
+ - name: Web-policy race Node test
+ working-directory: frontend
+ run: |
+ set -euo pipefail
+ node --experimental-strip-types tests/webPolicy.race.test.ts \
+ | tee "${RUNNER_TEMP}/web-policy-race.txt"
+ grep -F "webPolicy.race.test.ts ok" "${RUNNER_TEMP}/web-policy-race.txt"
+ if grep -Ei 'skip|todo' "${RUNNER_TEMP}/web-policy-race.txt"; then
+ echo "web-policy race test must not skip"
+ exit 1
+ fi
+ echo "webPolicy.race: 1/1 (no skips)"
- name: Typecheck + build
run: pnpm --dir frontend run build
+ - name: Webview graph build
+ run: pnpm --dir frontend run build:webview
+
+ browser-e2e:
+ name: Browser e2e (mocked backend)
+ runs-on: ubuntu-latest
+ # Isolated Chromium + mock HTTP/WS. Never starts switchbay.daemon,
+ # never binds or curls the live :8765 PWA, never sets E2E_BASE_URL
+ # (that would collide the two specs and point at vite :5173).
+ env:
+ E2E_MOCK: "1"
+ CI: "true"
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/setup@v2
+ with:
+ version: 11
+ runtime: node@22
+ cache: true
+ install: false
+ - name: Install uv
+ uses: astral-sh/setup-uv@v5
+ - name: Set up Python 3.13 + locked dev deps
+ run: |
+ uv python install 3.13
+ uv sync --locked --group dev
+ - name: Install frontend
+ run: pnpm --dir frontend install --frozen-lockfile
+ - name: Build frontend dist
+ run: pnpm --dir frontend run build
+ - name: Require mock e2e fixtures
+ run: |
+ set -euo pipefail
+ test -f frontend/tests/e2e/mock_backend.py
+ test -f frontend/tests/e2e/zen-web-policy.spec.ts
+ test -f frontend/tests/e2e/comms-desks.spec.ts
+ grep -F 'e2e_mock' frontend/tests/e2e/zen-web-policy.spec.ts
+ grep -F 'e2e_mock' frontend/tests/e2e/comms-desks.spec.ts
+ grep -F 'e2e_mock' frontend/tests/e2e/mock_backend.py
+ grep -F '/tmp/switchbay-e2e-mock-ws' frontend/tests/e2e/zen-web-policy.spec.ts
+ grep -F '/tmp/switchbay-e2e-mock-ws' frontend/tests/e2e/comms-desks.spec.ts
+ grep -F '/tmp/switchbay-e2e-mock-ws' frontend/tests/e2e/mock_backend.py
+ if grep -nE 'test\.(skip|fixme)\(' \
+ frontend/tests/e2e/zen-web-policy.spec.ts \
+ frontend/tests/e2e/comms-desks.spec.ts; then
+ echo "e2e specs must not skip"
+ exit 1
+ fi
+ - name: Install Playwright Chromium
+ run: pnpm --dir frontend exec playwright install --with-deps chromium
+ - name: Playwright zen-web-policy (mock port 41765)
+ working-directory: frontend
+ env:
+ E2E_MOCK: "1"
+ E2E_MOCK_PORT: "41765"
+ CI: "true"
+ run: |
+ set -euo pipefail
+ unset E2E_BASE_URL || true
+ if [ -n "${E2E_BASE_URL:-}" ]; then
+ echo "E2E_BASE_URL must stay unset so specs use their own ports"
+ exit 1
+ fi
+ pnpm exec playwright test tests/e2e/zen-web-policy.spec.ts \
+ --reporter=list --forbid-only --workers=1 \
+ | tee "${RUNNER_TEMP}/pw-zen-web-policy.txt"
+ python3 - "${RUNNER_TEMP}/pw-zen-web-policy.txt" 4 zen-web-policy <<'PY'
+ from pathlib import Path
+ import re, sys
+ plain = re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", Path(sys.argv[1]).read_text(errors="replace"))
+ want, label = int(sys.argv[2]), sys.argv[3]
+ if "zen-web-policy.spec.ts" not in plain:
+ raise SystemExit(f"{label}: spec file did not run")
+ if re.search(r"\b[1-9]\d*\s+skipped\b", plain, re.I):
+ raise SystemExit(f"{label}: skips are not allowed")
+ if re.search(r"\b[1-9]\d*\s+flaky\b", plain, re.I):
+ raise SystemExit(f"{label}: flaky results are not allowed")
+ failed = re.search(r"\b(\d+)\s+failed\b", plain, re.I)
+ if failed and int(failed.group(1)) != 0:
+ raise SystemExit(f"{label}: failed tests")
+ passed = re.findall(r"(?m)^\s*(\d+)\s+passed\b", plain)
+ if not passed:
+ raise SystemExit(f"{label}: no 'N passed' summary")
+ got = int(passed[-1])
+ if got != want:
+ raise SystemExit(f"{label}: expected {want} passed, got {got}")
+ print(f"{label}: {got}/{want} passed, no skips")
+ PY
+ - name: Playwright comms-desks (mock port 41766)
+ working-directory: frontend
+ env:
+ E2E_MOCK: "1"
+ E2E_COMMS_PORT: "41766"
+ CI: "true"
+ run: |
+ set -euo pipefail
+ unset E2E_BASE_URL || true
+ if [ -n "${E2E_BASE_URL:-}" ]; then
+ echo "E2E_BASE_URL must stay unset so specs use their own ports"
+ exit 1
+ fi
+ pnpm exec playwright test tests/e2e/comms-desks.spec.ts \
+ --reporter=list --forbid-only --workers=1 \
+ | tee "${RUNNER_TEMP}/pw-comms-desks.txt"
+ python3 - "${RUNNER_TEMP}/pw-comms-desks.txt" 4 comms-desks <<'PY'
+ from pathlib import Path
+ import re, sys
+ plain = re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", Path(sys.argv[1]).read_text(errors="replace"))
+ want, label = int(sys.argv[2]), sys.argv[3]
+ if "comms-desks.spec.ts" not in plain:
+ raise SystemExit(f"{label}: spec file did not run")
+ if re.search(r"\b[1-9]\d*\s+skipped\b", plain, re.I):
+ raise SystemExit(f"{label}: skips are not allowed")
+ if re.search(r"\b[1-9]\d*\s+flaky\b", plain, re.I):
+ raise SystemExit(f"{label}: flaky results are not allowed")
+ failed = re.search(r"\b(\d+)\s+failed\b", plain, re.I)
+ if failed and int(failed.group(1)) != 0:
+ raise SystemExit(f"{label}: failed tests")
+ passed = re.findall(r"(?m)^\s*(\d+)\s+passed\b", plain)
+ if not passed:
+ raise SystemExit(f"{label}: no 'N passed' summary")
+ got = int(passed[-1])
+ if got != want:
+ raise SystemExit(f"{label}: expected {want} passed, got {got}")
+ print(f"{label}: {got}/{want} passed, no skips")
+ PY
+ - name: Upload Playwright failure artifacts
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-e2e-mock
+ path: |
+ frontend/test-results/
+ if-no-files-found: warn
+ retention-days: 7
diff --git a/docs/concepts-and-data-flow.md b/docs/concepts-and-data-flow.md
index 90d5a47..56e8a6a 100644
--- a/docs/concepts-and-data-flow.md
+++ b/docs/concepts-and-data-flow.md
@@ -220,8 +220,25 @@ flowchart LR
- **Capture** writes curiosity-engine's own staging shapes; the CE
sweeps *are* the async curation half. Ingesting a file (Browser `+`)
- dispatches a background agent that classifies it into CE types and
- records `extracted_from` provenance.
+ or a **watch folder** (Settings → Watch folders) stages a copy into
+ `vault/` and runs curiosity-engine `local_ingest.py` for supported
+ formats (text, HTML, PDF, CSV, XLSX, **PPTX**). Watch ingest writes
+ vault extracts only — it does **not** create wiki pages; Curate does
+ that later. Watch folders only pick up files that arrive *after* you
+ add the folder (existing contents are baselined). On macOS, iCloud
+ Drive placeholders in a folder you already authorized are downloaded
+ on demand — that file only, overall wait bounded at 8s per attempt
+ including helper calls — before ingest; other cloud providers are
+ not auto-hydrated. A file is not marked seen until staging +
+ extraction succeed; placeholders, timeouts, and corrupt PPTX stay
+ pending/retryable rather than counting as ingested. Each ingest file
+ uses the interpreter that already has that extractor: workspace CE
+ venv for pypdf/openpyxl when present, Switch Bay host for
+ `python-pptx` when the workspace lacks it. Scan/graph stay on the
+ workspace venv (kuzu). Old vault extracts that say
+ `PPTX extraction unavailable` need an explicit re-ingest; Switch
+ Bay does not rewrite them. The extract's `source_path` /
+ `extracted_from` record the authorized original watch path.
- **Curation** (curiosity-engine, a bundled first-party skill) links and
promotes captured material into the wiki graph. A wiki write schedules
a background graph rebuild → `data.json`.
@@ -268,9 +285,10 @@ roles stay computational kinds (investigate / verify / synthesize /
execute), not job titles. **Standing desks** (Curate, Work, Code, Deck,
Auto) reuse a chief and org across waves: working while a run is live,
quiet after Stop or a finished wave, dismissed only when you drop the
-row. `/curate`, `/work`, and `/code` always seat; authoring an HTML
-slideshow reuses one Deck desk; a wiki question (`what do we know
-about X`) does not seat. Recurring Auto prompts live in the
+row. `/curate`, `/work`, and `/code` always seat — including a duration
+brief such as `/curate for 10 mins`. Authoring an HTML slideshow
+reuses one Deck desk; a wiki question (`what do we know about X`)
+does not seat. Recurring Auto prompts live in the
**Schedules** tab (per workspace; the daemon fires due items even when
that vault is not focused). A desk may keep `.orchestrator/APPROACH.md`
as the overnight problem-solving sequence.
@@ -371,6 +389,28 @@ group chat.
| **A2A** | Agent/thread interoperability (`message/send`). Not the orchestration algorithm. |
| **Model ladder** | Available model/provider capability and cost hierarchy. The orchestrator may use it; it must not bypass it. |
+### Live seats (Settings)
+
+Each standing desk has a **live-seat** cap — queue / backpressure, not a lifetime stop. When every seat is taken, extra workers wait; they are not dropped, and the desk does not shut down. The **chief of staff is counted**. Floor **4** (chief + verifier + synthesizer + specialist), default **8**, current hard max **8**. Settings → Auto orchestration (`desk_max_live_workers`) cannot go below 4 or above the hard max. Admin policy may only **tighten** the ceiling:
+
+```json
+{
+ "orchestration": { "max_live_workers": 5 }
+}
+```
+
+A baked enterprise cap is never raised or erased by a missing, zero, or malformed overlay. Nested Curate workers and Comms wiki curation share the same per-workspace Curate desk gate.
+
+---
+
+## Data flow — Comms streams
+
+Email and chat accounts are **curation sources**, not Switch Bay threads. Discovery is **metadata only** (headers, labels, channel names). No body content is fetched or written to the wiki until you **explicitly approve** a thread for a **specific workspace**. **Revoke** stops future retrieval for that source. Auto-relevance may suggest a workspace; it never approves.
+
+**Secret / Top Secret** (and unknown or missing classification under enterprise) is refused **before any body fetch**. Tenant secret label GUIDs can be listed in admin policy (`comms.tenant_label_ids`) and match when they appear inside `MSIP_Labels`. Gmail system labels such as `INBOX` / `UNREAD` are not classifications.
+
+**Teams and Slack message bodies are not retrieved**, even after Approve. Their adapters currently lack trustworthy pre-body classification; listing and review remain, and the UI does not imply content will flow. Wiki ingest of approved Gmail / Outlook / IMAP mail needs a **keyed, allowlisted, file-capable CLI** (Claude Code, Grok Build, Codex, Muse Code) — not an HTTP-only provider.
+
---
## Data flow 4 — rich answers become artifacts (not chat walls)
diff --git a/docs/enterprise.md b/docs/enterprise.md
index 3d1412a..22fd22f 100644
--- a/docs/enterprise.md
+++ b/docs/enterprise.md
@@ -97,11 +97,115 @@ Provider ids: `github_copilot`, `llamacpp`, `mlx`, `ollama`,
| `interactive_terminal` | **on** | Rail shell (POSIX PTY / Windows ConPTY). Same default as VS Code. |
| `agent_run_command` | **on** | Copilot/HTTP agents may run workspace commands (approval + hard-deny still apply). |
| `hf_model_download` | off (admin may set **true**) | Hugging Face / Ollama pulls from Settings. On-disk models still work when off. |
-| `comms_streams` | off | IMAP / Gmail / Slack / … as ingest sources |
+| `comms_streams` | off | IMAP / Gmail / Outlook / Slack / Teams as **discovery** sources. Metadata-only until an explicit per-workspace thread/channel approve. Teams and Slack **bodies stay blocked**. Connecting an account is not thread approval. See [Comms classification](#comms-classification-and-tenant-labels). |
| `github_share` | off | `gh` publish of a workspace |
| `media_generation` | off | External image/video APIs |
| `user_mcp_servers` | on | Local MCP add is useful and stays on-box |
-| `watch_folders` | on | Local directory poll |
+| `watch_folders` | on | Local directory poll. New arrivals only (existing files are baselined). Watcher writes a **deterministic vault extract**; it does not immediately author wiki pages (Curate does that later). macOS iCloud hydration is per-file, not a tree pin — not live-cloud certified in CI. |
+| `web_egress` | off | Workspace Web on/off. Off: no search/fetch. On: the user may still enable Web **per workspace** (workspace default remains off). Each search/fetch still needs a once/deny card that is **never remembered**. Model API transport is a separate allowlist. |
+| `pi_harness` | off | Optional PATH harness for hired packages. Off until IT opts in. |
+
+## Live worker ceiling
+
+Each standing desk (Curate / Work / Code / Deck / Auto / Research) has a
+**live-seat** cap — queue / backpressure, not a lifetime spawn budget.
+The chief of staff is counted. Floor **4** (chief + verifier +
+synthesizer + specialist), default **8**, current hard max **8**.
+Settings → Auto orchestration (`desk_max_live_workers`) cannot go below
+4 or above the hard max. Admin policy may only **tighten** the ceiling:
+
+```json
+{
+ "orchestration": { "max_live_workers": 5 }
+}
+```
+
+A baked enterprise cap is never raised or erased by a missing, zero, or
+malformed overlay. Nested Curate workers and Comms wiki curation share
+the same per-workspace Curate desk gate.
+
+## Comms classification and tenant labels
+
+`features.comms_streams` stays **off** in enterprise until IT opts in.
+When on:
+
+- Discovery stores headers, labels, and channel names only. Bodies are
+ not fetched until the operator **Approves for workspace** on a stable
+ thread/channel key (provider + account + id — not the subject line).
+- **Revoke** is durable and wins races with polling.
+- Suggested relevance never auto-approves. An existing connected mailbox
+ does **not** imply any thread is approved.
+- **Secret / Top Secret** is refused before any body fetch. Under
+ enterprise (or `comms.require_classification: true`), **unknown** and
+ **missing** classification are also refused before body fetch.
+- Gmail system labels (`INBOX`, `UNREAD`, …) are not classifications.
+- Teams and Slack listing/review remain; **message bodies are not
+ retrieved** even after Approve (no trustworthy pre-body classification
+ on those adapters).
+- Wiki ingest of approved Gmail / Outlook / IMAP mail needs a **keyed,
+ workspace-allowlisted, file-capable CLI** (Claude Code, Grok Build,
+ Codex, Muse Code — `shell` + `file_write`). Copilot / other HTTP-only
+ providers cannot write that path. Direct Comms curation does not use
+ the HTTP tool loop. Enterprise packages hide those CLIs by default, so
+ enabling `comms_streams` without enabling a file-capable provider
+ still cannot land wiki pages.
+
+Tenant secret **MIP / Purview label GUIDs** belong in admin policy.
+They match when the GUID appears inside `MSIP_Labels` (or as a label
+id). Replace the example id with your tenant's secret label GUIDs —
+the value below is the fixture used in unit tests, not a real tenant:
+
+```json
+{
+ "profile": "enterprise",
+ "features": { "comms_streams": true },
+ "comms": {
+ "require_classification": true,
+ "classification_headers": [
+ "Sensitivity",
+ "Classification",
+ "X-MS-Exchange-Organization-Classification",
+ "MSIP_Labels",
+ "X-Microsoft-Classification",
+ "X-Sensitivity",
+ "X-Tenant-Class"
+ ],
+ "secret_names": [
+ "secret",
+ "top secret",
+ "top-secret",
+ "classified secret",
+ "Restricted-Tenant"
+ ],
+ "tenant_label_ids": [
+ "a3a2f242-b872-42f3-89a6-ffffeeeedddd"
+ ]
+ }
+}
+```
+
+Default secret names and the Microsoft header list are always on; extra
+`classification_headers` / `secret_names` extend them. A baked
+enterprise overlay cannot turn `require_classification` off.
+
+### Fixture tests vs a real tenant
+
+CI and `tests/unit/test_comms_*.py` / `test_release_acceptance.py` use a
+**clean configuration**: tmp `admin.json`, synthetic GUIDs, fake IMAP /
+Graph responses, no live mailbox, no production LLM. They prove the
+gate (secret / unknown / missing / revoke / workspace isolation) on
+those fixtures.
+
+They do **not** prove your tenant's MIP label GUIDs, Exchange
+`MSIP_Labels` blob shape, Gmail label set, or Graph application
+permissions. IT still needs a real-tenant pass: drop the production
+secret label ids into `comms.tenant_label_ids`, connect a non-prod
+mailbox, confirm Secret mail is blocked before body fetch, confirm a
+normal-sensitivity thread stays pending until explicit Approve, and
+confirm Revoke stops a subsequent poll. Do not treat a green unit suite
+as tenant certification.
+
+Release notes for this cut: [`releases/v0.12.19.md`](releases/v0.12.19.md).
## SentinelOne / EDR
@@ -152,7 +256,7 @@ The employee machine then starts Python. No `uv`, no `pnpm`, no
| In-app git pull of Switch Bay + skills | Supply-chain, unexpected network | Default `in_app_update: false` (portal package). IT may bake `--in-app-update` so git checkouts pull `updates.repo` and restore `admin.baked.json` / `admin.json`. Skills stay off unless `updates.include_skills` is true. |
| Hosted LLM API keys (Anthropic, OpenAI, xAI, Gemini, Meta) | Data leaving the tenant | Hidden. Copilot stays inside the existing GitHub Enterprise / EMU subscription. |
| Coding CLIs (Claude Code, Grok, Codex, Muse) | Extra binaries, shell | Hidden. Copilot is HTTP; local models are HTTP to `localhost`. |
-| Comms streams (mail, Slack, …) | OAuth, mailbox read | Off. |
+| Comms streams (mail, Slack, …) | OAuth, mailbox read | Off. Even when enabled: metadata discovery only; explicit per-workspace approve; Secret / unknown / missing refused before body; Teams/Slack bodies stay blocked. |
| GitHub share (`gh repo create`) | Unapproved egress | Off. |
| FSL-1.1 license | Legal review | Internal use is in-scope. A Competing Use (reselling Switch Bay as a product) is not. Point counsel at `LICENSE`. |
| PWA vs signed `.app` | Portal wants a signed pkg | macOS stub + `build-package.sh` (Safari). Windows `SwitchBay.exe` (Edge `--app`). Company notarize / Authenticode on the bake machine. |
diff --git a/docs/known-issues.md b/docs/known-issues.md
index 7d8e644..cd751a5 100644
--- a/docs/known-issues.md
+++ b/docs/known-issues.md
@@ -20,6 +20,7 @@ page only keeps the one-line workarounds.
| Local 4B | Worker, not Copilot: short grounded answers + `[[wikilink]]`, not long synthesis. | Copilot for fleet agentic work; 4B for offline wiki lookup. |
| Enterprise | GitHub release archives are unsigned packaging inputs, not fleet installers. | Run bake, then deploy with Intune or Jamf (unsigned plus a path allowlist, or organization-signed). [`enterprise/packaging/README.md`](../enterprise/packaging/README.md). |
| Ingest | CE still UTF-8-prefixes large tagged HTML at 200 KiB. Switch Bay stages visible text first; tagged-fact extraction (iXBRL, JATS) is a future CE change. | Use `ce_ingest` (file or directory). Re-read old snippet-capped extracts with `read_source`. |
+| PPTX | Historic extracts may say `PPTX extraction unavailable` because CE `local_ingest.py` ran in a workspace venv without `python-pptx`. Switch Bay now picks the interpreter that already has each extractor (workspace pypdf/openpyxl when present; host `python-pptx` when the workspace lacks it). Existing placeholder extracts are **not** rewritten. | Re-ingest the original `.pptx` via `ce_ingest` or drop it in a watch folder / `vault/raw/`. |
## Platform support
diff --git a/docs/vscode.md b/docs/vscode.md
index a4ca695..2826540 100644
--- a/docs/vscode.md
+++ b/docs/vscode.md
@@ -60,7 +60,7 @@ Update extension…** if `dist/*.vsix` is already built.
PWA **+ Add workspace** adds a *wiki* to the PWA switcher. It does not write a code-repo pointer. Register repos from VS Code as above (or CE `setup.sh --register-code-repo`).
-Explorer context **Ingest into wiki vault** runs CE `local_ingest.py` (cheap pypdf/text extract + `vault.db` index). Vision is **not** used here — CE flags `multimodal_recommended` when figures/tables need a later CURATE wave. Files outside the wiki use `--source-path-only` (originals stay put).
+Explorer context **Ingest into wiki vault** runs CE `local_ingest.py` (pypdf/text/`python-pptx` extract + `vault.db` index). Each file uses the interpreter that already has that extractor (workspace CE venv for pypdf/openpyxl when present; Switch Bay host for `python-pptx` when the workspace lacks it). Vision is **not** used here — CE flags `multimodal_recommended` when figures/tables need a later CURATE wave. Files outside the wiki use `--source-path-only` (originals stay put). Historic `PPTX extraction unavailable` vault files need an explicit re-ingest.
## What to open
diff --git a/frontend/scripts/render-slideshow-pdf.mjs b/frontend/scripts/render-slideshow-pdf.mjs
index c878ac3..4af7d95 100644
--- a/frontend/scripts/render-slideshow-pdf.mjs
+++ b/frontend/scripts/render-slideshow-pdf.mjs
@@ -2,11 +2,25 @@ import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import path from "node:path";
+const here = path.dirname(fileURLToPath(import.meta.url));
+const frontendRoot = path.resolve(here, "..");
const require = createRequire(fileURLToPath(import.meta.url));
-const playwrightRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
-const { chromium } = require(
- require.resolve("playwright", { paths: [playwrightRoot] }),
-);
+
+function resolvePlaywright() {
+ // pnpm strict: `playwright` is a transitive of declared `@playwright/test`.
+ // Resolve through that package so we never depend on a hoisted/global copy.
+ const testPkg = require.resolve("@playwright/test/package.json", {
+ paths: [frontendRoot],
+ });
+ const testDir = path.dirname(testPkg);
+ try {
+ return require(require.resolve("playwright", { paths: [testDir, frontendRoot] }));
+ } catch {
+ return require(require.resolve("playwright-core", { paths: [testDir, frontendRoot] }));
+ }
+}
+
+const { chromium } = resolvePlaywright();
const [url, output] = process.argv.slice(2);
if (!url || !output) {
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index e457e9d..c837a54 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -130,6 +130,11 @@ export default function App() {
const [graphReloadTick, setGraphReloadTick] = useState(0);
const [selection, setSelectionLocal] = useState(null);
const [settingsOpen, setSettingsOpen] = useState(false);
+ useEffect(() => {
+ const onOpen = () => setSettingsOpen(true);
+ window.addEventListener("sy:open-settings", onOpen);
+ return () => window.removeEventListener("sy:open-settings", onOpen);
+ }, []);
// Non-null once the daemon is stopping (user Quit / `/quit`), which
// switches the whole app to a "stopped" overlay and halts reconnects.
const [stopped, setStopped] = useState<{ reason?: string } | null>(null);
@@ -1196,6 +1201,7 @@ export default function App() {
run_id: msg.run_id,
origin: msg.origin ?? null,
origin_path: msg.origin_path ?? null,
+ protected: Boolean(msg.protected),
state: "pending" as const,
};
if (msg.thread_id && msg.thread_id === focusedThreadRef.current) {
@@ -1383,6 +1389,15 @@ export default function App() {
if (focusedThreadRef.current && gone.includes(focusedThreadRef.current)) {
onResetRef.current?.();
}
+ } else if (msg.type === "open_comms") {
+ const focus = (tries: number) => {
+ if (switchToKindRef.current?.("comms")) return;
+ if (tries > 0) window.setTimeout(() => focus(tries - 1), 60);
+ };
+ focus(10);
+ window.dispatchEvent(new CustomEvent("sy:comms-review"));
+ } else if (msg.type === "comms.review") {
+ window.dispatchEvent(new CustomEvent("sy:comms-review"));
} else if (msg.type === "open_report") {
// A capable model built a rich HTML report — hand the id to the
// Report tab (it loads regardless) and focus the tab. The tab was
@@ -1568,6 +1583,11 @@ export default function App() {
return () => { cancelled = true; window.clearInterval(h); };
}, []);
+ useEffect(() => {
+ if (!workspace) return;
+ void loadWebPolicy(workspace);
+ }, [workspace]);
+
useEffect(() => {
if (!workspace) return;
let cancelled = false;
@@ -2445,6 +2465,9 @@ export default function App() {
onLoadOlder={onLoadOlder}
onOpenSettings={() => setSettingsOpen(true)}
onOpenHelp={() => setHelpOpen(true)}
+ onReset={onReset}
+ otherPerms={otherPerms}
+ pinnedAction={noWikiAction}
/>
) : (
import("../widgets/schedules/SchedulesTab"));
const PackFileListTab = lazy(() => import("../widgets/packtabs/PackFileListTab"));
const TerminalTab = lazy(() => import("../widgets/terminal/TerminalTab"));
const ReportTab = lazy(() => import("../widgets/report/ReportTab"));
+const CommsTab = lazy(() => import("../widgets/comms/CommsTab"));
const IntroTab = lazy(() => import("../widgets/intro/IntroTab"));
const HtmlDeckTab = lazy(() => import("../widgets/htmldeck/HtmlDeckTab"));
const LibraryTab = lazy(() => import("../widgets/library/LibraryTab"));
@@ -79,6 +80,7 @@ export function registerBuiltinTabs(): void {
registerTabKind("agents", AgentsAdapter, { bare: true });
// Rich HTML report (create_report) in a sandboxed iframe.
registerTabKind("report", (() => ) as TabComponent, { bare: true });
+ registerTabKind("comms", (() => ) as TabComponent, { bare: true });
// Durable report package (reports//).
registerTabKind("report-doc", (() => ) as TabComponent, {
bare: true,
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 517fc29..f212a1b 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -5818,6 +5818,8 @@ a.sy-source-cite { color: var(--accent); }
z-index: 6;
}
.sy-rail-reset {
+ flex: 0 0 auto;
+ white-space: nowrap;
padding: 2px 8px;
font-size: 10.5px;
font-weight: 500;
@@ -6051,7 +6053,7 @@ a.sy-source-cite { color: var(--accent); }
padding: 12px 14px;
font-size: 13px;
}
-.sy-rail-entry { margin-bottom: 8px; line-height: 1.45; }
+.sy-rail-entry { margin-bottom: 8px; line-height: 1.45; overflow-wrap: anywhere; }
/* No-wiki prompt: a pinned notice + a one-click button above the rail
* input. Distinct class from .sy-rail-action-btn (the curate/rebuild
* button row) so those keep their styling. */
@@ -6917,6 +6919,7 @@ a.sy-source-cite { color: var(--accent); }
border-top: 1px solid var(--line);
padding: 12px 14px 14px;
position: relative;
+ flex: 0 0 auto;
}
.sy-rail-composer-meta {
display: flex;
@@ -8200,7 +8203,17 @@ a.sy-source-cite { color: var(--accent); }
overflow: hidden;
}
.sy-zen-surf-body .sy-rail-pty { height: 100%; }
-
+.sy-zen-surf-body .sy-rail,
+.sy-rail--embedded {
+ height: 100%;
+ border-left: none;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+}
+.sy-rail--embedded .sy-rail-input-wrap {
+ flex: 0 0 auto;
+}
/* ── Zen Browser surface ───────────────────────────────────────────
* The three browsers Power stacks in its left column, laid out as
* equal side-by-side columns: files | wiki | sources. The pane is a
@@ -8375,7 +8388,8 @@ a.sy-source-cite { color: var(--accent); }
top: 16px;
left: 124px;
height: 28px;
- z-index: 40;
+ /* Above graph-search (z-index 70) so the brand remains clickable. */
+ z-index: 80;
display: inline-flex;
align-items: center;
}
diff --git a/frontend/src/layout/HelpModal.tsx b/frontend/src/layout/HelpModal.tsx
index 29a89ff..19efa93 100644
--- a/frontend/src/layout/HelpModal.tsx
+++ b/frontend/src/layout/HelpModal.tsx
@@ -178,7 +178,7 @@ export default function HelpModal({ open, onClose }: Props) {
sources / provenance
Where a wiki page came from (extracted_from). The Browser's bottom pane has a Sources view of external origins; extracted pages show a "from …" chip.
watch folder
- An outside directory Switch Bay polls; new files auto-ingest into the vault + wiki (Settings → Watch folders).
+ An outside directory Switch Bay polls; new files are staged and extracted into the vault (CE local ingest, including PPTX). Wiki pages come from a later Curate pass — watch ingest does not write wiki pages. iCloud placeholders in an authorized folder download on demand on macOS. Settings → Watch folders.
skill / pack
A skill is an instruction bundle agents load for a task; a pack bundles skills + tabs + file actions as an installable extension.
diff --git a/frontend/src/layout/SettingsModal.tsx b/frontend/src/layout/SettingsModal.tsx
index f34950b..fca160e 100644
--- a/frontend/src/layout/SettingsModal.tsx
+++ b/frontend/src/layout/SettingsModal.tsx
@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from "react";
+import WebPolicyToggle from "../widgets/WebPolicyToggle";
type ProviderInfo = {
auth_flow?: string;
@@ -773,7 +774,8 @@ function McpServersPanel({ open }: { open: boolean }) {
const refresh = () =>
fetch("/api/mcp-servers").then((r) => r.json())
- .then((b) => setServers(b.servers as McpServer[])).catch(() => setServers([]));
+ .then((b) => setServers(Array.isArray(b?.servers) ? b.servers as McpServer[] : []))
+ .catch(() => setServers([]));
useEffect(() => { if (open) refresh(); }, [open]);
const add = async () => {
@@ -920,8 +922,8 @@ function PacksPanel({ open }: { open: boolean }) {
try {
const r = await fetch("/api/packs");
if (!r.ok) return;
- const body = (await r.json()) as { packs: PackInfo[] };
- setPacks(body.packs);
+ const body = (await r.json()) as { packs?: PackInfo[] };
+ setPacks(Array.isArray(body.packs) ? body.packs : []);
} catch {
/* swallow — empty state covers it */
}
@@ -1357,8 +1359,8 @@ function UserTabsPanel({ open }: { open: boolean }) {
try {
const r = await fetch("/api/user-tabs");
if (!r.ok) return;
- const body = (await r.json()) as { tabs: UserTab[] };
- setTabs(body.tabs);
+ const body = (await r.json()) as { tabs?: UserTab[] };
+ setTabs(Array.isArray(body.tabs) ? body.tabs : []);
} catch {
/* empty state covers it */
}
@@ -1551,8 +1553,8 @@ function PermissionsPanel({ open }: { open: boolean }) {
try {
const r = await fetch("/api/permission/allow");
if (!r.ok) return;
- const body = (await r.json()) as { patterns: string[] };
- setPatterns(body.patterns);
+ const body = (await r.json()) as { patterns?: string[] };
+ setPatterns(Array.isArray(body.patterns) ? body.patterns : []);
} catch {
setPatterns([]);
}
@@ -1586,7 +1588,6 @@ function PermissionsPanel({ open }: { open: boolean }) {
// directly via the allow-list endpoint; codex's next spawn picks
// up the change without further plumbing.
const codexFullAccess = (patterns ?? []).includes("_codex:full-access");
- const codexWebSearch = (patterns ?? []).includes("_codex:web-search");
const toggleCodexFullAccess = async () => {
if (codexFullAccess) {
await revoke("_codex:full-access");
@@ -1614,34 +1615,6 @@ function PermissionsPanel({ open }: { open: boolean }) {
}
};
- const toggleCodexWebSearch = async () => {
- if (codexWebSearch) {
- await revoke("_codex:web-search");
- return;
- }
- if (!window.confirm(
- "Allow Codex native web_search in this workspace? Codex has no "
- + "per-call rail card, so this is an all-or-nothing grant. Prefer "
- + "Switch Bay research tools (search → vault → ingest) when you "
- + "want sources in the wiki.",
- )) return;
- try {
- const r = await fetch("/api/permission/allow", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ pattern: "_codex:web-search" }),
- });
- if (!r.ok) {
- const body = await r.json().catch(() => ({} as Record));
- setStatus({ ok: false, msg: body.error || `HTTP ${r.status}` });
- return;
- }
- await reload();
- } catch (e) {
- setStatus({ ok: false, msg: (e as Error).message });
- }
- };
-
return (
Permissions
@@ -1710,32 +1683,17 @@ function PermissionsPanel({ open }: { open: boolean }) {
- Codex web search: {" "}
- {codexWebSearch ? "native web_search enabled" : "disabled (default)"}
+ Web search / fetch: {" "}
+ off by default. On means each search or fetch may ask once —
+ never a blanket grant. Codex native search stays disabled.
- void toggleCodexWebSearch()}
- title={
- codexWebSearch
- ? "Disable Codex native web_search — next spawn forces web_search=disabled"
- : "Allow Codex native web_search for this workspace (no per-call card)"
- }
- >
- {codexWebSearch ? "on" : "off"}
-
+
- Codex has no per-tool hook surface like Claude Code, so
- per-command gating isn't possible. Sandbox and web search are
- spawn-time knobs. Claude Code and Grok Build web search still
- card on the rail. Prefer Switch Bay research tools when the
- result should land in the vault.
+ Native CLI web tools and Switch Bay research_search / research_fetch
+ share this workspace policy. Codex has no per-call hook, so its
+ native web_search stays off. Model API traffic is unrelated.
);
@@ -1766,6 +1724,14 @@ type SettingsBody = {
workspace_synced: string | null;
embedding_backend?: string;
embedding_vendors_keyed?: Record;
+ desk_max_live_workers?: number;
+ requested?: number;
+ min?: number;
+ default?: number;
+ hard_max?: number;
+ admin_ceiling?: number | null;
+ chief_counted?: boolean;
+ note?: string;
media?: {
modalities?: Record;
note?: string;
@@ -2352,7 +2318,12 @@ function LocalModelPanel({
if (!open) return;
void fetch("/api/localllm/harness")
.then((r) => (r.ok ? r.json() : null))
- .then((h) => { if (h) { setHarness(h); setHarnessDraft(h.text); } })
+ .then((h) => {
+ if (h && typeof h.text === "string") {
+ setHarness(h);
+ setHarnessDraft(h.text);
+ }
+ })
.catch(() => { /* older daemon */ });
}, [open]);
@@ -3218,7 +3189,8 @@ function WorkspacesHomePanel({ open }: { open: boolean }) {
const r = await fetch("/api/workspaces/home");
if (!r.ok) return;
const b = (await r.json()) as WorkspacesHomeBody;
- setBody(b);
+ if (!b || typeof b.home !== "string") return;
+ setBody({ ...b, candidates: Array.isArray(b.candidates) ? b.candidates : [] });
setDraft(b.home);
setStatus(null);
} catch { /* older daemon — panel stays hidden */ }
@@ -3545,7 +3517,7 @@ function CuratorPanel({ open }: { open: boolean }) {
}
};
- if (body === null) return null;
+ if (body === null || typeof body.profile !== "string") return null;
const dirty = draft !== body.profile;
// The cap is measured in estimated tokens (server uses the same
// chars/4 approximation), 2.5k per the 2026-07-05 ruling.
@@ -3817,7 +3789,7 @@ type OrchPolicy = {
updated_at?: number;
totals?: { orchestrations?: number; explorations?: number; resets?: number };
buckets?: { bucket: string; arms: { arm: string; n: number; mean_reward?: number | null }[] }[];
- hard_bounds?: Record;
+ hard_bounds?: Record>;
};
function OrchestrationPolicyPanel({ open }: { open: boolean }) {
@@ -3923,6 +3895,7 @@ function OrchestrationPolicyPanel({ open }: { open: boolean }) {
))}
)}
+
{status && (
{status.msg}
@@ -3932,6 +3905,70 @@ function OrchestrationPolicyPanel({ open }: { open: boolean }) {
);
}
+function DeskLiveCapControl() {
+ const [cap, setCap] = useState(8);
+ const [meta, setMeta] = useState<{ min: number; hard_max: number; chief_counted: boolean; note?: string }>({
+ min: 4, hard_max: 8, chief_counted: true,
+ });
+ const [busy, setBusy] = useState(false);
+ useEffect(() => {
+ void (async () => {
+ try {
+ const r = await fetch("/api/settings");
+ if (!r.ok) return;
+ const b = await r.json() as SettingsBody;
+ if (typeof b.desk_max_live_workers === "number") setCap(b.desk_max_live_workers);
+ setMeta({
+ min: b.min ?? 4,
+ hard_max: b.hard_max ?? 8,
+ chief_counted: b.chief_counted !== false,
+ note: b.note,
+ });
+ } catch { /* older daemon */ }
+ })();
+ }, []);
+ const save = async (n: number) => {
+ setBusy(true);
+ try {
+ const r = await fetch("/api/settings", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ desk_max_live_workers: n }),
+ });
+ if (r.ok) {
+ const b = await r.json() as SettingsBody;
+ if (typeof b.desk_max_live_workers === "number") setCap(b.desk_max_live_workers);
+ }
+ } finally {
+ setBusy(false);
+ }
+ };
+ return (
+
+
+ Live workers per desk
+ {meta.chief_counted ? " (chief counted)" : ""}
+
+
{
+ const n = Number(e.target.value);
+ if (Number.isFinite(n)) void save(n);
+ }}
+ />
+
min {meta.min} · max {meta.hard_max}
+ {meta.note &&
{meta.note}
}
+
+ );
+}
+
/** Image / video / voice generation prefs (xAI Imagine+Voice, OpenAI
* images/Sora/TTS/Realtime). Prefs only — rail tools that write
@@ -4098,6 +4135,7 @@ type StreamAccount = {
pending: number;
auto_curate: boolean;
last_poll: number | null;
+ last_error?: string | null;
tenant?: string;
workspace: string;
triage?: boolean;
@@ -4122,14 +4160,22 @@ type StreamsBody = {
// ── Watch folders panel (D5) ────────────────────────────────────────
// Auto-ingest NEW files from user-chosen external directories. Adding
// a folder baselines its current contents (only files arriving after
-// that point ingest); each new file dispatches one background ingest
-// agent, capped per beat so a folder-dump can't stampede.
+// that point ingest). Supported types are CE-extracted (not an LLM
+// describing the filename). Capped per beat so a folder-dump can't stampede.
type WatchFolder = { path: string; enabled: boolean; added_at: number };
+type WatchPending = {
+ path: string;
+ state: string;
+ error?: string | null;
+ retryable?: boolean;
+ attempts?: number;
+};
function WatchFoldersPanel({ open }: { open: boolean }) {
const [folders, setFolders] = useState(null);
- const [meta, setMeta] = useState<{ cap: number; interval: number } | null>(null);
+ const [pending, setPending] = useState([]);
+ const [meta, setMeta] = useState<{ cap: number; interval: number; icloud?: boolean } | null>(null);
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<{ ok: boolean; msg: string } | null>(null);
@@ -4140,10 +4186,19 @@ function WatchFoldersPanel({ open }: { open: boolean }) {
const r = await fetch("/api/watch-folders");
if (!r.ok) return;
const b = (await r.json()) as {
- folders: WatchFolder[]; cap_per_beat: number; interval_s: number;
+ folders: WatchFolder[];
+ pending?: WatchPending[];
+ cap_per_beat: number;
+ interval_s: number;
+ icloud_download?: boolean;
};
setFolders(b.folders);
- setMeta({ cap: b.cap_per_beat, interval: b.interval_s });
+ setPending(Array.isArray(b.pending) ? b.pending : []);
+ setMeta({
+ cap: b.cap_per_beat,
+ interval: b.interval_s,
+ icloud: !!b.icloud_download,
+ });
setStatus(null);
} catch { /* older daemon — panel stays hidden */ }
})();
@@ -4172,6 +4227,9 @@ function WatchFoldersPanel({ open }: { open: boolean }) {
if (Array.isArray((b as { folders?: WatchFolder[] }).folders)) {
setFolders((b as { folders: WatchFolder[] }).folders);
}
+ if (Array.isArray((b as { pending?: WatchPending[] }).pending)) {
+ setPending((b as { pending: WatchPending[] }).pending);
+ }
if (okMsg) setStatus({ ok: true, msg: okMsg });
} catch (e) {
setStatus({ ok: false, msg: (e as Error).message });
@@ -4187,12 +4245,15 @@ function WatchFoldersPanel({ open }: { open: boolean }) {
Watch folders
Folders Switch Bay keeps an eye on: any new file that
- appears gets staged into the vault and a background ingest agent
- extracts a wiki page, with provenance pointing at the original
- (see the Browser's Sources view). Existing contents are left
- alone when you add a folder — this is a tap on the shoulder for
- new material, not a bulk import
+ appears is staged into the vault and extracted with curiosity-engine
+ (text, HTML, PDF, CSV, XLSX, PPTX — not a model guessing from the
+ filename). Vault extracts land now; wiki pages are a later Curate
+ pass. Existing contents are left alone when you add a folder —
+ this is new material from now on, not a bulk import
{meta ? ` (checked ~every ${meta.interval}s, at most ${meta.cap} files per check)` : ""}.
+ {meta?.icloud
+ ? " On this Mac, iCloud Drive placeholders in a folder you already authorized are downloaded on demand (that file only) before ingest."
+ : " iCloud download-on-demand is macOS-only; other cloud placeholders stay pending until the file is local."}
{folders.length === 0 && (
@@ -4227,6 +4288,25 @@ function WatchFoldersPanel({ open }: { open: boolean }) {
))}
+ {pending.length > 0 && (
+
+
+ Pending / retrying ({pending.length})
+
+ {pending.slice(0, 20).map((p) => (
+
+ {p.path}
+ {" — "}
+ {p.state}{p.error ? `: ${p.error}` : ""}
+ {p.retryable === false ? " (not retried)" : ""}
+
+ ))}
+
+ )}
Comms streams
- Connect email / chat streams as curation sources :
- new messages are captured to a machine-local transit buffer,
- a curation pass extracts durable knowledge into the wiki with
- deep-links back to the source, and the buffer is then deleted —
- conversations are never archived here. Two ways in:{" "}
- Email (IMAP) is the simple path — any mail
- provider, just your address + an app password, nothing to
- register. The OAuth providers are the
- enterprise path: login happens in your browser on the
- provider's own pages (OAuth + PKCE, loopback redirect) against
- your own app registration, so consent, scopes and tenant
- policy stay under your (or your org's) control.
+ Connect email / chat streams as curation sources .
+ Discovery pulls headers and channel metadata only. Threads and
+ channels appear in the Comms tab for review —
+ relevant mail is suggested, never auto-added. Approve Gmail /
+ Outlook / IMAP for a specific workspace to pull currently clear
+ mail; revoke to stop future retrieval. Secret / unknown /
+ unclassified enterprise mail is refused before any body fetch.
+ Teams and Slack can be listed; their message bodies cannot be
+ retrieved (the providers have no pre-body classification).
+ void call("open-comms", "/api/comms/review/open", { method: "POST" })}
+ >
+ Open Comms
+
{body.accounts.map((a) => (
{a.label}
@@ -4346,6 +4431,7 @@ function StreamsPanel({ open }: { open: boolean }) {
: "not connected"}
{a.pending > 0 && ` · ${a.pending} pending`}
{a.last_poll ? ` · polled ${new Date(a.last_poll * 1000).toLocaleTimeString()}` : ""}
+ {a.last_error ? ` · ${a.last_error}` : ""}
{a.status !== "connected" && (
@@ -4362,29 +4448,10 @@ function StreamsPanel({ open }: { open: boolean }) {
{busy === "poll" ? "Polling…" : "Poll now"}
void call("curate", `/api/streams/${a.id}/curate`, { method: "POST" })
- .then((b) => b && setStatus({
- ok: true,
- msg: `Curated ${String(b.curated)} message${b.curated === 1 ? "" : "s"}`
- + (Array.isArray(b.workspaces) && b.workspaces.length
- ? ` into ${(b.workspaces as string[]).join(", ")}` : "")
- + (Number(b.skipped) > 0 ? `; ${String(b.skipped)} skipped as irrelevant` : "")
- + ".",
- }))}>
- {busy === "curate" ? "Curating…" : "Curate now"}
-
-
void call("auto", `/api/streams/${a.id}/auto`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ auto_curate: !a.auto_curate }),
- })}>
- {a.auto_curate ? "auto" : "manual"}
+ title="Open the Comms review queue (approve/revoke threads and channels)"
+ onClick={() => void call("open-comms", "/api/comms/review/open", { method: "POST" })}>
+ Review in Comms
setOpen((o) => !o)}
title={
activeIsRegistered
diff --git a/frontend/src/rail/Rail.tsx b/frontend/src/rail/Rail.tsx
index 5c09441..e3ca1f4 100644
--- a/frontend/src/rail/Rail.tsx
+++ b/frontend/src/rail/Rail.tsx
@@ -59,6 +59,8 @@ export type RailEntry =
/** Absolute cwd of an external source — enables "watch in shell".
* Unset for old hooks / thread-owned cards. */
origin_path?: string | null;
+ /** Protected web egress: once/deny only. */
+ protected?: boolean;
/** "pending" while awaiting click, then "approved" | "denied"
* briefly so the row can render an acknowledged-state before
* the WS `permission_resolved` drops it. */
@@ -165,6 +167,9 @@ type Props = {
onPopOutTerminal?: (threadId: string) => void;
onPopInTerminalTab?: (tabId: string) => void;
onJumpToTab?: (tabId: string) => void;
+ /** Full-height single-column embed (Zen docked Chat). */
+ embedded?: boolean;
+ onFloat?: () => void;
};
type ThreadInfo = {
@@ -214,7 +219,7 @@ function ThreadBar({
const r = await fetch("/api/threads");
if (!r.ok) return;
const body = (await r.json()) as { threads: ThreadInfo[] };
- setThreads(body.threads);
+ setThreads(Array.isArray(body.threads) ? body.threads : []);
} catch { /* daemon down — keep the stale list */ }
};
const loadProjects = async () => {
@@ -222,7 +227,7 @@ function ThreadBar({
const r = await fetch("/api/projects");
if (!r.ok) return;
const body = (await r.json()) as { projects: ProjectInfo[] };
- setProjects(body.projects.filter((p) => !p.archived && !p.synthetic));
+ setProjects((body.projects ?? []).filter((p) => !p.archived && !p.synthetic));
} catch { /* daemon down — keep the stale list */ }
};
useEffect(() => { void load(); }, [focusedThread]);
@@ -442,6 +447,7 @@ export default function Rail({
pinnedAction, otherPerms, activeRunIds,
focusedThread, focusedThreadKind, onSwitchThread, onNewThread,
termWs, poppedOutTab, onPopOutTerminal, onPopInTerminalTab, onJumpToTab,
+ embedded = false, onFloat,
}: Props) {
const [input, setInput] = useComposerDraft();
const orch = useOrchestrationControl();
@@ -516,8 +522,8 @@ export default function Rail({
try {
const r = await fetch("/api/action-buttons");
if (!r.ok) return;
- const body = (await r.json()) as { buttons: typeof customButtons };
- setCustomButtons(body.buttons);
+ const body = (await r.json()) as { buttons?: typeof customButtons };
+ setCustomButtons(Array.isArray(body.buttons) ? body.buttons : []);
} catch { /* leave empty */ }
};
useEffect(() => { void reloadButtons(); }, []);
@@ -561,7 +567,7 @@ export default function Rail({
useEffect(() => {
fetch("/api/verbs")
.then((r) => r.json())
- .then((b: { verbs: VerbInfo[] }) => setVerbs(b.verbs))
+ .then((b: { verbs: VerbInfo[] }) => setVerbs(Array.isArray(b.verbs) ? b.verbs : []))
.catch(() => setVerbs([]));
}, [focusedThread]);
@@ -771,13 +777,23 @@ export default function Rail({
setChatForced(false);
};
- return (
+ const railBody = (
<>
RAIL
+ {embedded && onFloat && (
+
onFloat()}
+ title="Float this thread at the bottom of the window"
+ >
+ ⇱ float
+
+ )}
);
+ if (embedded) {
+ return
{railBody}
;
+ }
+ return railBody;
}
/** Switch to the Agents tab and auto-expand the given run. Two
@@ -2075,6 +2095,7 @@ export function PermissionRow(props: {
>
Approve once
+ {entry.protected ? null : (
)}
+ )}
o.id === state.selected);
const label = current ? current.label.toLowerCase() : "effort";
diff --git a/frontend/src/sidebar/FileBrowser.tsx b/frontend/src/sidebar/FileBrowser.tsx
index c3e9167..d37618b 100644
--- a/frontend/src/sidebar/FileBrowser.tsx
+++ b/frontend/src/sidebar/FileBrowser.tsx
@@ -302,7 +302,7 @@ export default function FileBrowser({
return (await r.json()) as { files: string[] };
})
.then((d) => {
- if (!cancelled) setFiles(d.files);
+ if (!cancelled) setFiles(Array.isArray(d.files) ? d.files : []);
})
.catch((e: Error) => {
if (!cancelled) setError(e.message);
diff --git a/frontend/src/sidebar/SourceBrowser.tsx b/frontend/src/sidebar/SourceBrowser.tsx
index 06f239a..7d911a7 100644
--- a/frontend/src/sidebar/SourceBrowser.tsx
+++ b/frontend/src/sidebar/SourceBrowser.tsx
@@ -117,7 +117,15 @@ export default function SourceBrowser({
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return (await r.json()) as SourcesBody;
})
- .then((b) => { if (!cancelled) setBody(b); })
+ .then((b) => {
+ if (!cancelled) {
+ setBody({
+ sources: Array.isArray(b.sources) ? b.sources : [],
+ internal_pages: Number(b.internal_pages) || 0,
+ pages_scanned: Number(b.pages_scanned) || 0,
+ });
+ }
+ })
.catch((e: Error) => { if (!cancelled) setError(e.message); });
return () => { cancelled = true; };
}, [refreshKey]);
diff --git a/frontend/src/widgets/agents/AgentDashboardTab.tsx b/frontend/src/widgets/agents/AgentDashboardTab.tsx
index 7389c83..a97db8e 100644
--- a/frontend/src/widgets/agents/AgentDashboardTab.tsx
+++ b/frontend/src/widgets/agents/AgentDashboardTab.tsx
@@ -123,6 +123,8 @@ type Run = {
* "fan-out · N=4 · 3 of 4 running" badge even before child rows
* arrive. Absent on regular single-agent runs. */
fanout_n?: number | null;
+ desk_live_cap?: number | null;
+ desk_chief_counted?: boolean | null;
workers_total?: number | null;
workers_running?: number | null;
/** Set in fanout.py's finally block — flips workers from "running"
@@ -501,6 +503,14 @@ export default function AgentDashboardTab() {
Agent Dashboard
+
window.dispatchEvent(new CustomEvent("sy:open-settings"))}
+ title="Live worker cap (chief counted) is in Settings → Auto orchestration"
+ >
+ Live cap
+
+ · cap {run.desk_live_cap}
+
+ )}{" "}
{typeof workersRunning === "number"
? `· ${workersRunning} of ${workerCount} running`
: `· ${workerCount}`}
diff --git a/frontend/src/widgets/comms/CommsTab.tsx b/frontend/src/widgets/comms/CommsTab.tsx
new file mode 100644
index 0000000..7e7d6e5
--- /dev/null
+++ b/frontend/src/widgets/comms/CommsTab.tsx
@@ -0,0 +1,276 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { sameWorkspace, workspaceKey } from "../../lib/webPolicy";
+
+type CommsItem = {
+ key: string;
+ provider?: string;
+ account_id?: string;
+ stable_id?: string;
+ kind?: string;
+ status?: string;
+ block_reason?: string;
+ block_detail?: string;
+ subject?: string;
+ sender?: string;
+ deep_link?: string;
+ labels?: string[];
+ approved_workspaces?: string[];
+ suggested_workspaces?: string[];
+ suggested_relevance?: Record;
+ content_capability?: string;
+ content_capability_reason?: string;
+ ingest_state?: string;
+ ingest_error?: string;
+};
+
+type QueueBody = {
+ items?: CommsItem[];
+ workspace?: string;
+ workspaces?: { path: string; name: string }[];
+};
+
+function readFocusedWorkspace(): string {
+ try {
+ const raw = window.localStorage.getItem("sy.workspaces.snapshot");
+ if (!raw) return "";
+ const parsed = JSON.parse(raw) as { workspace?: string };
+ return typeof parsed.workspace === "string" ? parsed.workspace : "";
+ } catch {
+ return "";
+ }
+}
+
+/**
+ * Comms review — email threads and Teams/Slack channels.
+ * Patterned after Reviews: backlog, not a rail card. No body previews.
+ * Bound to the focused workspace (stale GET/POST ignored).
+ */
+export default function CommsTab() {
+ const [items, setItems] = useState([]);
+ const [idx, setIdx] = useState(0);
+ const [workspaces, setWorkspaces] = useState<{ path: string; name: string }[]>([]);
+ const [boundWs, setBoundWs] = useState(readFocusedWorkspace);
+ const [approveWs, setApproveWs] = useState(readFocusedWorkspace);
+ const [busy, setBusy] = useState(false);
+ const [err, setErr] = useState(null);
+ const epochRef = useRef(0);
+
+ const loadQueue = useCallback(async (explicitWs?: string) => {
+ const ws = workspaceKey(explicitWs || readFocusedWorkspace());
+ const epoch = ++epochRef.current;
+ try {
+ const q = ws ? `?workspace=${encodeURIComponent(ws)}` : "";
+ const r = await fetch(`/api/comms/review${q}`);
+ if (epoch !== epochRef.current) return;
+ if (!r.ok) return;
+ const body = (await r.json()) as QueueBody;
+ if (epoch !== epochRef.current) return;
+ const bodyWs = workspaceKey(body.workspace);
+ if (bodyWs && ws && !sameWorkspace(bodyWs, ws)) return;
+ const next = body.items ?? [];
+ setItems(next);
+ setWorkspaces(body.workspaces ?? []);
+ if (ws) {
+ setBoundWs(ws);
+ setApproveWs((cur) => (workspaceKey(cur) ? cur : ws));
+ }
+ setIdx((i) => Math.min(i, Math.max(0, next.length - 1)));
+ } catch { /* best-effort */ }
+ }, []);
+
+ useEffect(() => {
+ if (boundWs) setApproveWs(boundWs);
+ }, [boundWs]);
+
+ useEffect(() => {
+ const syncBound = () => {
+ const next = readFocusedWorkspace();
+ setBoundWs((prev) => (sameWorkspace(prev, next) || (!prev && !next) ? prev : next));
+ };
+ void loadQueue(boundWs);
+ const onCh = () => { void loadQueue(readFocusedWorkspace()); };
+ window.addEventListener("sy:comms-review", onCh);
+ window.addEventListener("storage", syncBound);
+ const iv = window.setInterval(syncBound, 1000);
+ return () => {
+ window.removeEventListener("sy:comms-review", onCh);
+ window.removeEventListener("storage", syncBound);
+ window.clearInterval(iv);
+ };
+ }, [loadQueue, boundWs]);
+
+ const current = items[idx] ?? null;
+
+ const act = async (action: "approve" | "reject" | "revoke") => {
+ if (!current) return;
+ const epoch = ++epochRef.current;
+ const ws = workspaceKey(boundWs || readFocusedWorkspace());
+ setBusy(true);
+ setErr(null);
+ try {
+ const payload: Record = { key: current.key, action };
+ if (ws) payload.workspace = action === "approve" ? (approveWs || ws) : ws;
+ if (action === "approve") {
+ if (!payload.workspace) {
+ setErr("workspace is required");
+ return;
+ }
+ }
+ const r = await fetch("/api/comms/review", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ if (epoch !== epochRef.current) return;
+ const b = await r.json().catch(() => ({} as {
+ error?: string; workspace?: string; ingest_error?: string; ingest_state?: string;
+ }));
+ if (epoch !== epochRef.current) return;
+ const bodyWs = workspaceKey((b as { workspace?: string }).workspace);
+ if (bodyWs && ws && !sameWorkspace(bodyWs, ws) && action !== "approve") return;
+ if (!r.ok) { setErr(b.error || `HTTP ${r.status}`); return; }
+ if (action === "approve" && b.ingest_error) setErr(b.ingest_error);
+ window.dispatchEvent(new CustomEvent("sy:comms-review"));
+ await loadQueue(ws);
+ } catch (e) {
+ setErr((e as Error).message);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const closeTab = async () => {
+ try {
+ const ws = workspaceKey(boundWs || readFocusedWorkspace());
+ const q = ws ? `?workspace=${encodeURIComponent(ws)}` : "";
+ await fetch(`/api/comms/review/close${q}`, { method: "POST" });
+ } catch { /* hello drops the tab */ }
+ };
+
+ if (!current) {
+ return (
+
+
+
✉
+
+ Comms review — email threads and Teams/Slack channels.
+ Discovery is metadata only. Nothing is added to the wiki
+ until you approve a source for a workspace. Teams and Slack
+ can be listed here; their message bodies cannot be retrieved.
+
+
+ void closeTab()}>
+ ✕ close
+
+
+
+
+ );
+ }
+
+ const suggested = current.suggested_workspaces ?? [];
+ const blocked = current.status === "blocked" || current.status === "revoked";
+ const secret = current.block_reason === "secret" || current.block_reason === "secret_mark"
+ || current.block_reason === "tenant_secret_label";
+ const failClosed = current.content_capability === "fail_closed";
+ const canApprove = !busy && !blocked && !secret && current.status !== "revoked" && !failClosed;
+
+ return (
+
+
+
+ {current.subject || current.stable_id || "Comms"}
+
+ {items.length > 0 && (
+
{idx + 1} / {items.length}
+ )}
+
+ {items.length > 1 && (
+ <>
+ setIdx((i) => Math.max(0, i - 1))}>← prev
+ = items.length - 1}
+ onClick={() => setIdx((i) => Math.min(items.length - 1, i + 1))}>next →
+ >
+ )}
+ void closeTab()}>
+ ✕ close
+
+
+
+ {err &&
{err}
}
+
+
+
{current.provider} · {current.kind} · {current.status}
+ {current.sender &&
{current.sender} }
+ {current.deep_link && (
+
open source
+ )}
+ {current.approved_workspaces && current.approved_workspaces.length > 0 && (
+
approved for {current.approved_workspaces.map((p) => p.split("/").pop()).join(", ")}
+ )}
+ {suggested.length > 0 && (
+
+ Suggested relevance: {suggested.map((p) => p.split("/").pop()).join(", ")}
+ {" "}(not approved)
+
+ )}
+ {failClosed && (
+
+ {current.content_capability_reason
+ || "Content fetch is not available for this adapter. Approval does not retrieve message bodies."}
+
+ )}
+ {current.block_detail && (
+
{current.block_detail}
+ )}
+ {current.status === "approved" && current.ingest_error && (
+
{current.ingest_error}
+ )}
+ {current.status === "approved" && !current.ingest_error
+ && current.ingest_state === "pending" && (
+
Approved — not in the wiki yet.
+ )}
+
+
+ No body preview. Unapproved sources contribute metadata only.
+
+
+
+ Workspace{" "}
+ setApproveWs(e.target.value)}
+ disabled={busy}
+ >
+ {workspaces.map((w) => (
+ {w.name}
+ ))}
+ {approveWs && !workspaces.some((w) => w.path === approveWs) && (
+ {approveWs.split("/").pop()}
+ )}
+
+
+
+ void act("approve")}>
+ {failClosed ? "Content cannot be retrieved" : "Approve for workspace"}
+
+ void act("reject")}>
+ Reject
+
+ void act("revoke")}>
+ Revoke
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/widgets/graph/static/sidebar.js b/frontend/src/widgets/graph/static/sidebar.js
index 04d8d27..dff5e0c 100644
--- a/frontend/src/widgets/graph/static/sidebar.js
+++ b/frontend/src/widgets/graph/static/sidebar.js
@@ -72,7 +72,7 @@ window.Sidebar = (function () {
searchEl = document.querySelector('#sidebar-search');
allPages = data.pages || {};
- allRecords = data.nodes.map(n => {
+ allRecords = (data.nodes || []).map(n => {
const page = allPages[n.id] || {};
const props = Object.values(page.properties || {})
.map(v => Array.isArray(v) ? v.join(' ') : String(v))
diff --git a/frontend/src/ws.ts b/frontend/src/ws.ts
index 02a51d5..0a78c85 100644
--- a/frontend/src/ws.ts
+++ b/frontend/src/ws.ts
@@ -271,6 +271,14 @@ export type OpenReport = {
* focus it. The tab itself loads `/api/intro`. */
export type OpenIntro = { type: "open_intro" };
+export type OpenComms = { type: "open_comms" };
+
+export type CommsReviewEvent = {
+ type: "comms.review";
+ key?: string;
+ action?: string;
+};
+
/** Open a workspace HTML slideshow (slideshows//) in the Slideshow tab. */
export type OpenHtmlDeck = {
type: "open_html_deck";
@@ -509,6 +517,8 @@ export type PermissionRequest = {
/** For external cards: absolute cwd of the source, when known —
* enables "watch in shell". Null for old hooks that don't send cwd. */
origin_path?: string | null;
+ /** Protected web egress: once/deny only — never remember. */
+ protected?: boolean;
};
export type OrchestrationHandoff = {
@@ -593,6 +603,8 @@ export type ServerMessage =
| FilesChanged
| OpenReport
| OpenIntro
+ | OpenComms
+ | CommsReviewEvent
| OpenHtmlDeck
| OpenReportDoc
| OpenWorksheet
diff --git a/frontend/src/zen/ZenChatBox.tsx b/frontend/src/zen/ZenChatBox.tsx
index 66e6394..e77b0fb 100644
--- a/frontend/src/zen/ZenChatBox.tsx
+++ b/frontend/src/zen/ZenChatBox.tsx
@@ -14,6 +14,7 @@ import VoiceButton from "../rail/VoiceButton";
import ReasoningPicker from "../rail/ReasoningPicker";
import { useOrchestrationControl } from "../rail/OrchestrationSlider";
import { useComposerDraft } from "../lib/composerDraft";
+import WebPolicyToggle from "../widgets/WebPolicyToggle";
// One persisted, drag-resizable height for BOTH the chat and the pty
// view — so the box no longer jumps between a content-sized chat and a
@@ -293,7 +294,7 @@ export default function ZenChatBox({
useEffect(() => {
fetch("/api/verbs")
.then((r) => r.json())
- .then((b: { verbs: VerbInfo[] }) => setVerbs(b.verbs))
+ .then((b: { verbs: VerbInfo[] }) => setVerbs(Array.isArray(b.verbs) ? b.verbs : []))
.catch(() => setVerbs([]));
}, [focusedThread]);
@@ -654,6 +655,7 @@ export default function ZenChatBox({
{orch.node}
+
void;
onOpenSettings: () => void;
onOpenHelp: () => void;
+ onReset: () => void;
+ otherPerms?: Extract[];
+ pinnedAction?: { text: string; label: string; command: string } | null;
};
export default function ZenShell({
@@ -75,6 +78,7 @@ export default function ZenShell({
onSwitchThread, onNewThread, termWs, activeRunIds, activeRuns,
hasMoreHistory, loadingOlder, onLoadOlder,
onOpenSettings, onOpenHelp,
+ onReset, otherPerms, pinnedAction,
}: Props) {
// ── Divider ────────────────────────────────────────────────────
const [split, setSplit] = useState(readSplit);
@@ -135,6 +139,27 @@ export default function ZenShell({
setSurface(prev && prev !== "chat" ? prev : "browser");
}, [setSurface]);
+ const dockedRail = (
+
+ );
+
const chatBox = (
setPtyPromoted(false)}
- chatSurface={chatDocked ? chatBox : null}
+ chatSurface={chatDocked ? dockedRail : null}
/>
diff --git a/frontend/tests/e2e/comms-desks.spec.ts b/frontend/tests/e2e/comms-desks.spec.ts
new file mode 100644
index 0000000..64ff80c
--- /dev/null
+++ b/frontend/tests/e2e/comms-desks.spec.ts
@@ -0,0 +1,135 @@
+/**
+ * Comms tab + live-cap setting against the isolated mock backend.
+ * E2E_MOCK=1. Unique port. Does not touch :8765 or real vaults.
+ */
+import { test, expect, type Page } from "@playwright/test";
+import { spawn, type ChildProcess } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const shotDir = "test-results/shots";
+const PORT = process.env.E2E_COMMS_PORT || "41766";
+const BASE = process.env.E2E_BASE_URL || `http://127.0.0.1:${PORT}`;
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const repo = path.resolve(here, "../../..");
+const dist = path.join(repo, "frontend/dist");
+
+let mockProc: ChildProcess | null = null;
+let startedHere = false;
+
+async function defWait(ms: number): Promise
{
+ await new Promise((r) => setTimeout(r, ms));
+}
+
+async function waitHealth(url: string, timeoutMs = 20_000): Promise {
+ const t0 = Date.now();
+ while (Date.now() - t0 < timeoutMs) {
+ try {
+ const r = await fetch(`${url}/api/health`);
+ if (r.ok) {
+ const body = await r.json() as { e2e_mock?: boolean; workspace?: string };
+ if (body.e2e_mock === true && body.workspace === "/tmp/switchbay-e2e-mock-ws") return;
+ }
+ } catch { /* not up */ }
+ await defWait(150);
+ }
+ throw new Error(`mock backend did not become healthy at ${url}`);
+}
+
+test.beforeAll(async () => {
+ if (!fs.existsSync(path.join(dist, "index.html"))) {
+ throw new Error("frontend/dist missing — build before Playwright");
+ }
+ mockProc = spawn(
+ "uv",
+ ["run", "--no-sync", "python", path.join(here, "mock_backend.py"),
+ "--host", "127.0.0.1", "--port", PORT, "--dist", dist],
+ {
+ cwd: repo,
+ env: { ...process.env, PYTHONPATH: path.join(repo, "src"), E2E_MOCK: "1" },
+ stdio: "pipe",
+ },
+ );
+ startedHere = true;
+ await waitHealth(BASE);
+});
+
+test.afterAll(async () => {
+ if (startedHere && mockProc && mockProc.pid) {
+ mockProc.kill("SIGTERM");
+ }
+});
+
+async function openPower(page: Page) {
+ await page.goto(BASE + "/", { waitUntil: "domcontentloaded" });
+ await page.waitForTimeout(400);
+ const zen = page.locator(".sy-zen");
+ if (await zen.count()) {
+ const toggle = page.getByRole("button", { name: /power/i }).or(page.locator("[title*='Power']"));
+ if (await toggle.count()) await toggle.first().click();
+ }
+}
+
+test("Comms tab approve and revoke in Power", async ({ page }) => {
+ fs.mkdirSync(shotDir, { recursive: true });
+ await openPower(page);
+ const commsTab = page.getByRole("button", { name: /Comms/i }).or(page.locator("[data-kind='comms']")).or(page.getByText("Comms"));
+ await commsTab.first().click();
+ await expect(page.getByText("Fixture thread")).toBeVisible({ timeout: 8000 });
+ await page.getByRole("button", { name: /Approve for workspace/i }).click();
+ await page.waitForTimeout(300);
+ await page.screenshot({ path: path.join(shotDir, "comms-power-approve.png") });
+ await page.getByRole("button", { name: /^Revoke$/i }).click();
+ await page.waitForTimeout(300);
+});
+
+test("live cap control is in Settings", async ({ page }) => {
+ await openPower(page);
+ const settings = page.getByRole("button", { name: /settings/i }).or(page.locator("[title*='Settings']"));
+ await settings.first().click();
+ await expect(page.getByText(/Live workers per desk/i)).toBeVisible({ timeout: 8000 });
+ const input = page.locator("#sy-desk-live-cap");
+ await expect(input).toBeVisible();
+ await input.fill("6");
+ await page.waitForTimeout(200);
+ const r = await page.request.get(BASE + "/api/settings");
+ const body = await r.json() as { desk_max_live_workers?: number };
+ expect(body.desk_max_live_workers).toBe(6);
+});
+
+test("Comms is in Zen surface list", async ({ page }) => {
+ await page.goto(BASE + "/", { waitUntil: "domcontentloaded" });
+ await page.waitForTimeout(400);
+ const zenToggle = page.getByRole("button", { name: /Toggle Power \/ Zen mode/i });
+ if (await zenToggle.count()) await zenToggle.click();
+ await page.waitForTimeout(400);
+ const picker = page.getByRole("button", { name: /Wiki/i }).or(page.locator(".sy-zen-surface-btn"));
+ await picker.first().click();
+ await expect(page.getByRole("button", { name: /^Comms$/i }).or(page.getByText("Comms"))).toBeVisible({ timeout: 5000 });
+ await page.getByText("Comms").first().click();
+ await expect(page.getByText("Fixture thread")).toBeVisible({ timeout: 8000 });
+});
+
+test("Comms shows suggestions and stays workspace-bound", async ({ page }) => {
+ fs.mkdirSync(shotDir, { recursive: true });
+ await openPower(page);
+ const commsTab = page.getByRole("button", { name: /Comms/i }).or(page.locator("[data-kind='comms']")).or(page.getByText("Comms"));
+ await commsTab.first().click();
+ await expect(page.getByText("Fixture thread")).toBeVisible({ timeout: 8000 });
+ await expect(page.getByText(/Suggested relevance/i)).toBeVisible();
+ await expect(page.getByText("Other workspace thread")).toHaveCount(0);
+ await page.screenshot({ path: path.join(shotDir, "comms-suggestions.png") });
+
+ const switcher = page.locator(".sy-ws-trigger").or(page.locator(".sy-mode-switcher"));
+ await switcher.first().click();
+ const other = page.getByText("switchbay-e2e-mock-ws-b").or(page.getByText("ws-b"));
+ await expect(other.first()).toBeVisible({ timeout: 5000 });
+ await other.first().click();
+ await page.waitForTimeout(500);
+ await commsTab.first().click();
+ await expect(page.getByText("Other workspace thread")).toBeVisible({ timeout: 8000 });
+ await expect(page.getByText("Fixture thread")).toHaveCount(0);
+ await page.screenshot({ path: path.join(shotDir, "comms-workspace-b.png") });
+});
diff --git a/frontend/tests/e2e/mock_backend.py b/frontend/tests/e2e/mock_backend.py
new file mode 100644
index 0000000..2cb40ba
--- /dev/null
+++ b/frontend/tests/e2e/mock_backend.py
@@ -0,0 +1,510 @@
+"""Isolated mock HTTP+WS backend for Playwright UI tests.
+
+Labeled E2E_MOCK. Does not call switchbay.daemon.run, does not touch
+the live vault, does not read workspaces.json. Serves frontend/dist
+and a deterministic /api + /ws surface.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import pathlib
+from typing import Any
+
+from aiohttp import WSMsgType, web
+
+WS_A = "/tmp/switchbay-e2e-mock-ws"
+WS_B = "/tmp/switchbay-e2e-mock-ws-b"
+
+STATE: dict[str, Any] = {
+ "workspace": WS_A,
+ "policies": {WS_A: False, WS_B: True},
+ "admin_allows": True,
+ "fail_save": False,
+ "clients": set(),
+ "desk_max_live_workers": 8,
+ "comms": [
+ {
+ "key": "gmail:acct:thread-e2e",
+ "provider": "gmail",
+ "account_id": "acct",
+ "stable_id": "thread-e2e",
+ "kind": "email_thread",
+ "status": "pending",
+ "subject": "Fixture thread",
+ "sender": "fixture@example.invalid",
+ "deep_link": "https://example.invalid/mail",
+ "approved_workspaces": [],
+ "suggested_workspaces": [WS_A],
+ "content_capability": "ok",
+ },
+ {
+ "key": "gmail:acct:thread-e2e-b",
+ "provider": "gmail",
+ "account_id": "acct",
+ "stable_id": "thread-e2e-b",
+ "kind": "email_thread",
+ "status": "pending",
+ "subject": "Other workspace thread",
+ "sender": "other@example.invalid",
+ "deep_link": "https://example.invalid/mail-b",
+ "approved_workspaces": [],
+ "suggested_workspaces": [WS_B],
+ "content_capability": "ok",
+ },
+ {
+ "key": "msgraph:acct:team-channel",
+ "provider": "msgraph",
+ "account_id": "acct",
+ "stable_id": "team/t1/channel/c1",
+ "kind": "channel",
+ "status": "pending",
+ "subject": "Fixture Teams channel",
+ "sender": "",
+ "deep_link": "https://example.invalid/teams",
+ "approved_workspaces": [],
+ "suggested_workspaces": [WS_A],
+ "content_capability": "fail_closed",
+ "content_capability_reason": (
+ "Teams channel messages have no documented metadata-only "
+ "projection; listing is supported, content fetch is refuse-closed. "
+ "Approval does not retrieve Teams message bodies."
+ ),
+ },
+ ],
+}
+
+
+def _custom(payload: dict[str, Any]) -> dict[str, Any]:
+ return {"type": "CUSTOM", "name": payload.get("type"), "value": payload}
+
+
+def _policy(workspace: str | None = None) -> dict[str, Any]:
+ ws = workspace or STATE["workspace"]
+ requested = bool(STATE["policies"].get(ws, False))
+ admin = bool(STATE["admin_allows"])
+ return {
+ "enabled": bool(requested and admin),
+ "admin_allows": admin,
+ "requested": requested,
+ "workspace": ws,
+ }
+
+
+def _hello() -> dict[str, Any]:
+ ws = STATE["workspace"]
+ return {
+ "type": "hello",
+ "workspace": ws,
+ "default_file": None,
+ "mode": {
+ "name": "default",
+ "tabs": [
+ {"id": "wiki", "title": "Wiki", "kind": "wiki", "source": "core"},
+ {"id": "agents", "title": "Agents", "kind": "agents", "source": "system"},
+ {"id": "comms", "title": "Comms", "kind": "comms", "source": "user"},
+ ],
+ },
+ "selection": None,
+ "workspaces": {
+ "paths": [WS_A, WS_B],
+ "active": ws,
+ },
+ "thread_id": "th-e2e",
+ "web_policy": _policy(ws),
+ }
+
+
+async def _broadcast(payload: dict[str, Any]) -> None:
+ dead = []
+ inner = payload if isinstance(payload, dict) else {}
+ raw = json.dumps(_custom(inner) if inner.get("type") != "CUSTOM" else inner)
+ for ws in list(STATE["clients"]):
+ try:
+ await ws.send_str(raw)
+ except Exception: # noqa: BLE001
+ dead.append(ws)
+ for ws in dead:
+ STATE["clients"].discard(ws)
+
+
+async def handle_health(_request: web.Request) -> web.Response:
+ return web.json_response({
+ "ok": True,
+ "boot_id": "e2e-mock",
+ "pid": 0,
+ "started_at": 0,
+ "frontend_mtime": 0,
+ "workspace": STATE["workspace"],
+ "service_managed": False,
+ "repo_root": "",
+ "policy": {"profile": "open", "source": "mock"},
+ "e2e_mock": True,
+ })
+
+
+def _requested_workspace(request: web.Request, body: dict[str, Any] | None = None) -> str:
+ if isinstance(body, dict):
+ raw = str(body.get("workspace") or "").strip()
+ if raw:
+ return raw
+ q = str(request.rel_url.query.get("workspace") or "").strip()
+ if q:
+ return q
+ hdr = (
+ request.headers.get("X-Switchbay-Workspace")
+ or request.headers.get("X-Workspace")
+ or ""
+ ).strip()
+ if hdr:
+ return hdr
+ return str(STATE["workspace"])
+
+
+async def handle_web_policy_get(request: web.Request) -> web.Response:
+ ws = _requested_workspace(request)
+ return web.json_response(_policy(ws))
+
+
+async def handle_web_policy_post(request: web.Request) -> web.Response:
+ try:
+ body = await request.json()
+ except json.JSONDecodeError:
+ body = {}
+ ws = _requested_workspace(request, body if isinstance(body, dict) else None)
+ if STATE["fail_save"] or request.headers.get("X-E2E-Fail") == "1":
+ return web.json_response(
+ {"error": "save failed", **_policy(ws)},
+ status=500,
+ )
+ if not isinstance(body, dict) or "enabled" not in body:
+ return web.json_response({"error": "enabled required"}, status=400)
+ STATE["policies"][ws] = bool(body.get("enabled"))
+ view = _policy(ws)
+ await _broadcast({"type": "web_policy", **view})
+ return web.json_response({"ok": True, **view})
+
+
+def _comms_visible(item: dict[str, Any], ws: str) -> bool:
+ if not ws:
+ return True
+ suggested = list(item.get("suggested_workspaces") or [])
+ approved = list(item.get("approved_workspaces") or [])
+ if ws in suggested or ws in approved:
+ return True
+ status = str(item.get("status") or "")
+ if status in ("pending", "blocked", "revoked") and not suggested and not approved:
+ return True
+ return False
+
+
+async def handle_comms_get(request: web.Request) -> web.Response:
+ ws = _requested_workspace(request)
+ items = [i for i in STATE["comms"] if _comms_visible(i, ws)]
+ return web.json_response({
+ "items": items,
+ "pending": sum(1 for i in items if i.get("status") == "pending"),
+ "workspace": ws,
+ "workspaces": [
+ {"path": WS_A, "name": "ws-a"},
+ {"path": WS_B, "name": "ws-b"},
+ ],
+ })
+
+
+async def handle_comms_post(request: web.Request) -> web.Response:
+ try:
+ body = await request.json()
+ except json.JSONDecodeError:
+ body = {}
+ key = str((body or {}).get("key") or "")
+ action = str((body or {}).get("action") or "")
+ for item in STATE["comms"]:
+ if item.get("key") == key:
+ if action == "approve":
+ item["status"] = "approved"
+ ws = str((body or {}).get("workspace") or STATE["workspace"])
+ item["approved_workspaces"] = [ws]
+ if item.get("content_capability") == "fail_closed":
+ item["ingest_state"] = "unsupported"
+ item["ingest_error"] = (
+ "This source can be listed, but message content cannot be retrieved."
+ )
+ else:
+ item["ingest_state"] = "ingested"
+ item["ingest_error"] = ""
+ elif action == "reject":
+ item["status"] = "rejected"
+ elif action == "revoke":
+ item["status"] = "revoked"
+ await _broadcast({"type": "comms.review", "key": key, "action": action})
+ return web.json_response({
+ "ok": True,
+ "item": item,
+ "ingest_state": item.get("ingest_state") or "",
+ "ingest_error": item.get("ingest_error") or "",
+ })
+ return web.json_response({"error": "unknown"}, status=404)
+
+
+async def handle_comms_open(_request: web.Request) -> web.Response:
+ await _broadcast({"type": "open_comms"})
+ return web.json_response({"ok": True})
+
+
+async def handle_settings_get(_request: web.Request) -> web.Response:
+ return web.json_response({
+ "rail_history_local": True,
+ "rail_history_path": "/tmp",
+ "workspace_synced": None,
+ "desk_max_live_workers": STATE["desk_max_live_workers"],
+ "requested": STATE["desk_max_live_workers"],
+ "min": 4,
+ "default": 8,
+ "hard_max": 8,
+ "admin_ceiling": None,
+ "chief_counted": True,
+ "note": "Total live seats per desk, including the chief-of-staff.",
+ })
+
+
+async def handle_settings_post(request: web.Request) -> web.Response:
+ try:
+ body = await request.json()
+ except json.JSONDecodeError:
+ body = {}
+ if "desk_max_live_workers" in (body or {}):
+ try:
+ n = int(body["desk_max_live_workers"])
+ except (TypeError, ValueError):
+ n = 8
+ STATE["desk_max_live_workers"] = max(4, min(8, n))
+ return await handle_settings_get(request)
+
+
+async def handle_workspaces(_request: web.Request) -> web.Response:
+ return web.json_response({
+ "paths": [WS_A, WS_B],
+ "active": STATE["workspace"],
+ })
+
+
+async def handle_workspaces_switch(request: web.Request) -> web.Response:
+ try:
+ body = await request.json()
+ except json.JSONDecodeError:
+ body = {}
+ path = str((body or {}).get("path") or WS_A)
+ STATE["workspace"] = path
+ STATE["policies"].setdefault(path, path == WS_B)
+ await _broadcast(_hello())
+ return web.json_response({"ok": True, "active": path})
+
+
+async def handle_mode(_request: web.Request) -> web.Response:
+ return web.json_response(_hello()["mode"])
+
+
+async def handle_threads(_request: web.Request) -> web.Response:
+ return web.json_response({
+ "threads": [{
+ "thread_id": "th-e2e",
+ "title": "Chat",
+ "kind": "structured-agent",
+ "project": None,
+ "created_at": 0,
+ "updated_at": 0,
+ "chat_count": 0,
+ "last_summary": "",
+ "running": 0,
+ }],
+ "focused": "th-e2e",
+ })
+
+
+def _empty_graph() -> dict[str, Any]:
+ return {
+ "workspace": STATE["workspace"],
+ "generated_at": "0",
+ "palette": {},
+ "nodes": [],
+ "edges": [],
+ "pages": {},
+ }
+
+
+async def handle_graph(_request: web.Request) -> web.Response:
+ return web.json_response(_empty_graph())
+
+
+async def handle_json(request: web.Request) -> web.Response:
+ path = request.path
+ if path.startswith("/api/graph"):
+ return web.json_response(_empty_graph())
+ if path.startswith("/api/llm/reasoning"):
+ return web.json_response({
+ "provider": "",
+ "model": None,
+ "options": [],
+ "selected": None,
+ })
+ if path.startswith("/api/workspaces"):
+ return web.json_response({
+ "home": STATE["workspace"],
+ "expanded": STATE["workspace"],
+ "exists": True,
+ "candidates": [],
+ "active": STATE["workspace"],
+ "ok": True,
+ })
+ if path.startswith("/api/curator-profile"):
+ return web.json_response({"profile": "", "text": ""})
+ if path.startswith("/api/walkthrough"):
+ return web.json_response({"done": True})
+ if path.startswith("/api/localllm/harness"):
+ return web.json_response({
+ "text": "", "lines": 0, "refine_lines": 0, "path": "",
+ })
+ if path.startswith("/api/localllm"):
+ return web.json_response({
+ "plan": {"ok": False, "ram_gb": 0},
+ "config": {}, "install": None,
+ "candidates": [], "installed": [], "servers": [],
+ "backends": {},
+ })
+ if path.startswith("/api/settings"):
+ return web.json_response({"orchestration_preference": 0.5})
+ if path.startswith("/api/pasteboard"):
+ return web.json_response({"slots": []})
+ if path.startswith("/api/action-buttons"):
+ return web.json_response({"buttons": []})
+ if path.startswith("/api/permission"):
+ return web.json_response({
+ "pending": [], "muted_origins": [], "patterns": [],
+ })
+ if path.startswith("/api/packs"):
+ return web.json_response({"packs": [], "registry": []})
+ if path.startswith("/api/mcp-servers"):
+ return web.json_response({"servers": []})
+ if path.startswith("/api/user-tabs"):
+ return web.json_response({"tabs": []})
+ if path.startswith("/api/watch-folders"):
+ return web.json_response({"folders": [], "pending": []})
+ if path.startswith("/api/streams"):
+ return web.json_response({"streams": []})
+ if path.startswith("/api/orchestration"):
+ return web.json_response({"runs": [], "policy": {}, "interrupted": []})
+ if path.startswith("/api/llm"):
+ return web.json_response({
+ "providers": [],
+ "keychain_available": False,
+ "keychain_backend": "none",
+ "default_provider": "",
+ "default_model": "",
+ })
+ if path.startswith("/api/verbs"):
+ return web.json_response({"verbs": []})
+ if path.startswith("/api/projects"):
+ return web.json_response({"projects": []})
+ if path.startswith("/api/tree"):
+ return web.json_response({"files": []})
+ if path.startswith("/api/sources"):
+ return web.json_response({
+ "sources": [], "internal_pages": 0, "pages_scanned": 0,
+ })
+ if path.startswith("/api/file-routes"):
+ return web.json_response({"routes": []})
+ if path.startswith("/api/threads"):
+ return await handle_threads(request)
+ if path.startswith("/api/rail/events"):
+ return web.json_response({"events": [], "has_more": False})
+ if path.startswith("/api/runs"):
+ return web.json_response({"runs": []})
+ if path.startswith("/api/permission/pending"):
+ return web.json_response({"pending": [], "muted_origins": []})
+ if path.startswith("/api/proposals"):
+ return web.json_response({"proposals": []})
+ return web.json_response({})
+
+
+async def handle_ws(request: web.Request) -> web.WebSocketResponse:
+ ws = web.WebSocketResponse()
+ await ws.prepare(request)
+ STATE["clients"].add(ws)
+ await ws.send_str(json.dumps(_custom(_hello())))
+ try:
+ async for msg in ws:
+ if msg.type in (WSMsgType.CLOSE, WSMsgType.ERROR):
+ break
+ finally:
+ STATE["clients"].discard(ws)
+ return ws
+
+
+async def handle_fail_save(request: web.Request) -> web.Response:
+ try:
+ body = await request.json()
+ except json.JSONDecodeError:
+ body = {}
+ STATE["fail_save"] = bool((body or {}).get("fail"))
+ return web.json_response({"ok": True, "fail_save": STATE["fail_save"]})
+
+
+async def handle_push(request: web.Request) -> web.Response:
+ body = await request.json()
+ if isinstance(body, dict) and body.get("type") == "web_policy":
+ ws = str(body.get("workspace") or STATE["workspace"])
+ if "enabled" in body:
+ STATE["policies"][ws] = bool(body.get("enabled"))
+ if "admin_allows" in body:
+ STATE["admin_allows"] = bool(body.get("admin_allows"))
+ await _broadcast(body if isinstance(body, dict) else {})
+ return web.json_response({"ok": True})
+
+
+def build_app(dist: pathlib.Path) -> web.Application:
+ app = web.Application()
+ app["dist"] = dist
+
+ async def handle_index(_request: web.Request) -> web.StreamResponse:
+ index = dist / "index.html"
+ if not index.is_file():
+ return web.Response(text="frontend dist missing", status=500)
+ return web.FileResponse(index)
+
+ app.router.add_get("/api/health", handle_health)
+ app.router.add_get("/api/comms/review", handle_comms_get)
+ app.router.add_post("/api/comms/review", handle_comms_post)
+ app.router.add_post("/api/comms/review/open", handle_comms_open)
+ app.router.add_get("/api/settings", handle_settings_get)
+ app.router.add_post("/api/settings", handle_settings_post)
+ app.router.add_get("/api/web-policy", handle_web_policy_get)
+ app.router.add_post("/api/web-policy", handle_web_policy_post)
+ app.router.add_get("/api/workspaces", handle_workspaces)
+ app.router.add_post("/api/workspaces/switch", handle_workspaces_switch)
+ app.router.add_get("/api/mode", handle_mode)
+ app.router.add_get("/api/threads", handle_threads)
+ app.router.add_get("/api/graph/data", handle_graph)
+ app.router.add_post("/api/e2e/fail-save", handle_fail_save)
+ app.router.add_post("/api/e2e/push", handle_push)
+ app.router.add_get("/ws", handle_ws)
+ app.router.add_route("GET", "/api/{tail:.*}", handle_json)
+ app.router.add_route("POST", "/api/{tail:.*}", handle_json)
+ app.router.add_get("/", handle_index)
+ if dist.is_dir():
+ app.router.add_static("/", dist, show_index=False)
+ return app
+
+
+def main() -> None:
+ p = argparse.ArgumentParser()
+ p.add_argument("--host", default="127.0.0.1")
+ p.add_argument("--port", type=int, default=41765)
+ p.add_argument("--dist", required=True)
+ args = p.parse_args()
+ web.run_app(build_app(pathlib.Path(args.dist)), host=args.host, port=args.port, print=None)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/frontend/tests/e2e/zen-web-policy.spec.ts b/frontend/tests/e2e/zen-web-policy.spec.ts
new file mode 100644
index 0000000..67c646b
--- /dev/null
+++ b/frontend/tests/e2e/zen-web-policy.spec.ts
@@ -0,0 +1,296 @@
+/**
+ * Geometry + web-policy control sync against an isolated mocked backend.
+ *
+ * E2E_MOCK=1. The mock serves frontend/dist and a fake /api + /ws. It
+ * does not start switchbay.daemon.run and does not touch the live vault.
+ */
+import { test, expect, type Page } from "@playwright/test";
+import { spawn, type ChildProcess } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const shotDir = "test-results/shots";
+const MOCK_WS = "/tmp/switchbay-e2e-mock-ws";
+const MOCK_WS_B = "/tmp/switchbay-e2e-mock-ws-b";
+const PORT = process.env.E2E_MOCK_PORT || "41765";
+const BASE = process.env.E2E_BASE_URL || `http://127.0.0.1:${PORT}`;
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const repo = path.resolve(here, "../../..");
+const dist = path.join(repo, "frontend/dist");
+
+let mockProc: ChildProcess | null = null;
+let startedHere = false;
+
+async function defWait(ms: number): Promise {
+ await new Promise((r) => setTimeout(r, ms));
+}
+
+async function waitHealth(url: string, timeoutMs = 20_000): Promise {
+ const t0 = Date.now();
+ while (Date.now() - t0 < timeoutMs) {
+ try {
+ const r = await fetch(`${url}/api/health`);
+ if (r.ok) {
+ const body = await r.json();
+ if (body.e2e_mock === true && body.workspace === MOCK_WS) return;
+ throw new Error("refusing non-fixture backend");
+ }
+ } catch {
+ /* not up yet */
+ }
+ await defWait(150);
+ }
+ throw new Error(`mock backend did not become healthy at ${url}`);
+}
+
+test.beforeAll(async () => {
+ if (!fs.existsSync(path.join(dist, "index.html"))) {
+ throw new Error("frontend/dist missing — build before Playwright");
+ }
+ try {
+ const r = await fetch(`${BASE}/api/health`);
+ if (r.ok) {
+ const body = (await r.json()) as { workspace?: string; e2e_mock?: boolean };
+ if (body.e2e_mock && body.workspace === MOCK_WS) return;
+ }
+ } catch {
+ /* start our own */
+ }
+ mockProc = spawn(
+ "uv",
+ [
+ "run", "--no-sync", "python",
+ path.join(here, "mock_backend.py"),
+ "--host", "127.0.0.1",
+ "--port", PORT,
+ "--dist", dist,
+ ],
+ {
+ cwd: repo,
+ env: { ...process.env, PYTHONPATH: path.join(repo, "src") },
+ stdio: "pipe",
+ },
+ );
+ startedHere = true;
+ await waitHealth(BASE);
+});
+
+test.afterAll(async () => {
+ if (startedHere && mockProc && mockProc.pid) {
+ mockProc.kill("SIGTERM");
+ }
+});
+
+test.use({ baseURL: BASE, viewport: { width: 1400, height: 900 } });
+test.setTimeout(60_000);
+
+async function assertIsolatedHealth(page: Page): Promise {
+ const health = await page.evaluate(async () => {
+ const r = await fetch("/api/health");
+ return r.json();
+ });
+ expect(health.e2e_mock, "backend must be the labeled mock, not the live daemon").toBe(true);
+ expect(health.workspace).toBe(MOCK_WS);
+ expect(String(health.workspace)).not.toContain("Workspaces/curiosity");
+}
+
+async function pinGeometry(page: Page, rail: ReturnType, tag: string) {
+ const stream = rail.locator(".sy-rail-stream");
+ const input = rail.locator(".sy-rail-input-wrap");
+ await expect(stream).toBeVisible();
+ await expect(input).toBeVisible();
+ const rb = await rail.boundingBox();
+ const sb = await stream.boundingBox();
+ const ib = await input.boundingBox();
+ expect(rb && sb && ib, `${tag} boxes`).toBeTruthy();
+ if (!rb || !sb || !ib) return;
+ const bottomGap = Math.abs(ib.y + ib.height - (rb.y + rb.height));
+ expect(bottomGap, `${tag} composer bottom pin`).toBeLessThanOrEqual(8);
+ expect(sb.y + sb.height, `${tag} stream above composer`).toBeLessThanOrEqual(ib.y + 4);
+ expect(sb.y, `${tag} stream inside rail`).toBeGreaterThanOrEqual(rb.y - 2);
+ expect(ib.y + ib.height, `${tag} no overflow`).toBeLessThanOrEqual(rb.y + rb.height + 8);
+ const overflowY = await rail.evaluate((el) => {
+ const s = getComputedStyle(el);
+ return { overflow: s.overflow, clientH: el.clientHeight, scrollH: el.scrollHeight };
+ });
+ expect(overflowY.scrollH, `${tag} rail scrollHeight`).toBeLessThanOrEqual(overflowY.clientH + 12);
+}
+
+async function gotoApp(page: Page, mode: "zen" | "power") {
+ await page.addInitScript((m) => {
+ localStorage.setItem("sy:ui-mode", m);
+ try { navigator.serviceWorker?.getRegistrations?.().then((rs) => rs.forEach((r) => r.unregister())); } catch { /* ignore */ }
+ }, mode);
+ const resp = await page.goto(BASE + "/", { waitUntil: "domcontentloaded" });
+ if (!resp || !resp.ok()) {
+ throw new Error(`GET / failed: ${resp?.status()} ${await resp?.text()}`);
+ }
+ await page.locator("#root").waitFor({ timeout: 10_000 });
+ try {
+ await page.locator(mode === "zen" ? ".sy-zen" : ".sy-shell").waitFor({ timeout: 15_000 });
+ } catch (err) {
+ const html = await page.content();
+ throw new Error(`UI did not render (${mode}). body=${html.slice(0, 1500)}`);
+ }
+ await assertIsolatedHealth(page);
+ // Hello WS remounts chrome; wait until the web control is attached
+ // and the first paint has settled.
+ await page.locator(".sy-web-policy").first().waitFor({ timeout: 10_000 });
+ await page.waitForTimeout(400);
+}
+
+test("zen floating chat is two columns and shows web on/off", async ({ page }) => {
+ await gotoApp(page, "zen");
+ const box = page.locator(".sy-zen-chatbox").first();
+ await expect(box).toBeVisible();
+ await expect(box.locator(".sy-zen-chat-left")).toBeVisible();
+ await expect(box.locator(".sy-zen-chat-right")).toBeVisible();
+ const web = box.locator(".sy-web-policy");
+ await expect(web).toBeVisible();
+ await expect(web.getByText("Web", { exact: true })).toBeVisible();
+ await expect(web.getByRole("button", { name: "off" })).toBeVisible();
+ await expect(web.getByRole("button", { name: "on" })).toBeVisible();
+ await page.locator(".sy-zen-chatbox .sy-web-policy").getByRole("button", { name: "on" }).click();
+ await expect(page.locator(".sy-zen-chatbox .sy-web-policy").getByRole("button", { name: "on" })).toHaveClass(/sy-web-policy-btn--on/);
+ await page.locator(".sy-zen-chatbox .sy-web-policy").getByRole("button", { name: "off" }).click();
+ await expect(page.locator(".sy-zen-chatbox .sy-web-policy").getByRole("button", { name: "off" })).toHaveClass(/sy-web-policy-btn--on/);
+ fs.mkdirSync(shotDir, { recursive: true });
+ await page.screenshot({ path: `${shotDir}/zen-floating-web.png`, fullPage: true });
+});
+
+test("zen docked chat pins composer at narrow and wide geometry", async ({ page }) => {
+ await gotoApp(page, "zen");
+ await page.locator(".sy-zen-surf-pickbtn").click();
+ await page.getByRole("menuitem", { name: "Chat" }).click();
+ const rail = page.locator(".sy-zen-surf-body .sy-rail--embedded, .sy-zen-surf-body .sy-rail");
+ await expect(rail.first()).toBeVisible();
+ await expect(rail.locator(".sy-zen-chat-left")).toHaveCount(0);
+ await expect(rail.locator(".sy-web-policy").getByText("Web", { exact: true })).toBeVisible();
+ await pinGeometry(page, rail.first(), "short transcript");
+
+ for (let i = 0; i < 24; i += 1) {
+ await page.request.post(`${BASE}/api/e2e/push`, {
+ data: {
+ type: "notice",
+ text: `long transcript line ${i} — ${"word ".repeat(20)}`,
+ kind: "chat",
+ workspace: MOCK_WS,
+ thread_id: "th-e2e",
+ },
+ });
+ }
+ await expect(rail.locator(".sy-rail-entry").first()).toBeVisible({ timeout: 10_000 });
+
+ fs.mkdirSync(shotDir, { recursive: true });
+ await page.setViewportSize({ width: 1400, height: 900 });
+ await pinGeometry(page, rail.first(), "wide");
+ await page.screenshot({ path: `${shotDir}/zen-docked-wide.png` });
+ await page.setViewportSize({ width: 900, height: 800 });
+ await pinGeometry(page, rail.first(), "narrow");
+ await page.screenshot({ path: `${shotDir}/zen-docked-narrow.png` });
+ await page.setViewportSize({ width: 1400, height: 900 });
+});
+
+test("web policy toggles, remote updates, workspace switch, failed save", async ({ page }) => {
+ await gotoApp(page, "zen");
+
+ const railWeb = page.locator(".sy-web-policy").first();
+ await expect(railWeb.getByRole("button", { name: "off" })).toBeVisible();
+ await page.locator(".sy-web-policy").first().getByRole("button", { name: "on" }).click();
+ await expect(page.locator(".sy-web-policy").first().getByRole("button", { name: "on" })).toHaveClass(/sy-web-policy-btn--on/);
+
+ const chrome = page.locator(".sy-zen-chrome--top");
+ await expect(chrome).toBeVisible();
+ await chrome.hover();
+ await chrome.getByRole("button", { name: "Settings", exact: true }).click();
+ const modal = page.locator(".sy-settings").first();
+ await expect(modal).toBeVisible({ timeout: 10_000 });
+ const settingsWeb = modal.locator(".sy-web-policy").first();
+ await expect(settingsWeb).toBeVisible({ timeout: 10_000 });
+ await settingsWeb.scrollIntoViewIfNeeded();
+ await expect(settingsWeb.getByRole("button", { name: "on" })).toHaveClass(/sy-web-policy-btn--on/);
+ fs.mkdirSync(shotDir, { recursive: true });
+ await expect(modal).toBeVisible();
+ await modal.screenshot({ path: `${shotDir}/settings-web-policy.png`, animations: "disabled" });
+
+ await page.request.post(`${BASE}/api/e2e/push`, {
+ data: {
+ type: "web_policy",
+ workspace: MOCK_WS,
+ enabled: false,
+ admin_allows: true,
+ requested: false,
+ },
+ });
+ await expect(settingsWeb.getByRole("button", { name: "off" })).toHaveClass(/sy-web-policy-btn--on/);
+ await expect(railWeb.getByRole("button", { name: "off" })).toHaveClass(/sy-web-policy-btn--on/);
+
+ await page.request.post(`${BASE}/api/e2e/fail-save`, { data: { fail: true } });
+ await settingsWeb.getByRole("button", { name: "on" }).click();
+ await expect(modal.locator(".sy-web-policy-err")).toBeVisible();
+ await expect(settingsWeb.getByRole("button", { name: "off" })).toHaveClass(/sy-web-policy-btn--on/);
+ await page.request.post(`${BASE}/api/e2e/fail-save`, { data: { fail: false } });
+ await page.keyboard.press("Escape");
+
+ await page.locator(".sy-ws-trigger").click();
+ await page.locator(".sy-ws-item").filter({ hasText: "switchbay-e2e-mock-ws-b" }).click();
+ await expect.poll(async () => {
+ const h = await page.evaluate(async () => {
+ const r = await fetch("/api/workspaces");
+ return r.json();
+ });
+ return h.active as string;
+ }).toBe(MOCK_WS_B);
+ await expect(page.locator(".sy-web-policy").first().getByRole("button", { name: "on" })).toHaveClass(/sy-web-policy-btn--on/);
+ await page.locator(".sy-ws-trigger").click();
+ await page.locator(".sy-ws-item").filter({ hasText: "switchbay-e2e-mock-ws" }).filter({ hasNotText: "switchbay-e2e-mock-ws-b" }).click();
+ await expect(page.locator(".sy-web-policy").first().getByRole("button", { name: "off" })).toHaveClass(/sy-web-policy-btn--on/);
+});
+
+
+test("377px Rail has one header Web control and no overlapping footer controls", async ({ page }) => {
+ await page.route("**/api/llm/providers", route => route.fulfill({ json: {
+ providers: [{ id: "grok-build", label: "Grok Build", category: "cli", default_model: "grok-4.6", has_key: true }],
+ default_provider: "grok-build", default_model: "grok-4.6",
+ } }));
+ await page.route("**/api/llm/reasoning-options", route => route.fulfill({ json: {
+ provider: "grok-build", model: "grok-4.6", selected: "xhigh",
+ options: [{ id: "low", label: "low" }, { id: "xhigh", label: "extra high" }],
+ } }));
+ await gotoApp(page, "power");
+ await page.addStyleTag({ content: ":root { --rail-w: 377px; }" });
+ const rail = page.locator(".sy-shell > .sy-rail");
+ await expect(rail.locator(".sy-web-policy")).toHaveCount(1);
+ await expect(rail.locator(".sy-rail-head .sy-web-policy")).toBeVisible();
+ await expect(rail.locator(".sy-rail-input-wrap .sy-web-policy")).toHaveCount(0);
+ await pinGeometry(page, rail, "377px Rail");
+ const inspect = async () => rail.locator(".sy-rail-composer-tools").evaluate((row) => {
+ const outer = row.getBoundingClientRect();
+ const els = [...row.querySelectorAll(".sy-orch > *, .sy-rail-composer-end > *")]
+ .map(el => ({ label: el.textContent || el.getAttribute("aria-label"), box: el.getBoundingClientRect() }))
+ .filter(x => x.box.width > 0 && x.box.height > 0);
+ return els.flatMap((x, i) => {
+ const issues: string[] = [];
+ if (x.box.left < outer.left - 1 || x.box.right > outer.right + 1) issues.push(`${x.label} overflows`);
+ for (const y of els.slice(i + 1)) {
+ if (Math.min(x.box.right, y.box.right) - Math.max(x.box.left, y.box.left) > 1 &&
+ Math.min(x.box.bottom, y.box.bottom) - Math.max(x.box.top, y.box.top) > 1) issues.push(`${x.label} overlaps ${y.label}`);
+ }
+ return issues;
+ });
+ });
+ await expect.poll(inspect).toEqual([]);
+ await page.request.post(`${BASE}/api/e2e/fail-save`, { data: { fail: true } });
+ await rail.locator(".sy-web-policy").getByRole("button", { name: "on", exact: true }).click();
+ await expect(rail.locator(".sy-web-policy-err")).toBeVisible();
+ await expect.poll(inspect).toEqual([]);
+ const reset = await rail.locator(".sy-rail-head .sy-rail-reset").boundingBox();
+ expect(reset?.height).toBeLessThan(28);
+ const headOverflow = await rail.locator(".sy-rail-head").evaluate(el => el.scrollWidth - el.clientWidth);
+ expect(headOverflow).toBeLessThanOrEqual(1);
+ fs.mkdirSync(shotDir, { recursive: true });
+ await rail.screenshot({ path: `${shotDir}/rail-377-failed-save.png` });
+ await page.request.post(`${BASE}/api/e2e/fail-save`, { data: { fail: false } });
+});
diff --git a/src/switchbay/admin_policy.py b/src/switchbay/admin_policy.py
index 3dc2c18..2015f50 100644
--- a/src/switchbay/admin_policy.py
+++ b/src/switchbay/admin_policy.py
@@ -290,6 +290,29 @@ def load(*, force: bool = False) -> dict[str, Any]:
raw = src.get("updates")
if isinstance(raw, dict):
updates.update(raw)
+ comms: dict[str, Any] = {}
+ for src in (baked_data, overlay):
+ raw = src.get("comms")
+ if isinstance(raw, dict):
+ comms.update(raw)
+ orchestration_admin: dict[str, Any] = {}
+ for src in (baked_data, overlay):
+ raw = src.get("orchestration")
+ if isinstance(raw, dict):
+ orchestration_admin.update(raw)
+ baked_orch = baked_data.get("orchestration") if isinstance(baked_data.get("orchestration"), dict) else {}
+ overlay_orch = overlay.get("orchestration") if isinstance(overlay.get("orchestration"), dict) else {}
+ baked_cap = _positive_int_cap(baked_orch.get("max_live_workers"))
+ overlay_cap = _positive_int_cap(overlay_orch.get("max_live_workers"))
+ if baked_cap is not None and overlay_cap is not None:
+ # Overlay may only tighten (lower) a baked ceiling.
+ orchestration_admin["max_live_workers"] = min(baked_cap, overlay_cap)
+ elif baked_cap is not None:
+ orchestration_admin["max_live_workers"] = baked_cap
+ elif overlay_cap is not None:
+ orchestration_admin["max_live_workers"] = overlay_cap
+ else:
+ orchestration_admin.pop("max_live_workers", None)
resolved = {
"profile": profile,
@@ -303,6 +326,8 @@ def load(*, force: bool = False) -> dict[str, Any]:
"skills": skills,
"paths": paths,
"updates": updates,
+ "comms": comms,
+ "orchestration": orchestration_admin,
"allow_profile_override": allow_override,
"tighten": tighten,
}
@@ -455,6 +480,39 @@ def update_repo() -> str:
return DEFAULT_UPDATE_REPO
+def _positive_int_cap(raw: Any) -> int | None:
+ """Valid positive live-seat cap, or None if unset/malformed.
+
+ Rejects bools (a subclass of int) and non-integral numbers so
+ ``True`` / ``4.9`` never become 1 / 4. Decimal digit strings such
+ as ``"5"`` are accepted. Zero and negatives are not caps.
+ """
+ if raw is None or isinstance(raw, bool):
+ return None
+ if isinstance(raw, int):
+ return raw if raw > 0 else None
+ if isinstance(raw, str):
+ s = raw.strip()
+ if s.startswith("+"):
+ s = s[1:]
+ if s.isdigit():
+ try:
+ n = int(s, 10)
+ except ValueError:
+ # isdigit also accepts characters int cannot parse; very
+ # long digit strings can exceed Python's conversion limit.
+ return None
+ return n if n > 0 else None
+ return None
+ return None
+
+
+def max_live_workers_ceiling() -> int | None:
+ """Admin live-seat ceiling, or None if unset. May only tighten."""
+ raw = (load().get("orchestration") or {}).get("max_live_workers")
+ return _positive_int_cap(raw)
+
+
def update_include_skills() -> bool:
"""Whether Settings → Update may touch curiosity-engine / merge.
diff --git a/src/switchbay/agents/ce_workers.py b/src/switchbay/agents/ce_workers.py
index 88791d3..cd77755 100644
--- a/src/switchbay/agents/ce_workers.py
+++ b/src/switchbay/agents/ce_workers.py
@@ -80,7 +80,8 @@ def worker_budget(
"""
if local:
return 0
- cap = min(policy.HARD_MAX_CONCURRENCY, max(1, int(configured)))
+ from .desk_admission import effective_live_cap
+ cap = min(effective_live_cap(), policy.HARD_MAX_CONCURRENCY, max(1, int(configured)))
s = policy.clamp_preference(preference)
if s <= 0.2:
return 1
@@ -137,6 +138,7 @@ async def run_from_tool(
completed: set[str] | None = None,
failed: set[str] | None = None,
running: set[str] | None = None,
+ desk_gate: Any = None,
) -> dict[str, Any]:
"""Fill the CE worker template and, on a provider, complete it."""
from .. import ce_host
@@ -235,22 +237,38 @@ async def run_from_tool(
)
filled["spawned"] = False
return filled
- rec = await orchestration._run_agent_node(
- node,
- provider=prov,
- model=wm or getattr(prov, "DEFAULT_MODEL", None),
- workspace=workspace,
- parent_run_id=parent_run_id,
- thread_id=thread_id,
- app=app,
- blackboard=evidence.Blackboard(),
- worker_index=None,
- plan=plan,
- graph_parent=parent,
- graph_completed=completed,
- graph_failed=failed,
- graph_running=running,
+ from .desk_admission import (
+ desk_domain_id, gate_for, slot_id as desk_slot,
)
+ nest_gate = desk_gate
+ if nest_gate is None:
+ from ..kernel.desk import DESK_CURATE
+ nest_gate = gate_for(
+ desk_domain_id(workspace, DESK_CURATE),
+ workspace=workspace,
+ )
+ nest_slot = desk_slot(parent_run_id, node_id)
+ await nest_gate.acquire(nest_slot, kind="nested")
+ try:
+ rec = await orchestration._run_agent_node(
+ node,
+ provider=prov,
+ model=wm or getattr(prov, "DEFAULT_MODEL", None),
+ workspace=workspace,
+ parent_run_id=parent_run_id,
+ thread_id=thread_id,
+ app=app,
+ blackboard=evidence.Blackboard(str(parent_run_id or node_id)),
+ worker_index=None,
+ plan=plan,
+ graph_parent=parent,
+ graph_completed=completed,
+ graph_failed=failed,
+ graph_running=running,
+ desk_gate=nest_gate,
+ )
+ finally:
+ await nest_gate.release_async(nest_slot)
text = str(rec.get("output") or "")
filled["text"] = text
filled["spawned"] = True
@@ -417,17 +435,23 @@ def _publish_handback(
def _next_node_id(plan: Any, parent: dict[str, Any] | None, role: str) -> str:
- used: set[str] = set()
- if plan is not None and hasattr(plan, "nodes"):
- used.update(n.node_id for n in plan.nodes)
+ extra: set[str] = set()
if parent is not None:
for row in parent.get("plan_nodes") or []:
if isinstance(row, dict) and row.get("node_id"):
- used.add(str(row["node_id"]))
+ extra.add(str(row["node_id"]))
prefix = "ce-rev" if role in REVIEW_ROLES else "ce-w"
- i = 0
+ if plan is not None and hasattr(plan, "nodes"):
+ from .orchestration import alloc_node_id, seed_node_seq
+ try:
+ seed_node_seq(plan)
+ return alloc_node_id(plan, prefix, extra)
+ except Exception: # noqa: BLE001
+ extra.update(n.node_id for n in plan.nodes)
+ extra.update(getattr(plan, "archived_ids", None) or [])
+ i = 1
while True:
- nid = prefix if i == 0 and prefix == "ce-rev" else f"{prefix}{i}"
- if nid not in used:
+ nid = f"{prefix}{i}"
+ if nid not in extra:
return nid
i += 1
diff --git a/src/switchbay/agents/desk_admission.py b/src/switchbay/agents/desk_admission.py
new file mode 100644
index 0000000..3336e0d
--- /dev/null
+++ b/src/switchbay/agents/desk_admission.py
@@ -0,0 +1,355 @@
+"""Per-desk live worker admission.
+
+Concurrency is a live-seat gate (queue/backpressure), not a lifetime
+spawn budget and not an early stop. The **chief is counted**. Nested
+CE workers share the same workspace+desk domain.
+
+Default 8 total live seats. User-configurable floor is 4 (chief +
+verifier + synthesizer + specialist). Admin/baked policy may only
+tighten the ceiling; preference/model/plan/nested dispatch cannot
+raise it.
+
+Cap is re-read from Settings/admin on acquire and while waiters
+block. Raising the cap wakes waiters; lowering admits no extras
+until live work drains under the new cap. Coordinating parents
+park their seat while awaiting nested children so the desk cannot
+deadlock when every holder is a waiter.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import os
+import threading
+from pathlib import Path
+from typing import Any
+
+from . import orchestration_policy as policy
+
+log = logging.getLogger("switchbay.agents.desk_admission")
+
+# Chief occupies one live seat for the life of execute().
+CHIEF_COUNTED = True
+MIN_LIVE_SEATS = 4
+DEFAULT_LIVE_SEATS = 8
+
+_GATES: dict[str, "DeskGate"] = {}
+_GATES_LOCK = threading.Lock()
+
+
+def effective_live_cap(workspace: Any = None) -> int:
+ """Server-enforced total live seats per desk, chief included.
+
+ ``max(MIN_LIVE_SEATS, min(user, admin_ceiling, HARD_MAX_CONCURRENCY))``.
+ Admin/baked values only tighten.
+ """
+ from .. import admin_policy, app_settings
+
+ hard = int(policy.HARD_MAX_CONCURRENCY) if policy.HARD_MAX_CONCURRENCY > 0 else DEFAULT_LIVE_SEATS
+ if hard < MIN_LIVE_SEATS:
+ hard = MIN_LIVE_SEATS
+ user = app_settings.get_desk_max_live_workers()
+ try:
+ user_n = int(user)
+ except (TypeError, ValueError):
+ user_n = DEFAULT_LIVE_SEATS
+ user_n = max(MIN_LIVE_SEATS, min(user_n, hard))
+ ceiling = admin_policy.max_live_workers_ceiling()
+ if ceiling is not None:
+ try:
+ ceil_n = int(ceiling)
+ except (TypeError, ValueError):
+ ceil_n = hard
+ # Admin may only tighten; never raise above the code hard cap.
+ user_n = min(user_n, max(MIN_LIVE_SEATS, min(ceil_n, hard)))
+ return max(MIN_LIVE_SEATS, min(user_n, hard))
+
+
+def public_view(workspace: Any = None) -> dict[str, Any]:
+ cap = effective_live_cap(workspace)
+ from .. import admin_policy, app_settings
+ return {
+ "desk_max_live_workers": cap,
+ "requested": app_settings.get_desk_max_live_workers(),
+ "min": MIN_LIVE_SEATS,
+ "default": DEFAULT_LIVE_SEATS,
+ "hard_max": int(policy.HARD_MAX_CONCURRENCY) or DEFAULT_LIVE_SEATS,
+ "admin_ceiling": admin_policy.max_live_workers_ceiling(),
+ "chief_counted": CHIEF_COUNTED,
+ "note": (
+ "Total live seats per desk, including the chief-of-staff. "
+ f"Floor {MIN_LIVE_SEATS} (chief + verifier + synthesizer + specialist). "
+ "Admin policy may only lower the ceiling."
+ ),
+ }
+
+
+def desk_domain_id(workspace: Path | str | None, desk_id: str | None) -> str:
+ """Stable admission domain: one gate per workspace + standing desk."""
+ desk = str(desk_id or "desk").strip() or "desk"
+ if workspace is None or str(workspace) == "":
+ return desk
+ try:
+ ws = str(Path(workspace).expanduser().resolve())
+ except (OSError, RuntimeError, ValueError):
+ ws = str(workspace)
+ return f"{ws}::{desk}"
+
+
+def slot_id(run_id: str | None, name: str) -> str:
+ """Lease identity unique across overlapping runs on the same desk."""
+ rid = str(run_id or "").strip()
+ nm = str(name or "").strip() or "worker"
+ if not rid:
+ return nm
+ return f"{rid}:{nm}"
+
+
+# Tests patch this so acquire waiters notice cap changes quickly.
+ACQUIRE_WAIT_TIMEOUT = 2.0
+
+
+def _wait_timeout() -> float:
+ return float(ACQUIRE_WAIT_TIMEOUT)
+
+
+class DeskGate:
+ """One admission domain (workspace + desk, shared by overlapping runs)."""
+
+ def __init__(self, desk_id: str, cap: int, *, workspace: Any = None) -> None:
+ self.desk_id = desk_id
+ self._workspace = workspace
+ self._follow_settings = workspace is not None
+ self._cap = max(MIN_LIVE_SEATS, int(cap))
+ self._held: dict[str, str] = {} # slot_id -> kind
+ self._parked: dict[str, str] = {} # slot_id -> kind (not counted)
+ self._refs = 0
+ self._cond = asyncio.Condition()
+
+ @property
+ def cap(self) -> int:
+ return self._cap
+
+ def live(self) -> int:
+ return len(self._held)
+
+ def free(self) -> int:
+ return max(0, self._cap - len(self._held))
+
+ def snapshot(self) -> dict[str, Any]:
+ return {
+ "desk_id": self.desk_id,
+ "cap": self._cap,
+ "live": len(self._held),
+ "parked": len(self._parked),
+ "refs": self._refs,
+ "chief_counted": CHIEF_COUNTED,
+ "slots": dict(self._held),
+ }
+
+ def refresh_cap(self, workspace: Any = None) -> int:
+ """Re-read Settings/admin ceiling and wake waiters if the cap rose.
+
+ Explicit test/pinned caps (no workspace) stay put so a fixture
+ ``DeskGate(..., cap=4)`` is not rewritten to the user default.
+ """
+ if workspace is not None:
+ self._workspace = workspace
+ self._follow_settings = True
+ if not self._follow_settings:
+ return self._cap
+ cap = effective_live_cap(self._workspace)
+ if cap != self._cap:
+ self.set_cap(cap)
+ return self._cap
+
+ def set_cap(self, cap: int) -> None:
+ new = max(MIN_LIVE_SEATS, int(cap))
+ prev = self._cap
+ self._cap = new
+ if new != prev:
+ self._wake()
+
+ def retain(self) -> None:
+ self._refs += 1
+
+ def drop_ref(self) -> int:
+ self._refs = max(0, self._refs - 1)
+ return self._refs
+
+ def _wake(self) -> None:
+ try:
+ loop = asyncio.get_running_loop()
+ except RuntimeError:
+ return
+ if not loop.is_running():
+ return
+
+ async def _notify() -> None:
+ async with self._cond:
+ self._cond.notify_all()
+
+ try:
+ loop.create_task(_notify())
+ except RuntimeError:
+ pass
+
+ def _drop_slot(self, sid: str) -> bool:
+ gone = False
+ if sid in self._held:
+ self._held.pop(sid, None)
+ gone = True
+ if sid in self._parked:
+ self._parked.pop(sid, None)
+ gone = True
+ return gone
+
+ async def acquire(self, slot_id: str, *, kind: str = "worker") -> bool:
+ if not slot_id:
+ return False
+ async with self._cond:
+ if slot_id in self._held:
+ return True
+ # A parked parent restoring via acquire (not unpark) is live again.
+ self._parked.pop(slot_id, None)
+ while True:
+ self.refresh_cap()
+ if len(self._held) < self._cap:
+ break
+ try:
+ await asyncio.wait_for(self._cond.wait(), timeout=_wait_timeout())
+ except asyncio.TimeoutError:
+ continue
+ self._held[slot_id] = kind
+ return True
+
+ def try_acquire(self, slot_id: str, *, kind: str = "worker") -> bool:
+ if not slot_id:
+ return False
+ if slot_id in self._held:
+ return True
+ self.refresh_cap()
+ if len(self._held) >= self._cap:
+ return False
+ self._parked.pop(slot_id, None)
+ self._held[slot_id] = kind
+ return True
+
+ def release(self, slot_id: str) -> None:
+ if not slot_id:
+ return
+ try:
+ loop = asyncio.get_running_loop()
+ except RuntimeError:
+ loop = None
+ if loop is not None and loop.is_running():
+ loop.create_task(self._release_async(slot_id))
+ return
+ self._drop_slot(slot_id)
+
+ async def _release_async(self, slot_id: str) -> None:
+ async with self._cond:
+ self._drop_slot(slot_id)
+ self._cond.notify_all()
+
+ async def release_async(self, slot_id: str) -> None:
+ await self._release_async(slot_id)
+
+ async def park(self, slot_id: str) -> bool:
+ """Temporarily free a coordinating parent's seat while nested work runs.
+
+ Live count drops; the slot is remembered so restore is identity-safe.
+ Nested children can then take the seat. No-op if the slot is not held.
+ """
+ if not slot_id:
+ return False
+ async with self._cond:
+ kind = self._held.pop(slot_id, None)
+ if kind is None:
+ return False
+ self._parked[slot_id] = kind
+ self._cond.notify_all()
+ return True
+
+ async def unpark(self, slot_id: str, *, restore: bool = True) -> bool:
+ """Restore a parked parent seat, or drop it if the parent is dying."""
+ if not slot_id:
+ return False
+ try:
+ async with self._cond:
+ kind = self._parked.pop(slot_id, None)
+ if kind is None:
+ return slot_id in self._held
+ if not restore:
+ self._cond.notify_all()
+ return False
+ if slot_id in self._held:
+ return True
+ while True:
+ self.refresh_cap()
+ if len(self._held) < self._cap:
+ break
+ try:
+ await asyncio.wait_for(
+ self._cond.wait(), timeout=_wait_timeout(),
+ )
+ except asyncio.TimeoutError:
+ continue
+ self._held[slot_id] = kind
+ return True
+ except asyncio.CancelledError:
+ async with self._cond:
+ self._parked.pop(slot_id, None)
+ self._held.pop(slot_id, None)
+ self._cond.notify_all()
+ raise
+
+
+def gate_for(
+ desk_id: str,
+ *,
+ cap: int | None = None,
+ workspace: Any = None,
+) -> DeskGate:
+ cid = str(desk_id or "desk")
+ with _GATES_LOCK:
+ g = _GATES.get(cid)
+ if g is None:
+ g = DeskGate(
+ cid,
+ cap if cap is not None else effective_live_cap(workspace),
+ workspace=workspace,
+ )
+ _GATES[cid] = g
+ else:
+ if workspace is not None:
+ g._workspace = workspace
+ g._follow_settings = True
+ if cap is not None and not g._follow_settings:
+ g.set_cap(cap)
+ else:
+ g.refresh_cap(workspace)
+ return g
+
+
+def drop_gate(desk_id: str) -> None:
+ with _GATES_LOCK:
+ g = _GATES.get(str(desk_id or ""))
+ if g is None:
+ return
+ if g._refs > 0 or g._held or g._parked:
+ return
+ _GATES.pop(str(desk_id or ""), None)
+
+
+def release_domain(gate: DeskGate | None) -> None:
+ if gate is None:
+ return
+ gate.drop_ref()
+ drop_gate(gate.desk_id)
+
+
+def reset_for_tests() -> None:
+ with _GATES_LOCK:
+ _GATES.clear()
diff --git a/src/switchbay/agents/fast_lookup.py b/src/switchbay/agents/fast_lookup.py
index ac71db7..c461af3 100644
--- a/src/switchbay/agents/fast_lookup.py
+++ b/src/switchbay/agents/fast_lookup.py
@@ -12,7 +12,6 @@
import asyncio
import logging
-import os
import re
import time
import uuid
@@ -33,6 +32,8 @@
SYNTH_MAX_TOKENS = 2048
CONFIRM_MAX_TOKENS = 400
REVISE_MAX_TOKENS = 1200
+# Production retires lookup runs after a delay. Tests set False.
+lookup_retire_tasks = True
_STOP = frozenset({
"a", "an", "the", "and", "or", "for", "that", "this", "with", "from",
@@ -500,7 +501,7 @@ async def _on_text(delta: str) -> None:
"output_tokens": out_tok,
"tokens": in_tok + out_tok,
}
- if not os.environ.get("PYTEST_CURRENT_TEST"):
+ if lookup_retire_tasks:
task = asyncio.create_task(_retire_run(runs, run_id, delay=8.0))
box = app.setdefault("_lookup_retire", [])
box.append(task)
diff --git a/src/switchbay/agents/orchestration.py b/src/switchbay/agents/orchestration.py
index f2afba2..e782140 100644
--- a/src/switchbay/agents/orchestration.py
+++ b/src/switchbay/agents/orchestration.py
@@ -23,8 +23,8 @@
from typing import Any
from .. import (
- atomicio, llmgateway, modestore, orchestrator_fs, protocol,
- routing_status, statedir, tools,
+ atomicio, llmgateway, modestore, orchestrator_fs, permissions,
+ protocol, routing_status, statedir, tools,
)
from . import evidence, fanout, orchestration_policy as policy, rail_default
@@ -80,6 +80,10 @@
# How often the snapshot worker writes SNAPSHOT.md so a daemon
# restart can restore in-flight investigators instead of starting over.
SNAPSHOT_INTERVAL_SEC = 15.0
+# How often an idle Curate desk re-reads the source/wiki fingerprint.
+IDLE_WAIT_SEC = 1.0
+# Tests set True so linger tasks do not leak the event loop.
+cleanup_retire_tasks = False
_NODE_LIVE_MIN_GAP_SEC = 2.0
# Opt-in measurement/protocol tools for a single `execute` node. Still
@@ -233,6 +237,38 @@
re.I | re.M,
)
+_DURATION_RE = re.compile(
+ r"\b(?:for|over)\s+(\d+(?:\.\d+)?)\s*"
+ r"(min(?:ute)?s?|m|hours?|hrs?|h|days?|d)\b",
+ re.I,
+)
+_CONTINUOUS_RE = re.compile(
+ r"\b(overnight|continuous|until\s+stop|keep\s+going)\b",
+ re.I,
+)
+
+
+def parse_duration_window(
+ text: str, *, now: float | None = None,
+) -> tuple[bool, float | None]:
+ """(repeat, until_ts). Duration/continuous Curate keeps waving."""
+ now = time.time() if now is None else now
+ t = text or ""
+ m = _DURATION_RE.search(t)
+ if m:
+ n = float(m.group(1))
+ unit = m.group(2).lower()
+ if unit.startswith("d"):
+ sec = n * 86400.0
+ elif unit.startswith("h"):
+ sec = n * 3600.0
+ else:
+ sec = n * 60.0
+ return True, now + sec
+ if _CONTINUOUS_RE.search(t):
+ return True, None
+ return False, None
+
# How often the chief re-checks channels while waiting for a weekly
# limit, a local server, or credits. Short enough that a user kill
# is noticed; long enough not to spin.
@@ -311,6 +347,16 @@ def _desk_context(workspace: Path) -> list[str]:
)
+def _live_cap() -> int:
+ """Server live-seat ceiling (chief counted). Admin may only tighten."""
+ try:
+ from .desk_admission import effective_live_cap
+ return int(effective_live_cap())
+ except Exception: # noqa: BLE001
+ hard = int(policy.HARD_MAX_CONCURRENCY) or 8
+ return max(4, hard)
+
+
@dataclass
class OrchestrationBounds:
max_nodes: int = policy.HARD_MAX_NODES
@@ -347,7 +393,7 @@ def clamp(self) -> OrchestrationBounds:
return OrchestrationBounds(
max_nodes=max_nodes,
max_depth=max(1, min(int(self.max_depth), policy.HARD_MAX_DEPTH)),
- max_concurrency=max(1, min(int(self.max_concurrency), policy.HARD_MAX_CONCURRENCY)),
+ max_concurrency=max(1, min(int(self.max_concurrency), _live_cap())),
max_expansions=max_expansions,
worker_timeout_sec=max(
5.0, min(float(self.worker_timeout_sec), policy.HARD_WORKER_TIMEOUT_SEC),
@@ -439,6 +485,8 @@ class OrchestrationPlan:
decision: dict[str, Any] = field(default_factory=dict)
allow_expand: bool = False
extra_system: str = ""
+ node_seq: int = 0
+ archived_ids: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return {
@@ -453,6 +501,8 @@ def to_dict(self) -> dict[str, Any]:
"decision": self.decision,
"allow_expand": self.allow_expand,
"extra_system": self.extra_system,
+ "node_seq": int(self.node_seq or 0),
+ "archived_ids": list(self.archived_ids or [])[-4000:],
}
def to_json(self) -> str:
@@ -466,7 +516,7 @@ def from_dict(cls, raw: Any) -> OrchestrationPlan:
if not isinstance(nodes_raw, list):
raise ValueError("plan.nodes must be an array")
nodes = [PlanNode.from_dict(n) for n in nodes_raw]
- return cls(
+ plan = cls(
orchestration_id=str(raw.get("orchestration_id") or ""),
strategy=str(raw.get("strategy") or "single"),
nodes=nodes,
@@ -478,7 +528,11 @@ def from_dict(cls, raw: Any) -> OrchestrationPlan:
decision=raw.get("decision") if isinstance(raw.get("decision"), dict) else {},
allow_expand=bool(raw.get("allow_expand")),
extra_system=str(raw.get("extra_system") or ""),
+ node_seq=int(raw.get("node_seq") or 0),
+ archived_ids=[str(x) for x in (raw.get("archived_ids") or []) if x],
)
+ seed_node_seq(plan)
+ return plan
def node_map(self) -> dict[str, PlanNode]:
return {n.node_id: n for n in self.nodes}
@@ -921,6 +975,7 @@ def plan_from_decision(
curator_model: str | None = None,
curator_harness: str | None = None,
project_hires: list[dict[str, Any]] | None = None,
+ research_hires: list[dict[str, Any]] | None = None,
workspace: Path | None = None,
available: list[tuple[str, str | None]] | None = None,
denied: list[str] | None = None,
@@ -931,6 +986,33 @@ def plan_from_decision(
objective, decision, oid, project_hires or [],
strategy=str(task_kind),
)
+ if task_kind == "research":
+ from ..kernel.packages import RESEARCH_ID, RESEARCH_TOOLS
+ hire = (research_hires or [None])[0] if research_hires else None
+ node = PlanNode(
+ node_id="research",
+ kind="synthesize",
+ objective=objective,
+ role=RESEARCH_ID,
+ difficulty="hard",
+ ladder_hint="hard",
+ tools=list(RESEARCH_TOOLS),
+ graph_access="read",
+ independence="low",
+ output_contract="synthesis",
+ provider=(str(hire["provider"]) if hire and hire.get("provider") else curator_provider),
+ model=(str(hire["model"]) if hire and hire.get("model") else curator_model),
+ harness=(str(hire["harness"]) if hire and hire.get("harness") else curator_harness),
+ )
+ return OrchestrationPlan(
+ orchestration_id=oid,
+ strategy="research",
+ nodes=[node],
+ objective=objective,
+ preference=decision.preference,
+ features=decision.features,
+ decision={**decision.to_dict(), "task_kind": "research"},
+ )
if task_kind == "curation":
# Parent run is the rail-picker chief. This node is the curator
# package, on a kernel-chosen worker model / harness.
@@ -949,6 +1031,19 @@ def plan_from_decision(
model=curator_model,
harness=curator_harness,
)
+ repeat, until = parse_duration_window(objective)
+ curate_decision = {**decision.to_dict(), "task_kind": "curation"}
+ if repeat:
+ curate_decision["curate_repeat"] = True
+ if until is not None:
+ curate_decision["curate_until"] = until
+ local = (
+ policy.provider_category(str(curator_provider or "")) == "local"
+ or str(curator_provider or "") in policy.LOCAL_PROVIDER_IDS
+ )
+ bounds = OrchestrationBounds(
+ max_concurrency=1 if local else policy.HARD_MAX_CONCURRENCY,
+ )
return OrchestrationPlan(
orchestration_id=oid,
strategy="ce_curate",
@@ -956,7 +1051,9 @@ def plan_from_decision(
objective=objective,
preference=decision.preference,
features=decision.features,
- decision={**decision.to_dict(), "task_kind": "curation"},
+ decision=curate_decision,
+ allow_expand=bool(repeat),
+ bounds=bounds.clamp(),
)
if task_kind == "deck":
from ..kernel.packages import SLIDESHOW_ID, slideshow_tools_for
@@ -993,6 +1090,17 @@ def plan_from_decision(
features=decision.features,
decision={**decision.to_dict(), "task_kind": "deck"},
)
+ if research_hires and task_kind not in {"projects", "code", "curation", "deck"}:
+ conservative = (
+ decision.strategy in {"single", "fast_lookup", ""}
+ and not decision.include_verify
+ and decision.n_investigators <= 1
+ )
+ if conservative or str(getattr(decision, "arm_id", "") or "") == "fast_lookup":
+ return _plan_projects(
+ objective, decision, oid, research_hires,
+ strategy=str(task_kind or "auto"),
+ )
if decision.strategy == "fast_lookup" or str(
getattr(decision, "arm_id", "") or ""
) == "fast_lookup":
@@ -1141,6 +1249,30 @@ def plan_from_decision(
))
join = ["verify"]
+ if research_hires:
+ from ..kernel.packages import RESEARCH_ID, get_package as _gp_r
+ rpkg = _gp_r(RESEARCH_ID)
+ if rpkg is not None:
+ h0 = research_hires[0] if isinstance(research_hires[0], dict) else {}
+ rid = "pkg-research"
+ nodes.append(PlanNode(
+ node_id=rid,
+ kind="synthesize",
+ objective=objective,
+ dependencies=[],
+ role=RESEARCH_ID,
+ difficulty="hard",
+ ladder_hint="hard",
+ tools=list(rpkg.tools),
+ graph_access="read",
+ independence="medium",
+ output_contract="synthesis",
+ provider=str(h0["provider"]) if h0.get("provider") else None,
+ model=str(h0["model"]) if h0.get("model") else None,
+ harness=str(h0["harness"]) if h0.get("harness") else None,
+ ))
+ join.append(rid)
+
is_curation = task_kind == "curation"
synth_tools = (
narrow_tools(list(CURATE_SYNTH_TOOLS), allow_curate=True)
@@ -1711,14 +1843,98 @@ def publish_blackboard_row(
parent["unique_sources"] = sum(int(r.get("sources") or 0) for r in rows)
+# Live DAG roster: running + pending. Completed workers belong in
+# artifacts/results, not the standing graph (long desks complete 1000+).
+_LIVE_ROSTER_MAX = 8
+
+
+def _node_view_row(
+ n: PlanNode,
+ *,
+ status: str,
+ rec: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+ rec = rec if isinstance(rec, dict) else {}
+ return {
+ "node_id": n.node_id,
+ "kind": n.kind,
+ "role": n.role or n.kind,
+ "dependencies": list(n.dependencies),
+ "objective": (n.objective or "")[:200],
+ "method_hint": (n.method_hint or "")[:160],
+ "retrieval_query": (n.retrieval_query or "")[:160],
+ "provider": rec.get("provider") or n.provider,
+ "model": rec.get("model") or n.model,
+ "status": status,
+ }
+
+
+def live_plan_nodes(
+ plan: OrchestrationPlan,
+ completed: set[str],
+ failed: set[str],
+ running: set[str],
+) -> list[PlanNode]:
+ """Nodes still on the live DAG: running, pending, and live dependencies."""
+ live_ids = set(running)
+ for n in plan.nodes:
+ if n.node_id not in completed and n.node_id not in failed:
+ live_ids.add(n.node_id)
+ needed = set(live_ids)
+ by_id = {n.node_id: n for n in plan.nodes}
+ for nid in list(live_ids):
+ node = by_id.get(nid)
+ if node is None:
+ continue
+ for dep in node.dependencies:
+ if dep in running or (dep not in completed and dep not in failed):
+ needed.add(dep)
+ out = [n for n in plan.nodes if n.node_id in needed]
+ return out
+
+
+def compact_plan_nodes(
+ plan: OrchestrationPlan,
+ completed: set[str],
+ failed: set[str],
+ running: set[str],
+) -> int:
+ """Drop finished workers from the live plan once the DAG is large.
+
+ Short waves keep nodes so handoffs/tests see them. Long desks
+ (1000+ completions) must not balloon the live graph. Results stay.
+ """
+ keep = live_plan_nodes(plan, completed, failed, running)
+ keep_ids = {n.node_id for n in keep}
+ if not any(n.kind == "synthesize" for n in keep):
+ for n in reversed(plan.nodes):
+ if n.kind == "synthesize":
+ keep.append(n)
+ keep_ids.add(n.node_id)
+ break
+ if len(plan.nodes) <= 8 and len(completed) < 8:
+ return 0
+ dropped_nodes = [n for n in plan.nodes if n.node_id not in keep_ids]
+ if not dropped_nodes:
+ return 0
+ archived = list(plan.archived_ids or [])
+ archived.extend(n.node_id for n in dropped_nodes)
+ plan.archived_ids = archived[-4000:]
+ plan.nodes = keep
+ return len(dropped_nodes)
+
+
def _plan_nodes_view(
plan: OrchestrationPlan,
completed: set[str],
failed: set[str],
running: set[str],
) -> list[dict[str, Any]]:
+ """Live plan.nodes only. Compact completed nodes out of the plan
+ during execute(); until then, recently finished workers still show.
+ """
out: list[dict[str, Any]] = []
- for n in plan.nodes:
+ for n in live_plan_nodes(plan, completed, failed, running):
if n.node_id in completed:
status = "done"
elif n.node_id in failed:
@@ -1727,18 +1943,7 @@ def _plan_nodes_view(
status = "running"
else:
status = "pending"
- out.append({
- "node_id": n.node_id,
- "kind": n.kind,
- "role": n.role or n.kind,
- "dependencies": list(n.dependencies),
- "objective": (n.objective or "")[:200],
- "method_hint": (n.method_hint or "")[:160],
- "retrieval_query": (n.retrieval_query or "")[:160],
- "provider": n.provider,
- "model": n.model,
- "status": status,
- })
+ out.append(_node_view_row(n, status=status))
return out
@@ -1746,6 +1951,8 @@ def live_org_summary(
plan: OrchestrationPlan,
*,
failed: set[str] | None = None,
+ completed: set[str] | None = None,
+ running: set[str] | None = None,
expansions: int = 0,
continuations: int = 0,
) -> str:
@@ -1756,8 +1963,13 @@ def live_org_summary(
the roster; the chief-of-staff lead must track that.
"""
failed = failed or set()
+ completed = completed or set()
+ running = running or set()
counts: dict[str, int] = {}
- for n in plan.nodes:
+ live = live_plan_nodes(plan, completed, failed, running)
+ if not live:
+ live = [n for n in plan.nodes if n.node_id not in failed]
+ for n in live:
if n.node_id in failed:
continue
if n.kind == "synthesize" and n.output_contract == "concat":
@@ -1810,7 +2022,8 @@ def standing_org_view(
"""
running = running or set()
nodes: list[dict[str, Any]] = []
- for n in plan.nodes:
+ roster = plan.nodes
+ for n in roster:
rec = results.get(n.node_id) if isinstance(results, dict) else None
rec = rec if isinstance(rec, dict) else {}
if full:
@@ -1824,21 +2037,21 @@ def standing_org_view(
if n.node_id not in completed and n.node_id not in running:
continue
status = "done" if n.node_id in completed else "running"
- nodes.append({
- "node_id": n.node_id,
- "kind": n.kind,
- "role": n.role or n.kind,
- "dependencies": list(n.dependencies),
- "objective": (n.objective or "")[:200],
- "provider": rec.get("provider") or n.provider,
- "model": rec.get("model") or n.model,
- "status": status,
- })
+ nodes.append(_node_view_row(n, status=status, rec=rec))
+ if not full:
+ live_first = [r for r in nodes if r.get("status") == "running"]
+ done = [r for r in nodes if r.get("status") != "running"]
+ keep_done = done[-max(0, _LIVE_ROSTER_MAX - len(live_first)):]
+ nodes = live_first + keep_done
+ if len(nodes) > _LIVE_ROSTER_MAX:
+ nodes = nodes[:_LIVE_ROSTER_MAX]
view: dict[str, Any] = {
"orchestration_id": plan.orchestration_id,
"strategy": plan.strategy,
"objective": (plan.objective or "")[:400],
- "decision_reason": live_org_summary(plan, failed=failed),
+ "decision_reason": live_org_summary(
+ plan, failed=failed, completed=completed, running=running,
+ ),
"arm_reason": (plan.decision or {}).get("reason") if isinstance(plan.decision, dict) else None,
"nodes": nodes,
}
@@ -1866,7 +2079,7 @@ def _sync_parent_graph(
else parent.get("decision_reason")
)
parent["org_summary"] = live_org_summary(
- plan, failed=failed,
+ plan, failed=failed, completed=completed, running=running,
expansions=int(parent.get("expansions") or 0),
continuations=int(parent.get("continuations") or 0),
)
@@ -1874,7 +2087,7 @@ def _sync_parent_graph(
parent["fanout_n"] = max(
1,
sum(
- 1 for n in plan.nodes
+ 1 for n in live_plan_nodes(plan, completed, failed, running)
if n.kind == "investigate" and n.node_id not in failed
),
)
@@ -2056,13 +2269,13 @@ def _bump_io(
def _note_landing(app: Any, parent_run_id: str, tname: str) -> None:
"""Count durable wiki/report writes on the chief run."""
- if tname in ("propose_wiki_page", "propose_page_edit"):
+ if tname in ("propose_wiki_page", "propose_page_edit", "ce_wiki_commit"):
key = "wiki_pages_landed"
elif tname == "create_report":
key = "reports_landed"
else:
return
- parent = (app.get("runs") or {}).get(parent_run_id)
+ parent = (app.get("runs") or {}).get(parent_run_id) if app is not None else None
if parent is not None:
parent[key] = int(parent.get(key) or 0) + 1
@@ -2372,17 +2585,28 @@ async def _run_pi_package_node(
model=str(model_s) if model_s != "?" else None,
workspace=workspace,
)
+ before_snap = None
+ try:
+ from .. import ce_host as _ce_pi
+ before_snap = _ce_pi.wiki_work_snapshot(workspace)
+ except Exception: # noqa: BLE001
+ before_snap = None
result = await run_node(req, harness="pi")
error = result.error
output = result.text or ""
in_tok = result.input_tokens
out_tok = result.output_tokens
- _retire_child(app, run_id, parent_run_id, error)
- if not error:
- await _broadcast(app, protocol.run_finished(
- thread_id, run_id, in_tok or None, out_tok or None, "end_turn",
- ))
- return {
+ trace = list(result.tool_trace or [])
+ wiki_landed = 0
+ reports_landed = 0
+ for tname in trace:
+ low = str(tname or "")
+ if low.endswith("create_report") or low == "create_report":
+ reports_landed += 1
+ _note_landing(app, parent_run_id, "create_report")
+ elif low.endswith("ce_wiki_commit") or low in ("propose_wiki_page", "propose_page_edit"):
+ wiki_landed += 1
+ rec = {
"node_id": node.node_id,
"kind": node.kind,
"run_id": run_id,
@@ -2395,10 +2619,32 @@ async def _run_pi_package_node(
"output_tokens": out_tok or None,
"task": {"description": node.objective, "difficulty": node.difficulty},
"worker_index": worker_index if worker_index is not None else 0,
- "wiki_pages_landed": 0,
- "reports_landed": 0,
+ "wiki_pages_landed": wiki_landed,
+ "reports_landed": reports_landed,
"harness": "pi",
+ "tool_trace": trace,
}
+ rec = _apply_work_receipt(rec, workspace, before_snap)
+ wiki_landed = int(rec.get("wiki_pages_landed") or 0)
+ if wiki_landed:
+ parent = (app.get("runs") or {}).get(parent_run_id) if app is not None else None
+ if parent is not None:
+ parent["wiki_pages_landed"] = int(parent.get("wiki_pages_landed") or 0) + wiki_landed
+ work = _receipt_had_work(rec) or reports_landed > 0
+ if not error and not work:
+ from . import orchestration_health as health
+ if health.looks_like_outage(output):
+ error = health.format_worker_error(str(pid), output)
+ rec["error"] = error
+ rec["ok"] = False
+ _retire_child(app, run_id, parent_run_id, error)
+ if not error:
+ await _broadcast(app, protocol.run_finished(
+ thread_id, run_id, in_tok or None, out_tok or None, "end_turn",
+ ))
+ rec["ok"] = error is None
+ rec["error"] = error
+ return rec
async def _run_agent_node(
@@ -2418,6 +2664,7 @@ async def _run_agent_node(
graph_completed: set[str] | None = None,
graph_failed: set[str] | None = None,
graph_running: set[str] | None = None,
+ desk_gate: Any = None,
) -> dict[str, Any]:
"""One DAG node as an ordinary child Run. Optional scoped tools."""
if (node.harness or "") == "pi":
@@ -2434,6 +2681,12 @@ async def _run_agent_node(
blackboard=blackboard,
plan=plan,
)
+ before_snap = None
+ try:
+ from .. import ce_host as _ce_nat
+ before_snap = _ce_nat.wiki_work_snapshot(workspace)
+ except Exception: # noqa: BLE001
+ before_snap = None
run_id = _node_run_id(parent_run_id, node.node_id, attempt)
pid = getattr(provider, "ID", "?")
model_s = model or getattr(provider, "DEFAULT_MODEL", "?")
@@ -2535,8 +2788,15 @@ def _persist(kind: str, summary: str, **kw: Any) -> None:
out_tok = 0
wiki_landed = 0
reports_landed = 0
+ cancelled_node = False
if node.role == "curator":
max_turns = 24
+ raw_turns = os.environ.get("SWITCHBAY_CURATE_MAX_TURNS")
+ if raw_turns:
+ try:
+ max_turns = max(1, min(24, int(raw_turns)))
+ except (TypeError, ValueError):
+ pass
elif node.role == "slideshow":
max_turns = 12
elif tool_names:
@@ -2653,88 +2913,97 @@ def _persist_assistant(text: str) -> None:
payload={"text": text[:24_000]},
)
- async for ev in provider.chat_stream(req):
- if isinstance(ev, llmgateway.TextChunk):
- current += ev.text
- _abort_if_outage(current)
- if msg_id is None:
- msg_id = protocol.new_message_id()
- await _broadcast(app, protocol.text_message_start(run_id, msg_id))
- await _broadcast(app, protocol.text_message_content(run_id, msg_id, ev.text))
- _touch_child(
- app, run_id, parent_run_id,
- activity=current[-120:].lstrip(),
- )
- _bump_io(
- app, run_id, parent_run_id,
- out=max(1, (len(ev.text) + 3) // 4),
- io_mode="write",
- )
- _flush_live(activity=current[-120:].lstrip())
- elif isinstance(ev, llmgateway.ReasoningChunk):
- reasoning_text += ev.text or ""
- _touch_child(
- app, run_id, parent_run_id,
- activity="💭 " + reasoning_text[-110:].lstrip(),
- )
- _flush_live(activity="💭 " + reasoning_text[-110:].lstrip())
- elif isinstance(ev, llmgateway.ToolUseChunk):
- await _flush_reasoning()
- if msg_id is not None:
- await _broadcast(app, protocol.text_message_end(run_id, msg_id))
- msg_id = None
- if current:
- assistant_blocks.append({"type": "text", "text": current})
- text_parts.append(current)
- _persist_assistant(current)
- current = ""
- assistant_blocks.append({
- "type": "tool_use", "id": ev.id, "name": ev.name, "input": ev.input,
- })
- preview = _summarise_tool_input(ev.input)
- recent_tools.append(ev.name)
- if len(recent_tools) > 40:
- del recent_tools[:-20]
- _flush_live(current_tool=ev.name, activity=f"⚙ {ev.name}({preview})")
- await _broadcast(app, protocol.tool_call_start(run_id, ev.id, ev.name))
- await _broadcast(app, protocol.tool_call_args(
- run_id, ev.id, json.dumps(ev.input or {}),
- ))
- await _broadcast(app, protocol.tool_call_end(run_id, ev.id))
- is_ours = ev.name in tools.REGISTRY
- _persist(
- "tool_use",
- f"{ev.name}({preview})",
- source="rail" if is_ours else f"agent:{pid}",
- actor=ev.name,
- payload={"id": ev.id, "name": ev.name, "input": ev.input},
- ref_id=ev.id, run_id=run_id,
- )
- _note_cli_dispatch(ev.name, ev.input)
- runs_now: dict[str, dict[str, Any]] = app.setdefault("runs", {})
- n_tools = int((runs_now.get(run_id) or {}).get("tool_count") or 0) + 1
- _touch_child(
- app, run_id, parent_run_id,
- tool_count=n_tools,
- current_tool=ev.name,
- activity=f"⚙ {ev.name}({preview})",
- )
- _bump_io(app, run_id, parent_run_id, io_mode="read")
- elif isinstance(ev, llmgateway.DoneChunk):
- stop = ev.stop_reason
- if ev.input_tokens:
- in_tok += ev.input_tokens
- _bump_io(app, run_id, parent_run_id, inp=int(ev.input_tokens))
- if ev.output_tokens:
- out_tok += ev.output_tokens
- if ev.session_id:
- cli_session = ev.session_id
- _bump_io(app, run_id, parent_run_id, io_mode="idle")
- _flush_live()
- break
+ try:
+ async for ev in provider.chat_stream(req):
+ if isinstance(ev, llmgateway.TextChunk):
+ current += ev.text
+ if not recent_tools and not wiki_landed:
+ _abort_if_outage(current)
+ if msg_id is None:
+ msg_id = protocol.new_message_id()
+ await _broadcast(app, protocol.text_message_start(run_id, msg_id))
+ await _broadcast(app, protocol.text_message_content(run_id, msg_id, ev.text))
+ _touch_child(
+ app, run_id, parent_run_id,
+ activity=current[-120:].lstrip(),
+ )
+ _bump_io(
+ app, run_id, parent_run_id,
+ out=max(1, (len(ev.text) + 3) // 4),
+ io_mode="write",
+ )
+ _flush_live(activity=current[-120:].lstrip())
+ elif isinstance(ev, llmgateway.ReasoningChunk):
+ reasoning_text += ev.text or ""
+ _touch_child(
+ app, run_id, parent_run_id,
+ activity="💭 " + reasoning_text[-110:].lstrip(),
+ )
+ _flush_live(activity="💭 " + reasoning_text[-110:].lstrip())
+ elif isinstance(ev, llmgateway.ToolUseChunk):
+ await _flush_reasoning()
+ if msg_id is not None:
+ await _broadcast(app, protocol.text_message_end(run_id, msg_id))
+ msg_id = None
+ if current:
+ assistant_blocks.append({"type": "text", "text": current})
+ text_parts.append(current)
+ _persist_assistant(current)
+ current = ""
+ assistant_blocks.append({
+ "type": "tool_use", "id": ev.id, "name": ev.name, "input": ev.input,
+ })
+ preview = _summarise_tool_input(ev.input)
+ recent_tools.append(ev.name)
+ if len(recent_tools) > 40:
+ del recent_tools[:-20]
+ _flush_live(current_tool=ev.name, activity=f"⚙ {ev.name}({preview})")
+ await _broadcast(app, protocol.tool_call_start(run_id, ev.id, ev.name))
+ await _broadcast(app, protocol.tool_call_args(
+ run_id, ev.id, json.dumps(ev.input or {}),
+ ))
+ await _broadcast(app, protocol.tool_call_end(run_id, ev.id))
+ is_ours = ev.name in tools.REGISTRY
+ _persist(
+ "tool_use",
+ f"{ev.name}({preview})",
+ source="rail" if is_ours else f"agent:{pid}",
+ actor=ev.name,
+ payload={"id": ev.id, "name": ev.name, "input": ev.input},
+ ref_id=ev.id, run_id=run_id,
+ )
+ _note_cli_dispatch(ev.name, ev.input)
+ runs_now: dict[str, dict[str, Any]] = app.setdefault("runs", {})
+ n_tools = int((runs_now.get(run_id) or {}).get("tool_count") or 0) + 1
+ _touch_child(
+ app, run_id, parent_run_id,
+ tool_count=n_tools,
+ current_tool=ev.name,
+ activity=f"⚙ {ev.name}({preview})",
+ )
+ _bump_io(app, run_id, parent_run_id, io_mode="read")
+ elif isinstance(ev, llmgateway.DoneChunk):
+ stop = ev.stop_reason
+ if ev.input_tokens:
+ in_tok += ev.input_tokens
+ _bump_io(app, run_id, parent_run_id, inp=int(ev.input_tokens))
+ if ev.output_tokens:
+ out_tok += ev.output_tokens
+ if ev.session_id:
+ cli_session = ev.session_id
+ _bump_io(app, run_id, parent_run_id, io_mode="idle")
+ _flush_live()
+ break
+ except BaseException:
+ if current:
+ assistant_blocks.append({"type": "text", "text": current})
+ text_parts.append(current)
+ current = ""
+ raise
await _flush_reasoning()
if current:
- _abort_if_outage(current)
+ if not recent_tools and not wiki_landed:
+ _abort_if_outage(current)
assistant_blocks.append({"type": "text", "text": current})
text_parts.append(current)
_persist_assistant(current)
@@ -2756,6 +3025,10 @@ def _persist_assistant(text: str) -> None:
]
if worker_uses:
from .ce_workers import run_from_tool
+ from .desk_admission import slot_id as _desk_slot
+
+ parent_slot = _desk_slot(parent_run_id, node.node_id)
+ parked = False
async def _ce_one(block: dict[str, Any]) -> tuple[str, Any]:
wid = str(block.get("id") or "")
@@ -2772,6 +3045,7 @@ async def _ce_one(block: dict[str, Any]) -> tuple[str, Any]:
plan=plan, parent=graph_parent,
completed=graph_completed, failed=graph_failed,
running=graph_running,
+ desk_gate=desk_gate,
)
except Exception as exc: # noqa: BLE001
return wid, {
@@ -2779,8 +3053,18 @@ async def _ce_one(block: dict[str, Any]) -> tuple[str, Any]:
"error": f"{type(exc).__name__}: {exc}",
}
- for wid, wout in await asyncio.gather(*[_ce_one(b) for b in worker_uses]):
- dispatch_out[wid] = wout
+ try:
+ if desk_gate is not None:
+ parked = await desk_gate.park(parent_slot)
+ for wid, wout in await asyncio.gather(*[_ce_one(b) for b in worker_uses]):
+ dispatch_out[wid] = wout
+ finally:
+ if parked and desk_gate is not None:
+ try:
+ await desk_gate.unpark(parent_slot)
+ except asyncio.CancelledError:
+ await desk_gate.release_async(parent_slot)
+ raise
for block in blocks:
if block.get("type") != "tool_use":
continue
@@ -2810,6 +3094,22 @@ async def _ce_one(block: dict[str, Any]) -> tuple[str, Any]:
try:
if tname == "ce_dispatch_worker" and tid in dispatch_out:
output = dispatch_out[tid]
+ elif permissions.needs_web_consent(tname, tinput):
+ async def _bcast(msg: Any) -> None:
+ await _broadcast(app, msg)
+ verdict, reason = await permissions.mediate_protected_call(
+ workspace=workspace, tool=tname, tool_input=tinput,
+ provider=str(pid or "switchbay"),
+ run_id=run_id, thread_id=thread_id,
+ broadcast=_bcast,
+ )
+ if verdict != "approve":
+ output = {"ok": False, "error": reason}
+ else:
+ output = await asyncio.to_thread(
+ tools.execute, tname, workspace, tinput,
+ consent=permissions.trusted_consent(),
+ )
else:
output = await asyncio.to_thread(
tools.execute, tname, workspace, tinput,
@@ -2837,9 +3137,13 @@ async def _ce_one(block: dict[str, Any]) -> tuple[str, Any]:
if isinstance(output, dict) and output.get("ok"):
if tname in ("propose_wiki_page", "propose_page_edit"):
wiki_landed += 1
+ _note_landing(app, parent_run_id, tname)
+ elif tname == "ce_wiki_commit" and output.get("committed"):
+ wiki_landed += 1
+ _note_landing(app, parent_run_id, tname)
elif tname == "create_report":
reports_landed += 1
- _note_landing(app, parent_run_id, tname)
+ _note_landing(app, parent_run_id, tname)
except Exception as e: # noqa: BLE001
err = f"{type(e).__name__}: {e}"
results.append({
@@ -2861,11 +3165,8 @@ async def _ce_one(block: dict[str, Any]) -> tuple[str, Any]:
)
messages.append({"role": "user", "content": results})
except asyncio.CancelledError:
- # Parent wait_for timeout or user kill. Do not tell the rail
- # "node cancelled" — that looks like a crash. The waiter
- # broadcasts `timeout` or the parent run is cancelled.
- _retire_child(app, run_id, parent_run_id, "cancelled")
- raise
+ cancelled_node = True
+ error = error or "cancelled"
except Exception as e: # noqa: BLE001
raw = f"{type(e).__name__}: {e}"
probe = (
@@ -2900,10 +3201,33 @@ async def _ce_one(block: dict[str, Any]) -> tuple[str, Any]:
)
output = "".join(text_parts).strip()
- if not error:
+ rec_out = {
+ "node_id": node.node_id,
+ "kind": node.kind,
+ "run_id": run_id,
+ "ok": error is None,
+ "error": error,
+ "output": output,
+ "provider": pid,
+ "model": model_s,
+ "input_tokens": in_tok or None,
+ "output_tokens": out_tok or None,
+ "task": {"description": node.objective, "difficulty": node.difficulty},
+ "worker_index": worker_index if worker_index is not None else 0,
+ "wiki_pages_landed": wiki_landed,
+ "reports_landed": reports_landed,
+ }
+ rec_out = _apply_work_receipt(rec_out, workspace, before_snap)
+ wiki_landed = int(rec_out.get("wiki_pages_landed") or 0)
+ work = _receipt_had_work(rec_out) or reports_landed > 0
+ if node.role != "curator":
+ work = work or bool(recent_tools)
+ if not error and not work:
from . import orchestration_health as health
if health.looks_like_outage(output):
error = health.format_worker_error(str(pid), output)
+ rec_out["error"] = error
+ rec_out["ok"] = False
await _broadcast(app, protocol.notice(
error, kind="chat",
workspace=str(workspace),
@@ -2963,22 +3287,15 @@ async def _ce_one(block: dict[str, Any]) -> tuple[str, Any]:
except OSError:
log.exception("failed to write node artifact")
- return {
- "node_id": node.node_id,
- "kind": node.kind,
- "run_id": run_id,
- "ok": error is None,
- "error": error,
- "output": output,
- "provider": pid,
- "model": model_s,
- "input_tokens": in_tok or None,
- "output_tokens": out_tok or None,
- "task": {"description": node.objective, "difficulty": node.difficulty},
- "worker_index": worker_index if worker_index is not None else 0,
- "wiki_pages_landed": wiki_landed,
- "reports_landed": reports_landed,
- }
+ rec_out["ok"] = error is None
+ rec_out["error"] = error
+ rec_out["output"] = output
+ rec_out["wiki_pages_landed"] = wiki_landed
+ if cancelled_node:
+ rec_out["ok"] = False
+ rec_out["error"] = rec_out.get("error") or "cancelled"
+ raise asyncio.CancelledError
+ return rec_out
# ── scheduler ──────────────────────────────────────────────────────
@@ -3011,7 +3328,7 @@ def _add_expansion_nodes(
available, and method-specific retrieval — not another copy of
the parent default.
"""
- existing = {n.node_id for n in plan.nodes}
+ existing = {n.node_id for n in plan.nodes} | set(plan.archived_ids or [])
new_nodes: list[PlanNode] = []
inv_ids: list[str] = []
tools_for = narrow_tools(list(READ_ONLY_TOOLS))
@@ -3036,11 +3353,7 @@ def _add_expansion_nodes(
except Exception: # noqa: BLE001
allocs = []
for i, obj in enumerate(decision.objectives or [plan.objective] * n_extra):
- nid = f"inv-x{len(existing) + i}"
- k = 0
- while nid in existing:
- k += 1
- nid = f"inv-x{len(existing) + i}-{k}"
+ nid = alloc_node_id(plan, "inv-x", existing)
pid, model = (
allocs[i] if i < len(allocs)
else (default_provider, default_model)
@@ -3066,11 +3379,8 @@ def _add_expansion_nodes(
inv_ids.append(nid)
existing.add(nid)
prev_verify = next((n for n in reversed(plan.nodes) if n.kind == "verify"), None)
- vid = "verify-x1"
- k = 2
- while vid in existing:
- vid = f"verify-x{k}"
- k += 1
+ vid = alloc_node_id(plan, "verify-x", existing)
+ existing.add(vid)
fallback = (
allocs[n_extra] if len(allocs) > n_extra
else (default_provider, default_model)
@@ -3103,6 +3413,140 @@ def _add_expansion_nodes(
return new_nodes
+def _max_id_seq(ids: Any) -> int:
+ best = 0
+ for nid in ids or []:
+ m = re.search(r"(\d+)$", str(nid))
+ if m:
+ try:
+ best = max(best, int(m.group(1)))
+ except ValueError:
+ continue
+ return best
+
+
+def seed_node_seq(plan: OrchestrationPlan) -> None:
+ """Resume/old plans: never restart the counter below live or archived IDs."""
+ taken = {n.node_id for n in plan.nodes}
+ taken.update(plan.archived_ids or [])
+ plan.node_seq = max(int(plan.node_seq or 0), _max_id_seq(taken))
+
+
+def alloc_node_id(plan: OrchestrationPlan, prefix: str, extra: set[str] | None = None) -> str:
+ """Monotonic unique IDs across compact/resume/retry. Never reuse archived IDs."""
+ seed_node_seq(plan)
+ taken = {n.node_id for n in plan.nodes}
+ taken.update(plan.archived_ids or [])
+ if extra:
+ taken.update(extra)
+ seq = max(int(plan.node_seq or 0), _max_id_seq(taken))
+ while True:
+ seq += 1
+ nid = f"{prefix}{seq}"
+ if nid not in taken:
+ plan.node_seq = seq
+ return nid
+
+
+def _bind_wave_instructions(plan: OrchestrationPlan, note: str) -> None:
+ """Replace the current wave note without dropping the original extra_system."""
+ dec = plan.decision if isinstance(plan.decision, dict) else {}
+ if "_extra_system_base" not in dec:
+ dec["_extra_system_base"] = plan.extra_system or ""
+ plan.decision = dec
+ base = str(dec.get("_extra_system_base") or "")
+ plan.extra_system = (base + ("\n\n" + note if note else "")).strip()
+
+
+def _receipt_had_work(rec: Any) -> bool:
+ """True when wiki/report *content* changed — not SHA/mtime/tool counts."""
+ if not isinstance(rec, dict):
+ return False
+ if int(rec.get("wiki_pages_landed") or 0) > 0:
+ return True
+ if rec.get("wiki_pages_changed"):
+ return True
+ if rec.get("wiki_pages_removed"):
+ return True
+ if str(rec.get("wiki_commit_diff") or "").strip():
+ return True
+ if int(rec.get("reports_landed") or 0) > 0:
+ return True
+ return False
+
+
+def _apply_work_receipt(
+ rec: dict[str, Any],
+ workspace: Path,
+ before: dict[str, Any] | None,
+) -> dict[str, Any]:
+ """Overwrite tool-call landings with actual wiki page/commit diff."""
+ from .. import ce_host
+
+ tool_n = int(rec.get("wiki_pages_landed") or 0)
+ rec["wiki_tool_commits"] = tool_n
+ try:
+ receipt = ce_host.wiki_diff_receipt(workspace, before)
+ except Exception: # noqa: BLE001
+ log.debug("wiki work receipt failed", exc_info=True)
+ return rec
+ rec["wiki_head_before"] = receipt.get("wiki_head_before") or ""
+ rec["wiki_head_after"] = receipt.get("wiki_head_after") or ""
+ rec["wiki_pages_changed"] = list(receipt.get("wiki_pages_changed") or [])
+ rec["wiki_pages_removed"] = list(receipt.get("wiki_pages_removed") or [])
+ rec["wiki_commit_diff"] = str(receipt.get("wiki_commit_diff") or "")
+ rec["wiki_committed"] = bool(receipt.get("wiki_committed"))
+ rec["wiki_pages_landed"] = int(receipt.get("wiki_pages_landed") or 0)
+ return rec
+
+
+def _add_curate_package_wave(
+ plan: OrchestrationPlan,
+ *,
+ workspace: Path | None = None,
+) -> list[PlanNode]:
+ """Another bounded CE curator package — not investigate/verify/synth.
+
+ Host re-runs ce_wave_prime so CE's planner stays authoritative.
+ """
+ nid = alloc_node_id(plan, "curate-w")
+ prev = next((n for n in reversed(plan.nodes) if n.role == "curator"), None)
+ if workspace is not None:
+ try:
+ from .. import ce_host
+ prime = ce_host.wave_prime(workspace, {})
+ note = (
+ "ce_wave_prime for this bounded wave "
+ "(execute this pick-mode; do not invent a planner):\n"
+ + json.dumps(prime, default=str)[:4000]
+ )
+ _bind_wave_instructions(plan, note)
+ except Exception: # noqa: BLE001
+ log.debug("ce_wave_prime on continue failed", exc_info=True)
+ node = PlanNode(
+ node_id=nid,
+ kind="synthesize",
+ objective=plan.objective,
+ role="curator",
+ difficulty="hard",
+ ladder_hint="hard",
+ tools=list(prev.tools) if prev else narrow_tools(
+ list(CURATE_SYNTH_TOOLS), allow_curate=True,
+ ),
+ graph_access="read",
+ independence="low",
+ output_contract="synthesis",
+ method_hint="ce:wave",
+ provider=prev.provider if prev else None,
+ model=prev.model if prev else None,
+ harness=prev.harness if prev else None,
+ dependencies=[prev.node_id] if prev else [],
+ )
+ plan.nodes.append(node)
+ plan = repair_plan(plan)
+ return [node]
+
+
def _add_continue_wave(
plan: OrchestrationPlan,
decision: policy.ExpansionDecision,
@@ -3117,8 +3561,7 @@ def _add_continue_wave(
Independent of the previous synth so DAG depth does not grow without
bound. The latest synthesizer is what ``execute`` reports.
"""
- existing = {n.node_id for n in plan.nodes}
- wave = 1 + sum(1 for n in plan.nodes if n.kind == "synthesize")
+ existing = {n.node_id for n in plan.nodes} | set(plan.archived_ids or [])
tools_for = narrow_tools(list(READ_ONLY_TOOLS))
feat = policy.TaskFeatures.from_dict(plan.features) if plan.features else None
n_extra = max(1, decision.n_extra)
@@ -3141,11 +3584,8 @@ def _add_continue_wave(
new_nodes: list[PlanNode] = []
inv_ids: list[str] = []
for i, obj in enumerate(decision.objectives or [plan.objective] * n_extra):
- nid = f"inv-c{wave}-{i}"
- k = 0
- while nid in existing:
- k += 1
- nid = f"inv-c{wave}-{i}-{k}"
+ nid = alloc_node_id(plan, "inv-c", existing)
+ existing.add(nid)
pid, model = (
allocs[i] if i < len(allocs)
else (default_provider, default_model)
@@ -3170,11 +3610,8 @@ def _add_continue_wave(
new_nodes.append(node)
inv_ids.append(nid)
existing.add(nid)
- vid = f"verify-c{wave}"
- k = 2
- while vid in existing:
- vid = f"verify-c{wave}-{k}"
- k += 1
+ vid = alloc_node_id(plan, "verify-c", existing)
+ existing.add(vid)
fallback = (
allocs[n_extra] if len(allocs) > n_extra
else (default_provider, default_model)
@@ -3197,11 +3634,8 @@ def _add_continue_wave(
provider=v_pid,
model=v_model,
))
- sid = f"synth-c{wave}"
- k = 2
- while sid in existing:
- sid = f"synth-c{wave}-{k}"
- k += 1
+ sid = alloc_node_id(plan, "synth-c", existing)
+ existing.add(sid)
s_pid, s_model = (
allocs[n_extra + 1] if len(allocs) > n_extra + 1
else (default_provider, default_model)
@@ -3226,15 +3660,49 @@ def _add_continue_wave(
return new_nodes
+def _plan_curate_until(plan: OrchestrationPlan | None) -> float | None:
+ if plan is None or not isinstance(plan.decision, dict):
+ return None
+ until = plan.decision.get("curate_until")
+ try:
+ return float(until) if until is not None else None
+ except (TypeError, ValueError):
+ return None
+
+
+def _batch_deadline_remaining(
+ until_f: float | None,
+ bounds: OrchestrationBounds,
+ started: float,
+ elapsed_prior: float,
+) -> float | None:
+ """Seconds until curate_until or wall-clock budget, whichever is sooner."""
+ caps: list[float] = []
+ if until_f is not None:
+ caps.append(until_f - time.time())
+ if bounds.wall_clock_sec > 0:
+ caps.append(bounds.wall_clock_sec - (time.time() - started + elapsed_prior))
+ if not caps:
+ return None
+ return min(caps)
+
+
def _worker_timeout(
bounds: OrchestrationBounds, *, started: float, elapsed_prior: float,
+ until_f: float | None = None,
) -> float:
- """Per-child cap. Parent wall clock 0 = unlimited, so do not shrink."""
+ """Per-child cap. Parent wall clock 0 = unlimited, so do not shrink.
+
+ ``curate_until`` is a hard deadline: do not apply the 15s wall-clock
+ floor, or a 200ms window could never fire during an in-flight wait.
+ """
cap = float(bounds.worker_timeout_sec)
- if bounds.wall_clock_sec <= 0:
- return cap
- remaining = bounds.wall_clock_sec - (time.time() - started + elapsed_prior)
- return min(cap, max(15.0, remaining))
+ if bounds.wall_clock_sec > 0:
+ remaining = bounds.wall_clock_sec - (time.time() - started + elapsed_prior)
+ cap = min(cap, max(15.0, remaining))
+ if until_f is not None:
+ cap = min(cap, max(0.05, until_f - time.time()))
+ return cap
async def _ask_orchestration_permission(
@@ -3273,14 +3741,16 @@ def _candidate_pairs(
*,
default_pid: str | None,
byok_approved: bool,
+ workspace: Path | None = None,
) -> list[tuple[str, str]]:
try:
keyed = policy.list_keyed_providers()
except Exception: # noqa: BLE001
keyed = []
+ denied = policy.get_denied_models(workspace)
filtered = [
(pid, model) for pid, model in keyed
- if policy.model_allowed(pid, model)
+ if policy.model_allowed(pid, model, denied)
]
src = filtered or keyed
if default_pid:
@@ -3316,12 +3786,18 @@ def _pick_available_provider(
candidates: list[tuple[str, str]],
exclude: set[str] | frozenset[str] | None = None,
preferred_model: str | None = None,
+ workspace: Path | None = None,
) -> tuple[Any, str | None] | None:
- """First cooling-free provider from preferred → default → roster."""
+ """First cooling-free provider from preferred → default → roster.
+
+ Current workspace model allowlists apply to checkpointed chiefs
+ and worker/continuation fallbacks, not just a fresh plan.
+ """
from . import orchestration_health as health
ordered: list[tuple[str, str | None]] = []
seen: set[str] = set()
skip = exclude or set()
+ denied = policy.get_denied_models(workspace)
def _add(pid: str | None, model: str | None) -> None:
if not pid or pid in seen:
@@ -3349,6 +3825,12 @@ def _add(pid: str | None, model: str | None) -> None:
continue
if not health.is_available(pid):
continue
+ chosen_model = model or (
+ default_model if pid == default_pid
+ else None
+ )
+ if not policy.model_allowed(pid, chosen_model, denied):
+ continue
if pid == default_pid:
return default_provider, model or default_model
try:
@@ -3357,6 +3839,10 @@ def _add(pid: str | None, model: str | None) -> None:
continue
if not prov.has_key():
continue
+ if not policy.model_allowed(
+ pid, model or getattr(prov, "DEFAULT_MODEL", None), denied,
+ ):
+ continue
return prov, model or getattr(prov, "DEFAULT_MODEL", None)
return None
@@ -3449,6 +3935,22 @@ async def execute(
plan = ensure_valid(plan)
plan.orchestration_id = parent_run_id
bounds = plan.bounds.clamp()
+ seed_node_seq(plan)
+ from .desk_admission import (
+ CHIEF_COUNTED, desk_domain_id, effective_live_cap, gate_for,
+ release_domain, slot_id as desk_slot,
+ )
+ from ..kernel.desk import DESK_AUTO, choose_desk
+ desk_name = choose_desk(
+ task_kind=str((plan.decision or {}).get("task_kind") or ""),
+ strategy=plan.strategy,
+ text=plan.objective or "",
+ ) or DESK_AUTO
+ domain = desk_domain_id(workspace, desk_name)
+ live_cap = effective_live_cap(workspace)
+ bounds.max_concurrency = max(1, min(int(bounds.max_concurrency), live_cap))
+ desk_gate = gate_for(domain, cap=live_cap, workspace=workspace)
+ desk_gate.retain()
persist_plan(workspace, plan)
try:
orchestrator_fs.ensure(workspace)
@@ -3483,6 +3985,8 @@ async def execute(
)
if restored_msgs and not parent.get("orchestration_messages"):
parent["orchestration_messages"] = list(restored_msgs)
+ parent["desk_live_cap"] = live_cap
+ parent["desk_chief_counted"] = CHIEF_COUNTED
_sync_parent_graph(
parent, plan, completed=completed, failed=failed, running=set(),
blackboard=bb,
@@ -3512,6 +4016,7 @@ async def execute(
"stop_reason": stop_reason,
"findings_at_last_synth": findings_at_last_synth,
"continuations": continuations,
+ "curate_until": _plan_curate_until(plan),
}
def _live_extra() -> dict[str, Any]:
@@ -3522,6 +4027,7 @@ def _live_extra() -> dict[str, Any]:
extra_ck["stop_reason"] = stop_reason
extra_ck["findings_at_last_synth"] = findings_at_last_synth
extra_ck["continuations"] = continuations
+ extra_ck["curate_until"] = _plan_curate_until(plan)
return {
**extra_ck,
"stage": (parent or {}).get("orchestration_stage"),
@@ -3540,24 +4046,32 @@ def _live_extra() -> dict[str, Any]:
default_pid = getattr(default_provider, "ID", None)
def _pairs() -> list[tuple[str, str]]:
- if os.environ.get("PYTEST_CURRENT_TEST"):
- pid = default_pid or ""
- return [(pid, default_model or "")] if pid else []
- return _candidate_pairs(default_pid=default_pid, byok_approved=byok_approved)
+ return _candidate_pairs(
+ default_pid=default_pid, byok_approved=byok_approved,
+ workspace=workspace,
+ )
+
+ def _pick(
+ preferred: str | None,
+ *,
+ exclude: set[str] | frozenset[str] | None = None,
+ preferred_model: str | None = None,
+ ) -> tuple[Any, str | None] | None:
+ return _pick_available_provider(
+ preferred, default_provider=default_provider,
+ default_model=default_model, candidates=_pairs(),
+ exclude=exclude, preferred_model=preferred_model,
+ workspace=workspace,
+ )
def _runnable(nodes: list[PlanNode]) -> list[PlanNode]:
return [
n for n in nodes
- if _pick_available_provider(
- n.provider, default_provider=default_provider,
- default_model=default_model, candidates=_pairs(),
- ) is not None
+ if _pick(n.provider, preferred_model=n.model) is not None
]
async def _maybe_start_local() -> bool:
nonlocal local_start_asked
- if os.environ.get("PYTEST_CURRENT_TEST"):
- return False
from .. import localllm
from . import orchestration_health as health
cfg = localllm.load_config()
@@ -3609,8 +4123,6 @@ async def _maybe_start_local() -> bool:
async def _maybe_enable_byok() -> bool:
nonlocal byok_approved, byok_asked
- if os.environ.get("PYTEST_CURRENT_TEST"):
- return False
if byok_approved:
return True
if policy.provider_category(default_pid or "") == "byok":
@@ -3688,10 +4200,7 @@ async def _wait_for_channels() -> str:
thread_id=thread_id, extra=_live_extra(),
)
while True:
- pick = _pick_available_provider(
- default_pid, default_provider=default_provider,
- default_model=default_model, candidates=_pairs(),
- )
+ pick = _pick(default_pid)
if pick is not None:
if parent is not None:
parent["status"] = "running"
@@ -3711,9 +4220,16 @@ async def _wait_for_channels() -> str:
))
return "ready"
now = time.time()
+ remain = _batch_deadline_remaining(
+ _plan_curate_until(plan), bounds, started, elapsed_prior,
+ )
+ if remain is not None and remain <= 0:
+ return "expired"
delay = WAIT_POLL_SEC
if scheduled:
delay = max(0.05, min(WAIT_POLL_SEC, scheduled - now))
+ if remain is not None:
+ delay = min(delay, max(0.05, remain))
persist_checkpoint(
workspace, parent_run_id,
phase="waiting_limits", completed=completed, failed=failed,
@@ -3726,16 +4242,10 @@ async def _wait_for_channels() -> str:
async def _try_open_channels() -> bool:
"""Start local / enable BYOK without sleeping on a reset clock."""
if await _maybe_start_local():
- if _pick_available_provider(
- default_pid, default_provider=default_provider,
- default_model=default_model, candidates=_pairs(),
- ) is not None:
+ if _pick(default_pid) is not None:
return True
if await _maybe_enable_byok():
- if _pick_available_provider(
- default_pid, default_provider=default_provider,
- default_model=default_model, candidates=_pairs(),
- ) is not None:
+ if _pick(default_pid) is not None:
return True
return False
@@ -3746,6 +4256,49 @@ async def _recover_channels() -> str:
def _maybe_continue() -> bool:
nonlocal continuations, findings_at_last_synth, stop_reason
+ dec = plan.decision if isinstance(plan.decision, dict) else {}
+ is_curate = (
+ plan.strategy == "ce_curate"
+ or str(dec.get("task_kind") or "") == "curation"
+ )
+ if is_curate and bool(dec.get("curate_repeat")):
+ until = dec.get("curate_until")
+ try:
+ until_f = float(until) if until is not None else None
+ except (TypeError, ValueError):
+ until_f = None
+ if until_f is not None and time.time() >= until_f:
+ stop_reason = "curate window ended"
+ return False
+ # HARD_MAX_NODES == 0 means no lifetime cap. Do not treat
+ # compacted live-graph size or historic completions as a stop.
+ live_n = len(live_plan_nodes(plan, completed, failed, live_running))
+ if bounds.max_nodes > 0 and live_n >= bounds.max_nodes:
+ stop_reason = (
+ f"curate stopped at configured live-node ceiling "
+ f"({bounds.max_nodes}); not objective-met"
+ )
+ return False
+ if bounds.max_expansions > 0 and continuations >= bounds.max_expansions:
+ stop_reason = (
+ f"curate stopped at configured continuation ceiling "
+ f"({bounds.max_expansions}); not objective-met"
+ )
+ return False
+ added = _add_curate_package_wave(plan, workspace=workspace)
+ if not added:
+ stop_reason = "curate continue produced no nodes"
+ return False
+ continuations += 1
+ persist_plan(workspace, plan)
+ if parent is not None:
+ parent["continuations"] = continuations
+ parent["step"] = f"curate wave +{len(added)}"
+ log.info(
+ "orchestration %s curate continue +%d",
+ parent_run_id, len(added),
+ )
+ return True
synth = next(
(n for n in reversed(plan.nodes) if n.kind == "synthesize"), None,
)
@@ -3781,6 +4334,9 @@ def _maybe_continue() -> bool:
if not cont.expand:
stop_reason = cont.reason
return False
+ if is_curate:
+ stop_reason = "curate does not expand into investigate/verify/synthesize"
+ return False
added = _add_continue_wave(
plan, cont,
default_provider=default_pid,
@@ -3808,6 +4364,43 @@ def _maybe_continue() -> bool:
)
return True
+ async def _wait_for_curate_work(prev_fp: str) -> str:
+ """Keep the desk alive without model tokens until sources change."""
+ from .. import ce_host as ceh
+ interval = float(IDLE_WAIT_SEC)
+ if parent is not None:
+ parent["orchestration_stage"] = "waiting_work"
+ parent["step"] = "waiting for new sources or Stop"
+ persist_checkpoint(
+ workspace, parent_run_id,
+ phase="running", completed=completed, failed=failed,
+ expansions=expansions, results=results_by_id,
+ elapsed_s=elapsed_prior + (time.time() - started),
+ thread_id=thread_id,
+ extra={**_live_extra(), "waiting_work": True},
+ )
+ # Planner/scan subprocesses are skipped here: they can block a
+ # bare fixture workspace. File fingerprint is the wake signal;
+ # callers that want a planner check pass planner=True themselves.
+ while True:
+ if parent and (parent.get("user_cancel") or parent.get("user_dismiss")):
+ return "stop"
+ until_w = _plan_curate_until(plan)
+ if until_w is not None and time.time() >= until_w:
+ return "deadline"
+ if (
+ bounds.wall_clock_sec > 0
+ and time.time() - started + elapsed_prior > bounds.wall_clock_sec
+ ):
+ return "expired"
+ try:
+ fp = ceh.work_availability_fingerprint(workspace)
+ except Exception: # noqa: BLE001
+ fp = prev_fp
+ if fp and fp != prev_fp:
+ return "work"
+ await asyncio.sleep(interval)
+
async def _start_handoffs(node: PlanNode) -> None:
"""Who actually dispatched this node — and, separately, whether
it was handed a board to read.
@@ -3843,6 +4436,16 @@ async def _start_handoffs(node: PlanNode) -> None:
)
async def _run_one(node: PlanNode) -> dict[str, Any]:
+ sid = desk_slot(parent_run_id, node.node_id)
+ live_running.add(node.node_id)
+ await desk_gate.acquire(sid, kind=node.kind or "worker")
+ try:
+ return await _run_one_inner(node)
+ finally:
+ live_running.discard(node.node_id)
+ await desk_gate.release_async(sid)
+
+ async def _run_one_inner(node: PlanNode) -> dict[str, Any]:
await _start_handoffs(node)
# Concat merger: no LLM, O(N) structured join of worker outputs.
if node.kind == "synthesize" and node.output_contract == "concat":
@@ -3877,6 +4480,7 @@ async def _run_one(node: PlanNode) -> dict[str, Any]:
idx = None
timeout = _worker_timeout(
bounds, started=started, elapsed_prior=elapsed_prior,
+ until_f=_plan_curate_until(plan),
)
if node.kind == "verify" and not bb.candidates():
failed_inv = [
@@ -3913,17 +4517,13 @@ async def _run_one(node: PlanNode) -> dict[str, Any]:
tried: set[str] = set()
last: dict[str, Any] | None = None
for attempt in range(1, _MAX_CHANNEL_TRIES + 1):
- picked = _pick_available_provider(
- node.provider, default_provider=default_provider,
- default_model=default_model, candidates=_pairs(),
- exclude=tried, preferred_model=node.model,
+ picked = _pick(
+ node.provider, exclude=tried, preferred_model=node.model,
)
if picked is None:
await _try_open_channels()
- picked = _pick_available_provider(
- node.provider, default_provider=default_provider,
- default_model=default_model, candidates=_pairs(),
- exclude=tried, preferred_model=node.model,
+ picked = _pick(
+ node.provider, exclude=tried, preferred_model=node.model,
)
if picked is None:
break
@@ -3945,6 +4545,12 @@ async def _run_one(node: PlanNode) -> dict[str, Any]:
src=node.node_id, dst=CHIEF_ID, kind="failover",
text=note,
)
+ before_snap = None
+ try:
+ from .. import ce_host as _ce_snap
+ before_snap = _ce_snap.wiki_work_snapshot(workspace)
+ except Exception: # noqa: BLE001
+ before_snap = None
try:
rec = await asyncio.wait_for(
_run_agent_node(
@@ -3955,12 +4561,27 @@ async def _run_one(node: PlanNode) -> dict[str, Any]:
plan=plan, graph_parent=parent,
graph_completed=completed, graph_failed=failed,
graph_running=live_running,
+ desk_gate=desk_gate,
),
timeout=timeout,
)
except asyncio.TimeoutError:
rid = _node_run_id(parent_run_id, node.node_id, attempt)
err = f"timed out after {timeout:.0f}s"
+ partial = ""
+ wiki_n = 0
+ try:
+ live = load_node_live(workspace, parent_run_id, node.node_id)
+ if isinstance(live, dict):
+ partial = str(live.get("partial_text") or "")
+ art = artifact_dir(workspace, parent_run_id) / f"node-{node.node_id}.md"
+ if art.is_file() and not partial:
+ partial = art.read_text(encoding="utf-8")
+ parent_rec = (app.get("runs") or {}).get(parent_run_id) if app else None
+ if isinstance(parent_rec, dict):
+ wiki_n = int(parent_rec.get("wiki_pages_landed") or 0)
+ except Exception: # noqa: BLE001
+ pass
try:
await _broadcast(app, protocol.run_error(
rid, "timeout", err, thread_id,
@@ -3975,16 +4596,20 @@ async def _run_one(node: PlanNode) -> dict[str, Any]:
))
except Exception: # noqa: BLE001
pass
- return {
+ timed = {
"node_id": node.node_id, "kind": node.kind,
"run_id": rid,
"ok": False,
"error": err,
- "output": "", "provider": pid,
+ "output": partial, "provider": pid,
"model": model, "input_tokens": None, "output_tokens": None,
"task": {"description": node.objective, "difficulty": node.difficulty},
"worker_index": idx or 0,
+ "wiki_pages_landed": wiki_n,
}
+ return _apply_work_receipt(timed, workspace, before_snap)
+ if isinstance(rec, dict):
+ rec = _apply_work_receipt(rec, workspace, before_snap)
last = rec
if rec.get("ok"):
return rec
@@ -4117,7 +4742,7 @@ def _emit_snapshot(
})
async def _snapshot_loop() -> None:
- interval = 0.05 if os.environ.get("PYTEST_CURRENT_TEST") else SNAPSHOT_INTERVAL_SEC
+ interval = float(SNAPSHOT_INTERVAL_SEC)
while True:
await asyncio.sleep(interval)
try:
@@ -4132,8 +4757,28 @@ async def _snapshot_loop() -> None:
log.debug("resume snapshot failed", exc_info=True)
snap_task = asyncio.create_task(_snapshot_loop())
+ no_progress_streak = 0
+ last_work_fp = ""
+ try:
+ from .. import ce_host as _ce_host_fp
+ last_work_fp = _ce_host_fp.work_availability_fingerprint(workspace)
+ except Exception: # noqa: BLE001
+ last_work_fp = ""
+ chief_slot = desk_slot(parent_run_id, "chief")
+ await desk_gate.acquire(chief_slot, kind="chief")
try:
while True:
+ desk_gate.refresh_cap(workspace)
+ live_cap = desk_gate.cap
+ if parent is not None:
+ parent["desk_live_cap"] = live_cap
+ bounds.max_concurrency = max(
+ 1, min(int(plan.bounds.max_concurrency), live_cap),
+ )
+ if parent and (parent.get("user_cancel") or parent.get("user_dismiss")):
+ cancelled = True
+ stop_reason = "cancelled"
+ break
if (
bounds.wall_clock_sec > 0
and time.time() - started + elapsed_prior > bounds.wall_clock_sec
@@ -4141,10 +4786,43 @@ async def _snapshot_loop() -> None:
error = "orchestration wall-clock budget exhausted"
stop_reason = error
break
+ dec_live = plan.decision if isinstance(plan.decision, dict) else {}
+ until_live = dec_live.get("curate_until")
+ try:
+ until_f = float(until_live) if until_live is not None else None
+ except (TypeError, ValueError):
+ until_f = None
+ if until_f is not None and time.time() >= until_f:
+ stop_reason = "curate window ended"
+ break
remaining = [n for n in plan.nodes if n.node_id not in completed and n.node_id not in failed]
if not remaining:
if cancelled:
break
+ dec_idle = plan.decision if isinstance(plan.decision, dict) else {}
+ is_curate_idle = (
+ plan.strategy == "ce_curate"
+ or str(dec_idle.get("task_kind") or "") == "curation"
+ ) and bool(dec_idle.get("curate_repeat"))
+ if is_curate_idle and no_progress_streak >= 2:
+ idle_reason = await _wait_for_curate_work(last_work_fp)
+ if idle_reason == "stop":
+ cancelled = True
+ stop_reason = "cancelled"
+ break
+ if idle_reason == "deadline":
+ stop_reason = "curate window ended"
+ break
+ if idle_reason == "expired":
+ error = "orchestration wall-clock budget exhausted"
+ stop_reason = error
+ break
+ no_progress_streak = 0
+ try:
+ from .. import ce_host as _ce_fp
+ last_work_fp = _ce_fp.work_availability_fingerprint(workspace)
+ except Exception: # noqa: BLE001
+ pass
if not _maybe_continue():
break
remaining = [
@@ -4166,10 +4844,18 @@ async def _snapshot_loop() -> None:
break
runnable = _runnable(ready)
if not runnable:
- await _recover_channels()
+ status = await _recover_channels()
+ if status == "expired":
+ stop_reason = "curate window ended"
+ break
continue
- batch = runnable[: bounds.max_concurrency]
- max_conc_seen = max(max_conc_seen, len(batch))
+ # Live cap is backpressure, never an early stop. If the desk
+ # is full, admit one waiter (acquire blocks) rather than halt.
+ worker_slots = desk_gate.free()
+ if worker_slots <= 0:
+ worker_slots = 1
+ batch = runnable[: min(bounds.max_concurrency, worker_slots)]
+ max_conc_seen = max(max_conc_seen, len(batch) + (1 if CHIEF_COUNTED else 0))
if parent is not None:
parent["orchestration_stage"] = batch[0].kind
parent["step"] = f"{batch[0].kind} ×{len(batch)}"
@@ -4183,8 +4869,11 @@ async def _snapshot_loop() -> None:
))
async def _record(node: PlanNode, rec: Any) -> None:
- nonlocal cancelled
+ nonlocal cancelled, no_progress_streak, last_work_fp
if isinstance(rec, asyncio.CancelledError):
+ if stop_reason:
+ failed.add(node.node_id)
+ return
cancelled = True
# Stop leaves in-flight nodes pending so Start
# retries them. Dismiss / crash still fail them.
@@ -4203,6 +4892,18 @@ async def _record(node: PlanNode, rec: Any) -> None:
}
_ingest(node, rec)
await _ingest_handoff(node, rec)
+ compact_plan_nodes(plan, completed, failed, live_running)
+ persist_plan(workspace, plan)
+ if node.role == "curator" and isinstance(rec, dict):
+ if _receipt_had_work(rec):
+ no_progress_streak = 0
+ else:
+ no_progress_streak += 1
+ try:
+ from .. import ce_host as _ce_fp2
+ last_work_fp = _ce_fp2.work_availability_fingerprint(workspace)
+ except Exception: # noqa: BLE001
+ pass
def _flush_board(*, running: set[str]) -> None:
bb.persist(artifact_dir(workspace, parent_run_id) / "blackboard.json")
@@ -4266,9 +4967,42 @@ async def _reap_pending() -> None:
try:
while pending:
+ remain = _batch_deadline_remaining(
+ _plan_curate_until(plan), bounds, started, elapsed_prior,
+ )
+ if remain is not None and remain <= 0:
+ if (
+ _plan_curate_until(plan) is not None
+ and time.time() >= (_plan_curate_until(plan) or 0)
+ ):
+ stop_reason = "curate window ended"
+ else:
+ error = "orchestration wall-clock budget exhausted"
+ stop_reason = error
+ await _reap_pending()
+ break
done, pending = await asyncio.wait(
- pending, return_when=asyncio.FIRST_COMPLETED,
+ pending,
+ return_when=asyncio.FIRST_COMPLETED,
+ timeout=remain,
)
+ if not done:
+ if (
+ _plan_curate_until(plan) is not None
+ and time.time() >= (_plan_curate_until(plan) or 0)
+ ):
+ stop_reason = "curate window ended"
+ elif (
+ bounds.wall_clock_sec > 0
+ and time.time() - started + elapsed_prior
+ >= bounds.wall_clock_sec
+ ):
+ error = "orchestration wall-clock budget exhausted"
+ stop_reason = error
+ else:
+ continue
+ await _reap_pending()
+ break
for t in done:
node = task_of[t]
try:
@@ -4279,7 +5013,7 @@ async def _reap_pending() -> None:
if cancelled and pending:
await _reap_pending()
_flush_board(running={task_of[t].node_id for t in pending})
- if cancelled:
+ if cancelled or stop_reason:
break
except asyncio.CancelledError:
# Parent Stop/crash: asyncio.wait does not cancel siblings.
@@ -4331,7 +5065,7 @@ async def _reap_pending() -> None:
parent_run_id, len(added), exp.reason,
)
_flush_board(running=set())
- if cancelled:
+ if cancelled or stop_reason:
break
except asyncio.CancelledError:
user_kill = bool(parent.get("user_cancel")) if parent is not None else False
@@ -4355,6 +5089,11 @@ async def _reap_pending() -> None:
log.exception("orchestration %s crashed", parent_run_id)
error = f"{type(e).__name__}: {e}"
finally:
+ try:
+ await desk_gate.release_async(chief_slot)
+ except Exception: # noqa: BLE001
+ pass
+ release_domain(desk_gate)
snap_task.cancel()
try:
await snap_task
@@ -4368,14 +5107,22 @@ async def _reap_pending() -> None:
except Exception: # noqa: BLE001
log.debug("final snapshot failed", exc_info=True)
- # Final output: synthesizer if present, else concat of investigators.
+ # Final output: newest synthesizer in results history (compacted
+ # live plan may only keep an older synth as a rest marker).
synth = next((n for n in reversed(plan.nodes) if n.kind == "synthesize"), None)
output = ""
- if synth and synth.node_id in results_by_id:
+ for rec in reversed(list(results_by_id.values())):
+ if rec.get("kind") == "synthesize" and rec.get("output"):
+ output = str(rec.get("output") or "")
+ break
+ if not output and synth and synth.node_id in results_by_id:
output = str(results_by_id[synth.node_id].get("output") or "")
if not output:
- inv = [results_by_id[n.node_id] for n in plan.nodes
- if n.kind == "investigate" and n.node_id in results_by_id]
+ inv = [r for r in results_by_id.values()
+ if r.get("kind") == "investigate" and r.get("output")]
+ if not inv:
+ inv = [results_by_id[n.node_id] for n in plan.nodes
+ if n.kind == "investigate" and n.node_id in results_by_id]
if inv:
output = fanout.merge(plan.objective, inv)
output = strip_objective_met(output)
@@ -4384,10 +5131,10 @@ async def _reap_pending() -> None:
r.get("ok") for r in results_by_id.values()
if r.get("kind") == "investigate"
)
- synth_ok = bool(
- synth and synth.node_id in results_by_id
- and results_by_id[synth.node_id].get("ok")
- and str(results_by_id[synth.node_id].get("output") or "").strip()
+ synth_ok = any(
+ r.get("ok") and str(r.get("output") or "").strip()
+ for r in results_by_id.values()
+ if r.get("kind") == "synthesize"
)
produced = inv_ok or synth_ok
@@ -4456,6 +5203,8 @@ async def _reap_pending() -> None:
user_stop = bool(parent and parent.get("user_cancel") and not user_dismiss)
if cancelled:
end_phase = "cancelled" if user_dismiss or not user_stop else "quiet"
+ elif stop_reason == "curate window ended":
+ end_phase = "quiet"
elif produced and not error:
end_phase = "completed"
else:
@@ -4530,10 +5279,9 @@ async def _reap_pending() -> None:
except OSError:
log.exception("orchestrator_fs brief failed")
- # Production: leave linger tasks running so the dashboard can still
- # poll completed workers. Tests cancel them so the loop doesn't
- # warn about pending tasks at teardown.
- if os.environ.get("PYTEST_CURRENT_TEST"):
+ # Production leaves linger tasks running so the dashboard can still
+ # poll completed workers. Tests set cleanup_retire_tasks.
+ if cleanup_retire_tasks:
for t in app.pop("_orch_retire", []) or []:
if not t.done():
t.cancel()
diff --git a/src/switchbay/agents/orchestration_health.py b/src/switchbay/agents/orchestration_health.py
index 51528d9..23c4827 100644
--- a/src/switchbay/agents/orchestration_health.py
+++ b/src/switchbay/agents/orchestration_health.py
@@ -83,14 +83,19 @@ def save_health(data: dict[str, Any]) -> None:
# Tight patterns for *streaming* assistant text. classify_error is
# looser (a "quota" in a traceback is enough) because it runs on
-# exceptions. Investigator prose about "import quotas" must not
-# kill the worker.
+# exceptions. Investigator prose about "import quotas" or research
+# that *mentions* HTTP 429 / rate limits must not kill the worker.
+# Only provider *banners* (weekly-limit, billing, HTTP error framing)
+# count as a channel outage.
_OUTAGE_TEXT_RE = re.compile(
r"hit your weekly limit|you've hit your (?:usage |rate )?limit"
r"|weekly limit\b.*\bresets"
r"|insufficient[_ ](?:quota|funds)|out of credits?"
- r"|\b429\b|rate[_ ]limit(?:ed)?(?: exceeded)?"
- r"|too many requests",
+ r"|HTTP\s*429"
+ r"|(?:error|failed|exception|providererror)[:\s].{0,60}"
+ r"(?:\b429\b|rate[_ ]limit|too many requests)"
+ r"|rate[_ ]limit(?:ed)? exceeded"
+ r"|^\s*(?:429|too many requests|rate[_ ]limited?)\s*[.!]?\s*$",
re.I | re.S,
)
@@ -123,14 +128,31 @@ def classify_error(err: str) -> str:
def looks_like_outage(text: str) -> str | None:
- """If streaming text *is* a channel outage, return its kind.
+ """If streaming text *is* a channel outage banner, return its kind.
- Used mid-stream so a weekly-limit banner aborts the worker
- immediately instead of being filed as a successful finding.
- Returns None for ordinary assistant prose.
+ Research that mentions 429 / rate limits / a quoted weekly-limit
+ banner is not an outage. Only a short provider banner (or an
+ error: framing whose body *is* the banner) counts.
"""
blob = (text or "").strip()
- if not blob or not _OUTAGE_TEXT_RE.search(blob):
+ if not blob:
+ return None
+ if blob.startswith("{") and ("findings" in blob or "claim" in blob):
+ return None
+ m = _OUTAGE_TEXT_RE.search(blob)
+ if not m:
+ return None
+ low = blob.lower()
+ if (
+ '"' in blob
+ or "the source" in low
+ or "this page documents" in low
+ or "protocol returns" in low
+ or "prescribes" in low
+ or "this is evidence" in low
+ ):
+ return None
+ if len(blob) > len(m.group(0)) + 100:
return None
kind = classify_error(blob)
return kind if kind in CHANNEL_RETRY_KINDS else None
diff --git a/src/switchbay/agents/orchestration_policy.py b/src/switchbay/agents/orchestration_policy.py
index a478ee4..9cd53c2 100644
--- a/src/switchbay/agents/orchestration_policy.py
+++ b/src/switchbay/agents/orchestration_policy.py
@@ -159,6 +159,7 @@ class TaskFeatures:
science: bool = False
experiment: bool = False
lookup: bool = False
+ web_ingest: bool = False
def bucket(self) -> str:
"""Coarse, stable context key for the bandit."""
@@ -242,7 +243,8 @@ def extract_features(
n_q = len(re.findall(r"\?", t))
n_list = len(_MULTI_RE.findall(t))
n_sub = max(1, n_q + max(0, n_list // 2))
- research = bool(_RESEARCH_RE.search(t) or _WEB_INGEST_RE.search(t))
+ web_ingest = bool(_WEB_INGEST_RE.search(t))
+ research = bool(_RESEARCH_RE.search(t) or web_ingest)
code = bool(_CODE_RE.search(t))
graph = bool(_GRAPH_RE.search(t)) if graph_available else False
finance = bool(_FINANCE_RE.search(t))
@@ -286,6 +288,7 @@ def extract_features(
science=science,
experiment=experiment,
lookup=lookup,
+ web_ingest=web_ingest,
)
@@ -305,9 +308,12 @@ def apply_task_context(
features.lookup = False
if not constrained:
features.n_subquestions = 1
- if task_kind in {"projects", "code", "deck"}:
+ if task_kind in {"projects", "code", "deck", "research"}:
features.graph = True
features.lookup = False
+ if task_kind == "research":
+ features.research = True
+ features.web_ingest = True
if not constrained:
features.n_subquestions = max(features.n_subquestions, 1)
return features
@@ -1060,10 +1066,7 @@ def allocate_models(
keyed = available
if keyed is None:
- if os.environ.get("PYTEST_CURRENT_TEST"):
- keyed = [(default_provider, default_model or "")] if default_provider else []
- else:
- keyed = list_keyed_providers()
+ keyed = list_keyed_providers()
pids: list[str] = []
hint_by_pid: dict[str, str | None] = {}
@@ -1621,6 +1624,19 @@ def reset_state(workspace: Path | None = None) -> dict[str, Any]:
return st
+def _live_seats_view() -> dict[str, Any]:
+ try:
+ from .desk_admission import public_view
+ return public_view()
+ except Exception: # noqa: BLE001
+ return {
+ "desk_max_live_workers": HARD_MAX_CONCURRENCY,
+ "min": 4,
+ "default": 8,
+ "chief_counted": True,
+ }
+
+
def inspect_state(workspace: Path | None = None) -> dict[str, Any]:
"""Safe diagnostics for Settings / debug. No secrets."""
st = load_state(workspace)
@@ -1657,6 +1673,8 @@ def inspect_state(workspace: Path | None = None) -> dict[str, Any]:
"max_continuations": HARD_MAX_CONTINUATIONS,
"wall_clock_sec": HARD_WALL_CLOCK_SEC,
"worker_timeout_sec": HARD_WORKER_TIMEOUT_SEC,
+ "live_seats": _live_seats_view(),
+ "chief_counted": True,
},
"utility": {
"lambda_c_floor": util.LAMBDA_C_FLOOR,
diff --git a/src/switchbay/app_settings.py b/src/switchbay/app_settings.py
index c8f0bad..a06ed57 100644
--- a/src/switchbay/app_settings.py
+++ b/src/switchbay/app_settings.py
@@ -131,3 +131,30 @@ def set_workspaces_home(value: str) -> None:
data = load()
data["workspaces_home"] = str(value).strip() or _WORKSPACES_HOME_DEFAULT
save(data)
+
+
+# Live seats per desk (chief counted). Floor 4, default 8. Admin may
+# only tighten the ceiling — see agents.desk_admission.
+_DESK_LIVE_DEFAULT = 8
+_DESK_LIVE_MIN = 4
+
+
+def get_desk_max_live_workers() -> int:
+ raw = load().get("desk_max_live_workers")
+ try:
+ n = int(raw) if raw is not None else _DESK_LIVE_DEFAULT
+ except (TypeError, ValueError):
+ n = _DESK_LIVE_DEFAULT
+ return max(_DESK_LIVE_MIN, n)
+
+
+def set_desk_max_live_workers(value: int) -> int:
+ try:
+ n = int(value)
+ except (TypeError, ValueError) as e:
+ raise ValueError("desk_max_live_workers must be an integer") from e
+ n = max(_DESK_LIVE_MIN, n)
+ data = load()
+ data["desk_max_live_workers"] = n
+ save(data)
+ return n
diff --git a/src/switchbay/ce_host.py b/src/switchbay/ce_host.py
index f0afb27..1212836 100644
--- a/src/switchbay/ce_host.py
+++ b/src/switchbay/ce_host.py
@@ -6,6 +6,7 @@
from __future__ import annotations
+import hashlib
import json
import os
import re
@@ -46,6 +47,13 @@ def _wiki_git(workspace: Path, *git_args: str, timeout: float = 60.0) -> dict[st
return {"ok": True, "stdout": out[-2000:], "stderr": err[-800:]}
+def wiki_head(workspace: Path) -> dict[str, Any]:
+ out = _wiki_git(workspace, "rev-parse", "HEAD")
+ if not out.get("ok"):
+ return out
+ return {"ok": True, "sha": str(out.get("stdout") or "").strip(), "committed": True}
+
+
def wiki_commit(workspace: Path, payload: dict[str, Any]) -> dict[str, Any]:
msg = str(payload.get("message") or "").strip()
if not msg:
@@ -66,6 +74,195 @@ def wiki_commit(workspace: Path, payload: dict[str, Any]) -> dict[str, Any]:
return {"ok": True, "committed": True, "stdout": committed.get("stdout")}
+def _file_sha256(path: Path) -> str:
+ h = hashlib.sha256()
+ try:
+ with path.open("rb") as fh:
+ while True:
+ chunk = fh.read(65536)
+ if not chunk:
+ break
+ h.update(chunk)
+ except OSError:
+ return ""
+ return h.hexdigest()
+
+
+def _tree_entries(
+ root: Path,
+ *,
+ rel_to: Path,
+ suffixes: tuple[str, ...] | None = None,
+ content_hash: bool = False,
+) -> list[tuple]:
+ out: list[tuple] = []
+ if not root.is_dir():
+ return out
+ for p in root.rglob("*"):
+ if not p.is_file():
+ continue
+ if ".git" in p.parts:
+ continue
+ if suffixes and p.suffix.lower() not in suffixes:
+ continue
+ try:
+ rel = str(p.relative_to(rel_to))
+ st = p.stat()
+ except OSError:
+ continue
+ size = int(st.st_size)
+ mtime = int(getattr(st, "st_mtime_ns", int(st.st_mtime * 1e9)))
+ if content_hash:
+ out.append((rel, size, mtime, _file_sha256(p)))
+ else:
+ out.append((rel, size, mtime))
+ out.sort()
+ return out
+
+
+def wiki_work_snapshot(workspace: Path) -> dict[str, Any]:
+ """HEAD + porcelain + wiki markdown inventory. No CE subprocess."""
+ ws = Path(workspace)
+ head = wiki_head(ws)
+ sha = str(head.get("sha") or "") if head.get("ok") else ""
+ status = _wiki_git(ws, "status", "--porcelain")
+ porcelain = str(status.get("stdout") or "") if status.get("ok") else ""
+ pages = _tree_entries(
+ ws / "wiki", rel_to=ws, suffixes=(".md",), content_hash=True,
+ )
+ return {"sha": sha, "porcelain": porcelain, "pages": pages}
+
+
+def work_availability_fingerprint(workspace: Path, *, planner: bool = False) -> str:
+ """Deterministic source/wiki/queue fingerprint for idle Curate.
+
+ File inventory only by default (cheap, no LLM, no evolve_guard).
+ ``planner=True`` adds pick-mode when a no-LLM planner check is wanted.
+ """
+ ws = Path(workspace)
+ snap = wiki_work_snapshot(ws)
+ vault = _tree_entries(ws / "vault", rel_to=ws)
+ curator_root = ws / ".curator"
+ curator: list[tuple[str, int, int]] = []
+ if curator_root.is_dir():
+ for p in curator_root.rglob("*"):
+ if not p.is_file():
+ continue
+ # Guard snapshots change on wave_prime; they are not work.
+ if p.name.startswith(".guard") or p.suffix == ".snapshot":
+ continue
+ try:
+ rel = str(p.relative_to(ws))
+ st = p.stat()
+ except OSError:
+ continue
+ curator.append((rel, int(st.st_size), int(getattr(st, "st_mtime_ns", int(st.st_mtime * 1e9)))))
+ curator.sort()
+ payload: dict[str, Any] = {
+ "sha": snap.get("sha") or "",
+ "porcelain": snap.get("porcelain") or "",
+ "pages": snap.get("pages") or [],
+ "vault": vault,
+ "curator": curator,
+ }
+ if planner:
+ mode = ""
+ queues: Any = None
+ try:
+ from . import ce_tools
+ picked = ce_tools._ce_planner(ws, {"verb": "pick-mode"})
+ if isinstance(picked, dict):
+ mode = str(picked.get("mode") or "")
+ if not mode and isinstance(picked.get("stdout"), str):
+ try:
+ body = json.loads(picked["stdout"])
+ if isinstance(body, dict):
+ mode = str(body.get("mode") or "")
+ except json.JSONDecodeError:
+ pass
+ scanned = ce_tools._ce_scan(ws, {"verb": "all"})
+ if isinstance(scanned, dict):
+ queues = scanned.get("queue_depths")
+ except Exception: # noqa: BLE001
+ mode = ""
+ queues = None
+ payload["planner_mode"] = mode
+ payload["queue_depths"] = queues
+ blob = json.dumps(payload, default=str, sort_keys=True)
+ return hashlib.sha256(blob.encode("utf-8")).hexdigest()
+
+
+def _page_content_key(row: Any) -> tuple[str, Any] | None:
+ """Identity for receipt comparison: content hash, not mtime."""
+ if not isinstance(row, (list, tuple)) or not row:
+ return None
+ rel = str(row[0])
+ if len(row) >= 4 and row[3]:
+ return rel, ("sha", str(row[3]))
+ if len(row) >= 3:
+ return rel, ("sz", int(row[1]))
+ return rel, ()
+
+
+def wiki_diff_receipt(workspace: Path, before: dict[str, Any] | None) -> dict[str, Any]:
+ """Actual wiki page/commit *content* diff since ``before``.
+
+ mtime-only rewrites and empty commits are not productive work.
+ Same-size edits with a preserved mtime still count.
+ """
+ ws = Path(workspace)
+ after = wiki_work_snapshot(ws)
+ before = before if isinstance(before, dict) else {}
+
+ def _map(pages: Any) -> dict[str, Any]:
+ out: dict[str, Any] = {}
+ for row in pages or []:
+ parsed = _page_content_key(row)
+ if parsed is None:
+ continue
+ rel, key = parsed
+ out[rel] = key
+ return out
+
+ before_pages = _map(before.get("pages"))
+ after_pages = _map(after.get("pages"))
+ changed = sorted(
+ set(after_pages) - set(before_pages)
+ | {
+ k for k in after_pages
+ if k in before_pages and after_pages[k] != before_pages[k]
+ }
+ )
+ removed = sorted(set(before_pages) - set(after_pages))
+ sha_before = str(before.get("sha") or "")
+ sha_after = str(after.get("sha") or "")
+ diff = ""
+ if sha_before and sha_after and sha_before != sha_after:
+ out = _wiki_git(ws, "diff", "--stat", sha_before, sha_after)
+ if out.get("ok"):
+ diff = str(out.get("stdout") or "")
+ if not diff and (changed or removed or (after.get("porcelain") or "") != (before.get("porcelain") or "")):
+ out = _wiki_git(ws, "diff", "--stat")
+ if out.get("ok"):
+ diff = str(out.get("stdout") or "")
+ if not diff:
+ names = _wiki_git(ws, "diff", "--name-only")
+ if names.get("ok"):
+ diff = str(names.get("stdout") or "")
+ landed = len(changed)
+ committed = bool(sha_before and sha_after and sha_before != sha_after)
+ return {
+ "wiki_head_before": sha_before,
+ "wiki_head_after": sha_after,
+ "wiki_pages_changed": changed,
+ "wiki_pages_removed": removed,
+ "wiki_commit_diff": diff[:4000],
+ "wiki_pages_landed": landed,
+ "wiki_committed": committed,
+ "wiki_snapshot": after,
+ }
+
+
def evolve_guard(workspace: Path, payload: dict[str, Any]) -> dict[str, Any]:
verb = str(payload.get("verb") or "snapshot").strip()
if verb not in ("snapshot", "check", "hash"):
@@ -84,7 +281,8 @@ def wave_prime(workspace: Path, payload: dict[str, Any] | None = None) -> dict[s
payload = payload or {}
wanted = str(payload.get("mode") or payload.get("wave_mode") or "").strip().lower()
- override = CURATE_MODE_ALIASES.get(wanted, wanted if wanted else "")
+ # Unknown tokens are briefs ("for 10 mins"), not pick-mode names.
+ override = CURATE_MODE_ALIASES.get(wanted, "")
steps: dict[str, Any] = {}
steps["guard"] = evolve_guard(workspace, {"verb": "snapshot"})
try:
diff --git a/src/switchbay/ce_protocol.py b/src/switchbay/ce_protocol.py
index ea37465..d54e945 100644
--- a/src/switchbay/ce_protocol.py
+++ b/src/switchbay/ce_protocol.py
@@ -25,6 +25,8 @@
"wire": "wire",
"conflicts": "cross-table-conflicts",
"cross-table-conflicts": "cross-table-conflicts",
+ "table-audit": "table-audit",
+ "audit": "table-audit",
}
QUERY_PROTOCOL = """\
diff --git a/src/switchbay/ce_tools.py b/src/switchbay/ce_tools.py
index 0f3dfab..1789971 100644
--- a/src/switchbay/ce_tools.py
+++ b/src/switchbay/ce_tools.py
@@ -15,6 +15,7 @@
import json
import re
+import time
from pathlib import Path
from typing import Any
@@ -71,6 +72,15 @@ def _safe_args(raw: Any) -> tuple[list[str], str | None]:
def _ce_run(workspace: Path, payload: dict[str, Any]) -> dict[str, Any]:
+ from . import permissions
+ if permissions.needs_web_consent("ce_run", payload):
+ blocked = permissions.web_egress_block_reason(
+ workspace, "ce_run", payload,
+ )
+ if blocked:
+ return {"ok": False, "error": blocked}
+ if not permissions.invocation_approved():
+ return {"ok": False, "error": "web egress denied"}
script = str(payload.get("script") or "").strip()
if script.endswith(".sh"):
return {"error": "use viewer/setup via dedicated Switch Bay actions, not ce_run"}
@@ -95,12 +105,15 @@ def _ce_run(workspace: Path, payload: dict[str, Any]) -> dict[str, Any]:
args, prep_meta = _with_ingest_prep(workspace, path="", extra=args)
if args is None:
return prep_meta
+ out = _run_local_ingest(
+ workspace, prep_meta, extra_flags=_ingest_extra_flags(args),
+ timeout=timeout,
+ )
+ return out
out = cebridge.run_script(
script, args, cwd=workspace, timeout=timeout,
require_json=bool(require_json),
)
- if prep_meta is not None and isinstance(out, dict):
- out["ingest_prep"] = prep_meta
return out
@@ -235,6 +248,15 @@ def _with_ingest_prep(
def _ce_ingest(workspace: Path, payload: dict[str, Any]) -> dict[str, Any]:
+ from . import permissions
+ if permissions.needs_web_consent("ce_ingest", payload):
+ blocked = permissions.web_egress_block_reason(
+ workspace, "ce_ingest", payload,
+ )
+ if blocked:
+ return {"ok": False, "error": blocked}
+ if not permissions.invocation_approved():
+ return {"ok": False, "error": "web egress denied"}
extra, err = _safe_args(payload.get("args"))
if err:
return {"error": err}
@@ -247,20 +269,403 @@ def _ce_ingest(workspace: Path, payload: dict[str, Any]) -> dict[str, Any]:
)
if args is None:
return meta
- out = cebridge.run_script("local_ingest.py", args, cwd=workspace, timeout=300.0)
- if isinstance(out, dict):
- out["ingest_prep"] = meta
- flagged = ingest_prep.flag_raw_pdf_extractions(workspace, out)
- if flagged:
- out["raw_pdf_extractions"] = flagged
- out["warning"] = (
- "extraction looks like raw PDF bytes, not prose. "
- "Current curiosity-engine uses pypdf; re-ingest the PDF "
- "or run pending-multimodal. Do not cite these files."
+ timeout = float(payload.get("timeout") or 300.0)
+ timeout = max(15.0, min(timeout, 900.0))
+ extra_flags = _ingest_extra_flags(args)
+ return _run_local_ingest(
+ workspace, meta, extra_flags=extra_flags, timeout=timeout,
+ )
+
+
+def _ingest_extra_flags(args: list[str]) -> list[str]:
+ extra: list[str] = []
+ i = 0
+ while i < len(args):
+ a = args[i]
+ if a == "--file" and i + 1 < len(args):
+ i += 2
+ continue
+ if a.startswith("-"):
+ extra.append(a)
+ if a in _INGEST_VALUE_FLAGS and i + 1 < len(args) and not args[i + 1].startswith("-"):
+ extra.append(args[i + 1])
+ i += 2
+ continue
+ i += 1
+ continue
+ i += 1
+ return extra
+
+
+def _targets_from_prep(
+ workspace: Path, meta: dict[str, Any],
+) -> list[str]:
+ converted = meta.get("converted")
+ if meta.get("staged") and isinstance(converted, list):
+ files = [
+ str(c.get("staged")) for c in converted
+ if isinstance(c, dict) and c.get("staged")
+ ]
+ if files:
+ return files
+ args = list(meta.get("ce_args") or [])
+ if args[:1] == ["--file"] and len(args) >= 2:
+ return [args[1]]
+ if args and not str(args[0]).startswith("-"):
+ root = Path(args[0])
+ if not root.is_absolute():
+ root = workspace / args[0]
+ if root.is_file():
+ return [args[0]]
+ if root.is_dir():
+ return [
+ ingest_prep.rel_to_workspace(workspace, p)
+ for p in ingest_prep._iter_source_files(root)
+ ]
+ drop = workspace / "vault" / "raw"
+ if drop.is_dir():
+ return [
+ ingest_prep.rel_to_workspace(workspace, p)
+ for p in ingest_prep._iter_source_files(drop)
+ ]
+ return []
+
+
+def _merge_local_ingest(parts: list[dict[str, Any]]) -> dict[str, Any]:
+ results: list[dict[str, Any]] = []
+ interpreters: list[list[str]] = []
+ considered = 0
+ notes: list[str] = []
+ for part in parts:
+ interp = part.get("interpreter")
+ if isinstance(interp, list) and interp:
+ interpreters.append([str(x) for x in interp])
+ if part.get("note"):
+ notes.append(str(part["note"]))
+ rows = part.get("results")
+ if isinstance(rows, list):
+ for row in rows:
+ if isinstance(row, dict):
+ results.append(row)
+ considered += int(part.get("considered") or len(rows))
+ continue
+ if part.get("error"):
+ results.append({
+ "ok": False,
+ "reason": part.get("error"),
+ "source_path": part.get("file"),
+ })
+ considered += 1
+ continue
+ if part.get("extracted") or part.get("extraction_method"):
+ results.append(part)
+ considered += 1
+ ok_n = sum(1 for r in results if r.get("ok") is True)
+ out: dict[str, Any] = {
+ "considered": considered or len(results),
+ "ok": ok_n,
+ "failed": max(0, len(results) - ok_n),
+ "results": results,
+ }
+ if interpreters:
+ # Unique, stable order.
+ seen: set[tuple[str, ...]] = set()
+ uniq: list[list[str]] = []
+ for item in interpreters:
+ key = tuple(item)
+ if key in seen:
+ continue
+ seen.add(key)
+ uniq.append(item)
+ out["interpreters"] = uniq
+ out["interpreter"] = uniq[0] if len(uniq) == 1 else uniq
+ if notes:
+ out["note"] = " | ".join(notes)[-1500:]
+ return out
+
+
+def _run_local_ingest(
+ workspace: Path,
+ meta: dict[str, Any],
+ *,
+ extra_flags: list[str],
+ timeout: float,
+) -> dict[str, Any]:
+ """Run CE local_ingest per file with the interpreter that has that extractor."""
+ files = _targets_from_prep(workspace, meta)
+ flags = list(extra_flags)
+ deadline = time.monotonic() + timeout
+ parts: list[dict[str, Any]] = []
+ if not files:
+ remain = max(1.0, deadline - time.monotonic())
+ py = cebridge.python_for_ingest(workspace, "")
+ part = cebridge.run_script(
+ "local_ingest.py", list(meta.get("ce_args") or []) + flags,
+ cwd=workspace, timeout=remain, python=py,
+ )
+ if isinstance(part, dict):
+ part["interpreter"] = py
+ parts.append(part)
+ else:
+ for rel in files:
+ remain = deadline - time.monotonic()
+ if remain <= 1.0:
+ parts.append({
+ "ok": False, "error": f"ingest timed out after {int(timeout)}s",
+ "file": rel, "interpreter": [],
+ })
+ continue
+ ext = Path(rel).suffix.lower()
+ py = cebridge.python_for_ingest(workspace, ext)
+ part = cebridge.run_script(
+ "local_ingest.py", ["--file", rel, *flags],
+ cwd=workspace, timeout=remain, python=py,
)
+ if isinstance(part, dict):
+ part["interpreter"] = py
+ part["file"] = rel
+ parts.append(part)
+ else:
+ parts.append({
+ "ok": False, "error": "ingest returned non-dict",
+ "file": rel, "interpreter": py,
+ })
+ out = _merge_local_ingest(parts)
+ out["ingest_prep"] = meta
+ out["timeout_s"] = timeout
+ out["extractor_host"] = cebridge.host_extractor_info()
+ if "interpreter" not in out:
+ out["interpreter"] = cebridge.python_for_ingest(workspace, "")
+ flagged = ingest_prep.flag_raw_pdf_extractions(workspace, out)
+ if flagged:
+ out["raw_pdf_extractions"] = flagged
+ out["warning"] = (
+ "extraction looks like raw PDF bytes, not prose. "
+ "Current curiosity-engine uses pypdf; re-ingest the PDF "
+ "or run pending-multimodal. Do not cite these files."
+ )
+ return _reject_failed_structured_extracts(workspace, out)
+
+
+_FAILED_METHOD_RE = re.compile(
+ r"^(pptx_failed|xlsx_failed|pypdf_failed|csv_failed)",
+ re.I,
+)
+
+
+def _ingest_rows(out: dict[str, Any]) -> list[dict[str, Any]]:
+ rows = out.get("results")
+ if isinstance(rows, list):
+ return [r for r in rows if isinstance(r, dict)]
+ return [out] if any(k in out for k in ("extracted", "extraction_method")) else []
+
+
+def _frontmatter_map(text: str) -> dict[str, str]:
+ if not text.startswith("---"):
+ return {}
+ end = text.find("\n---", 3)
+ if end < 0:
+ return {}
+ meta: dict[str, str] = {}
+ for ln in text[3:end].splitlines():
+ if ":" not in ln:
+ continue
+ key, _, val = ln.partition(":")
+ key = key.strip()
+ if key:
+ meta[key] = val.strip()
+ return meta
+
+
+def _row_extract_status(workspace: Path, row: dict[str, Any]) -> tuple[str, str]:
+ """Extractor method/quality from the row, else extract frontmatter only."""
+ method = str(row.get("extraction_method") or "")
+ quality = str(row.get("extraction_quality") or "")
+ if method or quality:
+ return method, quality
+ extracted = str(row.get("extracted") or row.get("extracted_path") or "")
+ if not extracted:
+ return method, quality
+ cand = Path(extracted)
+ if not cand.is_absolute():
+ cand = workspace / extracted
+ try:
+ text = cand.read_text(encoding="utf-8", errors="replace")[:4000]
+ except OSError:
+ return method, quality
+ meta = _frontmatter_map(text)
+ return (
+ method or str(meta.get("extraction_method") or ""),
+ quality or str(meta.get("extraction_quality") or ""),
+ )
+
+
+def _exact_index_paths(workspace: Path, extracted: Path) -> list[str]:
+ """Canonical vault-relative and absolute extract paths. No LIKE/globs."""
+ out: list[str] = []
+
+ def add(raw: str) -> None:
+ if raw and raw not in out:
+ out.append(raw)
+
+ add(str(extracted))
+ add(extracted.as_posix())
+ try:
+ resolved = extracted.resolve()
+ except OSError:
+ resolved = extracted
+ add(str(resolved))
+ add(resolved.as_posix())
+ try:
+ rel = resolved.relative_to((workspace / "vault").resolve())
+ add(str(rel))
+ add(rel.as_posix())
+ except (ValueError, OSError):
+ pass
return out
+def _deindex_exact_paths(workspace: Path, paths: list[str]) -> None:
+ db = workspace / "vault" / "vault.db"
+ paths = [p for p in paths if str(p).endswith(".extracted.md")]
+ if not db.is_file() or not paths:
+ return
+ import sqlite3
+ with sqlite3.connect(str(db)) as conn:
+ tables = {
+ r[0] for r in conn.execute(
+ "SELECT name FROM sqlite_master WHERE type IN ('table', 'view')"
+ )
+ }
+ for path in paths:
+ if "sources" in tables:
+ conn.execute("DELETE FROM sources WHERE path = ?", (path,))
+ if "source_meta" in tables:
+ conn.execute("DELETE FROM source_meta WHERE path = ?", (path,))
+ if "embedding_meta" in tables:
+ conn.execute("DELETE FROM embedding_meta WHERE path = ?", (path,))
+ conn.commit()
+
+
+def _unlink_failed_extract(workspace: Path, row: dict[str, Any]) -> None:
+ raw = str(row.get("extracted") or row.get("extracted_path") or "")
+ extra: list[str] = []
+ indexed = row.get("indexed")
+ if isinstance(indexed, dict) and indexed.get("path"):
+ extra.append(str(indexed["path"]))
+ cand: Path | None = None
+ if raw:
+ extra.append(raw)
+ extra.append(Path(raw).as_posix())
+ cand = Path(raw)
+ if not cand.is_absolute():
+ cand = workspace / raw
+ try:
+ resolved = cand.resolve()
+ ws = workspace.resolve()
+ except OSError:
+ cand = None
+ else:
+ if resolved == ws or ws not in resolved.parents:
+ cand = None
+ elif not resolved.name.endswith(".extracted.md"):
+ cand = None
+ else:
+ cand = resolved
+ paths = list(extra)
+ if cand is not None:
+ for p in _exact_index_paths(workspace, cand):
+ if p not in paths:
+ paths.append(p)
+ _deindex_exact_paths(workspace, paths)
+ try:
+ if cand.is_file():
+ cand.unlink()
+ except OSError:
+ pass
+ return
+ _deindex_exact_paths(workspace, paths)
+
+
+def _row_failed_extract(workspace: Path, row: dict[str, Any]) -> str | None:
+ if row.get("ok") is False:
+ return str(row.get("reason") or row.get("error") or "ingest failed")
+ method, quality = _row_extract_status(workspace, row)
+ if quality == "failed" or _FAILED_METHOD_RE.match(method):
+ return f"extraction failed ({method or quality})"
+ if quality == "empty" and (
+ method.startswith(("python-pptx", "openpyxl", "pptx", "xlsx"))
+ or method.startswith(("pptx_failed", "xlsx_failed"))
+ ):
+ return "extraction produced no content"
+ return None
+
+
+def _reject_failed_structured_extracts(
+ workspace: Path, out: dict[str, Any],
+) -> dict[str, Any]:
+ """Refuse metadata-only / unavailable-placeholder success.
+
+ CE ``local_ingest.py`` still writes ``.extracted.md`` and ``ok: true``
+ when python-pptx is missing or the PPTX is corrupt. Switch Bay treats
+ that as a retryable failure and removes the placeholder extract so it
+ is not accepted as ingested content. User vaults are not rewritten
+ except for extracts produced by this call.
+ """
+ if out.get("error") and "results" not in out:
+ out.setdefault("retryable", True)
+ return out
+ rows = _ingest_rows(out)
+ failed: list[dict[str, Any]] = []
+ ok_rows: list[dict[str, Any]] = []
+ for row in rows:
+ reason = _row_failed_extract(workspace, row)
+ if reason:
+ _unlink_failed_extract(workspace, row)
+ failed.append({**row, "reject_reason": reason})
+ elif row.get("ok") is False:
+ failed.append(row)
+ else:
+ ok_rows.append(row)
+ if not failed:
+ return out
+ out = dict(out)
+ out["failed_extractions"] = failed
+ out["results"] = ok_rows
+ out["failed"] = len(failed)
+ if not ok_rows:
+ reason = failed[0].get("reject_reason") or failed[0].get("reason") or (
+ "extraction failed"
+ )
+ out["ok"] = False
+ out["error"] = str(reason)
+ out["retryable"] = True
+ return out
+ out["ok"] = len(ok_rows)
+ warn = out.get("warning") or ""
+ extra = (
+ f"{len(failed)} file(s) failed structured extraction and were not "
+ "accepted. Re-ingest those sources once the file is readable."
+ )
+ out["warning"] = f"{warn} {extra}".strip() if warn else extra
+ return out
+
+
+def ingest_is_success(out: dict[str, Any] | None) -> bool:
+ """True when CE ingest produced accepted extracted content."""
+ if not isinstance(out, dict):
+ return False
+ if out.get("error"):
+ return False
+ if out.get("ok") is False:
+ return False
+ if isinstance(out.get("ok"), int) and out["ok"] <= 0:
+ return False
+ if out.get("retryable") and out.get("failed_extractions") and not _ingest_rows(out):
+ return False
+ return True
+
+
def _ce_query(workspace: Path, payload: dict[str, Any]) -> dict[str, Any]:
verb = str(payload.get("verb") or "introspect").strip()
extra, err = _safe_args(payload.get("args"))
@@ -508,7 +913,9 @@ def _ce_scan(workspace: Path, payload: dict[str, Any]) -> dict[str, Any]:
"path (vault/raw/). File or directory: pass `path` (a file is "
"accepted). Large HTML/XML/JSON is staged as readable text so "
"CE's extract cap indexes content rather than schema; originals "
- "are unchanged."
+ "are unchanged. PPTX/XLSX extractors run with the Switch Bay "
+ "interpreter (python-pptx / openpyxl); missing-extractor "
+ "placeholders are not accepted as success."
),
input_schema={
"type": "object",
diff --git a/src/switchbay/cebridge.py b/src/switchbay/cebridge.py
index 248c3c8..42695ba 100644
--- a/src/switchbay/cebridge.py
+++ b/src/switchbay/cebridge.py
@@ -13,12 +13,14 @@
from __future__ import annotations
import asyncio
+import importlib
import json
import logging
import os
import re
import shutil
import subprocess
+import sys
from pathlib import Path
from typing import Any
@@ -38,32 +40,79 @@
def _ce_root_candidates() -> list[Path]:
"""Where CE's scripts/ (setup.sh, viewer.sh, …) might live, in
- priority order. CE is installed as a global skill via `npx skills
- add -g` (~/.agents/skills, symlinked into ~/.claude/skills), so that
- is the primary location now; $SWITCHBAY_CE_ROOT overrides; the old
- standalone checkout is the final fallback."""
+ priority order. Explicit ``SWITCHBAY_CE_ROOT`` wins when it actually
+ has scripts/; otherwise bundled vendor then already-installed global
+ skills. Never requires sourcing a shell profile. The old standalone
+ checkout is the final fallback.
+ """
home = Path.home()
out: list[Path] = []
env = os.environ.get("SWITCHBAY_CE_ROOT")
if env:
out.append(Path(env).expanduser())
- out.append(home / ".claude" / "skills" / "curiosity-engine")
- out.append(home / ".agents" / "skills" / "curiosity-engine")
+ try:
+ from . import admin_policy
+ install = admin_policy.install_root()
+ if install is not None:
+ out.append(Path(install) / "vendor" / "curiosity-engine")
+ except Exception: # noqa: BLE001
+ pass
+ for rel in (
+ home / ".claude" / "skills" / "curiosity-engine",
+ home / ".agents" / "skills" / "curiosity-engine",
+ home / ".codex" / "skills" / "curiosity-engine",
+ home / ".grok" / "skills" / "curiosity-engine",
+ home / ".cursor" / "skills" / "curiosity-engine",
+ home / ".gemini" / "skills" / "curiosity-engine",
+ ):
+ out.append(rel)
out.append(DEFAULT_CE_ROOT)
- return out
+ seen: set[str] = set()
+ uniq: list[Path] = []
+ for p in out:
+ key = str(p)
+ if key in seen:
+ continue
+ seen.add(key)
+ uniq.append(p)
+ return uniq
def ce_root() -> Path:
- """Resolve CE's root by finding the candidate that actually has
- `scripts/` (so setup.sh / viewer.sh resolve). Falls back to the
- first candidate for a clear not-found error if none exist."""
+ """Resolve CE's root by finding a candidate that has ``scripts/``.
+
+ A missing bundled tree does not hide an already-installed global
+ skill. Falls back to the first candidate for a clear not-found
+ error if none exist.
+ """
cands = _ce_root_candidates()
for c in cands:
- if (c / "scripts").is_dir():
- return c
+ try:
+ if (c / "scripts").is_dir():
+ return c
+ except OSError:
+ continue
+ for c in cands:
+ try:
+ if (c / "SKILL.md").is_file():
+ return c
+ except OSError:
+ continue
return cands[0]
+def ce_scripts_available() -> bool:
+ """True when a CE install with a scripts/ tree is discoverable.
+
+ Independent of ``ce_auto_setup`` / ``ce_bundled_setup`` — those
+ flags gate *installation*, not read/execute of an existing skill.
+ """
+ try:
+ return (ce_root() / "scripts").is_dir()
+ except OSError:
+ return False
+
+
def output_dir(workspace: Path) -> Path:
"""Where viewer.sh writes the bundle for this workspace."""
cache = Path.home() / ".cache" / "curiosity-engine" / "wiki-view"
@@ -467,6 +516,8 @@ def _scrubbed_env() -> dict[str, str]:
if k not in {"VIRTUAL_ENV", "UV_PROJECT_ENVIRONMENT", "PYTHONPATH"}
}
env.setdefault("UV_PYTHON", _ce_python_pin())
+ # CE's installed tree is read-only for us; don't drop .pyc beside scripts.
+ env["PYTHONDONTWRITEBYTECODE"] = "1"
return env
@@ -488,19 +539,128 @@ def _ce_python() -> list[str]:
return ["uv", "run", "--directory", str(root), "python3"]
-def _script_python(cwd: Path) -> list[str]:
+# Per-extension extractor module for local_ingest.py. Prefer the
+# workspace venv when it already has the module (CE setup.sh commonly
+# installs pypdf + openpyxl). Fall back to the Switch Bay host for
+# modules the workspace lacks (python-pptx is a Switch Bay dep).
+# Never merge site-packages via PYTHONPATH. scan.py / graph.py always
+# stay on the workspace venv (kuzu).
+_EXT_EXTRACTOR = {
+ ".pptx": "pptx",
+ ".xlsx": "openpyxl",
+ ".pdf": "pypdf",
+}
+_EXTRACTOR_MODULES = ("pptx", "openpyxl", "pypdf", "pdfplumber")
+_interpreter_module_cache: dict[tuple[str, str], bool] = {}
+
+
+def host_has_module(name: str) -> bool:
+ """True when the running Switch Bay interpreter can import ``name``."""
+ try:
+ importlib.import_module(name)
+ return True
+ except ImportError:
+ return False
+
+
+def host_extractor_info() -> dict[str, Any]:
+ """Interpreter + extractor-module evidence for ingest diagnostics."""
+ found: dict[str, str | None] = {}
+ for name in _EXTRACTOR_MODULES:
+ try:
+ mod = importlib.import_module(name)
+ except ImportError:
+ found[name] = None
+ continue
+ found[name] = getattr(mod, "__file__", None)
+ return {
+ "executable": sys.executable,
+ "version": sys.version.split()[0],
+ "modules": found,
+ }
+
+
+def _same_interpreter(py: Path | str) -> bool:
+ # Separate venvs can symlink to the same binary but have different
+ # site-packages. Only the identical invocation path is the host.
+ return os.path.normcase(os.path.abspath(py)) == os.path.normcase(
+ os.path.abspath(sys.executable)
+ )
+
+
+def interpreter_has_module(py: Path | str, module: str) -> bool:
+ """Does ``py`` import ``module``? Host is in-process; others are probed."""
+ if _same_interpreter(py):
+ return host_has_module(module)
+ key = (str(py), module)
+ cached = _interpreter_module_cache.get(key)
+ if cached is not None:
+ return cached
+ try:
+ proc = subprocess.run(
+ [str(py), "-c", f"import {module}"],
+ capture_output=True,
+ timeout=8,
+ env=_scrubbed_env(),
+ )
+ ok = proc.returncode == 0
+ except (OSError, subprocess.TimeoutExpired):
+ ok = False
+ _interpreter_module_cache[key] = ok
+ return ok
+
+
+def python_for_ingest(cwd: Path, ext: str = "") -> list[str]:
+ """Interpreter for one ``local_ingest.py`` file extension.
+
+ Workspace wins when it already has the extractor so a CE venv with
+ pypdf/openpyxl keeps PDF/XLSX. Host wins for python-pptx when the
+ workspace is a minimal/bare venv. Text formats follow the workspace
+ venv when present (scan/graph stay on that venv too).
+ """
+ ws_py = _workspace_venv_python(cwd)
+ need = _EXT_EXTRACTOR.get((ext or "").lower())
+ if need:
+ if ws_py is not None and interpreter_has_module(ws_py, need):
+ return [str(ws_py)]
+ if host_has_module(need) and sys.executable:
+ return [sys.executable]
+ if ws_py is not None:
+ return [str(ws_py)]
+ if sys.executable:
+ return [sys.executable]
+ return ["uv", "run", "python3"]
+ if ws_py is not None:
+ return [str(ws_py)]
+ if sys.executable:
+ return [sys.executable]
+ return ["uv", "run", "python3"]
+
+
+def _script_python(cwd: Path, *, script: str = "") -> list[str]:
"""Interpreter for a CE script run *against* a workspace.
- kuzu lives in the workspace `.venv` (setup.sh). `uv run python3`
- from the workspace cwd discovers that venv. The skill-root venv
- (if any) does not have kuzu.
+ kuzu lives in the workspace `.venv` (setup.sh). Graph, scan, and
+ query scripts always use that interpreter. ``local_ingest.py``
+ without an extension hint prefers the workspace venv; callers that
+ know the file type pass ``python=python_for_ingest(...)``.
"""
+ name = Path(script).name if script else ""
+ if name == "local_ingest.py":
+ return python_for_ingest(cwd, "")
py = _workspace_venv_python(cwd)
if py is not None:
return [str(py)]
return ["uv", "run", "python3"]
+def script_python_for(cwd: Path, script: str, *, ext: str = "") -> list[str]:
+ """Public wrapper: interpreter argv for ``script`` in ``cwd``."""
+ if Path(script).name == "local_ingest.py":
+ return python_for_ingest(Path(cwd), ext)
+ return _script_python(Path(cwd), script=script)
+
+
_ALLOWED_SH = frozenset({"evolve_guard.sh"})
@@ -551,6 +711,7 @@ def run_script(
cwd: Path,
timeout: float = 120.0,
require_json: bool = True,
+ python: list[str] | None = None,
) -> dict[str, Any]:
"""Synchronously run a CE script and parse JSON from stdout.
@@ -577,7 +738,11 @@ def run_script(
if not Path(cwd).is_dir():
return {"error": f"workspace is not a directory: {cwd}"}
- cmd = [*_script_python(Path(cwd)), str(script_path), *(args or [])]
+ cmd = [
+ *(python or _script_python(Path(cwd), script=script_path.name)),
+ str(script_path),
+ *(args or []),
+ ]
try:
proc = subprocess.run(
cmd,
diff --git a/src/switchbay/command_palettes.py b/src/switchbay/command_palettes.py
index 204925d..2c364da 100644
--- a/src/switchbay/command_palettes.py
+++ b/src/switchbay/command_palettes.py
@@ -88,6 +88,14 @@
"list_threads",
"save_plot",
),
+ "research": _WIKI_READ + (
+ "research_search",
+ "research_fetch",
+ "ce_ingest",
+ "ce_query",
+ "ce_graph_retrieve",
+ "read_source",
+ ),
"code": _WIKI_READ + (
"read_workspace_plan",
"update_work_plan",
@@ -126,6 +134,7 @@
"report": "Rich HTML report (dropped on small local rungs)",
"work": "Work desk — projects + portfolio (not /project)",
"code": "Code desk — explore / plan / edit / review",
+ "research": "Research desk — search, fetch, ingest, cited brief",
}
# Keyword → shipped palette when a user command doesn't name tools.
diff --git a/src/switchbay/comms_review.py b/src/switchbay/comms_review.py
new file mode 100644
index 0000000..ba3d3d4
--- /dev/null
+++ b/src/switchbay/comms_review.py
@@ -0,0 +1,764 @@
+"""Comms review queue: metadata-only discovery, approval, revocation.
+
+Email threads and chat channels are sources. Discovery may only store
+safe metadata. Body fetch, MIME/HTML parsing, classification of body
+text, transit, logs of content, and wiki ingestion require an explicit
+per-workspace approval of a stable thread/channel key — and a fresh
+non-secret re-check immediately before every content fetch.
+
+Revocation is durable, keyed by provider + account + stable id (not
+subject/display name), survives restarts and account routing changes,
+and wins races with polling/queued work.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import logging
+import re
+import threading
+import time
+from pathlib import Path
+from typing import Any
+
+from . import admin_policy, atomicio, statedir
+
+log = logging.getLogger("switchbay.comms_review")
+
+STORE_VERSION = 1
+_LOCK = threading.RLock()
+
+KIND_EMAIL = "email_thread"
+KIND_CHANNEL = "channel"
+KIND_CHAT = "chat"
+
+STATUS_PENDING = "pending"
+STATUS_APPROVED = "approved"
+STATUS_REJECTED = "rejected"
+STATUS_REVOKED = "revoked"
+STATUS_BLOCKED = "blocked"
+
+DEFAULT_CLASSIFICATION_HEADERS = (
+ "Sensitivity",
+ "Classification",
+ "X-MS-Exchange-Organization-Classification",
+ "MSIP_Labels",
+ "X-Microsoft-Classification",
+ "X-Sensitivity",
+)
+DEFAULT_SECRET_NAMES = (
+ "secret",
+ "top secret",
+ "top-secret",
+ "classified secret",
+)
+# Outlook Sensitivity enum values that are not Secret/Top Secret.
+_KNOWN_PUBLIC = frozenset({
+ "normal", "personal", "private", "confidential", "public",
+ "internal", "general", "unclassified", "none", "",
+})
+
+_MSGID_RE = re.compile(r"<[^>]+>")
+_SECRET_SUBJ = "[redacted: classified]"
+
+
+def _store_path() -> Path:
+ return statedir.state_root() / "comms-review.json"
+
+
+def empty_store() -> dict[str, Any]:
+ return {"version": STORE_VERSION, "items": {}, "revoked": {}}
+
+
+def load() -> dict[str, Any]:
+ p = _store_path()
+ try:
+ raw = json.loads(p.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return empty_store()
+ if not isinstance(raw, dict) or raw.get("version") != STORE_VERSION:
+ return empty_store()
+ items = raw.get("items")
+ revoked = raw.get("revoked")
+ if not isinstance(items, dict):
+ items = {}
+ if not isinstance(revoked, dict):
+ revoked = {}
+ return {"version": STORE_VERSION, "items": items, "revoked": revoked}
+
+
+def save(data: dict[str, Any]) -> None:
+ p = _store_path()
+ p.parent.mkdir(parents=True, exist_ok=True)
+ payload = {
+ "version": STORE_VERSION,
+ "items": data.get("items") if isinstance(data.get("items"), dict) else {},
+ "revoked": data.get("revoked") if isinstance(data.get("revoked"), dict) else {},
+ }
+ atomicio.write_json_atomic(p, payload)
+ try:
+ p.chmod(0o600)
+ except OSError:
+ pass
+
+
+def source_key(provider: str, account_id: str, stable_id: str) -> str:
+ p = (provider or "").strip().lower()
+ a = (account_id or "").strip()
+ s = (stable_id or "").strip()
+ return f"{p}:{a}:{s}"
+
+
+def _norm_msgid(raw: str) -> str:
+ t = (raw or "").strip()
+ if not t:
+ return ""
+ if t[0] != "<":
+ t = f"<{t.strip('<>')}>"
+ return t.lower()
+
+
+def imap_thread_stable_id(
+ *,
+ message_id: str,
+ references: str,
+ in_reply_to: str,
+ uidvalidity: str | int,
+ fallback_uid: str = "",
+) -> str:
+ """Root Message-ID from References / In-Reply-To / Message-ID, scoped by UIDVALIDITY."""
+ refs = [_norm_msgid(m) for m in _MSGID_RE.findall(references or "")]
+ root = ""
+ if refs:
+ root = refs[0]
+ if not root:
+ irt = [_norm_msgid(m) for m in _MSGID_RE.findall(in_reply_to or "")]
+ if irt:
+ root = irt[0]
+ if not root:
+ root = _norm_msgid(message_id)
+ if not root:
+ root = f"uid:{fallback_uid}" if fallback_uid else "unknown"
+ uv = str(uidvalidity or "0")
+ digest = hashlib.sha1(root.encode("utf-8", errors="replace")).hexdigest()[:20]
+ return f"{uv}:{digest}"
+
+
+# Gmail/system labels that are not security classifications.
+SYSTEM_LABEL_IDS = frozenset({
+ "inbox", "unread", "starred", "important", "sent", "draft", "spam",
+ "trash", "category_personal", "category_social", "category_promotions",
+ "category_updates", "category_forums", "yellow_star", "chat", "all",
+ "snoozed",
+})
+
+
+def classification_policy() -> dict[str, Any]:
+ data = admin_policy.load()
+ raw = data.get("comms") if isinstance(data.get("comms"), dict) else {}
+ headers: list[str] = list(DEFAULT_CLASSIFICATION_HEADERS)
+ seen_h = {h.lower() for h in headers}
+ for h in raw.get("classification_headers") or []:
+ name = str(h).strip()
+ if name and name.lower() not in seen_h:
+ headers.append(name)
+ seen_h.add(name.lower())
+ names: list[str] = list(DEFAULT_SECRET_NAMES)
+ seen_n = {n.lower() for n in names}
+ for n in raw.get("secret_names") or []:
+ s = str(n).strip()
+ if s and s.lower() not in seen_n:
+ names.append(s)
+ seen_n.add(s.lower())
+ label_ids = raw.get("tenant_label_ids")
+ if not isinstance(label_ids, list):
+ label_ids = []
+ require = raw.get("require_classification")
+ if require is None:
+ require = admin_policy.profile() == "enterprise"
+ # Baked enterprise may not disable classification requirements.
+ if data.get("tighten") and str(data.get("profile") or "") == "enterprise":
+ require = True
+ return {
+ "headers": headers,
+ "secret_names": names,
+ "tenant_label_ids": [str(x).strip() for x in label_ids if str(x).strip()],
+ "require_classification": bool(require),
+ "enterprise": admin_policy.profile() == "enterprise",
+ }
+
+
+def _secret_match(value: str, secret_names: list[str]) -> bool:
+ low = (value or "").lower()
+ if not low.strip():
+ return False
+ for name in secret_names:
+ n = name.lower().strip()
+ if not n:
+ continue
+ if n in low.split(";") or n == low.strip():
+ return True
+ # MIP / header blobs: "MSIP_Label_...; name=Secret"
+ if re.search(rf"(?:^|[=\s;,]){re.escape(n)}(?:$|[\s;,])", low):
+ return True
+ return False
+
+
+def classify_metadata(
+ *,
+ headers: dict[str, str] | None = None,
+ label_ids: list[str] | None = None,
+) -> dict[str, Any]:
+ """Inspect classification labels/headers only. Never looks at bodies.
+
+ verdict:
+ secret — Secret/Top Secret (or tenant secret label id)
+ unknown — a classification mark we do not recognise
+ missing — no classification material present
+ clear — recognised non-secret mark, or missing under open profile
+ """
+ pol = classification_policy()
+ headers = {str(k): str(v) for k, v in (headers or {}).items() if k}
+ lower_map = {k.lower(): v for k, v in headers.items()}
+ raw_labels = [str(x) for x in (label_ids or []) if x]
+ security_labels = [x for x in raw_labels if x.lower() not in SYSTEM_LABEL_IDS]
+ hits: list[str] = []
+ for h in pol["headers"]:
+ val = lower_map.get(h.lower(), "")
+ if val:
+ hits.extend(p for p in val.split("\n") if p)
+ blob = " ".join(list(headers.values()) + raw_labels)
+ tenants = pol["tenant_label_ids"]
+ for tid in tenants:
+ if tid and tid.lower() in blob.lower():
+ return {
+ "verdict": "secret",
+ "reason": "tenant_secret_label",
+ "marks": [tid],
+ }
+ for lid in security_labels:
+ hits.append(lid)
+ if lid in tenants:
+ return {
+ "verdict": "secret",
+ "reason": "tenant_secret_label",
+ "marks": [lid],
+ }
+ if any(_secret_match(h, pol["secret_names"]) for h in hits):
+ return {"verdict": "secret", "reason": "secret_mark", "marks": hits[:8]}
+ class_hits = [h for h in hits if h.strip().lower() not in SYSTEM_LABEL_IDS]
+ if not class_hits:
+ if pol["require_classification"] or pol["enterprise"]:
+ return {
+ "verdict": "missing",
+ "reason": "classification_required",
+ "marks": [],
+ }
+ return {"verdict": "clear", "reason": "unmarked_open", "marks": []}
+ normalised = [h.strip().lower() for h in class_hits]
+ tenant_l = {x.lower() for x in tenants}
+ if any(n not in _KNOWN_PUBLIC and n not in tenant_l for n in normalised):
+ if pol["require_classification"] or pol["enterprise"]:
+ return {"verdict": "unknown", "reason": "unknown_label", "marks": class_hits[:8]}
+ return {"verdict": "clear", "reason": "recognised", "marks": class_hits[:8]}
+
+
+def is_revoked(key: str, data: dict[str, Any] | None = None) -> bool:
+ st = data if data is not None else load()
+ if key in (st.get("revoked") or {}):
+ return True
+ item = (st.get("items") or {}).get(key)
+ return isinstance(item, dict) and item.get("status") == STATUS_REVOKED
+
+
+def merge_header_maps(*maps: dict[str, Any] | None) -> dict[str, str]:
+ """Join header maps conservatively: duplicate names keep every value.
+
+ A later ``Sensitivity: normal`` must not erase an earlier Secret.
+ Body-like keys are dropped.
+ """
+ out: dict[str, str] = {}
+ canon: dict[str, str] = {}
+ skip = {"text", "body", "snippet"}
+ for mapping in maps:
+ if not isinstance(mapping, dict):
+ continue
+ for raw_k, raw_v in mapping.items():
+ if not raw_k:
+ continue
+ name = str(raw_k)
+ lk = name.lower()
+ if lk in skip:
+ continue
+ val = str(raw_v or "")
+ if lk in canon:
+ prev = out.get(canon[lk], "")
+ extra = [p for p in val.split("\n") if p and p not in prev.split("\n")]
+ if extra:
+ out[canon[lk]] = prev + ("\n" if prev else "") + "\n".join(extra)
+ else:
+ canon[lk] = name
+ out[name] = val
+ return out
+
+
+def _safe_subject(subject: str, *, secret: bool) -> str:
+ if secret:
+ return _SECRET_SUBJ
+ return (subject or "")[:180]
+
+
+def _ingest_view(item: dict[str, Any], workspace: str | None) -> dict[str, str]:
+ by = item.get("ingest_by_workspace")
+ if not isinstance(by, dict) or not by:
+ return {}
+ ws = str(workspace or "").strip()
+ if ws and isinstance(by.get(ws), dict):
+ rec = by[ws]
+ return {"state": str(rec.get("state") or ""), "error": str(rec.get("error") or "")}
+ # No bound workspace: show the most recently updated row.
+ latest: dict[str, Any] | None = None
+ latest_at = -1.0
+ for rec in by.values():
+ if not isinstance(rec, dict):
+ continue
+ try:
+ at = float(rec.get("at") or 0)
+ except (TypeError, ValueError):
+ at = 0.0
+ if at >= latest_at:
+ latest_at = at
+ latest = rec
+ if not isinstance(latest, dict):
+ return {}
+ return {"state": str(latest.get("state") or ""), "error": str(latest.get("error") or "")}
+
+
+def _public_item(item: dict[str, Any], *, workspace: str | None = None) -> dict[str, Any]:
+ secret = item.get("status") == STATUS_BLOCKED and item.get("block_reason") in (
+ "secret", "secret_mark", "tenant_secret_label",
+ )
+ ingest = _ingest_view(item, workspace)
+ return {
+ "key": item.get("key"),
+ "provider": item.get("provider"),
+ "account_id": item.get("account_id"),
+ "stable_id": item.get("stable_id"),
+ "kind": item.get("kind"),
+ "status": item.get("status"),
+ "block_reason": item.get("block_reason"),
+ "block_detail": item.get("block_detail"),
+ "subject": _safe_subject(str(item.get("subject") or ""), secret=bool(secret)),
+ "sender": "" if secret else (item.get("sender") or ""),
+ "deep_link": item.get("deep_link") or "",
+ "labels": [] if secret else list(item.get("labels") or [])[:12],
+ "approved_workspaces": list(item.get("approved_workspaces") or []),
+ "suggested_workspaces": list(item.get("suggested_workspaces") or []),
+ "suggested_relevance": item.get("suggested_relevance") if isinstance(item.get("suggested_relevance"), dict) else {},
+ "content_capability": item.get("content_capability") or "ok",
+ "content_capability_reason": item.get("content_capability_reason") or "",
+ "last_ts": item.get("last_ts"),
+ "updated_at": item.get("updated_at"),
+ "message_count": int(item.get("message_count") or 0),
+ "ingest_state": ingest.get("state") or "",
+ "ingest_error": ingest.get("error") or "",
+ }
+
+
+def upsert_discovery(record: dict[str, Any]) -> dict[str, Any]:
+ """Record safe metadata. Never stores body/snippet. Never auto-approves."""
+ provider = str(record.get("provider") or "")
+ account_id = str(record.get("account_id") or "")
+ stable_id = str(record.get("stable_id") or "")
+ key = source_key(provider, account_id, stable_id)
+ headers = record.get("headers") if isinstance(record.get("headers"), dict) else {}
+ labels = record.get("labels") if isinstance(record.get("labels"), list) else []
+ verdict = classify_metadata(headers=headers, label_ids=labels)
+ secret = verdict["verdict"] == "secret"
+ with _LOCK:
+ st = load()
+ if is_revoked(key, st):
+ item = dict((st["items"].get(key) if isinstance(st["items"].get(key), dict) else {}) or {})
+ item.update({
+ "key": key,
+ "provider": provider,
+ "account_id": account_id,
+ "stable_id": stable_id,
+ "status": STATUS_REVOKED,
+ "subject": _SECRET_SUBJ if secret else (record.get("subject") or item.get("subject") or ""),
+ "updated_at": time.time(),
+ })
+ st["items"][key] = item
+ save(st)
+ return _public_item(item)
+ existing = st["items"].get(key) if isinstance(st["items"].get(key), dict) else {}
+ status = str(existing.get("status") or STATUS_PENDING)
+ block_reason = existing.get("block_reason")
+ block_detail = existing.get("block_detail")
+ if secret:
+ status = STATUS_BLOCKED
+ block_reason = verdict.get("reason") or "secret"
+ block_detail = "classified: secret — content fetch refused"
+ elif verdict["verdict"] in ("unknown", "missing") and (
+ classification_policy()["enterprise"] or classification_policy()["require_classification"]
+ ):
+ # Stay pending for review, but content is fail-closed until marks are clear.
+ block_reason = verdict.get("reason")
+ block_detail = (
+ "classification missing or unknown; content fetch refused under enterprise policy"
+ )
+ if status == STATUS_APPROVED:
+ pass # keep approval; new mail on an approved thread stays approved
+ elif status == STATUS_REVOKED:
+ pass
+ elif status == STATUS_BLOCKED and not secret:
+ status = STATUS_PENDING
+ elif status == STATUS_REJECTED:
+ # New activity may re-surface as a suggestion, never as approval.
+ status = STATUS_PENDING
+ elif status not in (STATUS_APPROVED, STATUS_REVOKED, STATUS_BLOCKED):
+ status = STATUS_PENDING
+ cap = str(record.get("content_capability") or existing.get("content_capability") or "ok")
+ cap_reason = str(
+ record.get("content_capability_reason")
+ or existing.get("content_capability_reason")
+ or ""
+ )
+ stored_headers = merge_header_maps(
+ existing.get("headers") if isinstance(existing.get("headers"), dict) else {},
+ headers,
+ )
+ item = {
+ **existing,
+ "key": key,
+ "provider": provider,
+ "account_id": account_id,
+ "stable_id": stable_id,
+ "kind": record.get("kind") or existing.get("kind") or KIND_EMAIL,
+ "status": status,
+ "block_reason": block_reason,
+ "block_detail": block_detail,
+ "subject": _SECRET_SUBJ if secret else (record.get("subject") or existing.get("subject") or ""),
+ "sender": "" if secret else (record.get("sender") or existing.get("sender") or ""),
+ "deep_link": record.get("deep_link") or existing.get("deep_link") or "",
+ "labels": [] if secret else list(labels)[:16],
+ "headers": stored_headers,
+ "approved_workspaces": list(existing.get("approved_workspaces") or []),
+ "suggested_workspaces": list(existing.get("suggested_workspaces") or []),
+ "suggested_relevance": existing.get("suggested_relevance") or {},
+ "content_capability": cap,
+ "content_capability_reason": cap_reason,
+ "last_ts": record.get("ts") or existing.get("last_ts") or time.time(),
+ "updated_at": time.time(),
+ "message_count": int(existing.get("message_count") or 0) + 1,
+ "created_at": existing.get("created_at") or time.time(),
+ }
+ refs = list(existing.get("fetch_refs") or [])
+ new_ref = record.get("fetch_ref") if isinstance(record.get("fetch_ref"), dict) else None
+ if new_ref:
+ rid = str(new_ref.get("id") or new_ref.get("uid") or "")
+ if rid and not any(str(r.get("id") or r.get("uid") or "") == rid for r in refs if isinstance(r, dict)):
+ refs.append({k: v for k, v in new_ref.items() if k != "text" and k != "body" and k != "snippet"})
+ item["fetch_refs"] = refs
+ for meta_k in ("uidvalidity", "thread_id", "conversation_id", "imap_uid"):
+ if record.get(meta_k):
+ item[meta_k] = record.get(meta_k)
+ # Never persist body/snippet even if a caller passed one.
+ item.pop("text", None)
+ item.pop("snippet", None)
+ item.pop("body", None)
+ st["items"][key] = item
+ save(st)
+ return _public_item(item)
+
+
+def set_suggestions(
+ key: str,
+ *,
+ workspaces: list[str],
+ relevance: dict[str, float] | None = None,
+) -> None:
+ """Classifier suggestions only. Never changes approval/revocation."""
+ with _LOCK:
+ st = load()
+ if is_revoked(key, st):
+ return
+ item = st["items"].get(key)
+ if not isinstance(item, dict):
+ return
+ if item.get("status") in (STATUS_APPROVED, STATUS_REVOKED, STATUS_BLOCKED):
+ item["suggested_workspaces"] = list(workspaces)
+ if relevance:
+ item["suggested_relevance"] = {
+ str(k): float(v) for k, v in relevance.items()
+ }
+ st["items"][key] = item
+ save(st)
+ return
+ item["suggested_workspaces"] = list(workspaces)
+ if relevance:
+ item["suggested_relevance"] = {str(k): float(v) for k, v in relevance.items()}
+ st["items"][key] = item
+ save(st)
+
+
+def approve(key: str, workspace: str, *, allowed: list[str]) -> dict[str, Any]:
+ ws = str(workspace or "").strip()
+ if not ws:
+ return {"ok": False, "error": "workspace is required"}
+ allow = {str(p) for p in allowed}
+ if ws not in allow:
+ return {"ok": False, "error": "workspace is not on this account allowlist"}
+ with _LOCK:
+ st = load()
+ if is_revoked(key, st):
+ return {"ok": False, "error": "revoked — content will not be fetched"}
+ item = st["items"].get(key)
+ if not isinstance(item, dict):
+ return {"ok": False, "error": "unknown comms source"}
+ if item.get("status") == STATUS_BLOCKED and item.get("block_reason") in (
+ "secret", "secret_mark", "tenant_secret_label",
+ ):
+ return {"ok": False, "error": "secret sources cannot be approved"}
+ approved = list(item.get("approved_workspaces") or [])
+ if ws not in approved:
+ approved.append(ws)
+ item["approved_workspaces"] = approved
+ item["status"] = STATUS_APPROVED
+ item["updated_at"] = time.time()
+ st["items"][key] = item
+ save(st)
+ return {"ok": True, "item": _public_item(item)}
+
+
+def reject(key: str) -> dict[str, Any]:
+ with _LOCK:
+ st = load()
+ if is_revoked(key, st):
+ item = st["items"].get(key)
+ return {"ok": True, "item": _public_item(item) if isinstance(item, dict) else {"key": key, "status": STATUS_REVOKED}}
+ item = st["items"].get(key)
+ if not isinstance(item, dict):
+ return {"ok": False, "error": "unknown comms source"}
+ item["status"] = STATUS_REJECTED
+ item["updated_at"] = time.time()
+ st["items"][key] = item
+ save(st)
+ return {"ok": True, "item": _public_item(item)}
+
+
+def revoke(key: str, *, reason: str = "user") -> dict[str, Any]:
+ with _LOCK:
+ st = load()
+ st.setdefault("revoked", {})[key] = {
+ "revoked_at": time.time(),
+ "reason": (reason or "user")[:120],
+ }
+ item = st["items"].get(key) if isinstance(st["items"].get(key), dict) else {
+ "key": key, "status": STATUS_REVOKED,
+ }
+ item["status"] = STATUS_REVOKED
+ item["updated_at"] = time.time()
+ item["approved_workspaces"] = []
+ st["items"][key] = item
+ save(st)
+ return {"ok": True, "item": _public_item(item)}
+
+
+def get_item(key: str, workspace: str | None = None) -> dict[str, Any] | None:
+ st = load()
+ item = st["items"].get(key)
+ if not isinstance(item, dict):
+ if is_revoked(key, st):
+ return {"key": key, "status": STATUS_REVOKED}
+ return None
+ return _public_item(item, workspace=workspace)
+
+
+def set_ingest_state(
+ key: str,
+ workspace: str,
+ *,
+ state: str,
+ error: str = "",
+) -> None:
+ """Record per-workspace ingest outcome. Never changes approval."""
+ ws = str(workspace or "").strip()
+ if not key or not ws:
+ return
+ stt = str(state or "").strip()[:32]
+ err = str(error or "").strip()[:240]
+ with _LOCK:
+ st = load()
+ item = st["items"].get(key)
+ if not isinstance(item, dict):
+ return
+ by = item.get("ingest_by_workspace")
+ if not isinstance(by, dict):
+ by = {}
+ by[ws] = {"state": stt, "error": err, "at": time.time()}
+ item["ingest_by_workspace"] = by
+ item["updated_at"] = time.time()
+ st["items"][key] = item
+ save(st)
+
+
+def get_raw_item(key: str) -> dict[str, Any] | None:
+ """Internal: fetch refs / uidvalidity. Never includes body fields."""
+ st = load()
+ item = st["items"].get(key)
+ if not isinstance(item, dict):
+ return None
+ raw = dict(item)
+ raw.pop("text", None)
+ raw.pop("snippet", None)
+ raw.pop("body", None)
+ return raw
+
+
+def list_items(*, workspace: str | None = None, account_id: str | None = None) -> list[dict[str, Any]]:
+ from . import streams
+ st = load()
+ allow_by_acct: dict[str, set[str]] = {}
+ for acct in streams.list_accounts():
+ aid = str(acct.get("id") or "")
+ if aid:
+ allow_by_acct[aid] = set(streams.allowed_workspaces(acct))
+ out: list[dict[str, Any]] = []
+ ws = str(workspace or "").strip()
+ for item in (st.get("items") or {}).values():
+ if not isinstance(item, dict):
+ continue
+ aid = str(item.get("account_id") or "")
+ if account_id and aid != account_id:
+ continue
+ allowed = allow_by_acct.get(aid)
+ if allowed is None:
+ # Account gone: do not fall back to the active workspace.
+ continue
+ if ws and ws not in allowed:
+ continue
+ pub = _public_item(item, workspace=ws or None)
+ if ws:
+ approved = ws in (pub.get("approved_workspaces") or [])
+ suggested = ws in (pub.get("suggested_workspaces") or [])
+ pending = pub.get("status") in (STATUS_PENDING, STATUS_BLOCKED)
+ revoked = pub.get("status") == STATUS_REVOKED
+ if not (approved or suggested or pending or revoked):
+ continue
+ out.append(pub)
+ out.sort(key=lambda r: float(r.get("updated_at") or 0), reverse=True)
+ return out
+
+
+def authorize_content_fetch(
+ key: str,
+ workspace: str,
+ *,
+ allowed: list[str],
+ headers: dict[str, str] | None = None,
+ label_ids: list[str] | None = None,
+ classify: bool = True,
+) -> tuple[bool, str, dict[str, Any] | None]:
+ """Reload store immediately before a body fetch.
+
+ ``classify=False`` is auth-only (approval / allowlist / revocation /
+ capability). Cached discovery headers must not be used as a stand-in
+ for a fresh per-message classification — pass freshly fetched headers
+ with ``classify=True``.
+
+ Revocation wins. Secret cannot be overridden by prior approval.
+ """
+ st = load()
+ if is_revoked(key, st):
+ return False, "revoked", get_item(key)
+ item = st["items"].get(key)
+ if not isinstance(item, dict):
+ return False, "unknown comms source", None
+ ws = str(workspace or "")
+ if ws not in {str(p) for p in allowed}:
+ return False, "workspace is not on this account allowlist", _public_item(item)
+ if ws not in {str(p) for p in (item.get("approved_workspaces") or [])}:
+ return False, "not approved for this workspace", _public_item(item)
+ if item.get("status") != STATUS_APPROVED:
+ return False, f"status is {item.get('status')}", _public_item(item)
+ if str(item.get("content_capability") or "ok") == "fail_closed":
+ return False, str(
+ item.get("content_capability_reason")
+ or "adapter cannot establish safety before body fetch"
+ ), _public_item(item)
+ if not classify:
+ return True, "", _public_item(item)
+ verdict = classify_metadata(headers=headers, label_ids=label_ids)
+ if verdict["verdict"] == "secret":
+ revoke(key, reason="secret_recheck")
+ with _LOCK:
+ st2 = load()
+ rec = st2["items"].get(key)
+ if isinstance(rec, dict):
+ rec["status"] = STATUS_BLOCKED
+ rec["block_reason"] = verdict.get("reason") or "secret"
+ rec["block_detail"] = "secret on re-check — approval ignored"
+ rec["subject"] = _SECRET_SUBJ
+ rec["sender"] = ""
+ st2["items"][key] = rec
+ save(st2)
+ return False, "secret — content fetch refused", get_item(key)
+ if verdict["verdict"] in ("unknown", "missing") and (
+ classification_policy()["enterprise"] or classification_policy()["require_classification"]
+ ):
+ return False, "classification missing or unknown; not assumed public", _public_item(item)
+ return True, "", _public_item(item)
+
+
+def suggest_relevance(
+ record: dict[str, Any],
+ allowed_workspaces: list[str],
+) -> list[str]:
+ """Deterministic metadata suggestion. Never approves. Never uses bodies."""
+ verdict = classify_metadata(
+ headers=record.get("headers") if isinstance(record.get("headers"), dict) else {},
+ label_ids=record.get("labels") if isinstance(record.get("labels"), list) else [],
+ )
+ if verdict["verdict"] == "secret":
+ return []
+ if record.get("status") == STATUS_BLOCKED:
+ return []
+ subj = str(record.get("subject") or "")
+ if subj == _SECRET_SUBJ:
+ return []
+ blob = f"{subj} {record.get('sender') or ''} {record.get('kind') or ''}".lower()
+ hits: list[str] = []
+ for path in allowed_workspaces:
+ name = Path(path).name.lower()
+ if name and name in blob:
+ hits.append(path)
+ if not hits and len(allowed_workspaces) == 1:
+ hits = [allowed_workspaces[0]]
+ return hits
+
+
+def approved_items_for_account(account_id: str) -> list[dict[str, Any]]:
+ st = load()
+ out: list[dict[str, Any]] = []
+ for item in (st.get("items") or {}).values():
+ if not isinstance(item, dict):
+ continue
+ if str(item.get("account_id") or "") != account_id:
+ continue
+ if item.get("status") != STATUS_APPROVED:
+ continue
+ if is_revoked(str(item.get("key") or ""), st):
+ continue
+ out.append(dict(item))
+ return out
+
+
+def pending_count() -> int:
+ st = load()
+ n = 0
+ for item in (st.get("items") or {}).values():
+ if isinstance(item, dict) and item.get("status") == STATUS_PENDING:
+ n += 1
+ return n
diff --git a/src/switchbay/daemon.py b/src/switchbay/daemon.py
index 48d249c..1436a26 100644
--- a/src/switchbay/daemon.py
+++ b/src/switchbay/daemon.py
@@ -876,6 +876,11 @@ async def _warm_curation_history(workspace: Path) -> None:
log.exception("curation-history pre-warm failed for %s", workspace)
+def _desk_live_view(workspace: Path | None = None) -> dict[str, Any]:
+ from .agents.desk_admission import public_view
+ return public_view(workspace)
+
+
async def handle_settings_get(request: web.Request) -> web.Response:
"""General app preferences (see app_settings). Read-only mirror of
settings.json plus a couple of derived, display-only fields."""
@@ -899,6 +904,7 @@ async def handle_settings_get(request: web.Request) -> web.Response:
"media": media,
"orchestration_preference": orchestration_policy.get_preference(),
"orchestration_denied_models": orchestration_policy.get_denied_models(workspace),
+ **_desk_live_view(workspace),
})
@@ -925,6 +931,11 @@ async def handle_settings_post(request: web.Request) -> web.Response:
log.exception("rail-history relocation failed for %s", workspace)
if "orchestration_preference" in body:
orchestration_policy.set_preference(body["orchestration_preference"])
+ if "desk_max_live_workers" in body:
+ try:
+ app_settings.set_desk_max_live_workers(int(body["desk_max_live_workers"]))
+ except (TypeError, ValueError) as e:
+ return web.json_response({"error": str(e)}, status=400)
if "orchestration_denied_models" in body:
raw = body["orchestration_denied_models"]
if raw is None:
@@ -1337,6 +1348,9 @@ async def _fire_schedule(
prompt = str(item.get("prompt") or "").strip()
if not sid or not prompt:
return
+ kind, spec, sargs = _parse_schedule_prompt(prompt)
+ if kind == "skip":
+ return
await asyncio.to_thread(schedules.mark_started, store, sid, "pending")
pref = item.get("preference")
try:
@@ -1352,11 +1366,18 @@ async def _fire_schedule(
try:
for workspace in targets:
try:
- rid = await _dispatch_auto(
- app, None, prompt,
- preference=pref_f,
- workspace_override=workspace,
- )
+ if kind == "desk" and spec is not None:
+ _seat_desk_now(workspace, spec, sargs)
+ rid = await _run_desk(
+ app, spec, prompt=sargs,
+ workspace=workspace, preference=pref_f,
+ )
+ else:
+ rid = await _dispatch_auto(
+ app, None, prompt,
+ preference=pref_f,
+ workspace_override=workspace,
+ )
if rid:
last_rid = rid
await asyncio.to_thread(schedules.set_running, store, sid, rid)
@@ -1409,7 +1430,11 @@ async def _tick_schedules(app: web.Application) -> None:
for did in ended:
if did not in sbk.DESK_INFO:
continue
- sbk.quiet(ws, did, keep_run=True)
+ spec = _desk_spec_from_id(did)
+ if spec is not None:
+ _quiet_desk(app, spec, ws)
+ else:
+ sbk.quiet(ws, did, keep_run=True)
except Exception: # noqa: BLE001
log.exception("desk quiet on schedule window failed")
for item in items:
@@ -1418,19 +1443,11 @@ async def _tick_schedules(app: web.Application) -> None:
asyncio.create_task(_fire_schedule(app, ws, item))
-def _workspace_from_request(request: web.Request) -> Path:
- raw = (request.query.get("workspace") or "").strip()
- workspace: Path = request.app["workspace"]
- if raw:
- cand = Path(raw)
- if cand.is_dir() and workspaces.is_within_home(cand):
- workspace = cand
- return workspace
-
-
async def handle_orchestration_org(request: web.Request) -> web.Response:
"""Standing desk roster for Agent Space when no run is live."""
workspace = _workspace_from_request(request)
+ if isinstance(workspace, web.Response):
+ return workspace
org = await asyncio.to_thread(orchestrator_fs.load_org, workspace)
return web.json_response({
"org": org,
@@ -1439,6 +1456,62 @@ async def handle_orchestration_org(request: web.Request) -> web.Response:
})
+def _desk_has_live_work(
+ app: web.Application, workspace: Path, rec: Any,
+) -> bool:
+ """True when a real run or tracked launch is still going."""
+ runs: dict[str, dict[str, Any]] = app.get("runs") or {}
+ rid = getattr(rec, "run_id", None)
+ if rid:
+ r = runs.get(str(rid))
+ if r and r.get("status") in _ORCH_BUSY_STATUSES:
+ ws = str(r.get("workspace") or "")
+ if not ws or ws == str(workspace):
+ return True
+ launches: dict[tuple[str, str], asyncio.Task] = app.get("desk_launches") or {}
+ task = launches.get(_desk_launch_key(workspace, str(rec.desk_id)))
+ return bool(task is not None and not task.done())
+
+
+def _reconcile_desk_state(
+ app: web.Application, workspace: Path, rec: Any,
+) -> str:
+ """Derive working/quiet from live runs and launches only.
+
+ Checkpoints prove resumability, not liveness. A leftover
+ running/interrupted/waiting_limits/planning snapshot without a live
+ run or tracked launch is quiet, keeping the resumable ID.
+ """
+ from . import kernel as sbk
+ if rec.state == sbk.STATE_DISMISSED:
+ return rec.state
+ if _desk_has_live_work(app, workspace, rec):
+ if rec.state != sbk.STATE_WORKING:
+ try:
+ sbk.set_working(workspace, rec.desk_id, run_id=rec.run_id)
+ except Exception: # noqa: BLE001
+ pass
+ return sbk.STATE_WORKING
+ if rec.state != sbk.STATE_WORKING:
+ return rec.state
+ phase = ""
+ if rec.run_id:
+ try:
+ ck = orchestration.load_checkpoint(workspace, rec.run_id)
+ except Exception: # noqa: BLE001
+ ck = None
+ st = (ck or {}).get("status") if isinstance(ck, dict) else None
+ phase = str((st or {}).get("phase") or "")
+ try:
+ sbk.quiet(
+ workspace, rec.desk_id, run_id=rec.run_id,
+ keep_run=bool(rec.run_id and phase not in ("completed", "")),
+ )
+ except Exception: # noqa: BLE001
+ pass
+ return sbk.STATE_QUIET
+
+
async def handle_desks_list(request: web.Request) -> web.Response:
"""Standing (working + quiet) desks for the dashboard Desks list."""
from . import kernel as sbk
@@ -1452,8 +1525,11 @@ async def handle_desks_list(request: web.Request) -> web.Response:
objective = rec.objective
if not objective and org and rec.run_id and rec.run_id == oid:
objective = org.get("objective")
+ state = _reconcile_desk_state(request.app, workspace, rec)
+ row = rec.to_dict()
+ row["state"] = state
desks.append({
- **rec.to_dict(),
+ **row,
"label": info.get("label") or rec.desk_id,
"slash": info.get("slash"),
"objective": objective,
@@ -3700,77 +3776,61 @@ async def handle_ce_action_run(request: web.Request) -> web.Response:
effective_pid = pid or _resolve_default_provider()
is_local = _provider_is_local(effective_pid)
- _lrung = None
- if is_local:
- _lcfg = await asyncio.to_thread(localllm.load_config)
- _lrung = rail_default.resolve_local_rung(
- localllm.ram_gb(),
- model_hint=rail_default.model_hint_from_cfg(_lcfg),
- )
- ce_prompt = _ce_action_prompt(
- action, args, local=is_local, local_rung=_lrung,
- )
- if ce_prompt is None:
- return web.json_response(
- {"error": f"not a CE action: {action}"}, status=400)
- extra_system = ""
- if action in ("curate", "curator"):
- cap = _CURATOR_PROFILE_CAP_TOKENS
- if _lrung is not None:
- cap = max(400, _lrung.extra_system_chars // 4)
- elif is_local:
- cap = 400
- prof = await asyncio.to_thread(_curator_profile, workspace, cap)
- extra_system = _curator_profile_system(prof)
- fb = await asyncio.to_thread(_review_feedback_system, workspace)
- if fb:
- extra_system = (extra_system + "\n\n" + fb).strip()
- prime = await asyncio.to_thread(
- _curate_wave_prime_system, workspace, args, local=is_local,
- )
- if prime:
- extra_system = (extra_system + "\n\n" + prime).strip()
-
+ is_curate = action in ("curate", "curator")
label = pid or _resolve_default_provider()
try:
plabel = llmgateway.get(label).LABEL
except llmgateway.ProviderError:
plabel = label
run_model = cp_model or _effective_model(label)
- excerpt = f"[{action} · background · {plabel} · {run_model}] {args}".strip()
-
- is_curate = action in ("curate", "curator")
- constrained = is_local or bool(args and len(args) <= 80 and "\n" not in args)
if is_curate:
raw_pref = body.get("preference")
try:
pref = float(raw_pref) if raw_pref is not None else None
except (TypeError, ValueError):
pref = None
- task = asyncio.create_task(_dispatch_auto(
- request.app, None, ce_prompt,
+ cspec = _desk_spec("curate") or {
+ "desk_id": "curate", "cancel": ("curate",), "slash": "curate",
+ "command": "curate", "task_kind": "curation", "staff": _CURATE_STAFF,
+ }
+ _launch_desk(
+ request.app, cspec, prompt=args, workspace=workspace,
preference=pref,
provider_override=pid, model_override=cp_model,
- input_excerpt=excerpt,
- extra_system=extra_system or None,
- command="curate",
- task_kind="curation",
- constrained=constrained,
lock_provider=bool(provider),
- ))
- else:
- task = asyncio.create_task(_dispatch_chat(
- request.app, None, ce_prompt,
- provider_override=pid, model_override=cp_model,
- input_excerpt=excerpt,
- extra_system=extra_system or None,
- command=action if is_local else None,
- ))
+ )
+ return web.json_response({
+ "ok": True, "action": action,
+ "provider": label, "provider_label": plabel, "model": run_model,
+ "background": True,
+ "orchestrated": not _curate_constrained(local=is_local, text=args),
+ })
+
+ _lrung = None
+ if is_local:
+ _lcfg = await asyncio.to_thread(localllm.load_config)
+ _lrung = rail_default.resolve_local_rung(
+ localllm.ram_gb(),
+ model_hint=rail_default.model_hint_from_cfg(_lcfg),
+ )
+ ce_prompt = _ce_action_prompt(
+ action, args, local=is_local, local_rung=_lrung,
+ )
+ if ce_prompt is None:
+ return web.json_response(
+ {"error": f"not a CE action: {action}"}, status=400)
+ excerpt = f"[{action} · background · {plabel} · {run_model}] {args}".strip()
+ task = asyncio.create_task(_dispatch_chat(
+ request.app, None, ce_prompt,
+ provider_override=pid, model_override=cp_model,
+ input_excerpt=excerpt,
+ command=action if is_local else None,
+ ))
task.add_done_callback(_make_dispatch_error_surface(request.app, None))
return web.json_response({
"ok": True, "action": action,
"provider": label, "provider_label": plabel, "model": run_model,
- "background": True, "orchestrated": is_curate and not constrained,
+ "background": True, "orchestrated": False,
})
@@ -4183,6 +4243,15 @@ async def handle_permission_request(request: web.Request) -> web.Response:
# by the display label so it survives per-session run-id churn.
muted: set[str] = request.app.setdefault("muted_origins", set())
if origin in muted:
+ # Protected egress must fail closed — never skip into the
+ # CLI's static allowlist.
+ if permissions.needs_web_consent(tool, tool_input):
+ return web.json_response({
+ "decision": "deny",
+ "remember": False,
+ "muted": True,
+ "reason": "web egress denied",
+ })
return web.json_response({"decision": "skip", "muted": True})
# Hard deny home/FS-wide scans BEFORE pre-approve / cards — agents
@@ -4201,11 +4270,23 @@ async def handle_permission_request(request: web.Request) -> web.Response:
"reason": deny_reason,
})
+ if permissions.needs_web_consent(tool, tool_input):
+ blocked = permissions.web_egress_block_reason(workspace, tool, tool_input)
+ if blocked:
+ return web.json_response({
+ "decision": "deny",
+ "remember": False,
+ "reason": blocked,
+ })
+ # Policy on: per-call card. Never short-circuit via allow-list.
+ else:
+ pattern = permissions.pattern_for(tool, tool_input)
+ if permissions.is_pre_approved(
+ workspace, pattern, tool=tool, tool_input=tool_input,
+ ):
+ return web.json_response({"decision": "approve", "remember": True, "cached": True})
+
pattern = permissions.pattern_for(tool, tool_input)
- if permissions.is_pre_approved(
- workspace, pattern, tool=tool, tool_input=tool_input,
- ):
- return web.json_response({"decision": "approve", "remember": True, "cached": True})
rec = permissions.register(
workspace=workspace, provider=provider, tool=tool,
@@ -4217,6 +4298,7 @@ async def handle_permission_request(request: web.Request) -> web.Response:
req_id=rec.req_id, provider=provider, tool=tool,
tool_input=tool_input, pattern=rec.pattern, run_id=rec.run_id,
thread_id=rec.thread_id, origin=rec.origin, origin_path=rec.origin_path,
+ protected=permissions.is_protected_egress(tool),
))
decision = await permissions.await_decision(rec)
# Tell the frontend the card is settled even when await_decision
@@ -4310,9 +4392,16 @@ async def handle_permission_mute(request: web.Request) -> web.Response:
# Clear this source's in-flight cards now.
for rec in permissions.list_pending():
if rec.decision is None and rec.origin == origin:
- permissions.resolve(rec.req_id, decision="skip", remember=False)
+ # Protected cards deny (fail closed). Other tools skip
+ # to the CLI allowlist.
+ verdict = (
+ "deny"
+ if permissions.needs_web_consent(rec.tool, rec.tool_input)
+ else "skip"
+ )
+ permissions.resolve(rec.req_id, decision=verdict, remember=False)
await _broadcast(
- request.app, protocol.permission_resolved(rec.req_id, "skip"),
+ request.app, protocol.permission_resolved(rec.req_id, verdict),
)
else:
muted.discard(origin)
@@ -4369,6 +4458,12 @@ async def handle_permission_allow_add(request: web.Request) -> web.Response:
pattern = str(body.get("pattern") or "").strip()
if not pattern:
return web.json_response({"error": "pattern required"}, status=400)
+ if permissions.is_protected_pattern(pattern):
+ return web.json_response({
+ "ok": False,
+ "error": "web egress cannot be remembered as a blanket grant",
+ "patterns": permissions.list_allowed(workspace),
+ }, status=400)
patterns = permissions.add_pattern(workspace, pattern)
return web.json_response({"ok": True, "patterns": patterns})
@@ -5676,9 +5771,19 @@ def _mark_orchestrations_quiet(workspace: Path, ids: set[str]) -> None:
"Spoken English; cite wiki and vault. Do not invent numbers."
)
+_RESEARCH_STAFF = (
+ "Search the open web, fetch into this workspace vault, ingest, "
+ "then write a cited brief. Use research_search and research_fetch."
+)
+
+
+_CURATE_STAFF = (
+ "Run curiosity-engine CURATE over this workspace."
+)
+
def _desk_spec(sname: str) -> dict[str, Any] | None:
- """Slash name → work/code desk spec. None if not a desk slash."""
+ """Slash name → standing-desk spec. None if not a desk slash."""
n = (sname or "").strip().lower()
if n in ("work", "working", "steer", "steering"):
return {
@@ -5705,7 +5810,7 @@ def _desk_spec(sname: str) -> dict[str, Any] | None:
"cancel": ("curate",),
"task_kind": "curation",
"slash": "curate",
- "staff": "/curate",
+ "staff": _CURATE_STAFF,
}
if n in ("deck", "slideshow-author"):
return {
@@ -5716,6 +5821,15 @@ def _desk_spec(sname: str) -> dict[str, Any] | None:
"slash": None,
"staff": _DECK_STAFF,
}
+ if n == "research":
+ return {
+ "desk_id": "research",
+ "command": "research",
+ "cancel": ("research",),
+ "task_kind": "research",
+ "slash": "research",
+ "staff": _RESEARCH_STAFF,
+ }
if n == "auto":
return {
"desk_id": "auto",
@@ -5738,6 +5852,8 @@ def _desk_spec_from_id(desk_id: str) -> dict[str, Any] | None:
return _desk_spec("curate")
if did in ("deck", "slideshow"):
return _desk_spec("deck")
+ if did == "research":
+ return _desk_spec("research")
if did == "auto":
return _desk_spec("auto")
return None
@@ -5777,6 +5893,8 @@ def _quiet_desk(app: web.Application, spec: dict[str, Any], workspace: Path) ->
rid = rec.run_id if rec is not None else None
if rid:
n += _cancel_run_ids(app, {rid})
+ if _cancel_desk_launch(app, workspace, str(spec["desk_id"])):
+ n += 1
try:
sbk.quiet(workspace, str(spec["desk_id"]), keep_run=True)
except Exception: # noqa: BLE001
@@ -5802,6 +5920,8 @@ def _dismiss_desk(app: web.Application, spec: dict[str, Any], workspace: Path) -
rid = rec.run_id if rec is not None else None
if rid:
n += _cancel_run_ids(app, {rid}, dismiss=True)
+ if _cancel_desk_launch(app, workspace, str(spec["desk_id"])):
+ n += 1
return n
@@ -5818,11 +5938,39 @@ def _maybe_resume_quiet_desk(
rec = sbk.get(workspace, str(spec["desk_id"]))
if rec is None or rec.state != sbk.STATE_QUIET or not rec.run_id:
return None
+ try:
+ from . import schedules as _sched
+ for item in _sched.list_items(workspace):
+ if str(item.get("desk_id") or "") != rec.desk_id:
+ continue
+ until = item.get("until_at")
+ try:
+ until_f = float(until) if until is not None else None
+ except (TypeError, ValueError):
+ until_f = None
+ if until_f is not None and time.time() >= until_f:
+ return None
+ except Exception: # noqa: BLE001
+ pass
ck = orchestration.load_checkpoint(workspace, rec.run_id)
st = (ck or {}).get("status") if isinstance(ck, dict) else None
phase = str((st or {}).get("phase") or "")
if phase not in ("quiet", "interrupted", "running"):
return None
+ until = None
+ if isinstance(st, dict):
+ until = st.get("curate_until")
+ plan = (ck or {}).get("plan") if isinstance(ck, dict) else None
+ if until is None and plan is not None:
+ dec = getattr(plan, "decision", None)
+ if isinstance(dec, dict):
+ until = dec.get("curate_until")
+ try:
+ until_f = float(until) if until is not None else None
+ except (TypeError, ValueError):
+ until_f = None
+ if until_f is not None and time.time() >= until_f:
+ return None
oid = rec.run_id
sbk.set_working(workspace, rec.desk_id, run_id=oid)
t = asyncio.create_task(
@@ -5832,6 +5980,264 @@ def _maybe_resume_quiet_desk(
return oid
+def _curate_constrained(*, local: bool, text: str = "") -> bool:
+ """Local short curate stays a single session. Cloud always host-waves.
+
+ Duration/continuous local still uses host package waves (one worker).
+ Mode aliases belong in the prime payload; they are not a reason to
+ skip the desk.
+ """
+ if not local:
+ return False
+ from .agents.orchestration import parse_duration_window
+ repeat, _until = parse_duration_window(text)
+ return not repeat
+
+
+def _parse_schedule_prompt(
+ prompt: str,
+) -> tuple[str, dict[str, Any] | None, str]:
+ """Classify a schedule prompt: desk fire, skip control, or Auto.
+
+ ``skip`` is ``/curate stop`` / ``/work dismiss`` — those are not
+ fires. Deck/Auto have no public slash, so ``/deck`` stays Auto.
+ """
+ parsed = rail.parse(prompt)
+ if parsed.get("kind") != "slash":
+ return "auto", None, prompt
+ spec = _desk_spec(str(parsed.get("name") or ""))
+ if not spec or not spec.get("slash"):
+ return "auto", None, prompt
+ sargs = str(parsed.get("args") or "")
+ if _desk_slash_verb(sargs) in ("quiet", "dismiss"):
+ return "skip", spec, sargs
+ return "desk", spec, sargs
+
+
+def _schedule_desk_launch(prompt: str) -> tuple[dict[str, Any] | None, str]:
+ """Schedule prompt → (desk spec, args) when it should fire a desk."""
+ kind, spec, sargs = _parse_schedule_prompt(prompt)
+ if kind == "desk":
+ return spec, sargs
+ return None, prompt
+
+
+def _desk_launch_key(workspace: Path, desk_id: str) -> tuple[str, str]:
+ return (str(workspace), str(desk_id))
+
+
+def _track_desk_launch(
+ app: web.Application, workspace: Path, desk_id: str, task: asyncio.Task,
+) -> None:
+ launches: dict[tuple[str, str], asyncio.Task] = app.setdefault(
+ "desk_launches", {},
+ )
+ key = _desk_launch_key(workspace, desk_id)
+ old = launches.get(key)
+ if old is not None and old is not task and not old.done():
+ old.cancel()
+ launches[key] = task
+
+
+def _cancel_desk_launch(
+ app: web.Application, workspace: Path, desk_id: str,
+) -> bool:
+ launches: dict[tuple[str, str], asyncio.Task] = app.get("desk_launches") or {}
+ task = launches.pop(_desk_launch_key(workspace, desk_id), None)
+ if task is None or task.done():
+ return False
+ task.cancel()
+ return True
+
+
+def _seat_desk_now(
+ workspace: Path,
+ spec: dict[str, Any],
+ prompt: str,
+ *,
+ provider: str | None = None,
+ model: str | None = None,
+) -> None:
+ """Stand the desk before dispatch so the dashboard is not empty.
+
+ Keep a live ``run_id`` — ``seat()`` would otherwise clear it and
+ Stop/Start would lose the wave that is already running.
+ """
+ from . import kernel as sbk
+ did = str(spec["desk_id"])
+ pid = provider or _resolve_default_provider()
+ mdl = model or (_effective_model(pid) if pid else None)
+ obj = (prompt or "").strip() or str(spec.get("staff") or did)
+ keep_run = None
+ try:
+ rec = sbk.get(workspace, did)
+ if rec is not None and rec.state == sbk.STATE_WORKING and rec.run_id:
+ keep_run = rec.run_id
+ except Exception: # noqa: BLE001
+ keep_run = None
+ try:
+ sbk.seat(
+ workspace, did,
+ chief_provider=str(pid or "unknown"),
+ chief_model=mdl,
+ objective=obj[:2000],
+ run_id=keep_run,
+ )
+ except Exception: # noqa: BLE001
+ log.exception("desk seat on launch failed")
+
+
+async def _curate_extra_system(
+ workspace: Path, args: str, *, local: bool, local_rung: Any = None,
+) -> str:
+ """Profile + Reviews feedback + Phase 1 prime. Off the WS loop."""
+ cap = _CURATOR_PROFILE_CAP_TOKENS
+ if local_rung is not None:
+ cap = max(400, int(getattr(local_rung, "extra_system_chars", 0) or 0) // 4)
+ elif local:
+ cap = 400
+ prof = await asyncio.to_thread(_curator_profile, workspace, cap)
+ extra = _curator_profile_system(prof)
+ fb = await asyncio.to_thread(_review_feedback_system, workspace)
+ if fb:
+ extra = (extra + "\n\n" + fb).strip()
+ prime = await asyncio.to_thread(
+ _curate_wave_prime_system, workspace, args, local=local,
+ )
+ if prime:
+ extra = (extra + "\n\n" + prime).strip()
+ return extra
+
+
+async def _run_curate_desk(
+ app: web.Application,
+ args: str,
+ *,
+ preference: float | None = None,
+ workspace: Path,
+ workspace_override: Path | None = None,
+ provider_override: str | None = None,
+ model_override: str | None = None,
+ lock_provider: bool = False,
+) -> str | None:
+ """Build the CE prompt off-loop, then dispatch the Curate desk."""
+ pid = provider_override
+ model = model_override
+ if not pid:
+ pid, model = _ce_action_provider(workspace)
+ effective = pid or _resolve_default_provider()
+ is_local = _provider_is_local(effective)
+ lrung = None
+ if is_local:
+ lcfg = await asyncio.to_thread(localllm.load_config)
+ lrung = rail_default.resolve_local_rung(
+ localllm.ram_gb(),
+ model_hint=rail_default.model_hint_from_cfg(lcfg),
+ )
+ ce_prompt = _ce_action_prompt(
+ "curate", args, local=is_local, local_rung=lrung,
+ ) or _CURATE_STAFF
+ extra = await _curate_extra_system(
+ workspace, args, local=is_local, local_rung=lrung,
+ )
+ from . import kernel as sbk
+ rec = sbk.get(workspace, sbk.DESK_CURATE)
+ if rec is None or rec.state == sbk.STATE_DISMISSED:
+ return None
+ excerpt = f"[curate · background] {args}".strip()
+ return await _dispatch_auto(
+ app, None, ce_prompt,
+ preference=preference,
+ workspace_override=workspace_override,
+ provider_override=pid,
+ model_override=model,
+ input_excerpt=excerpt,
+ extra_system=extra or None,
+ command="curate",
+ task_kind="curation",
+ constrained=_curate_constrained(local=is_local, text=args),
+ lock_provider=lock_provider,
+ )
+
+
+async def _run_desk(
+ app: web.Application,
+ spec: dict[str, Any],
+ *,
+ prompt: str,
+ workspace: Path,
+ preference: float | None = None,
+ provider_override: str | None = None,
+ model_override: str | None = None,
+ lock_provider: bool = False,
+) -> str | None:
+ ws_path = Path(workspace)
+ me = asyncio.current_task()
+ if me is not None:
+ _track_desk_launch(app, ws_path, str(spec["desk_id"]), me)
+ # Pin the captured vault. Comparing to the focused workspace and
+ # passing None lets a mid-preflight workspace switch steal the run.
+ override = ws_path
+ kind = spec.get("task_kind")
+ rid: str | None = None
+ try:
+ from . import kernel as sbk
+ rec = sbk.get(ws_path, str(spec["desk_id"]))
+ if rec is not None and rec.state == sbk.STATE_DISMISSED:
+ return None
+ if kind == "curation":
+ rid = await _run_curate_desk(
+ app, prompt,
+ preference=preference,
+ workspace=Path(workspace),
+ workspace_override=override,
+ provider_override=provider_override,
+ model_override=model_override,
+ lock_provider=lock_provider,
+ )
+ else:
+ staff = not (prompt or "").strip()
+ text = (prompt or "").strip() or str(spec["staff"])
+ cmd = spec.get("command")
+ excerpt = (
+ f"[{cmd} · background] {prompt}"
+ if cmd else
+ f"[desk · background] {prompt}"
+ ).strip()
+ rid = await _dispatch_auto(
+ app, None, text,
+ preference=preference,
+ workspace_override=override,
+ provider_override=provider_override,
+ model_override=model_override,
+ lock_provider=lock_provider,
+ input_excerpt=excerpt,
+ command=cmd,
+ task_kind=kind,
+ family_staff=staff and kind in {"projects", "code"},
+ )
+ finally:
+ launches: dict[tuple[str, str], asyncio.Task] = app.get("desk_launches") or {}
+ key = _desk_launch_key(ws_path, str(spec["desk_id"]))
+ if launches.get(key) is me:
+ launches.pop(key, None)
+ # Only tear down a placeholder seat (working, no run yet).
+ # A started wave is quieted by `_finish_desk`; Stop uses keep_run.
+ if not rid:
+ from . import kernel as sbk
+ try:
+ rec = sbk.get(ws_path, str(spec["desk_id"]))
+ if (
+ rec is not None
+ and rec.state == sbk.STATE_WORKING
+ and not rec.run_id
+ ):
+ sbk.quiet(ws_path, rec.desk_id)
+ except Exception: # noqa: BLE001
+ pass
+ return rid
+
+
def _launch_desk(
app: web.Application,
spec: dict[str, Any],
@@ -5839,29 +6245,27 @@ def _launch_desk(
prompt: str,
workspace: Path | None = None,
preference: float | None = None,
+ provider_override: str | None = None,
+ model_override: str | None = None,
+ lock_provider: bool = False,
) -> None:
- staff = not (prompt or "").strip()
- text = (prompt or "").strip() or str(spec["staff"])
- cmd = spec.get("command")
- excerpt = (
- f"[{cmd} · background] {prompt}"
- if cmd else
- f"[desk · background] {prompt}"
- ).strip()
- kind = spec.get("task_kind")
focused = Path(app["workspace"])
- override = None
- if workspace is not None and Path(workspace) != focused:
- override = workspace
- t = asyncio.create_task(_dispatch_auto(
- app, None, text,
+ ws_path = Path(workspace) if workspace is not None else focused
+ _seat_desk_now(
+ ws_path, spec, prompt,
+ provider=provider_override,
+ model=model_override,
+ )
+ t = asyncio.create_task(_run_desk(
+ app, spec,
+ prompt=prompt,
+ workspace=ws_path,
preference=preference,
- workspace_override=override,
- input_excerpt=excerpt,
- command=cmd,
- task_kind=kind,
- family_staff=staff and kind in {"projects", "code"},
+ provider_override=provider_override,
+ model_override=model_override,
+ lock_provider=lock_provider,
))
+ _track_desk_launch(app, ws_path, str(spec["desk_id"]), t)
t.add_done_callback(_make_dispatch_error_surface(app, None))
@@ -7477,6 +7881,9 @@ def _page(msg: str, ok: bool) -> web.Response:
async def handle_streams_poll(request: web.Request) -> web.Response:
+ blocked = _policy_block("comms_streams")
+ if blocked:
+ return blocked
acct = await asyncio.to_thread(streams.get_account, request.match_info["account_id"])
if acct is None:
return web.json_response({"error": "no such account"}, status=404)
@@ -7491,6 +7898,7 @@ async def handle_streams_poll(request: web.Request) -> web.Response:
async def handle_streams_auto(request: web.Request) -> web.Response:
+ """Cadence only: poll/ingest already-approved threads. Never auto-approves."""
try:
body = await request.json()
except json.JSONDecodeError:
@@ -7501,19 +7909,110 @@ async def handle_streams_auto(request: web.Request) -> web.Response:
)
if acct is None:
return web.json_response({"error": "no such account"}, status=404)
- return web.json_response({"ok": True, "auto_curate": acct["auto_curate"]})
+ return web.json_response({
+ "ok": True,
+ "auto_curate": bool(acct.get("auto_curate")),
+ "note": "cadence for approved threads only; does not approve sources",
+ })
+
+
+def _comms_curation_pair_ok(
+ pid: str | None,
+ model: str | None,
+ *,
+ denied: list[str] | tuple[str, ...] | set[str] | None,
+) -> bool:
+ """Keyed, allowlisted, file-capable CLI (shell + file_write).
+
+ Direct Comms curation calls ``chat_stream`` with no tools / ToolUse
+ loop, so HTTP ``can_curate`` (tools-only) providers cannot write.
+ Grok Build qualifies via ``can_execute``.
+ """
+ from .agents import orchestration_policy as orch_pol
+ if not pid:
+ return False
+ if not admin_policy.provider_allowed(pid):
+ return False
+ if not orch_pol.model_allowed(pid, model, denied):
+ return False
+ try:
+ provider = llmgateway.get(pid)
+ except llmgateway.ProviderError:
+ return False
+ try:
+ keyed = bool(provider.has_key())
+ except Exception: # noqa: BLE001
+ keyed = False
+ if not keyed:
+ return False
+ return bool(llmgateway.can_execute(pid))
-def _stream_curation_provider() -> str | None:
- """Comms curation writes wiki pages, so it needs a file-capable
- CLI provider. Claude Code first, Codex fallback."""
- for pid in ("claude_code", "openai_codex"):
+def _comms_curation_route(workspace: Path | None) -> tuple[str | None, str | None]:
+ """File-capable CLI routing (can_execute), plus model allowlists.
+
+ Direct Comms curation has no ToolUse loop, so HTTP tools-only
+ providers are skipped. Does not add a second source-approval
+ prompt: Comms sources are already explicitly approved.
+ """
+ from .agents import orchestration_policy as orch_pol
+ ws = Path(workspace) if workspace is not None else None
+ denied = orch_pol.get_denied_models(ws) if ws is not None else orch_pol.get_denied_models()
+ candidates: list[tuple[str | None, str | None]] = []
+ if ws is not None:
try:
- if llmgateway.get(pid).has_key():
- return pid
- except llmgateway.ProviderError:
+ candidates.append(_ce_action_provider(ws))
+ except Exception: # noqa: BLE001
+ log.exception("comms curator CE-action route failed")
+ try:
+ hint_pid, hint_model = _auto_roster_pair(ws)
+ picked = orch_pol.pick_chief_pair(
+ default_provider=hint_pid,
+ default_model=hint_model,
+ preference=orch_pol.get_preference(),
+ workspace=ws,
+ )
+ candidates.append(picked)
+ except Exception: # noqa: BLE001
+ log.exception("comms curator roster route failed")
+ for pid, prov in llmgateway.PROVIDERS.items():
+ try:
+ keyed = bool(prov.has_key())
+ except Exception: # noqa: BLE001
+ keyed = False
+ if keyed:
+ candidates.append((pid, _effective_model(pid)))
+ seen: set[tuple[str, str | None]] = set()
+ for pid, model in candidates:
+ if not pid:
continue
- return None
+ key = (pid, model)
+ if key in seen:
+ continue
+ seen.add(key)
+ if _comms_curation_pair_ok(pid, model, denied=denied):
+ return pid, model
+ return None, None
+
+
+def _comms_user_ingest_error(raw: str | None) -> str:
+ """Short user-facing ingest failure. No implementation details."""
+ low = str(raw or "").lower()
+ if not low.strip():
+ return "Could not add this to the wiki. It stays approved."
+ if "revok" in low:
+ return "This source was revoked before it could be added."
+ if "comms_streams" in low or "disabled by admin" in low:
+ return "Comms ingestion is turned off by policy."
+ if "allowlist" in low:
+ return "This workspace is not allowed for the account."
+ if "no wiki commit" in low or "produced no wiki" in low:
+ return "Nothing new was added to the wiki. It stays queued."
+ if "provider" in low or "configured" in low or "curator" in low or "file-capable" in low:
+ return "No file-capable curator is available for this workspace."
+ if "retrieve" in low or "fetch" in low or "not connected" in low:
+ return "Could not retrieve the approved mail. It stays approved."
+ return "Could not add this to the wiki. It stays approved."
async def _triage_events(
@@ -7584,92 +8083,184 @@ async def _curate_into(
charter invariant: no cross-workspace curator). Headless and
OUTSIDE the rail — the messages must not enter the conversation
log; the wiki pages the agent writes ARE the durable output.
- Registered in the runs registry so the dashboard shows it."""
- pid = _stream_curation_provider()
- if pid is None:
- return False, "comms curation needs Claude Code or Codex configured"
+ Registered in the runs registry so the dashboard shows it.
+
+ Occupies one worker seat on the workspace Curate desk so Comms
+ shares live-worker limits with /curate.
+ """
+ from . import comms_review as _cr
+ from .agents import desk_admission as seats
+ from .kernel.desk import DESK_CURATE
if not ws.is_dir():
return False, f"target workspace missing: {ws}"
+ if any(_cr.is_revoked(str(e.get("comms_key") or "")) for e in events):
+ return False, "revoked during handoff"
+ if not admin_policy.feature_enabled("comms_streams"):
+ return False, admin_policy.feature_error("comms_streams")
+ allow = set(streams.live_allowed_workspaces(acct))
+ if str(ws) not in allow:
+ return False, "workspace is not on this account allowlist"
+ pid, model = _comms_curation_route(ws)
+ if not pid:
+ return False, "no file-capable curator is available for this workspace"
+ if not model:
+ model = _effective_model(pid)
provider = llmgateway.get(pid)
run_id = f"run-{uuid.uuid4().hex[:8]}"
runs: dict[str, dict[str, Any]] = app.setdefault("runs", {})
runs[run_id] = {
- "run_id": run_id, "provider": pid, "model": _effective_model(pid),
+ "run_id": run_id, "provider": pid, "model": model,
"input_excerpt": f"curate comms: {acct['label']} → {ws.name} ({len(events)} msgs)",
"started_at": time.time(), "last_chunk_at": time.time(),
"tool_count": 0, "status": "running",
"task": asyncio.current_task(),
"workspace": str(ws), "workspace_name": ws.name,
"is_background": True,
+ "desk": DESK_CURATE,
}
- try:
+ gate = None
+ sid = None
+ acquired = False
+ try:
+ from . import ce_host
+ before = await asyncio.to_thread(ce_host.wiki_work_snapshot, ws)
+ if any(_cr.is_revoked(str(e.get("comms_key") or "")) for e in events):
+ return False, "revoked during handoff"
+ if str(ws) not in set(streams.live_allowed_workspaces(acct)):
+ return False, "workspace is not on this account allowlist"
ws_desc = await asyncio.to_thread(streams.workspace_descriptor, str(ws))
profile = await asyncio.to_thread(_curator_profile, ws)
+ if any(_cr.is_revoked(str(e.get("comms_key") or "")) for e in events):
+ return False, "revoked during handoff"
+ domain = seats.desk_domain_id(ws, DESK_CURATE)
+ gate = seats.gate_for(domain, workspace=ws)
+ gate.retain()
+ sid = seats.slot_id(run_id, "comms-curator")
+ await gate.acquire(sid, kind="worker")
+ acquired = True
+ if any(_cr.is_revoked(str(e.get("comms_key") or "")) for e in events):
+ return False, "revoked during handoff"
+ if not admin_policy.feature_enabled("comms_streams"):
+ return False, admin_policy.feature_error("comms_streams")
+ if str(ws) not in set(streams.live_allowed_workspaces(acct)):
+ return False, "workspace is not on this account allowlist"
req = llmgateway.ChatRequest(
messages=[{"role": "user",
"content": streams.curation_prompt(
acct, events, workspace_desc=ws_desc,
profile=profile or None)}],
- model=_effective_model(pid),
+ model=model,
workspace=str(ws),
- reasoning_effort=_effort_for(
- pid, _effective_model(pid), "ladder"),
+ reasoning_effort=_effort_for(pid, model, "ladder"),
)
+ if any(_cr.is_revoked(str(e.get("comms_key") or "")) for e in events):
+ return False, "revoked during handoff"
async for ev in provider.chat_stream(req):
runs[run_id]["last_chunk_at"] = time.time()
if isinstance(ev, llmgateway.DoneChunk):
break
+ receipt = await asyncio.to_thread(ce_host.wiki_diff_receipt, ws, before)
+ landed = int(receipt.get("wiki_pages_landed") or 0)
+ changed = list(receipt.get("wiki_pages_changed") or [])
+ committed = bool(receipt.get("wiki_committed"))
+ if run_id in runs:
+ runs[run_id]["wiki_pages_landed"] = landed
+ runs[run_id]["wiki_committed"] = committed
+ if not committed or (landed <= 0 and not changed):
+ return False, "curation produced no wiki commit"
return True, None
+ except asyncio.CancelledError:
+ raise
except Exception as e: # noqa: BLE001
log.exception("stream curation failed for %s → %s", acct["id"], ws)
return False, str(e)
finally:
+ if gate is not None:
+ try:
+ if acquired and sid:
+ await gate.release_async(sid)
+ finally:
+ seats.release_domain(gate)
runs.pop(run_id, None)
async def _run_stream_curation(app: web.Application, acct: dict[str, Any]) -> dict[str, Any]:
- """The full pass: (optional) triage → per-workspace scoped
- curation runs → consume exactly what was processed. Failed runs
- leave their events in transit for the next attempt."""
- events = (await asyncio.to_thread(streams.pending_events, acct["id"]))[:150]
- if not events:
- return {"ok": True, "curated": 0, "skipped": 0, "note": "transit empty"}
- allow = streams.allowed_workspaces(acct)
+ """Curate only approved, non-revoked, allowlisted transit events."""
+ from . import comms_review
+ raw = (await asyncio.to_thread(streams.pending_events, acct["id"]))[:150]
+ allow = set(streams.allowed_workspaces(acct))
if not allow:
return {"ok": False,
"error": "no workspaces allowlisted for this stream — "
"tick at least one in Settings"}
- # Legacy "default" mode (pre-allowlist-only) = no gate over what
- # was a one-entry allowlist → fanout covers it exactly.
- mode = acct.get("routing") or ("smart" if acct.get("triage") else "fanout")
- if mode == "default":
- mode = "fanout"
+ keep: list[dict[str, Any]] = []
+ purge: list[str] = []
skipped: list[str] = []
- if mode == "smart":
- triaged = await _triage_events(app, acct, events)
- if triaged is None:
- # No privileged workspace to guess into — leave the batch
- # PENDING and say why (retried next poll/curate).
- return {"ok": False,
- "error": "triage unavailable (no provider key?) — "
- "messages stay pending"}
- groups, skipped = triaged
- else:
- # No gate: full batch to every allowed workspace; each scoped
- # curator is the keep/skip decision (full text + wiki
- # context — the per-workspace skip-bin, paid in curation
- # tokens).
- groups = {p: events for p in allow}
+ for e in raw:
+ eid = str(e.get("id") or "")
+ key = str(e.get("comms_key") or "")
+ ws = str(e.get("approved_workspace") or "")
+ if not e.get("approved") or not key or not ws:
+ if eid:
+ purge.append(eid)
+ continue
+ if comms_review.is_revoked(key):
+ if eid:
+ purge.append(eid)
+ continue
+ if ws not in allow:
+ if eid:
+ purge.append(eid)
+ continue
+ item = comms_review.get_item(key)
+ if not item or ws not in (item.get("approved_workspaces") or []):
+ if eid:
+ purge.append(eid)
+ continue
+ keep.append(e)
+ if purge:
+ await asyncio.to_thread(streams.consume_transit, acct["id"], purge)
+ if not admin_policy.feature_enabled("comms_streams"):
+ return {"ok": False, "error": admin_policy.feature_error("comms_streams")}
+ if not keep:
+ return {"ok": True, "curated": 0, "skipped": 0, "note": "no approved transit"}
+ groups: dict[str, list[dict[str, Any]]] = {}
+ for e in keep:
+ groups.setdefault(str(e["approved_workspace"]), []).append(e)
errors: list[str] = []
targets: list[str] = []
succeeded_ws: set[str] = set()
for ws_path, evs in groups.items():
+ from . import comms_review as _cr
+ if any(_cr.is_revoked(str(e.get("comms_key") or "")) for e in evs):
+ errors.append("revoked during handoff")
+ continue
ok, err = await _curate_into(app, acct, Path(ws_path), evs)
+ if any(_cr.is_revoked(str(e.get("comms_key") or "")) for e in evs):
+ errors.append("revoked during handoff")
+ for e in evs:
+ _cr.set_ingest_state(
+ str(e.get("comms_key") or ""), ws_path,
+ state="error",
+ error=_comms_user_ingest_error("revoked during handoff"),
+ )
+ continue
if ok:
succeeded_ws.add(ws_path)
targets.append(Path(ws_path).name)
+ for e in evs:
+ _cr.set_ingest_state(
+ str(e.get("comms_key") or ""), ws_path,
+ state="ingested", error="",
+ )
elif err:
errors.append(err)
+ for e in evs:
+ _cr.set_ingest_state(
+ str(e.get("comms_key") or ""), ws_path,
+ state="error",
+ error=_comms_user_ingest_error(err),
+ )
# An event is consumed only when EVERY workspace it was routed to
# curated successfully — a multi-labelled message whose second
# target failed stays in transit for the retry (the workspace
@@ -7701,13 +8292,168 @@ async def _run_stream_curation(app: web.Application, acct: dict[str, Any]) -> di
async def handle_streams_curate(request: web.Request) -> web.Response:
+ from . import admin_policy
+ if not admin_policy.feature_enabled("comms_streams"):
+ return web.json_response(
+ {"error": admin_policy.feature_error("comms_streams")}, status=403,
+ )
acct = await asyncio.to_thread(streams.get_account, request.match_info["account_id"])
if acct is None:
return web.json_response({"error": "no such account"}, status=404)
+ await streams.ingest_approved_updates(acct)
result = await _run_stream_curation(request.app, acct)
return web.json_response(result, status=200 if result.get("ok") else 502)
+async def handle_comms_review_list(request: web.Request) -> web.Response:
+ from . import comms_review
+ resolved = _workspace_from_request(request)
+ if isinstance(resolved, web.Response):
+ return resolved
+ workspace = resolved
+ items = await asyncio.to_thread(
+ comms_review.list_items, workspace=str(workspace),
+ )
+ return web.json_response({
+ "items": items,
+ "pending": sum(1 for i in items if i.get("status") == "pending"),
+ "workspace": str(workspace),
+ "workspaces": [
+ {"path": p, "name": Path(p).name}
+ for p in (workspaces.load().get("paths") or [])
+ ],
+ })
+
+
+async def handle_comms_review_open(request: web.Request) -> web.Response:
+ resolved = _workspace_from_request(request)
+ if isinstance(resolved, web.Response):
+ return resolved
+ workspace = resolved
+ await asyncio.to_thread(tabstore.add_comms_tab, workspace)
+ await _broadcast(request.app, _hello_payload(request.app))
+ await _broadcast(request.app, protocol.custom({"type": "open_comms"}))
+ return web.json_response({"ok": True})
+
+
+async def _ingest_after_approve(
+ app: web.Application,
+ acct: dict[str, Any],
+ key: str,
+ workspace: str,
+) -> dict[str, Any]:
+ """Fetch + curate an already-approved source. Approval already stuck.
+
+ Returns ingest_state / ingest_error for the HTTP body. Fail-closed
+ adapters do not pretend content was pulled.
+ """
+ from . import comms_review
+ cap = str((comms_review.get_item(key) or {}).get("content_capability") or "ok")
+ if cap == "fail_closed" or acct.get("provider") not in ("imap", "gmail", "msgraph"):
+ msg = "This source can be listed, but message content cannot be retrieved."
+ await asyncio.to_thread(
+ comms_review.set_ingest_state, key, workspace,
+ state="unsupported", error=msg,
+ )
+ return {"ingest_state": "unsupported", "ingest_error": msg}
+ await asyncio.to_thread(
+ comms_review.set_ingest_state, key, workspace,
+ state="pending", error="",
+ )
+ fetched = await streams.fetch_approved_thread(acct, key, workspace)
+ if not fetched.get("ok"):
+ err = _comms_user_ingest_error(str(fetched.get("error") or "retrieve"))
+ await asyncio.to_thread(
+ comms_review.set_ingest_state, key, workspace,
+ state="error", error=err,
+ )
+ return {"ingest_state": "error", "ingest_error": err}
+ if not fetched.get("events"):
+ return {"ingest_state": "pending", "ingest_error": ""}
+ curated = await _run_stream_curation(app, acct)
+ if curated.get("ok") and int(curated.get("curated") or 0) > 0:
+ await asyncio.to_thread(
+ comms_review.set_ingest_state, key, workspace,
+ state="ingested", error="",
+ )
+ return {"ingest_state": "ingested", "ingest_error": ""}
+ if curated.get("ok"):
+ err = _comms_user_ingest_error("curation produced no wiki commit")
+ await asyncio.to_thread(
+ comms_review.set_ingest_state, key, workspace,
+ state="error", error=err,
+ )
+ return {"ingest_state": "error", "ingest_error": err}
+ err = _comms_user_ingest_error(str(curated.get("error") or ""))
+ await asyncio.to_thread(
+ comms_review.set_ingest_state, key, workspace,
+ state="error", error=err,
+ )
+ return {"ingest_state": "error", "ingest_error": err}
+
+
+async def handle_comms_review_act(request: web.Request) -> web.Response:
+ from . import comms_review
+ try:
+ body = await request.json()
+ except json.JSONDecodeError:
+ return web.json_response({"error": "invalid json"}, status=400)
+ key = str(body.get("key") or "").strip()
+ action = str(body.get("action") or body.get("decision") or "").strip().lower()
+ if not key or action not in ("approve", "reject", "revoke"):
+ return web.json_response({"error": "key and action required"}, status=400)
+ resolved = _workspace_from_request(request, body if isinstance(body, dict) else None)
+ if isinstance(resolved, web.Response):
+ return resolved
+ ws = str(resolved)
+ item = await asyncio.to_thread(comms_review.get_item, key)
+ acct_id = str((item or {}).get("account_id") or "")
+ acct = await asyncio.to_thread(streams.get_account, acct_id) if acct_id else None
+ if acct is None:
+ return web.json_response({"error": "account missing; will not use active workspace"}, status=400)
+ allowed = streams.allowed_workspaces(acct)
+ if action == "approve":
+ if not str(body.get("workspace") or "").strip():
+ return web.json_response({"error": "workspace is required"}, status=400)
+ ws = str(body.get("workspace") or "").strip()
+ out = await asyncio.to_thread(
+ comms_review.approve, key, ws, allowed=allowed,
+ )
+ if out.get("ok"):
+ ingest = await _ingest_after_approve(
+ request.app, acct, key, ws,
+ )
+ out.update(ingest)
+ item = await asyncio.to_thread(
+ comms_review.get_item, key, ws,
+ )
+ if item:
+ out["item"] = item
+ elif action == "reject":
+ out = await asyncio.to_thread(comms_review.reject, key)
+ else:
+ out = await asyncio.to_thread(comms_review.revoke, key, reason="user")
+ if not out.get("ok"):
+ return web.json_response(out, status=400)
+ await asyncio.to_thread(tabstore.add_comms_tab, resolved)
+ await _broadcast(request.app, protocol.custom({
+ "type": "comms.review", "key": key, "action": action,
+ }))
+ return web.json_response(out)
+
+
+async def handle_comms_review_close(request: web.Request) -> web.Response:
+ resolved = _workspace_from_request(request)
+ if isinstance(resolved, web.Response):
+ return resolved
+ workspace = resolved
+ removed = await asyncio.to_thread(tabstore.remove_comms_tab, workspace)
+ if removed:
+ await _broadcast(request.app, _hello_payload(request.app))
+ await _broadcast(request.app, protocol.nav("graph", {}, "Graph"))
+ return web.json_response({"ok": True, "removed": removed})
+
+
async def _stream_poll_loop(app: web.Application) -> None:
"""Background: poll every connected account on an interval;
auto-curate the ones that opted in. Every failure is contained —
@@ -7715,17 +8461,18 @@ async def _stream_poll_loop(app: web.Application) -> None:
while True:
try:
await asyncio.sleep(_STREAM_POLL_INTERVAL)
+ if not admin_policy.feature_enabled("comms_streams"):
+ continue
for acct in await asyncio.to_thread(streams.list_accounts):
if streams.account_status(acct) != "connected":
continue
try:
await streams.poll_account(acct)
+ # poll_account discovers metadata and ingests already-
+ # approved threads. auto_curate is cadence for the
+ # curator pass only — it never approves sources.
if acct.get("auto_curate"):
- pending = await asyncio.to_thread(
- streams.pending_events, acct["id"],
- )
- if pending:
- await _run_stream_curation(app, acct)
+ await _run_stream_curation(app, acct)
except Exception: # noqa: BLE001
log.exception("stream poll failed: %s", acct.get("label"))
except asyncio.CancelledError:
@@ -7832,89 +8579,86 @@ async def _decisions_heartbeat_loop(app: web.Application) -> None:
_WATCH_FOLDERS_INTERVAL = 60.0
-async def _dispatch_watch_ingest(app: web.Application, src_abs: str) -> None:
- """Ingest one file a watch folder surfaced: stage a copy into the
- vault (the ingest agent is workspace-scoped and can't read the
- original), then dispatch the background agent with
- `extracted_from` pointing at the ORIGINAL absolute path — watch
- folders are exactly the external provenance the Sources view
- renders."""
- workspace: Path = app["workspace"]
- src = Path(src_abs)
- safe_name = re.sub(r"[^A-Za-z0-9._-]+", "_", src.name) or "file.bin"
+async def _dispatch_watch_ingest(
+ app: web.Application, cand: watchfolders.Candidate, *, workspace: Path,
+) -> None:
+ """Hydrate (if needed), stage, and CE-ingest one watched file.
- def _stage() -> tuple[str, int]:
- payload = src.read_bytes()
- digest = hashlib.sha1(payload).hexdigest()[:12]
- target_dir = workspace / "vault" / digest
- target_dir.mkdir(parents=True, exist_ok=True)
- target = target_dir / safe_name
- if not target.exists():
- target.write_bytes(payload)
- return str(target.relative_to(workspace)), len(payload)
-
- rel, total = await asyncio.to_thread(_stage)
- ext = src.suffix.lower().lstrip(".")
- ext_hint = f"The file has extension `.{ext}`." if ext else "The file has no extension."
- prompt = (
- f"A watched folder picked up a new file. The original lives at "
- f"`{src_abs}` (outside the workspace); a copy was staged at "
- f"`{rel}` (size {total} bytes) for you to read. {ext_hint} "
- f"Ingest it as a CE-shaped wiki page:\n\n"
- f" 1. Read the staged copy (use Read for text/markdown; for "
- f"PDFs or other binaries, try a shell extraction first — e.g. "
- f"`pdftotext` if available, or just describe by filename + "
- f"size when the contents aren't readable).\n"
- f" 2. Classify into one of CE's page types: `source` "
- f"(PDFs, articles, reports), `note` (plain user-authored "
- f"text), `figure` (images), `unclassified` (anything else).\n"
- f" 3. Slugify the filename to a kebab-case stem. Write the "
- f"wiki page to `wiki/s/.md` with frontmatter "
- f"`type: `, `title: \"[] \"`, "
- f"`created` / `updated` dates, and `extracted_from: "
- f"{src_abs}` — the ORIGINAL path, so provenance points at "
- f"the user's file, not the vault copy.\n"
- f" 4. Body: 2-4 paragraphs of metadata + key takeaways.\n"
- f" 5. Don't run any other tools after Write."
- )
- run_id = f"run-{uuid.uuid4().hex[:8]}"
+ ``workspace`` is the one captured at scan time. Do not re-read
+ ``app["workspace"]`` — a UI switch before this task runs must not
+ ingest into a different vault that happens to watch the same folder.
- async def _runner() -> None:
+ Deterministic supported extraction runs here — no model dispatch.
+ Marked seen only after accepted extracted content is handed off.
+ """
+ inflight: set[str] = app.setdefault("watch_inflight", set())
+ key = cand.logical
+ if key in inflight:
+ return
+ inflight.add(key)
+ try:
+ result = await asyncio.to_thread(watchfolders.handoff, workspace, cand)
+ payload = {
+ "path": cand.path,
+ "logical": cand.logical,
+ "status": result.status,
+ "vault_path": result.vault_rel,
+ "extracted": result.extracted,
+ "error": result.error,
+ }
+ if result.status == "success":
+ _log_event(
+ app, "exec", f"watch-folder ingest: {cand.logical}",
+ source="watchfolders", actor="system", payload=payload,
+ )
+ _broadcast_files_changed_soon(app)
+ else:
+ log.info(
+ "watch-folder %s %s: %s",
+ result.status, cand.logical, result.error or "",
+ )
+ except Exception: # noqa: BLE001
+ log.exception("watch-folder ingest failed: %s", cand.logical)
try:
- await _dispatch_chat(
- app, ws=None, text=prompt,
- input_excerpt=f"watch-ingest {safe_name}",
- run_id=run_id,
- command="ingest",
+ await asyncio.to_thread(
+ watchfolders.record_pending,
+ workspace, cand.logical,
+ state="error",
+ error="internal watch-ingest error",
+ retryable=True,
)
except Exception: # noqa: BLE001
- log.exception("watch-folder ingest run %s crashed", run_id)
-
- task = asyncio.create_task(_runner())
- task.add_done_callback(_make_dispatch_error_surface(app, run_id))
- _log_event(
- app, "exec", f"watch-folder ingest: {src_abs}",
- source="watchfolders", actor="system",
- payload={"path": src_abs, "vault_path": rel, "run_id": run_id},
- )
+ log.exception("watch-folder pending record failed")
+ finally:
+ inflight.discard(key)
async def _watch_folders_loop(app: web.Application) -> None:
"""Background: poll the active workspace's watch folders and
- auto-ingest new files (capped per beat — each file is one agent
- run). Sleep-first; every failure contained."""
+ auto-ingest new files (capped per beat). Sleep-first; hydration
+ and staging run off-loop with timeouts so the heartbeat stays
+ responsive. Every failure contained."""
while True:
try:
await asyncio.sleep(_WATCH_FOLDERS_INTERVAL)
+ if not admin_policy.feature_enabled("watch_folders"):
+ continue
ws: Path = app["workspace"]
+ inflight: set[str] = app.setdefault("watch_inflight", set())
+ inflight_snap = set(inflight)
picked, backlog = await asyncio.to_thread(
- watchfolders.scan_new, ws,
+ watchfolders.scan_candidates, ws, inflight=inflight_snap,
)
- for src_abs in picked:
- try:
- await _dispatch_watch_ingest(app, src_abs)
- except Exception: # noqa: BLE001
- log.exception("watch-folder ingest failed: %s", src_abs)
+ if Path(app["workspace"]) != ws:
+ continue
+ tasks = [
+ asyncio.create_task(
+ _dispatch_watch_ingest(app, cand, workspace=ws),
+ )
+ for cand in picked
+ ]
+ if tasks:
+ await asyncio.gather(*tasks, return_exceptions=True)
if backlog:
log.info(
"watch folders: %d more new files queued for later "
@@ -7928,12 +8672,9 @@ async def _watch_folders_loop(app: web.Application) -> None:
async def handle_watch_folders_get(request: web.Request) -> web.Response:
workspace: Path = request.app["workspace"]
- folders = await asyncio.to_thread(watchfolders.list_folders, workspace)
- return web.json_response({
- "folders": folders,
- "cap_per_beat": watchfolders.MAX_PER_BEAT,
- "interval_s": int(_WATCH_FOLDERS_INTERVAL),
- })
+ status = await asyncio.to_thread(watchfolders.api_status, workspace)
+ status["interval_s"] = int(_WATCH_FOLDERS_INTERVAL)
+ return web.json_response(status)
async def handle_watch_folders_add(request: web.Request) -> web.Response:
@@ -7963,8 +8704,8 @@ async def handle_watch_folders_add(request: web.Request) -> web.Response:
request.app, "exec", f"watch folder added: {res['path']}",
source="watchfolders", actor="user", payload=res,
)
- folders = await asyncio.to_thread(watchfolders.list_folders, workspace)
- return web.json_response({"ok": True, "folder": res, "folders": folders})
+ status = await asyncio.to_thread(watchfolders.api_status, workspace)
+ return web.json_response({"ok": True, "folder": res, **status})
async def handle_watch_folders_remove(request: web.Request) -> web.Response:
@@ -7977,8 +8718,8 @@ async def handle_watch_folders_remove(request: web.Request) -> web.Response:
ok = await asyncio.to_thread(watchfolders.remove_folder, workspace, raw)
if not ok:
return web.json_response({"error": "not a watched folder"}, status=404)
- folders = await asyncio.to_thread(watchfolders.list_folders, workspace)
- return web.json_response({"ok": True, "folders": folders})
+ status = await asyncio.to_thread(watchfolders.api_status, workspace)
+ return web.json_response({"ok": True, **status})
async def handle_watch_folders_toggle(request: web.Request) -> web.Response:
@@ -7994,8 +8735,8 @@ async def handle_watch_folders_toggle(request: web.Request) -> web.Response:
)
if not ok:
return web.json_response({"error": "not a watched folder"}, status=404)
- folders = await asyncio.to_thread(watchfolders.list_folders, workspace)
- return web.json_response({"ok": True, "folders": folders})
+ status = await asyncio.to_thread(watchfolders.api_status, workspace)
+ return web.json_response({"ok": True, **status})
def _bulk_listing(root: Path) -> tuple[str, int, int]:
@@ -8223,15 +8964,13 @@ async def handle_decision_decide(request: web.Request) -> web.Response:
def _make_dispatch_error_surface(
app: web.Application,
- target: web.WebSocketResponse | str,
+ target: web.WebSocketResponse | str | None,
):
"""Return a `add_done_callback` that converts unhandled
exceptions in dispatch tasks into rail-visible notices.
- `target` is either a WebSocket (route the notice to that one
- client — the rail surface) or a run_id string (no specific
- client; broadcast to every connected client so any open dashboard
- sees the failure). Headless background dispatches use the latter.
+ `target` is a WebSocket (that one client), a run_id string, or
+ None for a headless launch — both of the last two broadcast.
Without this, an exception that escapes _dispatch_chat or
_dispatch_fanout BEFORE their internal try/except blocks
@@ -8254,7 +8993,7 @@ def _on_done(task: asyncio.Task) -> None:
)
async def _send():
try:
- if isinstance(target, str):
+ if target is None or isinstance(target, str):
await _broadcast(app, notice)
else:
await target.send_json(notice)
@@ -8350,9 +9089,8 @@ async def _dispatch_verb(
)
-_CE_CURATE_MODES = {
- "figures", "tables", "sources", "repair", "analyses", "sweep",
-}
+from .ce_protocol import CURATE_MODE_ALIASES as _CURATE_MODE_ALIASES
+_CE_CURATE_MODES = frozenset(_CURATE_MODE_ALIASES)
# D6: per-workspace curator profile — user-authored steering injected
@@ -8578,8 +9316,11 @@ def _curate_wave_prime_system(
if local:
return ""
from . import ce_host
+ from .ce_protocol import CURATE_MODE_ALIASES
token = args.split(None, 1)[0].lower() if (args or "").strip() else ""
- payload = {"mode": token} if token else {}
+ payload: dict[str, Any] = (
+ {"mode": token} if token in CURATE_MODE_ALIASES else {}
+ )
try:
prime = ce_host.wave_prime(workspace, payload)
except Exception as exc: # noqa: BLE001
@@ -9748,6 +10489,11 @@ def _desk_stopped_notice(task_kind: str | None) -> str:
"Stopped — Deck desk is quiet. "
"Dismiss it from Desks."
)
+ if kind == "research":
+ return (
+ "Stopped — /research desk is quiet. "
+ "Dismiss with /research dismiss."
+ )
slash = {"projects": "work", "code": "code"}.get(kind, "")
if slash:
return (
@@ -9778,6 +10524,7 @@ def _finish_desk(
"code": sbk.DESK_CODE,
"deck": sbk.DESK_DECK,
"auto": sbk.DESK_AUTO,
+ "research": sbk.DESK_RESEARCH,
}.get(kind)
if not desk_id:
return
@@ -9857,13 +10604,13 @@ async def _dispatch_auto(
or orchestration_policy.provider_category(pid) == "local"
)
# Provider-backed /curate is always the CE curator node so it can
- # dispatch Phase 2 workers. Local stays the single-session fallback.
- # Constrained /curate still seats the Curate desk (chat path).
- ce_provider_wave = (
- task_kind == "curation" and not constrained and not local_pid
- )
+ # dispatch Phase 2 workers. Local short curate stays a single
+ # session (constrained=True). Duration/continuous local still host-
+ # waves, one worker at a time — do not skip duration machinery.
+ ce_provider_wave = task_kind == "curation" and not constrained
projects_desk_wave = task_kind in {"projects", "code"}
deck_wave = task_kind == "deck"
+ research_desk_wave = task_kind == "research"
desk_id = sbk.choose_desk(
task_kind=task_kind,
command=command,
@@ -9887,7 +10634,7 @@ async def _dispatch_auto(
decision.strategy == "fast_lookup"
and not ce_provider_wave
and not projects_desk_wave
- and task_kind not in {"curation", "projects", "code", "deck", "auto"}
+ and task_kind not in {"curation", "projects", "code", "deck", "auto", "research"}
):
from .agents import fast_lookup as fast_lookup_mod
from .kernel.hire import pick_fast_model, pick_kernel_model, strong_check_mode
@@ -9939,6 +10686,8 @@ async def _dispatch_auto(
if (
plain_single and not ce_provider_wave
and not projects_desk_wave and not deck_wave
+ and not research_desk_wave
+ and not bool(features.web_ingest)
):
await _chat_notice(
app, ws,
@@ -10114,6 +10863,7 @@ async def _dispatch_auto(
)
curator_provider = curator_model = curator_harness = None
project_hires: list[dict] | None = None
+ research_hires: list[dict] | None = None
if str(task_kind or "") == "curation":
from .agents.rail_default import ALLOWED_TOOLS as _CHIEF_TOOLS
hire = sbk.decide_hire(
@@ -10188,7 +10938,59 @@ async def _dispatch_auto(
"reports_to": "chief",
}],
)
+ elif str(task_kind or "") == "research":
+ from .agents.rail_default import ALLOWED_TOOLS as _CHIEF_TOOLS
+ pkg = sbk.get_package(sbk.RESEARCH_ID)
+ hire = sbk.decide_hire(
+ sbk.HireRequest(
+ package_id=sbk.RESEARCH_ID,
+ justification="research desk",
+ needed_tools=list(pkg.tools if pkg else []),
+ needed_skills=list(pkg.needed_skills if pkg else []),
+ kind="specialist",
+ desk_prior=True,
+ ),
+ preference=pref,
+ chief=(pid, model),
+ org=[],
+ workspace=workspace,
+ chief_tools=list(_CHIEF_TOOLS),
+ pi_available=sbk.pi_available(),
+ )
+ if hire.accepted:
+ curator_provider = hire.provider
+ curator_model = hire.model
+ curator_harness = hire.harness
+ research_hires = [hire.to_dict()] if hire.accepted else None
+ sbk.seat(
+ workspace, sbk.DESK_RESEARCH,
+ chief_provider=pid,
+ chief_model=model,
+ thread_id=thread_id,
+ run_id=parent_run_id,
+ objective=text,
+ org=[{
+ "package": sbk.RESEARCH_ID,
+ "provider": curator_provider or pid,
+ "model": curator_model or model,
+ "harness": curator_harness or "rail",
+ "reports_to": "chief",
+ }],
+ )
elif desk_id == sbk.DESK_AUTO:
+ from .agents.rail_default import ALLOWED_TOOLS as _CHIEF_TOOLS
+ auto_hires = sbk.pick_auto_hires(
+ text,
+ preference=pref,
+ chief=(pid, model),
+ workspace=workspace,
+ chief_tools=list(_CHIEF_TOOLS),
+ pi_available=sbk.pi_available(),
+ research=bool(features.research and features.web_ingest),
+ web_ingest=bool(features.web_ingest),
+ )
+ if auto_hires:
+ research_hires = [d.to_dict() for d in auto_hires]
sbk.seat(
workspace, sbk.DESK_AUTO,
chief_provider=pid,
@@ -10196,6 +10998,13 @@ async def _dispatch_auto(
thread_id=thread_id,
run_id=parent_run_id,
objective=text,
+ org=[{
+ "package": d.package_id,
+ "provider": d.provider or pid,
+ "model": d.model or model,
+ "harness": d.harness or "rail",
+ "reports_to": "chief",
+ } for d in auto_hires],
)
elif str(task_kind or "") in {"projects", "code"}:
from . import kernel as sbk
@@ -10247,11 +11056,22 @@ async def _dispatch_auto(
curator_model=curator_model,
curator_harness=curator_harness,
project_hires=project_hires,
+ research_hires=research_hires,
workspace=workspace,
)
plan.features = features.to_dict()
if extra_system:
plan.extra_system = extra_system
+ if local_pid:
+ b = plan.bounds
+ plan.bounds = orchestration.OrchestrationBounds(
+ max_nodes=b.max_nodes,
+ max_depth=b.max_depth,
+ max_concurrency=1,
+ max_expansions=b.max_expansions,
+ worker_timeout_sec=b.worker_timeout_sec,
+ wall_clock_sec=b.wall_clock_sec,
+ ).clamp()
if parent_run_id in runs:
runs[parent_run_id]["planner_provider"] = planner_meta.get("provider")
runs[parent_run_id]["planner_model"] = planner_meta.get("model")
@@ -10266,8 +11086,12 @@ async def _dispatch_auto(
parent_run_id=parent_run_id, default_provider=provider,
default_model=model, ws=ws,
)
+ window_ended = str(
+ (result.telemetry or {}).get("stop_reason") or "",
+ ) == "curate window ended"
_finish_desk(
- workspace, task_kind, parent_run_id, cancelled=result.cancelled,
+ workspace, task_kind, parent_run_id,
+ cancelled=result.cancelled or window_ended,
)
if result.cancelled:
if parent_run_id in runs:
@@ -10351,6 +11175,63 @@ async def _drop_cancelled(rid: str = parent_run_id) -> None:
return parent_run_id
+def _task_kind_from_plan(plan: Any) -> str | None:
+ if plan is None:
+ return None
+ dec = getattr(plan, "decision", None) or {}
+ if isinstance(dec, dict) and dec.get("task_kind"):
+ return str(dec["task_kind"])
+ strat = str(getattr(plan, "strategy", "") or "")
+ return {
+ "ce_curate": "curation",
+ "research": "research",
+ "deck": "deck",
+ "projects": "projects",
+ "code": "code",
+ "auto": "auto",
+ }.get(strat)
+
+
+def _finish_resume_desk(
+ workspace: Path, plan: Any, orchestration_id: str, *, cancelled: bool,
+) -> None:
+ """Quiet this run's desk unless a newer overlapping seat owns it."""
+ from . import kernel as sbk
+ kind = _task_kind_from_plan(plan)
+ if not kind:
+ try:
+ for rec in sbk.list_standing(workspace):
+ if rec.run_id == orchestration_id:
+ kind = {
+ sbk.DESK_CURATE: "curation",
+ sbk.DESK_PROJECTS: "projects",
+ sbk.DESK_CODE: "code",
+ sbk.DESK_DECK: "deck",
+ sbk.DESK_RESEARCH: "research",
+ sbk.DESK_AUTO: "auto",
+ }.get(rec.desk_id)
+ break
+ except Exception: # noqa: BLE001
+ kind = None
+ if not kind:
+ return
+ try:
+ desk_id = {
+ "curation": sbk.DESK_CURATE,
+ "projects": sbk.DESK_PROJECTS,
+ "code": sbk.DESK_CODE,
+ "deck": sbk.DESK_DECK,
+ "research": sbk.DESK_RESEARCH,
+ "auto": sbk.DESK_AUTO,
+ }.get(kind)
+ rec = sbk.get(workspace, desk_id) if desk_id else None
+ if rec is not None and rec.run_id and rec.run_id != orchestration_id:
+ return
+ except Exception: # noqa: BLE001
+ pass
+ _finish_desk(workspace, kind, orchestration_id, cancelled=cancelled)
+
+
async def _resume_orchestration(
app: web.Application, orchestration_id: str,
*,
@@ -10366,13 +11247,15 @@ async def _resume_orchestration(
)
if ck is None or ck.get("plan") is None:
log.warning("resume %s: no checkpoint", orchestration_id)
+ _finish_resume_desk(workspace, None, orchestration_id, cancelled=False)
return
status = ck["status"]
phase = str(status.get("phase") or "")
+ plan = ck["plan"]
if phase != "quiet" and not orchestration.checkpoint_resumable(status):
log.info("resume %s: not resumable (phase=%s)", orchestration_id, status.get("phase"))
+ _finish_resume_desk(workspace, plan, orchestration_id, cancelled=False)
return
- plan = ck["plan"]
thread_id = str(status.get("thread_id") or app.get("thread_id") or "")
if not thread_id:
thread_id = await asyncio.to_thread(conversations.new_thread, workspace)
@@ -10383,12 +11266,14 @@ async def _resume_orchestration(
provider = llmgateway.get(pid)
except llmgateway.ProviderError as e:
await _broadcast(app, protocol.notice(f"resume failed: {e}", kind="chat"))
+ _finish_resume_desk(workspace, plan, orchestration_id, cancelled=False)
return
if not provider.has_key():
await _broadcast(app, protocol.notice(
f"resume failed: no key for {getattr(provider, 'LABEL', pid)}",
kind="chat",
))
+ _finish_resume_desk(workspace, plan, orchestration_id, cancelled=False)
return
model = status.get("default_model") or _effective_model(pid) or provider.PROVIDER.get("default_model")
runs: dict[str, dict[str, Any]] = app.setdefault("runs", {})
@@ -10476,6 +11361,10 @@ async def _resume_orchestration(
"end_turn",
))
except asyncio.CancelledError:
+ rec = runs.get(orchestration_id)
+ if rec is not None:
+ rec["status"] = "cancelled"
+ rec["user_cancel"] = True
await _broadcast(app, protocol.run_error(
orchestration_id, "cancelled", "resume cancelled", thread_id,
))
@@ -10486,6 +11375,11 @@ async def _resume_orchestration(
orchestration_id, "server", str(e), thread_id,
))
finally:
+ rec = runs.get(orchestration_id) or {}
+ cancelled = rec.get("status") == "cancelled" or bool(rec.get("user_cancel"))
+ _finish_resume_desk(
+ workspace, plan, orchestration_id, cancelled=cancelled,
+ )
runs.pop(orchestration_id, None)
@@ -11516,9 +12410,24 @@ async def _flush_reasoning() -> None:
"propose_wiki_page", "propose_page_edit",
) and (local_rung is None or local_rung.force_scaffold):
tinput = {**tinput, "scaffold": True}
- output = await asyncio.to_thread(
- tools.execute, tname, workspace, tinput,
- )
+ if permissions.needs_web_consent(tname, tinput):
+ verdict, reason = await permissions.mediate_protected_call(
+ workspace=workspace, tool=tname, tool_input=tinput,
+ provider=str(pid or "switchbay"),
+ run_id=run_id, thread_id=thread_id,
+ broadcast=lambda msg: _broadcast(app, msg),
+ )
+ if verdict != "approve":
+ output = {"ok": False, "error": reason}
+ else:
+ output = await asyncio.to_thread(
+ tools.execute, tname, workspace, tinput,
+ consent=permissions.trusted_consent(),
+ )
+ else:
+ output = await asyncio.to_thread(
+ tools.execute, tname, workspace, tinput,
+ )
# Cap results for every model — a 100-turn curate
# that stuffed unbounded JSON into `messages` is a
# plausible path to multi-GB RSS.
@@ -12193,8 +13102,29 @@ async def handle_slideshow_close(request: web.Request) -> web.Response:
return web.json_response({"ok": True, "removed": removed})
+def _pdf_render_error(detail: str) -> tuple[str, int]:
+ """Classify renderer failure: missing runtime vs browser vs spawn."""
+ blob = (detail or "").lower()
+ if "cannot find package" in blob or "cannot find module" in blob:
+ return (
+ "PDF renderer cannot resolve Playwright. "
+ "Install frontend deps with `pnpm --dir frontend install --frozen-lockfile`.",
+ 503,
+ )
+ if "executable doesn't exist" in blob or "browserType.launch" in blob:
+ return (
+ "Playwright Chromium is not installed. "
+ "Run `pnpm --dir frontend exec playwright install chromium`.",
+ 503,
+ )
+ if "enoent" in blob or "no such file" in blob:
+ return ("PDF renderer failed to spawn Node or Playwright.", 503)
+ return ("PDF rendering failed", 500)
+
+
async def handle_slideshow_pdf(request: web.Request) -> web.Response:
"""Render every HTML slide to one 16:9 PDF page under vault/exports."""
+ from . import runtime
workspace: Path = request.app["workspace"]
try:
body = await request.json()
@@ -12205,12 +13135,18 @@ async def handle_slideshow_pdf(request: web.Request) -> web.Response:
return web.json_response({"error": "invalid slideshow slug"}, status=400)
if await asyncio.to_thread(html_decks.entry_html, workspace, slug) is None:
return web.json_response({"error": "slideshow not found"}, status=404)
- node = shutil.which("node")
+ spawn_env = runtime.spawn_env()
+ node = runtime.resolve_node(spawn_env)
renderer = (
Path(__file__).resolve().parents[2]
/ "frontend" / "scripts" / "render-slideshow-pdf.mjs"
)
- if not node or not renderer.is_file():
+ if not node:
+ return web.json_response({
+ "error": "Node.js is not installed or not executable",
+ "detail": "PDF export needs a Node runtime on PATH (nvm, Homebrew, or Volta).",
+ }, status=503)
+ if not renderer.is_file():
return web.json_response(
{"error": "PDF renderer is not installed"}, status=503,
)
@@ -12222,27 +13158,38 @@ async def handle_slideshow_pdf(request: web.Request) -> web.Response:
url = f"http://127.0.0.1:{port}/api/slideshows/{slug}/index.html"
proc: asyncio.subprocess.Process | None = None
try:
- proc = await asyncio.create_subprocess_exec(
- node,
- str(renderer),
- url,
- str(temporary),
- cwd=str(renderer.parent.parent),
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.STDOUT,
- )
+ try:
+ proc = await asyncio.create_subprocess_exec(
+ node,
+ str(renderer),
+ url,
+ str(temporary),
+ cwd=str(renderer.parent.parent),
+ env=spawn_env,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.STDOUT,
+ )
+ except OSError as e:
+ return web.json_response({
+ "error": "PDF renderer failed to spawn",
+ "detail": str(e),
+ }, status=503)
output, _ = await asyncio.wait_for(proc.communicate(), timeout=120)
if proc.returncode != 0 or not temporary.is_file():
detail = output.decode(errors="replace")[-1600:]
+ msg, status = _pdf_render_error(detail)
return web.json_response({
- "error": "PDF rendering failed",
+ "error": msg,
"detail": detail or "Install the Playwright Chromium browser.",
- }, status=500)
+ }, status=status)
await asyncio.to_thread(os.replace, temporary, destination)
except asyncio.TimeoutError:
if proc is not None:
proc.kill()
- await proc.communicate()
+ try:
+ await proc.communicate()
+ except Exception: # noqa: BLE001
+ pass
return web.json_response({"error": "PDF rendering timed out"}, status=504)
finally:
if temporary.is_file():
@@ -15242,6 +16189,16 @@ async def _check_external_edit_async(app: web.Application, rel: str) -> None:
log.exception("file_state check failed for %s", rel)
+def _ws_response() -> web.WebSocketResponse:
+ """Rail socket. Compression off: aiohttp 3.14 rejects some
+ permessage-deflate / PONG sequences ("Received frame with
+ non-zero reserved bits", aio-libs/aiohttp#13274), dropping
+ ``user_input``. The composer still echoes the slash; the desk
+ never seats. Safari and Chromium both hit it.
+ """
+ return web.WebSocketResponse(heartbeat=30.0, compress=False)
+
+
async def handle_ws(request: web.Request) -> web.WebSocketResponse:
# Defense-in-depth: the _origin_guard middleware already rejected
# non-loopback Origins, but re-check here so a future routing change
@@ -15251,7 +16208,7 @@ async def handle_ws(request: web.Request) -> web.WebSocketResponse:
return web.json_response(
{"error": "cross-origin websocket refused"}, status=403,
)
- ws = web.WebSocketResponse(heartbeat=30.0)
+ ws = _ws_response()
await ws.prepare(request)
workspace: Path = request.app["workspace"]
request.app["ws_clients"].add(ws)
@@ -15408,14 +16365,19 @@ async def handle_ws(request: web.Request) -> web.WebSocketResponse:
continue
desk_spec = _desk_spec(sname.lower())
if desk_spec and desk_spec.get("task_kind") in (
- "projects", "code",
+ "projects", "code", "curation", "research",
):
- # Work/code desks. /work is the projects
- # desk; /steer is a silent alias. Not
- # /project (thread binding) and not
+ # Work / code / curate desks. /work is the
+ # projects desk; /steer is a silent alias.
+ # Not /project (thread binding) and not
# /portfolio (Library).
slash = str(desk_spec["slash"])
verb = _desk_slash_verb(sargs)
+ reviews_note = (
+ " Reviews stay in the Reviews tab."
+ if desk_spec.get("task_kind") == "curation"
+ else ""
+ )
if verb is None and not sargs.strip():
resumed = _maybe_resume_quiet_desk(
request.app, desk_spec,
@@ -15437,7 +16399,8 @@ async def handle_ws(request: web.Request) -> web.WebSocketResponse:
f"Quieted the {slash} desk"
+ (f" ({n} run" + ("" if n == 1 else "s")
+ " stopped)" if n else "")
- + f". Dismiss with /{slash} dismiss.",
+ + f". Dismiss with /{slash} dismiss."
+ + reviews_note,
kind="slash",
))
continue
@@ -15450,20 +16413,42 @@ async def handle_ws(request: web.Request) -> web.WebSocketResponse:
f"Dismissed the {slash} desk"
+ (f" ({n} run" + ("" if n == 1 else "s")
+ " stopped)" if n else "")
- + ".",
+ + "."
+ + reviews_note,
kind="slash",
))
continue
+ extra_pid = extra_model = None
+ if desk_spec.get("task_kind") == "curation":
+ extra_pid, extra_model = _ce_action_provider(
+ request.app["workspace"],
+ )
_launch_desk(
request.app, desk_spec,
prompt=sargs.strip(),
preference=pref,
+ provider_override=extra_pid,
+ model_override=extra_model,
)
- await ws.send_json(protocol.notice(
+ notice = (
f"/{slash} running in the background. "
f"Quiet with /{slash} stop; dismiss with "
- f"/{slash} dismiss.",
- kind="chat",
+ f"/{slash} dismiss."
+ )
+ if extra_pid:
+ try:
+ extra_label = llmgateway.get(extra_pid).LABEL
+ except llmgateway.ProviderError:
+ extra_label = extra_pid
+ notice = (
+ f"/{slash} running in the background on "
+ f"{extra_label} ({extra_model}). "
+ "The rail stays free. "
+ f"Quiet with /{slash} stop; dismiss with "
+ f"/{slash} dismiss."
+ )
+ await ws.send_json(protocol.notice(
+ notice, kind="chat",
))
continue
if sname.lower() in ("effort", "reasoning", "think"):
@@ -15783,59 +16768,10 @@ async def handle_ws(request: web.Request) -> web.WebSocketResponse:
t.add_done_callback(
_make_dispatch_error_surface(request.app, ws))
continue
+ # ingest / add-source (curate is a desk slash above).
# Worker-rung routing: a hybrid ladder
# (normal→local) runs the CE action on the local
- # model without flipping the global default. Fall
- # back to the default when no rung applies. Resolve
- # it FIRST so the prompt can be localised — the
- # local model gets skill-free operating rules
- # (it can't load the skill).
- if sname.lower() in ("curate", "curator"):
- cspec = _desk_spec("curate") or {
- "desk_id": "curate",
- "cancel": ("curate",),
- "slash": "curate",
- }
- cverb = _desk_slash_verb(sargs)
- if cverb == "quiet":
- n = _quiet_desk(
- request.app, cspec,
- request.app["workspace"],
- )
- await ws.send_json(protocol.notice(
- f"Quieted the curate desk"
- + (f" ({n} run" + ("" if n == 1 else "s")
- + " stopped)" if n else "")
- + ". Dismiss with /curate dismiss. "
- "Reviews stay in the Reviews tab.",
- kind="slash",
- ))
- continue
- if cverb == "dismiss":
- n = _dismiss_desk(
- request.app, cspec,
- request.app["workspace"],
- )
- await ws.send_json(protocol.notice(
- f"Dismissed the curate desk"
- + (f" ({n} run" + ("" if n == 1 else "s")
- + " stopped)" if n else "")
- + ". Reviews stay in the Reviews tab.",
- kind="slash",
- ))
- continue
- if cverb is None and not sargs.strip():
- resumed = _maybe_resume_quiet_desk(
- request.app, cspec,
- request.app["workspace"],
- )
- if resumed:
- await ws.send_json(protocol.notice(
- f"Resuming the quiet curate desk "
- f"(`{resumed}`). Same DAG.",
- kind="slash",
- ))
- continue
+ # model without flipping the global default.
cp_pid, cp_model = _ce_action_provider(
request.app["workspace"],
)
@@ -15852,41 +16788,7 @@ async def handle_ws(request: web.Request) -> web.WebSocketResponse:
sname.lower(), sargs, local=_ce_local,
local_rung=_lrung,
)
- extra_system = ""
if ce_prompt is not None:
- if sname.lower() in ("curate", "curator"):
- # D6: steer the curator with the
- # workspace profile, verbatim + capped —
- # system-side so Jump does not dump it.
- _cap = _CURATOR_PROFILE_CAP_TOKENS
- if _lrung is not None:
- _cap = max(400, _lrung.extra_system_chars // 4)
- elif _ce_local:
- _cap = 400
- prof = await asyncio.to_thread(
- _curator_profile,
- request.app["workspace"],
- _cap,
- )
- extra_system = _curator_profile_system(prof)
- fb = await asyncio.to_thread(
- _review_feedback_system,
- request.app["workspace"],
- )
- if fb:
- extra_system = (
- extra_system + "\n\n" + fb
- ).strip()
- prime = await asyncio.to_thread(
- _curate_wave_prime_system,
- request.app["workspace"],
- sargs,
- local=_ce_local,
- )
- if prime:
- extra_system = (
- extra_system + "\n\n" + prime
- ).strip()
if cp_pid:
try:
cp_label = llmgateway.get(cp_pid).LABEL
@@ -15906,39 +16808,17 @@ async def handle_ws(request: web.Request) -> web.WebSocketResponse:
f"/{sname.lower()} stop.",
kind="chat",
))
- # Background: do not occupy the focused
- # rail thread (user can keep chatting).
excerpt = (
f"[{sname.lower()} · background] "
f"{sargs}"
).strip()
- _is_curate = sname.lower() in ("curate", "curator")
- _constrained = _ce_local or bool(
- sargs.strip()
- and len(sargs.strip()) <= 80
- and "\n" not in sargs
- )
- if _is_curate:
- t = asyncio.create_task(_dispatch_auto(
- request.app, None, ce_prompt,
- preference=pref,
- provider_override=cp_pid,
- model_override=cp_model,
- input_excerpt=excerpt,
- extra_system=extra_system or None,
- command="curate",
- task_kind="curation",
- constrained=_constrained,
- ))
- else:
- t = asyncio.create_task(_dispatch_chat(
- request.app, None, ce_prompt,
- provider_override=cp_pid,
- model_override=cp_model,
- input_excerpt=excerpt,
- extra_system=extra_system or None,
- command=sname.lower() if _ce_local else None,
- ))
+ t = asyncio.create_task(_dispatch_chat(
+ request.app, None, ce_prompt,
+ provider_override=cp_pid,
+ model_override=cp_model,
+ input_excerpt=excerpt,
+ command=sname.lower() if _ce_local else None,
+ ))
t.add_done_callback(
_make_dispatch_error_surface(request.app, None),
)
@@ -16323,6 +17203,10 @@ def build_app(workspace: Path) -> web.Application:
app.router.add_post("/api/streams/{account_id}/curate", handle_streams_curate)
app.router.add_post("/api/streams/{account_id}/auto", handle_streams_auto)
app.router.add_post("/api/streams/{account_id}/routing", handle_streams_routing)
+ app.router.add_get("/api/comms/review", handle_comms_review_list)
+ app.router.add_post("/api/comms/review", handle_comms_review_act)
+ app.router.add_post("/api/comms/review/open", handle_comms_review_open)
+ app.router.add_post("/api/comms/review/close", handle_comms_review_close)
app.router.add_get("/api/workspaces", handle_workspaces_get)
app.router.add_post("/api/workspaces/add", handle_workspaces_add)
app.router.add_post("/api/workspaces/switch", handle_workspaces_switch)
@@ -16641,6 +17525,16 @@ async def _seed_reviews_tab(_app: web.Application) -> None:
log.exception("reviews tab restore on boot failed")
app.on_startup.append(_seed_reviews_tab)
+ async def _seed_comms_tab(_app: web.Application) -> None:
+ from . import comms_review
+ ws: Path = _app["workspace"]
+ try:
+ if await asyncio.to_thread(comms_review.pending_count) > 0:
+ await asyncio.to_thread(tabstore.add_comms_tab, ws)
+ except Exception: # noqa: BLE001
+ log.exception("comms tab restore on boot failed")
+ app.on_startup.append(_seed_comms_tab)
+
# A never-curated wiki (the freshly-seeded demo, or a hand-authored
# one) has no `.curator/graph.kuzu`, and viewer.sh only READS that —
# so the Graph tab would render nodes with zero edges. Build it once,
@@ -16991,7 +17885,9 @@ def run(workspace: Path, host: str = "127.0.0.1", port: int = 8765) -> int:
from . import daemonlog
log_path = daemonlog.configure()
from . import http as sbhttp
+ from . import runtime as sb_runtime
sbhttp.install_gates()
+ sb_runtime.apply_to_environ()
log.info(
"boot: profile=%s overlay=%s baked=%s",
admin_policy.profile(),
diff --git a/src/switchbay/helpers/icloud_download.js b/src/switchbay/helpers/icloud_download.js
new file mode 100644
index 0000000..3919e68
--- /dev/null
+++ b/src/switchbay/helpers/icloud_download.js
@@ -0,0 +1,104 @@
+// Download-on-demand for one already-authorized iCloud item.
+// Invoked as: /usr/bin/osascript -l JavaScript icloud_download.js
+// Paths arrive only via argv — never interpolated into this source or a shell.
+// Uses NSFileManager.startDownloadingUbiquitousItemAtURL:error: (public API).
+// Does not evict, pin, or start a recursive/global iCloud sync.
+
+ObjC.import("Foundation");
+
+function unwrap(v) {
+ if (v === undefined || v === null) return null;
+ try {
+ return ObjC.unwrap(v);
+ } catch (e) {
+ return String(v);
+ }
+}
+
+function nsErrorInfo(errRef) {
+ try {
+ var err = errRef && errRef[0];
+ if (!err || err.isNil && err.isNil()) return null;
+ var desc = unwrap(err.localizedDescription);
+ var domain = unwrap(err.domain);
+ var code = err.code !== undefined ? Number(err.code) : null;
+ return { description: desc, domain: domain, code: code };
+ } catch (e) {
+ return null;
+ }
+}
+
+function probe(url, fm) {
+ var ubiquitous = false;
+ try {
+ ubiquitous = !!fm.isUbiquitousItemAtURL(url);
+ } catch (e) {
+ ubiquitous = false;
+ }
+ var status = null;
+ var downloading = null;
+ try {
+ var statusRef = Ref();
+ var err = $();
+ var ok = url.getResourceValueForKeyError(
+ statusRef,
+ $.NSURLUbiquitousItemDownloadingStatusKey,
+ err
+ );
+ if (ok) status = unwrap(statusRef[0]);
+ } catch (e) {
+ status = null;
+ }
+ try {
+ var downRef = Ref();
+ var err2 = $();
+ var ok2 = url.getResourceValueForKeyError(
+ downRef,
+ $.NSURLUbiquitousItemIsDownloadingKey,
+ err2
+ );
+ if (ok2) downloading = !!unwrap(downRef[0]);
+ } catch (e) {
+ downloading = null;
+ }
+ return {
+ ok: true,
+ ubiquitous: ubiquitous,
+ status: status,
+ downloading: downloading,
+ };
+}
+
+function run(argv) {
+ var action = argv && argv.length ? String(argv[0] || "") : "";
+ var path = argv && argv.length > 1 ? String(argv[1] || "") : "";
+ if (!path) {
+ return JSON.stringify({ ok: false, error: "path required" });
+ }
+ if (action !== "probe" && action !== "status" && action !== "start") {
+ return JSON.stringify({ ok: false, error: "unknown action" });
+ }
+ var url = $.NSURL.fileURLWithPath(path);
+ var fm = $.NSFileManager.defaultManager;
+ if (action === "probe" || action === "status") {
+ return JSON.stringify(probe(url, fm));
+ }
+ var err = $();
+ var started = false;
+ try {
+ started = !!fm.startDownloadingUbiquitousItemAtURLError(url, err);
+ } catch (e) {
+ return JSON.stringify({
+ ok: false,
+ started: false,
+ error: String(e),
+ });
+ }
+ var info = nsErrorInfo(err);
+ return JSON.stringify({
+ ok: started,
+ started: started,
+ error: started ? null : (info && info.description) || "startDownloadingUbiquitousItem failed",
+ ns_error: info,
+ });
+}
diff --git a/src/switchbay/icloud_download.py b/src/switchbay/icloud_download.py
new file mode 100644
index 0000000..43c5a2d
--- /dev/null
+++ b/src/switchbay/icloud_download.py
@@ -0,0 +1,245 @@
+"""macOS iCloud Drive download-on-demand for a single watched file.
+
+Uses the public Foundation API ``NSFileManager.startDownloadingUbiquitousItemAtURL:error:``
+via a bundled JXA helper (``/usr/bin/osascript -l JavaScript``). The file path is
+passed as an argv element — never interpolated into JavaScript or a shell command.
+
+This starts a download for one item and then polls until the bytes are local.
+It does not evict, pin, change "Keep Downloaded", or recursively sync a tree.
+Only iCloud on darwin is implemented; other cloud placeholders stay retryable
+pending without claiming support.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import subprocess
+import sys
+import time
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from . import statedir
+
+log = logging.getLogger(__name__)
+
+HELPER = Path(__file__).resolve().parent / "helpers" / "icloud_download.js"
+OSASCRIPT = "/usr/bin/osascript"
+# Overall bound for probe + start + poll (not 8s each).
+HYDRATE_WAIT = 8.0
+_POLL = 0.25
+
+# Legacy iCloud stub: ".Presentation.pptx.icloud"
+_ICLOUD_STUB_SUFFIX = ".icloud"
+
+
+@dataclass
+class HydrateResult:
+ ok: bool
+ ready: bool
+ path: str
+ error: str | None = None
+ retryable: bool = True
+ ubiquitous: bool = False
+ started: bool = False
+ detail: str | None = None
+
+
+def supported() -> bool:
+ """True when this process can invoke the public macOS download API."""
+ return (
+ sys.platform == "darwin"
+ and Path(OSASCRIPT).is_file()
+ and HELPER.is_file()
+ )
+
+
+def is_icloud_stub(path: Path | str) -> bool:
+ name = Path(path).name
+ return name.startswith(".") and name.endswith(_ICLOUD_STUB_SUFFIX) and len(name) > 8
+
+
+def logical_path(path: Path | str) -> Path:
+ """Map a legacy ``.name.ext.icloud`` stub to the visible filename."""
+ p = Path(path)
+ if is_icloud_stub(p):
+ return p.with_name(p.name[1 : -len(_ICLOUD_STUB_SUFFIX)])
+ return p
+
+
+def _is_ready(path: Path) -> bool:
+ try:
+ if is_icloud_stub(path):
+ return False
+ if not path.is_file():
+ return False
+ if statedir.is_dataless(path):
+ return False
+ return path.stat().st_size > 0
+ except OSError:
+ return False
+
+
+def ready_path(path: Path) -> Path | None:
+ """Return a locally readable path for ``path``, or None if still a placeholder."""
+ logical = logical_path(path)
+ for cand in (logical, path):
+ try:
+ if _is_ready(cand):
+ return cand
+ except OSError:
+ continue
+ return None
+
+
+def run_helper(action: str, path: Path, *, timeout: float = 15.0) -> dict[str, Any]:
+ """Run the JXA helper. ``path`` is argv-only (no string interpolation)."""
+ if action not in {"probe", "status", "start"}:
+ return {"ok": False, "error": f"unknown action {action!r}"}
+ if not supported():
+ return {"ok": False, "error": "icloud download helper unavailable", "unsupported": True}
+ try:
+ proc = subprocess.run(
+ [OSASCRIPT, "-l", "JavaScript", str(HELPER), action, str(path)],
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ check=False,
+ )
+ except subprocess.TimeoutExpired:
+ return {"ok": False, "error": f"osascript timed out after {int(timeout)}s", "retryable": True}
+ except OSError as e:
+ return {"ok": False, "error": f"osascript failed: {e}", "retryable": True}
+ stdout = (proc.stdout or "").strip()
+ parsed: Any = None
+ if stdout:
+ try:
+ parsed = json.loads(stdout)
+ except json.JSONDecodeError:
+ # JXA may print logs; take the last JSON object.
+ start, end = stdout.rfind("{"), stdout.rfind("}")
+ if start >= 0 and end > start:
+ try:
+ parsed = json.loads(stdout[start : end + 1])
+ except json.JSONDecodeError:
+ parsed = None
+ if isinstance(parsed, dict):
+ if proc.returncode and "error" not in parsed:
+ parsed["error"] = (proc.stderr or "").strip() or f"osascript exited {proc.returncode}"
+ return parsed
+ err = (proc.stderr or stdout or f"osascript exited {proc.returncode}")[-400:]
+ return {"ok": False, "error": err, "retryable": True}
+
+
+def hydrate_file(path: Path, *, timeout: float = HYDRATE_WAIT) -> HydrateResult:
+ """Ensure ``path`` is local. Starts an iCloud download when needed; bounded wait."""
+ path = Path(path)
+ existing = ready_path(path)
+ if existing is not None:
+ return HydrateResult(ok=True, ready=True, path=str(existing), retryable=False)
+
+ hint = statedir.sync_service_hint(path)
+ stub = is_icloud_stub(path)
+ dataless = False
+ try:
+ dataless = path.exists() and statedir.is_dataless(path)
+ except OSError:
+ dataless = False
+
+ if hint and hint != "iCloud" and not stub:
+ return HydrateResult(
+ ok=False,
+ ready=False,
+ path=str(path),
+ error=f"{hint} placeholder; download-on-demand is iCloud-only",
+ retryable=True,
+ detail=hint,
+ )
+
+ if sys.platform != "darwin":
+ return HydrateResult(
+ ok=False,
+ ready=False,
+ path=str(path),
+ error="cloud placeholder; download-on-demand is macOS iCloud only",
+ retryable=True,
+ )
+
+ if not supported():
+ return HydrateResult(
+ ok=False,
+ ready=False,
+ path=str(path),
+ error="osascript/JXA helper unavailable",
+ retryable=True,
+ )
+
+ deadline = time.monotonic() + max(0.2, timeout)
+
+ def _remain() -> float:
+ return max(0.05, deadline - time.monotonic())
+
+ probe = run_helper("probe", path, timeout=min(_remain(), 2.0))
+ ubiquitous = bool(probe.get("ubiquitous"))
+ if not ubiquitous and not stub and not dataless:
+ # Local non-iCloud file that is empty or missing — not ours to download.
+ try:
+ size = path.stat().st_size if path.is_file() else 0
+ except OSError as e:
+ return HydrateResult(
+ ok=False, ready=False, path=str(path),
+ error=f"access denied: {e}", retryable=True,
+ )
+ if size == 0:
+ return HydrateResult(
+ ok=False, ready=False, path=str(path),
+ error="placeholder size 0", retryable=True, detail="size0",
+ )
+ if not path.exists():
+ return HydrateResult(
+ ok=False, ready=False, path=str(path),
+ error="source disappeared", retryable=True, detail="missing",
+ )
+ return HydrateResult(ok=True, ready=True, path=str(path), retryable=False)
+
+ if time.monotonic() >= deadline:
+ return HydrateResult(
+ ok=False, ready=False, path=str(path),
+ error="iCloud download did not finish in time",
+ retryable=True, ubiquitous=ubiquitous, detail="timeout",
+ )
+ start = run_helper("start", path, timeout=min(_remain(), 2.0))
+ started = bool(start.get("started") or start.get("ok"))
+ while time.monotonic() < deadline:
+ got = ready_path(path)
+ if got is not None:
+ return HydrateResult(
+ ok=True, ready=True, path=str(got),
+ ubiquitous=ubiquitous, started=started, retryable=False,
+ )
+ time.sleep(_POLL)
+
+ got = ready_path(path)
+ if got is not None:
+ return HydrateResult(
+ ok=True, ready=True, path=str(got),
+ ubiquitous=ubiquitous, started=started, retryable=False,
+ )
+ err = start.get("error") if isinstance(start, dict) else None
+ return HydrateResult(
+ ok=False,
+ ready=False,
+ path=str(path),
+ error=str(err or "iCloud download did not finish in time"),
+ retryable=True,
+ ubiquitous=ubiquitous,
+ started=started,
+ detail="timeout",
+ )
+
+
+def helper_argv(action: str, path: Path) -> list[str]:
+ """Argv used to invoke the helper (tests assert no shell interpolation)."""
+ return [OSASCRIPT, "-l", "JavaScript", str(HELPER), action, str(path)]
diff --git a/src/switchbay/kernel/__init__.py b/src/switchbay/kernel/__init__.py
index d3d37d1..ba3a9fe 100644
--- a/src/switchbay/kernel/__init__.py
+++ b/src/switchbay/kernel/__init__.py
@@ -12,6 +12,7 @@
DESK_DECK,
DESK_INFO,
DESK_PROJECTS,
+ DESK_RESEARCH,
STATE_DISMISSED,
STATE_QUIET,
STATE_WORKING,
@@ -31,6 +32,7 @@
from .harness import NodeRequest, NodeResult, pi_available, pick_harness, run_node
from .hire import (
HireDecision, HireRequest, decide_hire, pick_family_hires,
+ pick_auto_hires,
pick_critic_model, pick_fast_model, pick_kernel_model, pick_worker_model,
strong_check_mode,
)
@@ -64,6 +66,7 @@
"DESK_DECK",
"DESK_INFO",
"DESK_PROJECTS",
+ "DESK_RESEARCH",
"choose_desk",
"looks_like_deck",
"STATE_DISMISSED",
@@ -76,6 +79,7 @@
"NodeResult",
"Package",
"decide_hire",
+ "pick_auto_hires",
"family_ids",
"packages_for_desk",
"pick_family_hires",
diff --git a/src/switchbay/kernel/desk.py b/src/switchbay/kernel/desk.py
index 6adedb5..94552f4 100644
--- a/src/switchbay/kernel/desk.py
+++ b/src/switchbay/kernel/desk.py
@@ -21,6 +21,7 @@
DESK_PROJECTS = "projects"
DESK_CODE = "code"
DESK_DECK = "deck"
+DESK_RESEARCH = "research"
STATE_WORKING = "working"
STATE_QUIET = "quiet"
@@ -37,6 +38,7 @@
DESK_PROJECTS: {"label": "Work", "slash": "work"},
DESK_CODE: {"label": "Code", "slash": "code"},
DESK_DECK: {"label": "Deck", "slash": None},
+ DESK_RESEARCH: {"label": "Research", "slash": "research"},
}
# Explicit slash / internal command → standing desk. Curate always
@@ -57,6 +59,7 @@
"make-slides": DESK_DECK,
"make_slides": DESK_DECK,
"slideshow-author": DESK_DECK,
+ "research": DESK_RESEARCH,
}
_TASK_KIND_DESK: dict[str, str] = {
@@ -65,6 +68,7 @@
"code": DESK_CODE,
"deck": DESK_DECK,
"auto": DESK_AUTO,
+ "research": DESK_RESEARCH,
}
# Authoring a presentation. A subject mention ("HTML slideshows",
@@ -112,10 +116,12 @@ def choose_desk(
) -> str | None:
"""Which standing desk this run reuses, or None for a one-shot.
- Named desks (curate / work / code / deck) always seat — including
- a single-worker ``/curate``. Simple wiki/lookup questions do not.
- Deeper Auto (plan, research, multi-worker) reuses Auto. A second
- slideshow ask returns the same Deck id, not a new desk.
+ Named desks (curate / work / code / deck / research) always seat —
+ including a single-worker ``/curate``. Simple wiki/lookup questions
+ do not. Deeper Auto (plan, multi-worker) reuses Auto. Explicit
+ ``/research`` seats Research; Auto web-ingest hires the research
+ package on the Auto desk. A second slideshow ask returns the same
+ Deck id, not a new desk.
"""
cmd = (command or "").strip().lower()
if cmd in _COMMAND_DESK:
diff --git a/src/switchbay/kernel/harness_pi.py b/src/switchbay/kernel/harness_pi.py
index fc92031..1e2b881 100644
--- a/src/switchbay/kernel/harness_pi.py
+++ b/src/switchbay/kernel/harness_pi.py
@@ -25,6 +25,7 @@
SPIKE_PI = REPO_ROOT / ".local" / "pi-spike" / "node_modules" / ".bin" / "pi"
# launchd PATH is often /usr/bin:/bin. Pi's shebang is `env node`.
+# Extra bins come from switchbay.runtime (nvm/volta/homebrew/…).
_NODE_PATH_DIRS = (
"/opt/homebrew/bin",
"/usr/local/bin",
@@ -107,28 +108,15 @@ def _inject_provider_key(env: dict[str, str], req: NodeRequest) -> None:
def enrich_path(env: dict[str, str], *, extra_dirs: tuple[str, ...] = ()) -> None:
- """Prepend Homebrew / local bins so `env node` works under launchd."""
- parts = [p for p in env.get("PATH", "").split(os.pathsep) if p]
- seen = set(parts)
- prefix: list[str] = []
- home_local = str(Path.home() / ".local" / "bin")
- for d in (*extra_dirs, *_NODE_PATH_DIRS, home_local):
- if d and d not in seen and Path(d).is_dir():
- prefix.append(d)
- seen.add(d)
- if prefix:
- env["PATH"] = os.pathsep.join(prefix + parts)
+ """Prepend Homebrew / nvm / local bins so `env node` works under launchd."""
+ from .. import runtime
+ enriched = runtime.enrich_env(env, extra_dirs=extra_dirs)
+ env["PATH"] = enriched.get("PATH", env.get("PATH", ""))
def resolve_node(env: dict[str, str]) -> str | None:
- found = shutil.which("node", path=env.get("PATH") or os.defpath)
- if found:
- return found
- for d in _NODE_PATH_DIRS:
- cand = Path(d) / "node"
- if cand.is_file() and os.access(cand, os.X_OK):
- return str(cand)
- return None
+ from .. import runtime
+ return runtime.resolve_node(env)
def shebang_wants_node(binary: str) -> bool:
@@ -317,6 +305,8 @@ async def run(self, req: NodeRequest) -> NodeResult:
text_parts: list[str] = []
tools: list[str] = []
err: str | None = None
+ in_tok = 0
+ out_tok = 0
settled = False
result: NodeResult | None = None
try:
@@ -366,6 +356,13 @@ async def run(self, req: NodeRequest) -> NodeResult:
tools.append(name)
elif kind == "agent_settled":
settled = True
+ usage = ev.get("usage") or ev.get("tokenUsage") or {}
+ if isinstance(usage, dict):
+ try:
+ in_tok = int(usage.get("input") or usage.get("input_tokens") or in_tok or 0)
+ out_tok = int(usage.get("output") or usage.get("output_tokens") or out_tok or 0)
+ except (TypeError, ValueError):
+ pass
break
elif kind == "response" and ev.get("success") is False:
err = str(ev.get("error") or ev.get("message") or "pi rpc failed")[:400]
@@ -389,5 +386,14 @@ async def run(self, req: NodeRequest) -> NodeResult:
error=err,
harness=self.name,
tool_trace=tools,
+ input_tokens=in_tok,
+ output_tokens=out_tok,
)
- return result or NodeResult(text="", error=err, harness=self.name, tool_trace=tools)
+ return result or NodeResult(
+ text="".join(text_parts),
+ error=err,
+ harness=self.name,
+ tool_trace=tools,
+ input_tokens=in_tok,
+ output_tokens=out_tok,
+ )
diff --git a/src/switchbay/kernel/hire.py b/src/switchbay/kernel/hire.py
index e5faca9..a9fddbe 100644
--- a/src/switchbay/kernel/hire.py
+++ b/src/switchbay/kernel/hire.py
@@ -17,7 +17,7 @@
from .packages import (
CODE_EDIT_ID, CODE_EXPLORE_ID, CODE_PLAN_ID, CODE_REVIEW_ID,
CODING_FAMILY, PROJECT_COMMS_ID, PROJECT_PLAN_ID, PROJECT_REVIEW_ID,
- PROJECT_SENSE_ID, PROJECTS_FAMILY,
+ PROJECT_SENSE_ID, PROJECTS_FAMILY, RESEARCH_ID,
WRITES_COMMS, WRITES_PLANS, WRITES_PRODUCT, WRITES_REVIEW, get_package,
)
@@ -529,6 +529,49 @@ def pick_family_hires(
return out
+def pick_auto_hires(
+ text: str,
+ *,
+ preference: float,
+ chief: tuple[str, str | None],
+ workspace: Path | None = None,
+ available: list[tuple[str, str | None]] | None = None,
+ denied: list[str] | None = None,
+ chief_tools: list[str] | tuple[str, ...] | None = None,
+ pi_available: bool = False,
+ research: bool = False,
+ web_ingest: bool = False,
+) -> list[HireDecision]:
+ """Auto candidate packages. Research is hired for explicit web ingest."""
+ _ = text
+ s = policy.clamp_preference(preference)
+ out: list[HireDecision] = []
+ org: list[dict[str, Any]] = []
+ if research or web_ingest:
+ d = decide_hire(
+ HireRequest(
+ package_id=RESEARCH_ID,
+ justification="auto: web research / vault ingest",
+ needed_tools=["research_search", "research_fetch"],
+ needed_skills=["vault-ingest-research"],
+ desk_prior=False,
+ kind="specialist",
+ ),
+ preference=s,
+ chief=chief,
+ org=org,
+ workspace=workspace,
+ available=available,
+ denied=denied,
+ chief_tools=chief_tools,
+ pi_available=pi_available,
+ )
+ if d.accepted:
+ out.append(d)
+ org.append({"package": RESEARCH_ID})
+ return out
+
+
def _chief_upgrade(
*,
preference: float,
diff --git a/src/switchbay/kernel/packages.py b/src/switchbay/kernel/packages.py
index e6440b8..7e5fbcb 100644
--- a/src/switchbay/kernel/packages.py
+++ b/src/switchbay/kernel/packages.py
@@ -332,6 +332,7 @@ def _pkg(
family="knowledge",
writes=WRITES_PRODUCT,
needed_skills=("vault-ingest-research",),
+ desks=("research", "auto"),
),
CODE_EXPLORE_ID: _pkg(
CODE_EXPLORE_ID, CODE_EXPLORE_TOOLS, CODE_EXPLORE_SYSTEM,
diff --git a/src/switchbay/llmgateway/claude_code_settings.py b/src/switchbay/llmgateway/claude_code_settings.py
index 91dd726..ffc2cdd 100644
--- a/src/switchbay/llmgateway/claude_code_settings.py
+++ b/src/switchbay/llmgateway/claude_code_settings.py
@@ -360,6 +360,32 @@ def emit(verdict, reason=""):
"origin_thread": os.environ.get("CSWY_THREAD_ID") or "",
}).encode()
+PROTECTED = {
+ "WebSearch", "WebFetch", "web_search", "web_fetch",
+ "research_search", "research_fetch",
+}
+
+def _basename(name):
+ n = str(name or "")
+ return n.split("__")[-1] if "__" in n else n
+
+def _protected(name):
+ n = str(name or "")
+ if n in PROTECTED:
+ return True
+ return _basename(n) in PROTECTED
+
+def _mcp_registry_gated(name):
+ # Native hook + MCP server would card twice. Registry tools are
+ # gated at tools.execute; the hook must not also card them.
+ n = str(name or "")
+ if n.startswith("mcp__") or n.startswith("switchbay__"):
+ return _basename(n) in PROTECTED
+ return False
+
+if _mcp_registry_gated(tool):
+ emit("passthrough", "switchbay: registry tool gated at execution")
+
try:
req = urllib.request.Request(
f"http://127.0.0.1:{PORT}/api/permission/request",
@@ -370,8 +396,11 @@ def emit(verdict, reason=""):
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
body = json.loads(resp.read().decode("utf-8") or "{}")
except (urllib.error.URLError, OSError, json.JSONDecodeError):
- # Daemon unreachable or timed out. claude-code falls through to
- # its static allowlist; grok would fail open, so deny explicitly.
+ # Daemon unreachable or timed out. Protected web egress fails
+ # closed. Other tools: claude-code falls through to its static
+ # allowlist; grok would fail open, so deny explicitly.
+ if _protected(tool):
+ emit("deny", "switchbay: web egress denied (daemon unreachable)")
emit("passthrough", "switchbay: approval unavailable (daemon unreachable)")
decision = body.get("decision") or "deny"
@@ -379,6 +408,9 @@ def emit(verdict, reason=""):
emit("approve")
elif decision == "skip":
# Source muted in the rail — Switch Bay stops mediating.
+ # Protected egress must never fall through to the CLI allowlist.
+ if _protected(tool):
+ emit("deny", "switchbay: web egress denied")
emit("passthrough", "switchbay: mediation muted")
else:
emit("deny")
diff --git a/src/switchbay/llmgateway/openai_codex.py b/src/switchbay/llmgateway/openai_codex.py
index de7a1de..d0f10c4 100644
--- a/src/switchbay/llmgateway/openai_codex.py
+++ b/src/switchbay/llmgateway/openai_codex.py
@@ -406,13 +406,24 @@ def sandbox_for(req: base.ChatRequest, workspace: Path) -> str:
def web_search_overrides(workspace: Path) -> list[str]:
"""Codex has no PreToolUse hook, so native web_search cannot card.
- Always set ``tools.web_search`` explicitly. A globally disabled
- default would otherwise stay off when Settings says enabled.
- The Switch Bay ``research_*`` MCP tools remain the vault-ingest path.
+ Always disable native search, including leftover ``_codex:web-search``
+ sentinels, ``--search``, and ``~/.codex/config.toml``. Switch Bay
+ ``research_*`` tools are the vault-ingest path and go through the
+ per-call web policy.
"""
- if permissions.is_pre_approved(workspace, permissions.CODEX_WEB_SEARCH_SENTINEL):
- return ["-c", "tools.web_search=true"]
- return ["-c", "tools.web_search=false"]
+ _ = workspace
+ # Authoritative Codex setting is top-level web_search = "disabled"
+ # (developers.openai.com/codex/config-basic). `-c` TOML overrides
+ # beat ~/.codex/config.toml, including a pre-existing
+ # web_search = "live". Legacy tools/features flags stay as belt
+ # and braces for older CLIs.
+ return [
+ "-c", 'web_search="disabled"',
+ "-c", "tools.web_search=false",
+ "-c", "features.web_search_request=false",
+ "-c", "features.standalone_web_search=false",
+ "-c", "features.web_search_cached=false",
+ ]
def _mcp_overrides(
diff --git a/src/switchbay/mcp_server.py b/src/switchbay/mcp_server.py
index b7c4fb6..81437aa 100644
--- a/src/switchbay/mcp_server.py
+++ b/src/switchbay/mcp_server.py
@@ -179,6 +179,34 @@ def write_mcp_activity(workspace: Path, name: str, args: dict[str, Any] | None)
log.debug("mcp activity write skipped", exc_info=True)
+def _request_web_approval(
+ workspace: Path, name: str, args: dict[str, Any],
+) -> str:
+ """Long-poll the daemon permission card. Fail closed on errors."""
+ import urllib.error
+ import urllib.request
+ port = os.environ.get("CSWY_DAEMON_PORT") or "8765"
+ body = json.dumps({
+ "provider": "mcp",
+ "tool": name,
+ "input": args,
+ "cwd": str(workspace),
+ "origin_thread": os.environ.get("CSWY_THREAD_ID") or "",
+ }).encode()
+ try:
+ req = urllib.request.Request(
+ f"http://127.0.0.1:{port}/api/permission/request",
+ data=body,
+ headers={"Content-Type": "application/json"},
+ method="POST",
+ )
+ with urllib.request.urlopen(req, timeout=95) as resp:
+ payload = json.loads(resp.read().decode("utf-8") or "{}")
+ except (urllib.error.URLError, OSError, json.JSONDecodeError, TimeoutError):
+ return "deny"
+ return str(payload.get("decision") or "deny")
+
+
def _call_tool(workspace: Path, name: str, args: dict[str, Any]) -> dict[str, Any]:
"""Execute a registry tool and shape the result into MCP's
content-block contract. Errors come back with isError=True so the
diff --git a/src/switchbay/permissions.py b/src/switchbay/permissions.py
index bd32d8e..a934fef 100644
--- a/src/switchbay/permissions.py
+++ b/src/switchbay/permissions.py
@@ -27,9 +27,12 @@
from __future__ import annotations
import asyncio
+import contextlib
+import contextvars
import fnmatch
import json
import logging
+import os
import re
import time
import uuid
@@ -43,18 +46,202 @@
ALLOW_FILE = "permission-allow.json"
-# Native CLI web tools — never on the builtin allow floor. They must
-# hit the rail card (or a remembered Approve+remember). Grok's
-# internal names are web_search / web_fetch; Claude uses WebSearch /
-# WebFetch. Codex has no PreToolUse: see `_codex:web-search`.
+# Native CLI web tools and Switch Bay research tools — never on the
+# builtin allow floor, never session-cached, never remembered. They
+# hit a once/deny card only when the workspace web policy is on.
+# Grok's internal names are web_search / web_fetch; Claude uses
+# WebSearch / WebFetch. Codex has no PreToolUse: native search is
+# always disabled (the `_codex:web-search` sentinel is ignored).
WEB_SEARCH_TOOLS = frozenset({
"WebSearch", "WebFetch", "web_search", "web_fetch",
})
+RESEARCH_EGRESS_TOOLS = frozenset({
+ "research_search", "research_fetch",
+})
CODEX_WEB_SEARCH_SENTINEL = "_codex:web-search"
def is_web_search_tool(tool: str) -> bool:
return str(tool or "") in WEB_SEARCH_TOOLS
+
+
+def _tool_basename(tool: str) -> str:
+ t = str(tool or "")
+ if t.startswith("mcp__"):
+ parts = t.split("__")
+ if len(parts) >= 3:
+ return parts[-1]
+ if t.startswith("switchbay__"):
+ return t.split("__", 1)[-1]
+ return t
+
+
+def is_protected_egress(tool: str) -> bool:
+ """True for native web search/fetch, research_*, and MCP aliases."""
+ t = str(tool or "")
+ if t in WEB_SEARCH_TOOLS or t in RESEARCH_EGRESS_TOOLS:
+ return True
+ if t == CODEX_WEB_SEARCH_SENTINEL:
+ return True
+ base = _tool_basename(t)
+ if base in WEB_SEARCH_TOOLS or base in RESEARCH_EGRESS_TOOLS:
+ return True
+ low = base.lower().replace("-", "_")
+ if low in {"websearch", "webfetch", "web_search", "web_fetch"}:
+ return True
+ return False
+
+
+def is_protected_pattern(pattern: str) -> bool:
+ p = str(pattern or "").strip()
+ if not p:
+ return False
+ if p == CODEX_WEB_SEARCH_SENTINEL:
+ return True
+ tool = p.split("(", 1)[0]
+ return is_protected_egress(tool)
+
+
+_HTTP_URL_RE = re.compile(r"https?://", re.IGNORECASE)
+_CE_URL_TOOLS = frozenset({"ce_run", "ce_ingest"})
+_FETCH_SCRIPT_HINT = re.compile(
+ r"(fetch|download|http|url)", re.IGNORECASE,
+)
+_CE_NETWORK_SCRIPTS = frozenset({
+ "identifier_resolve.py",
+})
+_CE_RESOLVE_LOCAL_MODES = frozenset({"status", "review"})
+
+# Unforgeable in-process consent. Never constructed from payload/env.
+_CONSENT_MARK = object()
+_invocation_consent: contextvars.ContextVar[bool] = contextvars.ContextVar(
+ "switchbay_web_invocation_consent", default=False,
+)
+
+
+class TrustedConsent:
+ """Invocation-local consent that JSON/env cannot represent."""
+
+ __slots__ = ("_mark",)
+
+ def __init__(self, mark: object) -> None:
+ self._mark = mark
+
+
+def trusted_consent() -> TrustedConsent:
+ return TrustedConsent(_CONSENT_MARK)
+
+
+def is_trusted_consent(obj: Any) -> bool:
+ return isinstance(obj, TrustedConsent) and obj._mark is _CONSENT_MARK
+
+
+def invocation_approved() -> bool:
+ return bool(_invocation_consent.get())
+
+
+@contextlib.contextmanager
+def approved_invocation():
+ token = _invocation_consent.set(True)
+ try:
+ yield
+ finally:
+ _invocation_consent.reset(token)
+
+
+def _payload_has_http_url(payload: dict[str, Any] | None) -> bool:
+ data = payload or {}
+ for key in ("url", "href", "path", "directory", "uri"):
+ val = str(data.get(key) or "").strip()
+ if val.lower().startswith(("http://", "https://")):
+ return True
+ args = data.get("args")
+ if isinstance(args, str):
+ blob = args
+ elif isinstance(args, list):
+ blob = " ".join(str(a) for a in args)
+ else:
+ blob = ""
+ script = str(data.get("script") or "")
+ return bool(_HTTP_URL_RE.search(blob) or _HTTP_URL_RE.search(script))
+
+
+def needs_web_consent(tool: str, tool_input: dict[str, Any] | None = None) -> bool:
+ """True when this call may hit the network and needs a once/deny card."""
+ if is_protected_egress(tool):
+ return True
+ base = _tool_basename(tool)
+ if base in _CE_URL_TOOLS or str(tool or "") in _CE_URL_TOOLS:
+ payload = tool_input or {}
+ script = Path(str(payload.get("script") or "")).name
+ if script and not script.endswith(".py"):
+ script = f"{script}.py"
+ args = payload.get("args")
+ if isinstance(args, str):
+ arg_list = args.split()
+ elif isinstance(args, list):
+ arg_list = [str(a) for a in args]
+ else:
+ arg_list = []
+ if script == "identifier_resolve.py":
+ # Network only on `run --yes`. status/review are local.
+ tokens = {a.strip() for a in arg_list}
+ if tokens & _CE_RESOLVE_LOCAL_MODES and "run" not in tokens:
+ return False
+ return "run" in tokens and ("--yes" in tokens or "-y" in tokens)
+ if script in _CE_NETWORK_SCRIPTS or _FETCH_SCRIPT_HINT.search(script):
+ return True
+ return _payload_has_http_url(payload)
+ return False
+
+
+def strip_forged_approval(payload: dict[str, Any] | None) -> dict[str, Any]:
+ """Drop model-supplied approval flags. Never treat them as consent."""
+ data = dict(payload or {})
+ for key in ("_approved", "approved", "_consent", "consent", "_web_approved"):
+ data.pop(key, None)
+ return data
+
+
+def request_protected_sync(
+ workspace: Path,
+ tool: str,
+ tool_input: dict[str, Any] | None = None,
+ *,
+ timeout: float | None = None,
+) -> str:
+ """Long-poll the daemon permission card. Fail closed. Never skip."""
+ import urllib.error
+ import urllib.request
+
+ port = os.environ.get("CSWY_DAEMON_PORT") or "8765"
+ wait = float(timeout if timeout is not None else min(REQUEST_TIMEOUT_S, 120.0))
+ body = json.dumps({
+ "provider": "registry",
+ "tool": tool,
+ "input": tool_input or {},
+ "cwd": str(workspace),
+ "origin_thread": os.environ.get("CSWY_THREAD_ID") or "",
+ }).encode()
+ try:
+ req = urllib.request.Request(
+ f"http://127.0.0.1:{port}/api/permission/request",
+ data=body,
+ headers={"Content-Type": "application/json"},
+ method="POST",
+ )
+ with urllib.request.urlopen(req, timeout=wait) as resp:
+ payload = json.loads(resp.read().decode("utf-8") or "{}")
+ except (urllib.error.URLError, OSError, json.JSONDecodeError, TimeoutError, ValueError):
+ return "deny"
+ if not isinstance(payload, dict):
+ return "deny"
+ decision = str(payload.get("decision") or "deny")
+ if decision == "skip":
+ return "deny"
+ return "approve" if decision == "approve" else "deny"
+
+
# Generous: a single-user local app shouldn't auto-deny while the user
# reads the request or works through a backlog of prompts from several
# concurrent agents. 30 min; the frontend is told when it lapses so the
@@ -151,8 +338,14 @@ def add_pattern(workspace: Path, pattern: str) -> list[str]:
"""Append a pattern to the workspace's allow list without going
through the request/decide dance. Used by Settings UI controls
(e.g. the Codex elevated-sandbox toggle) that directly express
- "I want this pattern allowed forever". Returns the new list."""
+ "I want this pattern allowed forever". Returns the new list.
+
+ Protected egress patterns are ignored — web search/fetch must
+ never become a remembered blanket grant.
+ """
cur = _load_allow(workspace)
+ if is_protected_pattern(pattern):
+ return cur
if pattern not in cur:
cur.append(pattern)
_save_allow(workspace, cur)
@@ -291,6 +484,10 @@ def is_pre_approved(
"Approve + remember" saved `Bash(find*)`."""
if tool and tool_input is not None and hard_deny_reason(tool, tool_input):
return False
+ # Protected web egress never pre-approves — not via the builtin
+ # MCP wildcard, not via a remembered pattern, not via session.
+ if is_protected_egress(tool or "") or is_protected_pattern(pattern):
+ return False
# CE/CM scope is checked on the full call, before the coarse
# pattern comparison below (which cannot express it).
if tool and tool_input is not None and ce_scope_allows(
@@ -303,7 +500,11 @@ def is_pre_approved(
allows.extend(_load_allow(workspace))
allows.extend(_SESSION_ALLOW.get(str(workspace), ()))
for allow in allows:
+ if is_protected_pattern(allow):
+ continue
if fnmatch.fnmatch(pattern, allow) or fnmatch.fnmatch(allow, pattern):
+ if is_protected_egress(tool or "") or is_protected_pattern(pattern):
+ return False
return True
return False
@@ -506,9 +707,27 @@ def resolve(
if rec is None:
return None
rec.decision = decision
- rec.remember = remember
- if decision == "approve" and (remember or session):
+ protected = (
+ is_protected_egress(rec.tool)
+ or is_protected_pattern(rec.pattern)
+ or needs_web_consent(rec.tool, rec.tool_input)
+ )
+ if protected and decision == "skip":
+ rec.decision = "deny"
+ decision = "deny"
+ if protected:
+ # Forged remember / pattern / session overrides are ignored.
+ rec.remember = False
+ remember = False
+ session = False
+ pattern = None
+ else:
+ rec.remember = remember
+ if decision == "approve" and (remember or session) and not protected:
pat = (pattern or "").strip() or rec.pattern
+ if is_protected_pattern(pat):
+ rec.event.set()
+ return rec
if session:
_SESSION_ALLOW.setdefault(str(rec.workspace), set()).add(pat)
else:
@@ -520,6 +739,72 @@ def resolve(
return rec
+async def mediate_protected_call(
+ *,
+ workspace: Path,
+ tool: str,
+ tool_input: dict[str, Any],
+ provider: str = "switchbay",
+ run_id: str | None = None,
+ thread_id: str | None = None,
+ broadcast: Any = None,
+) -> tuple[str, str]:
+ """Gate a protected web call. Returns (approve|deny, reason).
+
+ Policy off / admin deny / timeout fail closed. Policy on shows a
+ once/deny card (never remembered).
+ """
+ from . import protocol
+ blocked = web_egress_block_reason(workspace, tool, tool_input)
+ if blocked:
+ return "deny", blocked
+ rec = register(
+ workspace=workspace, provider=provider, tool=tool,
+ tool_input=tool_input, run_id=run_id, thread_id=thread_id,
+ )
+ if broadcast is not None:
+ await broadcast(protocol.permission_request(
+ req_id=rec.req_id, provider=provider, tool=tool,
+ tool_input=tool_input, pattern=rec.pattern, run_id=run_id,
+ thread_id=thread_id, protected=True,
+ ))
+ decision = await await_decision(rec)
+ if broadcast is not None:
+ await broadcast(protocol.permission_resolved(rec.req_id, decision))
+ if decision != "approve":
+ return "deny", "web egress denied"
+ # Toggle-off while the card was pending must still fail closed.
+ blocked = web_egress_block_reason(workspace, tool, tool_input)
+ if blocked:
+ return "deny", blocked
+ return "approve", ""
+
+
+def web_egress_block_reason(
+ workspace: Path,
+ tool: str,
+ tool_input: dict[str, Any] | None = None,
+) -> str | None:
+ """Deny reason when web egress must fail closed without a card.
+
+ None means the call may proceed to a once/deny card (policy on).
+ """
+ from . import web_policy
+ if not needs_web_consent(tool, tool_input):
+ return None
+ if not web_policy.admin_allows():
+ return "web egress is disabled by admin policy"
+ if not web_policy.is_enabled(workspace):
+ return "web egress is off for this workspace"
+ payload = tool_input or {}
+ url = str(payload.get("url") or payload.get("href") or "").strip()
+ if url:
+ from . import admin_policy
+ if not admin_policy.egress_allowed(url):
+ return f"web egress blocked by admin allowlist: {url}"
+ return None
+
+
async def await_decision(rec: PendingRequest) -> str:
"""Wait for `decision` to land or timeout. On timeout we deny by
default — safer than approving a request the user never saw."""
diff --git a/src/switchbay/protocol.py b/src/switchbay/protocol.py
index 457256a..460975f 100644
--- a/src/switchbay/protocol.py
+++ b/src/switchbay/protocol.py
@@ -267,6 +267,7 @@ def permission_request(
tool_input: dict[str, Any], pattern: str, run_id: str | None,
thread_id: str | None = None, origin: str | None = None,
origin_path: str | None = None,
+ protected: bool = False,
) -> dict[str, Any]:
"""Inline rail dialog ask: the agent's pre-tool hook (claude-code)
or sandbox-denial path (codex) wants to run a tool that isn't on
@@ -288,6 +289,7 @@ def permission_request(
"thread_id": thread_id,
"origin": origin,
"origin_path": origin_path,
+ "protected": bool(protected),
})
diff --git a/src/switchbay/research.py b/src/switchbay/research.py
index a587e2c..46a25fb 100644
--- a/src/switchbay/research.py
+++ b/src/switchbay/research.py
@@ -66,6 +66,9 @@ def public_http_target(url: str) -> tuple[str, str, int, list[str]]:
host = (parsed.hostname or "").strip().lower()
if not host or host in _BLOCKED_HOSTS or host.endswith(".local"):
raise ValueError(f"blocked host: {host or raw}")
+ from . import admin_policy
+ if not admin_policy.egress_allowed(raw):
+ raise ValueError(f"blocked by admin egress policy: {raw}")
port = parsed.port or (443 if parsed.scheme == "https" else 80)
try:
infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
@@ -450,7 +453,15 @@ def fetch_to_vault(
return out
-def _research_search(_workspace: Path, payload: dict[str, Any]) -> dict[str, Any]:
+def _research_search(workspace: Path, payload: dict[str, Any]) -> dict[str, Any]:
+ from . import permissions
+ blocked = permissions.web_egress_block_reason(
+ workspace, "research_search", payload,
+ )
+ if blocked:
+ return {"ok": False, "error": blocked}
+ if not permissions.invocation_approved():
+ return {"ok": False, "error": "web egress denied"}
return search_web(
str(payload.get("query") or ""),
source=str(payload.get("source") or "auto"),
@@ -459,6 +470,14 @@ def _research_search(_workspace: Path, payload: dict[str, Any]) -> dict[str, Any
def _research_fetch(workspace: Path, payload: dict[str, Any]) -> dict[str, Any]:
+ from . import permissions
+ blocked = permissions.web_egress_block_reason(
+ workspace, "research_fetch", payload,
+ )
+ if blocked:
+ return {"ok": False, "error": blocked}
+ if not permissions.invocation_approved():
+ return {"ok": False, "error": "web egress denied"}
url = str(payload.get("url") or "").strip()
ingest = payload.get("ingest")
if isinstance(ingest, str):
diff --git a/src/switchbay/runtime.py b/src/switchbay/runtime.py
new file mode 100644
index 0000000..760368e
--- /dev/null
+++ b/src/switchbay/runtime.py
@@ -0,0 +1,514 @@
+"""Shared executable / PATH resolution for launchd-starved environments.
+
+Never sources shell profiles. Discovers user-managed node/pnpm via
+explicit env (``NVM_BIN``, ``NVM_DIR``, ``PNPM_HOME``, Volta/asdf/fnm/mise)
+and well-known default install locations. An explicitly selected binary
+always wins if it is a real executable.
+"""
+
+from __future__ import annotations
+
+import os
+import shutil
+from collections.abc import Mapping
+from pathlib import Path
+
+# Homebrew / MacPorts / user-local — always considered, never required.
+_SYSTEM_BIN_DIRS = (
+ "/opt/homebrew/bin",
+ "/usr/local/bin",
+ "/opt/local/bin",
+)
+
+_EXPLICIT_NODE_ENV = ("SWITCHBAY_NODE", "NODE_BINARY", "NODE")
+_EXPLICIT_PNPM_ENV = ("SWITCHBAY_PNPM", "PNPM_BINARY")
+
+
+def home_dir(
+ *,
+ home: Path | str | None = None,
+ environ: Mapping[str, str] | None = None,
+) -> Path:
+ if home is not None:
+ return Path(home).expanduser()
+ env = environ if environ is not None else os.environ
+ raw = (env.get("HOME") or "").strip()
+ if raw:
+ return Path(raw).expanduser()
+ return Path.home()
+
+
+def is_executable(path: Path | str) -> bool:
+ """True when ``path`` is a file the process can execute.
+
+ Follows a symlink to the target. Directories, missing paths, and
+ non-executable files are rejected.
+ """
+ try:
+ p = Path(path)
+ if not p.is_file():
+ return False
+ return os.access(p, os.X_OK)
+ except OSError:
+ return False
+
+
+def _existing_dir(path: Path | str) -> str | None:
+ try:
+ p = Path(path)
+ if p.is_dir():
+ return str(p)
+ except OSError:
+ return None
+ return None
+
+
+def _first_nonempty_line(path: Path) -> str | None:
+ """First non-empty, non-comment line, or None. Empty files are None."""
+ try:
+ if not path.is_file():
+ return None
+ for line in path.read_text(encoding="utf-8").splitlines():
+ text = line.strip()
+ if text and not text.startswith("#"):
+ return text
+ except OSError:
+ return None
+ return None
+
+
+def _strip_node_v(name: str) -> str:
+ raw = name.strip()
+ if raw.startswith("v") and len(raw) > 1 and raw[1].isdigit():
+ return raw[1:]
+ return raw
+
+
+def _version_sort_key(name: str) -> tuple[int, ...]:
+ parts: list[int] = []
+ for bit in _strip_node_v(name).split("."):
+ try:
+ parts.append(int(bit))
+ except ValueError:
+ parts.append(0)
+ return tuple(parts)
+
+
+def _nvm_lookup_version_bin(versions: Path, wanted: str) -> str | None:
+ """Exact version directory, else highest installed major/partial match."""
+ wanted = _strip_node_v(wanted)
+ if not wanted:
+ return None
+ for cand in (versions / f"v{wanted}" / "bin", versions / wanted / "bin"):
+ d = _existing_dir(cand)
+ if d:
+ return d
+ if not versions.is_dir():
+ return None
+ matches: list[str] = []
+ prefix = f"v{wanted}"
+ try:
+ for child in versions.iterdir():
+ name = child.name
+ if name == prefix or name.startswith(prefix + "."):
+ if (child / "bin").is_dir():
+ matches.append(name)
+ except OSError:
+ return None
+ if not matches:
+ return None
+ matches.sort(key=_version_sort_key)
+ return str(versions / matches[-1] / "bin")
+
+
+def _nvm_resolve_name(nvm_dir: Path, name: str, seen: set[str] | None = None) -> str | None:
+ """Resolve an nvm alias or version (including ``lts/*`` chains)."""
+ raw = (name or "").strip()
+ if not raw:
+ return None
+ seen = seen if seen is not None else set()
+ key = raw.lower()
+ if key in seen:
+ return None
+ seen.add(key)
+ for alias_file in (
+ nvm_dir / "alias" / raw,
+ nvm_dir / "alias" / "lts" / raw,
+ ):
+ line = _first_nonempty_line(alias_file)
+ if line:
+ return _nvm_resolve_name(nvm_dir, line, seen)
+ return _nvm_lookup_version_bin(nvm_dir / "versions" / "node", raw)
+
+
+def _nvm_bin_dirs(
+ *,
+ home: Path,
+ environ: Mapping[str, str],
+) -> list[str]:
+ """nvm bin directories without sourcing nvm.sh.
+
+ Prefer ``NVM_BIN`` (already the active version). Else resolve
+ ``NVM_DIR`` (default ``~/.nvm``) via ``alias/default``, including
+ major versions (``22``), partials (``22.11``), ``lts/*``, and
+ alias chains. Empty alias files are ignored.
+ """
+ out: list[str] = []
+ nvm_bin = (environ.get("NVM_BIN") or "").strip()
+ if nvm_bin:
+ d = _existing_dir(Path(nvm_bin).expanduser())
+ if d:
+ out.append(d)
+ nvm_dir_raw = (environ.get("NVM_DIR") or "").strip()
+ nvm_dir = Path(nvm_dir_raw).expanduser() if nvm_dir_raw else (home / ".nvm")
+ wanted = _first_nonempty_line(nvm_dir / "alias" / "default")
+ if wanted:
+ d = _nvm_resolve_name(nvm_dir, wanted)
+ if d:
+ out.append(d)
+ current = nvm_dir / "current" / "bin"
+ d = _existing_dir(current)
+ if d:
+ out.append(d)
+ return out
+
+
+def _version_manager_dirs(
+ *,
+ home: Path,
+ environ: Mapping[str, str],
+) -> list[str]:
+ out: list[str] = []
+
+ volta_home = (environ.get("VOLTA_HOME") or "").strip()
+ volta = Path(volta_home).expanduser() if volta_home else (home / ".volta")
+ d = _existing_dir(volta / "bin")
+ if d:
+ out.append(d)
+
+ fnm_multi = (environ.get("FNM_MULTISHELL_PATH") or "").strip()
+ if fnm_multi:
+ d = _existing_dir(Path(fnm_multi).expanduser())
+ if d:
+ out.append(d)
+ fnm_dir_raw = (environ.get("FNM_DIR") or "").strip()
+ fnm_roots = []
+ if fnm_dir_raw:
+ fnm_roots.append(Path(fnm_dir_raw).expanduser())
+ fnm_roots.extend((
+ home / ".local" / "share" / "fnm",
+ home / ".fnm",
+ ))
+ for root in fnm_roots:
+ for rel in ("aliases/default/bin", "current/bin"):
+ d = _existing_dir(root / rel)
+ if d:
+ out.append(d)
+
+ asdf_data = (environ.get("ASDF_DATA_DIR") or "").strip()
+ asdf_dir_raw = (environ.get("ASDF_DIR") or "").strip()
+ asdf_roots = []
+ if asdf_data:
+ asdf_roots.append(Path(asdf_data).expanduser())
+ if asdf_dir_raw:
+ asdf_roots.append(Path(asdf_dir_raw).expanduser())
+ asdf_roots.append(home / ".asdf")
+ for root in asdf_roots:
+ d = _existing_dir(root / "shims")
+ if d:
+ out.append(d)
+
+ mise_data = (environ.get("MISE_DATA_DIR") or "").strip()
+ mise_roots = []
+ if mise_data:
+ mise_roots.append(Path(mise_data).expanduser())
+ mise_roots.extend((
+ home / ".local" / "share" / "mise",
+ home / ".mise",
+ ))
+ for root in mise_roots:
+ d = _existing_dir(root / "shims")
+ if d:
+ out.append(d)
+
+ pnpm_home = (environ.get("PNPM_HOME") or "").strip()
+ if pnpm_home:
+ d = _existing_dir(Path(pnpm_home).expanduser())
+ if d:
+ out.append(d)
+ else:
+ d = _existing_dir(home / "Library" / "pnpm")
+ if d:
+ out.append(d)
+ d = _existing_dir(home / ".local" / "share" / "pnpm")
+ if d:
+ out.append(d)
+
+ return out
+
+
+def extra_path_dirs(
+ *,
+ home: Path | str | None = None,
+ environ: Mapping[str, str] | None = None,
+ extra_dirs: tuple[str, ...] | list[str] = (),
+) -> list[str]:
+ """Candidate PATH prefixes that currently exist as directories.
+
+ Order: caller extras, explicit version-manager env, well-known
+ Homebrew/local bins, ``~/.local/bin`` / ``~/bin``. Missing dirs
+ are omitted. Does not source shell profiles.
+ """
+ env = environ if environ is not None else os.environ
+ h = home_dir(home=home, environ=env)
+ seen: set[str] = set()
+ out: list[str] = []
+
+ def add(raw: str | Path | None) -> None:
+ if not raw:
+ return
+ d = _existing_dir(Path(str(raw)).expanduser())
+ if d and d not in seen:
+ seen.add(d)
+ out.append(d)
+
+ for d in extra_dirs:
+ add(d)
+ add(env.get("NVM_BIN"))
+ for d in _nvm_bin_dirs(home=h, environ=env):
+ add(d)
+ for d in _version_manager_dirs(home=h, environ=env):
+ add(d)
+ for d in _SYSTEM_BIN_DIRS:
+ add(d)
+ add(h / ".local" / "bin")
+ add(h / "bin")
+ return out
+
+
+def enrich_env(
+ env: dict[str, str] | None = None,
+ *,
+ extra_dirs: tuple[str, ...] | list[str] = (),
+ prepend: tuple[str, ...] | list[str] = (),
+ home: Path | str | None = None,
+ environ: Mapping[str, str] | None = None,
+) -> dict[str, str]:
+ """Return a copy of ``env`` with discovered bins appended after PATH.
+
+ ``prepend`` wins (explicit selected runtimes). Existing PATH entries
+ beat fallback discoveries (nvm / Homebrew / version managers) so a
+ selected PATH runtime is not overridden. Missing dirs are omitted.
+ """
+ base = dict(env if env is not None else (environ if environ is not None else os.environ))
+ discovered = extra_path_dirs(
+ home=home, environ=base, extra_dirs=tuple(extra_dirs),
+ )
+ existing = [p for p in base.get("PATH", "").split(os.pathsep) if p]
+ seen: set[str] = set()
+ prefix: list[str] = []
+
+ def add(raw: str) -> None:
+ if not raw or raw in seen:
+ return
+ seen.add(raw)
+ prefix.append(raw)
+
+ for d in prepend:
+ if d:
+ add(str(Path(d).expanduser()))
+ for d in existing:
+ add(d)
+ for d in discovered:
+ add(d)
+ if prefix:
+ base["PATH"] = os.pathsep.join(prefix)
+ return base
+
+
+def apply_to_environ(env: dict[str, str] | None = None) -> dict[str, str]:
+ """In-place enrich of ``os.environ`` (or ``env``). Returns the mapping."""
+ target = env if env is not None else os.environ
+ enriched = enrich_env(dict(target))
+ target["PATH"] = enriched.get("PATH", target.get("PATH", ""))
+ return target
+
+
+def resolve_executable(
+ name: str,
+ env: Mapping[str, str] | None = None,
+ *,
+ extra_dirs: tuple[str, ...] | list[str] = (),
+ explicit: str | Path | None = None,
+ home: Path | str | None = None,
+) -> str | None:
+ """Resolve ``name`` to an executable path, or None.
+
+ Precedence: ``explicit`` (if it is a real executable), then
+ existing PATH, then discovered fallbacks (nvm/volta/homebrew) so a
+ launchd-minimal PATH still finds user-managed runtimes without
+ overriding a selected PATH binary.
+ """
+ if explicit:
+ p = Path(str(explicit)).expanduser()
+ if is_executable(p):
+ return str(p.resolve()) if p.exists() else str(p)
+ enriched = enrich_env(
+ dict(env if env is not None else os.environ),
+ extra_dirs=tuple(extra_dirs),
+ home=home,
+ )
+ path = enriched.get("PATH") or os.defpath
+ found = shutil.which(name, path=path)
+ if found and is_executable(found):
+ return found
+ for d in extra_path_dirs(
+ home=home,
+ environ=enriched,
+ extra_dirs=tuple(extra_dirs),
+ ):
+ cand = Path(d) / name
+ if is_executable(cand):
+ return str(cand)
+ return None
+
+
+def _explicit_from_env(keys: tuple[str, ...], environ: Mapping[str, str]) -> str | None:
+ for k in keys:
+ raw = (environ.get(k) or "").strip()
+ if raw:
+ return raw
+ return None
+
+
+def resolve_node(
+ env: Mapping[str, str] | None = None,
+ *,
+ extra_dirs: tuple[str, ...] | list[str] = (),
+ explicit: str | Path | None = None,
+ home: Path | str | None = None,
+) -> str | None:
+ environ = env if env is not None else os.environ
+ chosen = explicit or _explicit_from_env(_EXPLICIT_NODE_ENV, environ)
+ return resolve_executable(
+ "node", environ, extra_dirs=extra_dirs, explicit=chosen, home=home,
+ )
+
+
+def resolve_pnpm(
+ env: Mapping[str, str] | None = None,
+ *,
+ extra_dirs: tuple[str, ...] | list[str] = (),
+ explicit: str | Path | None = None,
+ home: Path | str | None = None,
+) -> str | None:
+ environ = env if env is not None else os.environ
+ chosen = explicit or _explicit_from_env(_EXPLICIT_PNPM_ENV, environ)
+ return resolve_executable(
+ "pnpm", environ, extra_dirs=extra_dirs, explicit=chosen, home=home,
+ )
+
+
+def spawn_env(
+ env: Mapping[str, str] | None = None,
+ *,
+ extra_dirs: tuple[str, ...] | list[str] = (),
+ prepend: tuple[str, ...] | list[str] = (),
+ home: Path | str | None = None,
+) -> dict[str, str]:
+ """Environment dict suitable for ``subprocess`` / asyncio spawn.
+
+ After resolving Node, that binary's directory is prepended so
+ ``#!/usr/bin/env node`` shebangs (pnpm/npx) use the same runtime.
+ Re-enrichment does not let Homebrew/system fallbacks override it.
+ """
+ base = dict(env if env is not None else os.environ)
+ extra = tuple(extra_dirs)
+ pre = [str(Path(d).expanduser()) for d in prepend if d]
+ chosen = _explicit_from_env(_EXPLICIT_NODE_ENV, base)
+ node = resolve_node(base, extra_dirs=extra, explicit=chosen, home=home)
+ if node:
+ bindir = str(Path(node).parent)
+ if bindir not in pre:
+ pre.insert(0, bindir)
+ return enrich_env(
+ base,
+ extra_dirs=extra,
+ prepend=tuple(pre),
+ home=home,
+ )
+
+
+# Narrow OS-service allowlist. Secrets, full shell PATH, and profile
+# dumps stay out of launchd/systemd/scheduled-task env.
+_SERVICE_RUNTIME_KEYS = (
+ "NVM_BIN",
+ "NVM_DIR",
+ "PNPM_HOME",
+ "SWITCHBAY_NODE",
+ "SWITCHBAY_PNPM",
+ "NODE_BINARY",
+ "PNPM_BINARY",
+ "VOLTA_HOME",
+ "ASDF_DATA_DIR",
+ "ASDF_DIR",
+ "FNM_DIR",
+ "FNM_MULTISHELL_PATH",
+ "MISE_DATA_DIR",
+)
+
+_SERVICE_BOOTSTRAP_PATH = ("/usr/bin", "/bin", "/usr/sbin", "/sbin")
+
+
+def service_runtime_exports(
+ *,
+ environ: Mapping[str, str] | None = None,
+ home: Path | str | None = None,
+) -> dict[str, str]:
+ """Allowlisted runtime dirs for an OS-service supervisor.
+
+ Copies configured NVM/pnpm/explicit binary locations and puts the
+ resolved selected bin directories on a *minimal* PATH (bootstrap +
+ those dirs). Does not source shells or copy secret env.
+ """
+ env = dict(environ if environ is not None else os.environ)
+ h = home_dir(home=home, environ=env)
+ out: dict[str, str] = {}
+ for key in _SERVICE_RUNTIME_KEYS:
+ raw = (env.get(key) or "").strip()
+ if raw:
+ out[key] = raw
+
+ bins: list[str] = []
+ seen: set[str] = set()
+
+ def add_dir(raw: str | Path | None) -> None:
+ if not raw:
+ return
+ d = _existing_dir(Path(str(raw)).expanduser())
+ if d and d not in seen:
+ seen.add(d)
+ bins.append(d)
+
+ add_dir(out.get("NVM_BIN"))
+ add_dir(out.get("PNPM_HOME"))
+ node = resolve_node(env, home=h)
+ if node:
+ add_dir(Path(node).parent)
+ pnpm = resolve_pnpm(env, home=h)
+ if pnpm:
+ add_dir(Path(pnpm).parent)
+ add_dir(h / ".local" / "bin")
+
+ path_parts: list[str] = []
+ path_seen: set[str] = set()
+ for p in _SERVICE_BOOTSTRAP_PATH:
+ if p not in path_seen:
+ path_seen.add(p)
+ path_parts.append(p)
+ for d in bins:
+ if d not in path_seen:
+ path_seen.add(d)
+ path_parts.append(d)
+ out["PATH"] = os.pathsep.join(path_parts)
+ return out
diff --git a/src/switchbay/service.py b/src/switchbay/service.py
index d26ca5b..7fd0979 100644
--- a/src/switchbay/service.py
+++ b/src/switchbay/service.py
@@ -68,12 +68,13 @@ def _install_bundled_skills() -> None:
"""Install our first-party skills into ~/.claude/skills via
`npx skills add [`. Best-effort + non-fatal: warns (doesn't
fail the service install) if npx/network is unavailable."""
- from . import admin_policy
+ from . import admin_policy, runtime
if not admin_policy.feature_enabled("install_skills_npx"):
print(" bundled skills: SKIPPED (admin policy install_skills_npx=false)")
return
- uvx = shutil.which("uvx")
- npx = shutil.which("npx")
+ path = runtime.spawn_env().get("PATH")
+ uvx = shutil.which("uvx", path=path)
+ npx = shutil.which("npx", path=path)
if not uvx and not npx:
print(" bundled skills: SKIPPED (npx/uvx not found). Install Node or uv, then:")
for ref in BUNDLED_SKILLS:
@@ -92,6 +93,7 @@ def _install_bundled_skills() -> None:
r = subprocess.run(
argv,
capture_output=True, text=True, timeout=180,
+ env=runtime.spawn_env(),
)
if r.returncode == 0:
print(f" bundled skill installed: {ref}")
@@ -145,6 +147,14 @@ def _xml_text(s: str) -> str:
)
+def _systemd_env_line(key: str, value: str) -> str:
+ """Serialize one systemd Environment= assignment, quoting if needed."""
+ if any(ch in value for ch in (' ', '\t', '"', "\\")):
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
+ return f'Environment={key}="{escaped}"'
+ return f"Environment={key}={value}"
+
+
def _service_environment(repo: Path) -> dict[str, str]:
env = {
"PYTHONPATH": str(repo / "src"),
@@ -154,6 +164,8 @@ def _service_environment(repo: Path) -> dict[str, str]:
profile = _stamped_profile(repo)
if profile:
env["SWITCHBAY_PROFILE"] = profile
+ from . import runtime
+ env.update(runtime.service_runtime_exports())
return env
@@ -232,7 +244,8 @@ def _mac_write_plist(repo: Path) -> Path:
p = _mac_plist_path()
p.parent.mkdir(parents=True, exist_ok=True)
env = _service_environment(repo)
- env["PATH"] = f"/usr/bin:/bin:/usr/sbin:/sbin:{Path.home() / '.local' / 'bin'}"
+ # PATH comes from _service_environment: bootstrap bins plus the
+ # allowlisted/resolved runtime dirs. Do not overwrite those keys.
env_xml = "\n".join(
f" ]{_xml_text(k)} {_xml_text(v)} "
for k, v in env.items()
@@ -371,7 +384,7 @@ def _linux(action: str, repo: Path) -> int:
u = _linux_unit_path()
u.parent.mkdir(parents=True, exist_ok=True)
env_lines = "\n".join(
- f"Environment={k}={v}" for k, v in _service_environment(repo).items()
+ _systemd_env_line(k, v) for k, v in _service_environment(repo).items()
)
u.write_text(
f"""[Unit]
diff --git a/src/switchbay/streams.py b/src/switchbay/streams.py
index 7e8b5de..a835ca2 100644
--- a/src/switchbay/streams.py
+++ b/src/switchbay/streams.py
@@ -194,14 +194,22 @@ def _config_path() -> Path:
"label": "Outlook / Teams (M365, OAuth)",
"auth": "oauth",
"needs_secret": False,
- "scopes": "offline_access User.Read Mail.Read Chat.Read",
+ "scopes": (
+ "offline_access User.Read Mail.Read Chat.Read "
+ "Team.ReadBasic.All Channel.ReadBasic.All"
+ ),
"setup_help": (
"Entra admin center → App registrations → New registration "
"(public client; redirect URI type 'Mobile and desktop "
"applications' with the loopback URI shown after you add "
"the account). Delegated permissions: User.Read, Mail.Read, "
- "Chat.Read. Paste the Application (client) ID; set tenant "
- "to your tenant ID (or 'common'). "
+ "Chat.Read, Team.ReadBasic.All, Channel.ReadBasic.All. "
+ "Paste the Application (client) ID; set tenant to your "
+ "tenant ID (or 'common'). Teams discovery uses GET "
+ "/me/joinedTeams (no OData query options) and GET "
+ "/teams/{id}/channels ($select/$filter only — never $top). "
+ "https://learn.microsoft.com/en-us/graph/api/user-list-joinedteams?view=graph-rest-1.0 "
+ "https://learn.microsoft.com/en-us/graph/api/channel-list?view=graph-rest-1.0 "
"https://entra.microsoft.com"
),
},
@@ -228,6 +236,29 @@ def _config_path() -> Path:
_POLL_CHANNEL_CAP = 25 # slack channels / teams chats per cycle
_POLL_PAGE = 50
+# Official Gmail partial-response projection. format=metadata still
+# returns `snippet` unless `fields` excludes it.
+_GMAIL_METADATA_FIELDS = "id,threadId,labelIds,internalDate,payload/headers"
+_GMAIL_METADATA_HEADERS = [
+ "From", "Subject", "Date", "Message-ID", "References", "In-Reply-To",
+ "Sensitivity", "Classification",
+ "X-MS-Exchange-Organization-Classification", "MSIP_Labels",
+ "X-Microsoft-Classification", "X-Sensitivity",
+]
+_IMAP_HEADER_SPEC = (
+ "(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE MESSAGE-ID REFERENCES "
+ "IN-REPLY-TO SENSITIVITY CLASSIFICATION "
+ "X-MS-EXCHANGE-ORGANIZATION-CLASSIFICATION MSIP_LABELS "
+ "X-MICROSOFT-CLASSIFICATION X-SENSITIVITY)])"
+)
+# Graph mail $select — internetMessageHeaders is documented selectable.
+# Do not include body, bodyPreview, uniqueBody.
+_GRAPH_MAIL_SELECT = (
+ "id,subject,from,webLink,receivedDateTime,conversationId,"
+ "internetMessageId,internetMessageHeaders,inferenceClassification,"
+ "importance,isDraft,parentFolderId,hasAttachments"
+)
+
# ── account config ──────────────────────────────────────────────────
@@ -481,6 +512,22 @@ def remove_account(account_id: str) -> bool:
return True
+def live_account(acct: dict[str, Any] | None) -> dict[str, Any] | None:
+ """Prefer the stored account so allowlist/revocation races are visible."""
+ if not isinstance(acct, dict):
+ return acct
+ aid = str(acct.get("id") or "")
+ if not aid:
+ return acct
+ live = get_account(aid)
+ return live if isinstance(live, dict) else acct
+
+
+def live_allowed_workspaces(acct: dict[str, Any] | None) -> list[str]:
+ live = live_account(acct)
+ return allowed_workspaces(live) if isinstance(live, dict) else []
+
+
def allowed_workspaces(acct: dict[str, Any]) -> list[str]:
"""The stream's target-workspace ALLOWLIST — the only routing
authority (no privileged/default workspace). Migrates legacy
@@ -560,6 +607,37 @@ def pending_events(account_id: str) -> list[dict[str, Any]]:
return out
+def quarantine_legacy_transit(account_id: str) -> int:
+ """Move pre-review body transit aside without parsing or classifying it."""
+ p = _state_dir(account_id) / "transit.jsonl"
+ if not p.is_file():
+ return 0
+ dest = _state_dir(account_id) / "transit.quarantine.jsonl"
+ try:
+ raw = p.read_bytes()
+ except OSError:
+ return 0
+ if not raw.strip():
+ try:
+ p.unlink()
+ except OSError:
+ pass
+ return 0
+ try:
+ with dest.open("ab") as fh:
+ fh.write(raw)
+ if not raw.endswith(b"\n"):
+ fh.write(b"\n")
+ dest.chmod(0o600)
+ except OSError:
+ log.exception("comms transit quarantine write failed")
+ try:
+ p.unlink()
+ except OSError:
+ pass
+ return raw.count(b"\n") or 1
+
+
def consume_transit(account_id: str, ids: list[str]) -> int:
"""Remove exactly the given events from transit (post-curation).
Anything not consumed — a tail beyond the batch cap, or messages
@@ -611,6 +689,55 @@ def _append_transit(account_id: str, events: list[dict[str, Any]]) -> None:
pass
+def _receipts_path(account_id: str) -> Path:
+ return _state_dir(account_id) / "receipts.json"
+
+
+def _load_receipts(account_id: str) -> dict[str, float]:
+ try:
+ raw = json.loads(_receipts_path(account_id).read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return {}
+ if not isinstance(raw, dict):
+ return {}
+ out: dict[str, float] = {}
+ for k, v in raw.items():
+ try:
+ out[str(k)] = float(v)
+ except (TypeError, ValueError):
+ continue
+ return out
+
+
+def _save_receipts(account_id: str, data: dict[str, float]) -> None:
+ p = _receipts_path(account_id)
+ p.parent.mkdir(parents=True, exist_ok=True)
+ atomicio.write_json_atomic(p, data)
+ try:
+ p.chmod(0o600)
+ except OSError:
+ pass
+
+
+def receipt_key(source_event_id: str, workspace: str) -> str:
+ return f"{source_event_id}::{workspace}"
+
+
+def has_receipt(account_id: str, source_event_id: str, workspace: str) -> bool:
+ return receipt_key(source_event_id, workspace) in _load_receipts(account_id)
+
+
+def record_receipts(account_id: str, pairs: list[tuple[str, str]]) -> None:
+ if not pairs:
+ return
+ data = _load_receipts(account_id)
+ now = time.time()
+ for source_event_id, workspace in pairs:
+ if source_event_id and workspace:
+ data[receipt_key(source_event_id, workspace)] = now
+ _save_receipts(account_id, data)
+
+
# ── OAuth: loopback + PKCE ──────────────────────────────────────────
# In-flight authorisations: state token → {account_id, verifier, exp}.
@@ -851,12 +978,70 @@ def _decode_header(raw: str) -> str:
return raw
+def _headers_from_list(raw: Any) -> dict[str, str]:
+ """Gmail/Graph header arrays — keep duplicate names (any Secret wins)."""
+ from . import comms_review
+ pairs: list[dict[str, str]] = []
+ if not isinstance(raw, list):
+ return {}
+ for h in raw:
+ if isinstance(h, dict) and h.get("name"):
+ pairs.append({str(h["name"]): str(h.get("value") or "")})
+ merged: dict[str, str] = {}
+ for p in pairs:
+ merged = comms_review.merge_header_maps(merged, p)
+ return merged
+
+
+def _header_map(msg: email.message.Message) -> dict[str, str]:
+ """Preserve duplicate classification headers (any Secret wins)."""
+ from . import comms_review
+ out: dict[str, str] = {}
+ names: list[str] = []
+ seen: set[str] = set()
+ for k, _v in msg.items():
+ if not k:
+ continue
+ lk = str(k)
+ if lk.lower() in seen:
+ continue
+ seen.add(lk.lower())
+ names.append(lk)
+ for name in names:
+ vals = msg.get_all(name) or [msg.get(name)]
+ decoded = [_decode_header(str(v or "")) for v in vals if v is not None]
+ out[name] = "\n".join(decoded)
+ return comms_review.merge_header_maps(out)
+
+
+def _gmail_metadata_headers() -> list[str]:
+ from . import comms_review
+ base = list(_GMAIL_METADATA_HEADERS)
+ seen = {h.lower() for h in base}
+ for h in comms_review.classification_policy()["headers"]:
+ if h.lower() not in seen:
+ base.append(h)
+ seen.add(h.lower())
+ return base
+
+
+def _imap_header_spec() -> str:
+ from . import comms_review
+ fields = [
+ "FROM", "SUBJECT", "DATE", "MESSAGE-ID", "REFERENCES", "IN-REPLY-TO",
+ ]
+ seen = {f.lower() for f in fields}
+ for h in comms_review.classification_policy()["headers"]:
+ token = h.upper().replace("_", "-") if "_" in h and " " not in h else h.upper()
+ if token.lower() not in seen:
+ fields.append(token)
+ seen.add(token.lower())
+ return "(BODY.PEEK[HEADER.FIELDS (" + " ".join(fields) + ")])"
+
+
def _poll_imap(acct: dict[str, Any], cur: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, Any]]:
- """Blocking IMAP fetch (call via to_thread). Cursor = last seen
- UID per folder (INBOX only in v1); first run backfills one day by
- date. Fetches headers + a short text preview; deep link is the
- Gmail search URL on Gmail hosts, the message: URL scheme (opens
- Apple Mail / compatible clients) elsewhere."""
+ """Blocking IMAP discovery (call via to_thread). Headers only — never TEXT/RFC822."""
+ from . import comms_review
pw = secretstore.get(f"stream-secret:{acct['id']}") or ""
if not pw:
raise ValueError("not connected — re-add the account")
@@ -865,6 +1050,14 @@ def _poll_imap(acct: dict[str, Any], cur: dict[str, Any]) -> tuple[list[dict[str
try:
conn.login(acct["username"], pw)
conn.select("INBOX", readonly=True)
+ uidvalidity = "0"
+ try:
+ _typ, dat = conn.response("UIDVALIDITY")
+ if dat and dat[0]:
+ uidvalidity = str(dat[0].decode() if isinstance(dat[0], bytes) else dat[0])
+ except Exception: # noqa: BLE001
+ uidvalidity = str(cur.get("uidvalidity") or "0")
+ cur["uidvalidity"] = uidvalidity
last_uid = int(cur.get("imap_uid") or 0)
if last_uid:
typ, data = conn.uid("SEARCH", None, f"UID {last_uid + 1}:*")
@@ -875,44 +1068,57 @@ def _poll_imap(acct: dict[str, Any], cur: dict[str, Any]) -> tuple[list[dict[str
raise ValueError(f"IMAP search failed: {typ}")
uids = [int(u) for u in (data[0] or b"").split() if int(u) > last_uid]
for uid in uids[-_POLL_PAGE:]:
- typ, msg_data = conn.uid(
- "FETCH", str(uid),
- "(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE MESSAGE-ID)] "
- "BODY.PEEK[TEXT]<0.2048>)",
- )
+ typ, msg_data = conn.uid("FETCH", str(uid), _imap_header_spec())
if typ != "OK" or not msg_data:
continue
header_bytes = b""
- body_bytes = b""
for part in msg_data:
if isinstance(part, tuple) and len(part) == 2:
- if b"HEADER" in part[0]:
- header_bytes = part[1]
- elif b"TEXT" in part[0]:
- body_bytes = part[1]
- msg = email.message_from_bytes(header_bytes)
+ header_bytes = part[1] or header_bytes
+ msg = email.message_from_bytes(header_bytes or b"")
ts = time.time()
try:
from email.utils import parsedate_to_datetime
ts = parsedate_to_datetime(msg.get("Date", "")).timestamp()
except Exception: # noqa: BLE001
pass
- preview = body_bytes.decode("utf-8", errors="replace")
- preview = _strip_html(preview) if "<" in preview else re.sub(r"\s+", " ", preview)
+ headers = _header_map(msg)
mid = (msg.get("Message-ID") or "").strip().strip("<>")
- if mid and "gmail" in acct["host"]:
+ stable = comms_review.imap_thread_stable_id(
+ message_id=msg.get("Message-ID") or "",
+ references=msg.get("References") or "",
+ in_reply_to=msg.get("In-Reply-To") or "",
+ uidvalidity=uidvalidity,
+ fallback_uid=str(uid),
+ )
+ if mid and "gmail" in str(acct.get("host") or ""):
link = f"https://mail.google.com/mail/u/0/#search/rfc822msgid:{mid}"
elif mid:
link = f"message://%3C{mid}%3E"
else:
link = ""
- events.append(_ev(
- acct, eid=f"{acct['host']}:{uid}", stream="inbox", ts=ts,
- sender=_decode_header(msg.get("From", "")),
- subject=_decode_header(msg.get("Subject", "")),
- text=preview[:1500],
- deep_link=link,
- ))
+ events.append({
+ "provider": "imap",
+ "account_id": acct["id"],
+ "stable_id": stable,
+ "kind": comms_review.KIND_EMAIL,
+ "sender": _decode_header(msg.get("From", "")),
+ "subject": _decode_header(msg.get("Subject", "")),
+ "deep_link": link,
+ "ts": ts,
+ "headers": headers,
+ "labels": [],
+ "text": "",
+ "uid": uid,
+ "uidvalidity": uidvalidity,
+ "imap_uid": uid,
+ "fetch_ref": {
+ "id": str(uid),
+ "uid": str(uid),
+ "uidvalidity": uidvalidity,
+ "message_id": msg.get("Message-ID") or "",
+ },
+ })
last_uid = max(last_uid, uid)
cur["imap_uid"] = last_uid
finally:
@@ -1045,22 +1251,123 @@ def tagname(el: Any) -> str:
return out
+def _record_discovery(records: list[dict[str, Any]]) -> int:
+ """Safe metadata → Comms review. Never writes body transit. Suggests only."""
+ from . import comms_review
+ n = 0
+ allow: list[str] = []
+ acct_id = ""
+ for rec in records:
+ rec = dict(rec)
+ rec.pop("text", None)
+ rec.pop("snippet", None)
+ rec.pop("body", None)
+ pub = comms_review.upsert_discovery(rec)
+ n += 1
+ acct_id = str(rec.get("account_id") or acct_id)
+ if not allow and acct_id:
+ acct = get_account(acct_id)
+ allow = allowed_workspaces(acct) if acct else []
+ key = str(pub.get("key") or "")
+ if not key or comms_review.is_revoked(key):
+ continue
+ if pub.get("status") == comms_review.STATUS_BLOCKED:
+ continue
+ sugg = comms_review.suggest_relevance(rec, allow)
+ if sugg:
+ comms_review.set_suggestions(key, workspaces=sugg)
+ return n
+
+
+def _graph_headers(raw: Any) -> dict[str, str]:
+ return _headers_from_list(raw)
+
+
+def _graph_raise_if_error(data: Any, url: str) -> dict[str, Any]:
+ if not isinstance(data, dict):
+ raise ValueError(f"graph {url}: unexpected payload")
+ err = data.get("error")
+ if err:
+ msg = err.get("message") if isinstance(err, dict) else str(err)
+ raise ValueError(f"graph {url}: {msg}")
+ return data
+
+
+async def _graph_collect(
+ sess: aiohttp.ClientSession,
+ acct: dict[str, Any],
+ url: str,
+ *,
+ params: dict[str, str] | None = None,
+ cap: int,
+) -> tuple[list[dict[str, Any]], str]:
+ """Page Graph lists. Pass OData only on the first URL, never on nextLink.
+
+ ``joinedTeams`` must be called with no query parameters.
+ ``/channels`` may use ``$select`` / ``$filter`` only — never ``$top``.
+ """
+ items: list[dict[str, Any]] = []
+ next_url: str | None = url
+ send_params = dict(params) if params else None
+ while next_url and len(items) < cap:
+ if send_params:
+ data = await _api_get(sess, acct, next_url, **send_params)
+ send_params = None
+ else:
+ data = await _api_get(sess, acct, next_url)
+ data = _graph_raise_if_error(data, next_url)
+ items.extend(data.get("value") or [])
+ next_url = str(data.get("@odata.nextLink") or "") or None
+ return items[:cap], next_url or ""
+
+
async def poll_account(acct: dict[str, Any]) -> int:
- """One poll cycle: fetch messages newer than the cursor, normalise
- into transit, advance the cursor. Returns the number of NEW events.
- Raises ValueError with a human message on auth/config problems."""
+ """Discovery poll: headers/container metadata only. Never auto-approves.
+
+ Unapproved sources do not enter body transit. Legacy body transit is
+ quarantined without parsing.
+ """
+ from . import admin_policy, comms_review
+ if not admin_policy.feature_enabled("comms_streams"):
+ raise ValueError(admin_policy.feature_error("comms_streams"))
+ quarantine_legacy_transit(acct["id"])
cur = _cursor(acct["id"])
since = float(cur.get("since") or (time.time() - 86400)) # first run: 1 day
- if acct["provider"] in ("imap", "imessage"):
- sync_poll = _poll_imap if acct["provider"] == "imap" else _poll_imessage
- events, cur = await asyncio.to_thread(sync_poll, acct, cur)
- before = len(pending_events(acct["id"]))
- _append_transit(acct["id"], events)
- new = len(pending_events(acct["id"])) - before
+ discovered: list[dict[str, Any]] = []
+ if acct["provider"] == "imap":
+ events, cur = await asyncio.to_thread(_poll_imap, acct, cur)
+ n = _record_discovery(events)
+ if events:
+ cur["since"] = max([float(e.get("ts") or 0) for e in events] + [since])
+ _save_cursor(acct["id"], cur)
+ update_account(acct["id"], last_poll=time.time())
+ n += await ingest_approved_updates(acct)
+ return n
+ if acct["provider"] == "imessage":
+ # Local DB can yield bodies; refuse content, keep chat containers.
+ discovered.append({
+ "provider": "imessage",
+ "account_id": acct["id"],
+ "stable_id": "this-mac",
+ "kind": comms_review.KIND_CHAT,
+ "sender": "",
+ "subject": "iMessage (this Mac)",
+ "deep_link": "",
+ "ts": time.time(),
+ "headers": {},
+ "labels": [],
+ "content_capability": "fail_closed",
+ "content_capability_reason": (
+ "iMessage has no classification labels before body read"
+ ),
+ })
+ n = _record_discovery(discovered)
_save_cursor(acct["id"], cur)
update_account(acct["id"], last_poll=time.time())
- return max(0, new)
+ n += await ingest_approved_updates(acct)
+ return n
events: list[dict[str, Any]] = []
+ pre_recorded = 0
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=60),
) as sess:
@@ -1075,65 +1382,160 @@ async def poll_account(acct: dict[str, Any]) -> int:
sess, acct,
f"https://gmail.googleapis.com/gmail/v1/users/me/messages/{m['id']}",
format="metadata",
- metadataHeaders=["From", "Subject"],
+ metadataHeaders=_gmail_metadata_headers(),
+ fields=_GMAIL_METADATA_FIELDS,
)
- headers = {
- h["name"].lower(): h["value"]
- for h in (msg.get("payload") or {}).get("headers") or []
- }
+ hdrs = _headers_from_list((msg.get("payload") or {}).get("headers"))
ts = float(msg.get("internalDate") or 0) / 1000
- events.append(_ev(
- acct, eid=m["id"], stream="inbox", ts=ts,
- sender=headers.get("from", ""),
- subject=headers.get("subject", ""),
- text=msg.get("snippet", ""),
- deep_link=f"https://mail.google.com/mail/u/0/#all/{m['id']}",
- ))
+ thread_id = str(msg.get("threadId") or m.get("threadId") or m["id"])
+ events.append({
+ "provider": "gmail",
+ "account_id": acct["id"],
+ "stable_id": thread_id,
+ "kind": "email_thread",
+ "sender": hdrs.get("From") or hdrs.get("from") or "",
+ "subject": hdrs.get("Subject") or hdrs.get("subject") or "",
+ "deep_link": f"https://mail.google.com/mail/u/0/#all/{thread_id}",
+ "ts": ts,
+ "headers": hdrs,
+ "labels": list(msg.get("labelIds") or []),
+ "text": "",
+ "thread_id": thread_id,
+ "fetch_ref": {
+ "id": str(msg.get("id") or m["id"]),
+ "thread_id": thread_id,
+ },
+ })
elif acct["provider"] == "msgraph":
iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(since))
- mail = await _api_get(
+ mail = _graph_raise_if_error(await _api_get(
sess, acct,
"https://graph.microsoft.com/v1.0/me/messages",
**{
"$filter": f"receivedDateTime gt {iso}",
"$orderby": "receivedDateTime desc",
"$top": str(_POLL_PAGE),
- "$select": "id,subject,from,bodyPreview,webLink,receivedDateTime",
+ "$select": _GRAPH_MAIL_SELECT,
},
- )
+ ), "https://graph.microsoft.com/v1.0/me/messages")
for m in mail.get("value") or []:
ts = _parse_iso(m.get("receivedDateTime"))
sender = ((m.get("from") or {}).get("emailAddress") or {})
- events.append(_ev(
- acct, eid=m["id"], stream="mail", ts=ts,
- sender=sender.get("address") or sender.get("name") or "",
- subject=m.get("subject") or "",
- text=m.get("bodyPreview") or "",
- deep_link=m.get("webLink") or "",
- ))
- chats = await _api_get(
- sess, acct, "https://graph.microsoft.com/v1.0/me/chats",
- **{"$top": str(_POLL_CHANNEL_CAP)},
- )
- for chat in chats.get("value") or []:
- msgs = await _api_get(
- sess, acct,
- f"https://graph.microsoft.com/v1.0/me/chats/{chat['id']}/messages",
- **{"$top": "20"},
+ conv = str(m.get("conversationId") or m.get("id") or "")
+ events.append({
+ "provider": "msgraph",
+ "account_id": acct["id"],
+ "stable_id": conv,
+ "kind": "email_thread",
+ "sender": sender.get("address") or sender.get("name") or "",
+ "subject": m.get("subject") or "",
+ "deep_link": m.get("webLink") or "",
+ "ts": ts,
+ "headers": _graph_headers(m.get("internetMessageHeaders")),
+ "labels": [],
+ "text": "",
+ "conversation_id": conv,
+ "fetch_ref": {
+ "id": str(m.get("id") or ""),
+ "conversation_id": conv,
+ },
+ })
+ # Team channels (container metadata). Chat.Read lists chats
+ # without messages. Do NOT call /messages (no metadata-only
+ # $select on chat-list-messages).
+ # joinedTeams supports NO OData query parameters; channels
+ # support $filter/$select only — never $top. Page via
+ # @odata.nextLink and cap locally so later channels are
+ # not permanently hidden.
+ n_mail = _record_discovery(events)
+ events = []
+ try:
+ resume = str(cur.get("joined_teams_resume") or "")
+ teams_url = resume or "https://graph.microsoft.com/v1.0/me/joinedTeams"
+ teams, nxt = await _graph_collect(
+ sess, acct, teams_url, params=None, cap=_POLL_CHANNEL_CAP,
)
- topic = chat.get("topic") or "chat"
- for m in msgs.get("value") or []:
- ts = _parse_iso(m.get("createdDateTime"))
- if ts <= since or m.get("messageType") != "message":
+ cur["joined_teams_resume"] = nxt
+ channel_resume = cur.get("channel_resume") if isinstance(cur.get("channel_resume"), dict) else {}
+ new_resume: dict[str, str] = dict(channel_resume)
+ for team in teams:
+ tid = str(team.get("id") or "")
+ if not tid:
continue
- frm = ((m.get("from") or {}).get("user") or {})
- events.append(_ev(
- acct, eid=m["id"], stream=f"teams:{topic}", ts=ts,
- sender=frm.get("displayName") or "",
- subject=topic,
- text=_strip_html((m.get("body") or {}).get("content") or ""),
- deep_link="", # Graph exposes no webUrl for 1:1/group chats
- ))
+ ch_resume = str(channel_resume.get(tid) or "")
+ ch_url = ch_resume or (
+ f"https://graph.microsoft.com/v1.0/teams/{tid}/channels"
+ )
+ ch_params = None if ch_resume else {
+ "$select": "id,displayName,webUrl,membershipType",
+ }
+ chans, ch_nxt = await _graph_collect(
+ sess, acct, ch_url, params=ch_params, cap=_POLL_CHANNEL_CAP,
+ )
+ if ch_nxt:
+ new_resume[tid] = ch_nxt
+ else:
+ new_resume.pop(tid, None)
+ for ch in chans:
+ cid = str(ch.get("id") or "")
+ if not cid:
+ continue
+ events.append({
+ "provider": "msgraph",
+ "account_id": acct["id"],
+ "stable_id": f"team/{tid}/channel/{cid}",
+ "kind": "channel",
+ "sender": "",
+ "subject": ch.get("displayName") or team.get("displayName") or "",
+ "deep_link": ch.get("webUrl") or "",
+ "ts": time.time(),
+ "headers": {},
+ "labels": [],
+ "content_capability": "fail_closed",
+ "content_capability_reason": (
+ "Teams channel messages have no documented "
+ "metadata-only projection; listing is supported, "
+ "content fetch is refuse-closed. Approval does "
+ "not retrieve Teams message bodies."
+ ),
+ })
+ cur["channel_resume"] = new_resume
+ if acct.get("id"):
+ update_account(acct["id"], last_error=None)
+ except Exception as e: # noqa: BLE001
+ log.exception("Teams metadata discovery failed")
+ if acct.get("id"):
+ update_account(acct["id"], last_error=f"Teams metadata: {e}")
+ n_mail += _record_discovery(events)
+ pre_recorded = n_mail
+ events = []
+ try:
+ chats = await _api_get(
+ sess, acct, "https://graph.microsoft.com/v1.0/me/chats",
+ **{"$select": "id,topic,chatType,webUrl,createdDateTime",
+ "$top": str(_POLL_CHANNEL_CAP)},
+ )
+ except Exception: # noqa: BLE001
+ chats = {"value": []}
+ for chat in chats.get("value") or []:
+ events.append({
+ "provider": "msgraph",
+ "account_id": acct["id"],
+ "stable_id": f"chat/{chat.get('id')}",
+ "kind": "chat",
+ "sender": "",
+ "subject": chat.get("topic") or "chat",
+ "deep_link": chat.get("webUrl") or "",
+ "ts": _parse_iso(chat.get("createdDateTime")) or time.time(),
+ "headers": {},
+ "labels": [],
+ "content_capability": "fail_closed",
+ "content_capability_reason": (
+ "Teams chat messages cannot be listed without body; "
+ "listing is supported, content fetch is refuse-closed. "
+ "Approval does not retrieve Teams message bodies."
+ ),
+ })
elif acct["provider"] == "slack":
token = await _access_token(sess, acct)
async with sess.get(
@@ -1150,29 +1552,27 @@ async def poll_account(acct: dict[str, Any]) -> int:
raise ValueError(f"slack users.conversations: {convs.get('error')}")
team = _tokens(acct["id"]).get("team_id") or ""
for ch in convs.get("channels") or []:
- async with sess.get(
- "https://slack.com/api/conversations.history",
- headers=_bearer(token),
- params={"channel": ch["id"], "oldest": f"{since:.6f}",
- "limit": str(_POLL_PAGE)},
- ) as r:
- hist = await r.json()
- if not hist.get("ok"):
- continue # not a member / no perms for this one
name = ch.get("name") or ch.get("user") or ch["id"]
- for m in hist.get("messages") or []:
- if m.get("subtype"):
- continue # joins, topic changes, bot noise
- ts = float(m.get("ts") or 0)
- plink = f"https://app.slack.com/client/{team}/{ch['id']}"
- events.append(_ev(
- acct, eid=f"{ch['id']}:{m.get('ts')}",
- stream=f"#{name}", ts=ts,
- sender=m.get("user") or "",
- subject=f"#{name}",
- text=m.get("text") or "",
- deep_link=plink,
- ))
+ plink = f"https://app.slack.com/client/{team}/{ch['id']}" if team else ""
+ events.append({
+ "provider": "slack",
+ "account_id": acct["id"],
+ "stable_id": f"{team}/{ch['id']}" if team else str(ch["id"]),
+ "kind": "channel",
+ "sender": "",
+ "subject": f"#{name}",
+ "deep_link": plink,
+ "ts": time.time(),
+ "headers": {},
+ "labels": [],
+ "content_capability": "fail_closed",
+ "content_capability_reason": (
+ "Slack conversations.history returns message text; "
+ "include_all_metadata is not metadata-only. Listing is "
+ "supported; content fetch is refuse-closed. Approval "
+ "does not retrieve Slack message bodies."
+ ),
+ })
elif acct["provider"] == "telegram":
token = secretstore.get(f"stream-secret:{acct['id']}") or ""
offset = int(cur.get("tg_offset") or 0)
@@ -1201,12 +1601,20 @@ async def poll_account(acct: dict[str, Any]) -> int:
link = f"https://t.me/c/{str(cid)[4:]}/{m.get('message_id')}"
else:
link = ""
- events.append(_ev(
- acct, eid=f"{upd.get('update_id')}", stream=title,
- ts=float(m.get("date") or time.time()),
- sender=frm.get("username") or frm.get("first_name") or "",
- subject=title, text=textv, deep_link=link,
- ))
+ events.append({
+ "provider": "telegram",
+ "account_id": acct["id"],
+ "stable_id": str(cid or title),
+ "kind": "chat",
+ "sender": frm.get("username") or frm.get("first_name") or "",
+ "subject": title,
+ "deep_link": link,
+ "ts": float(m.get("date") or time.time()),
+ "headers": {},
+ "labels": [],
+ "content_capability": "fail_closed",
+ "content_capability_reason": "Telegram getUpdates includes message text",
+ })
elif acct["provider"] == "discord":
token = secretstore.get(f"stream-secret:{acct['id']}") or ""
hdrs = {"Authorization": f"Bot {token}"}
@@ -1230,34 +1638,22 @@ async def poll_account(acct: dict[str, Any]) -> int:
if scanned >= _POLL_CHANNEL_CAP:
break
scanned += 1
- params = {"limit": "50"}
- known = after.get(str(ch["id"]))
- if known:
- params["after"] = known
- async with sess.get(
- f"https://discord.com/api/v10/channels/{ch['id']}/messages",
- headers=hdrs, params=params,
- ) as r:
- msgs = await r.json()
- if not isinstance(msgs, list):
- continue # no access to this channel
- for m in msgs:
- content = m.get("content") or ""
- ts = _parse_iso(m.get("timestamp"))
- prev = int(after.get(str(ch["id"])) or 0)
- after[str(ch["id"])] = str(max(prev, int(m["id"])))
- if not content:
- continue
- if not known and ts <= since:
- continue # first run: last day only
- events.append(_ev(
- acct, eid=f"{m['id']}",
- stream=f"#{ch.get('name')}", ts=ts,
- sender=(m.get("author") or {}).get("username") or "",
- subject=f"{g.get('name')} #{ch.get('name')}",
- text=content,
- deep_link=f"https://discord.com/channels/{g['id']}/{ch['id']}/{m['id']}",
- ))
+ events.append({
+ "provider": "discord",
+ "account_id": acct["id"],
+ "stable_id": f"{g['id']}/{ch['id']}",
+ "kind": "channel",
+ "sender": "",
+ "subject": f"{g.get('name')} #{ch.get('name')}",
+ "deep_link": f"https://discord.com/channels/{g['id']}/{ch['id']}",
+ "ts": time.time(),
+ "headers": {},
+ "labels": [],
+ "content_capability": "fail_closed",
+ "content_capability_reason": (
+ "Discord channel message list returns body text"
+ ),
+ })
cur["dc_after"] = after
elif acct["provider"] == "github":
token = secretstore.get(f"stream-secret:{acct['id']}") or ""
@@ -1281,34 +1677,496 @@ async def poll_account(acct: dict[str, Any]) -> int:
if api_url
else (n.get("repository") or {}).get("html_url") or ""
)
- events.append(_ev(
- acct, eid=f"{n.get('id')}", stream=repo,
- ts=_parse_iso(n.get("updated_at")),
- sender=n.get("reason") or "",
- subject=f"{subj.get('type')}: {subj.get('title')}",
- text=subj.get("title") or "", deep_link=link,
- ))
+ events.append({
+ "provider": "github",
+ "account_id": acct["id"],
+ "stable_id": str(n.get("id") or ""),
+ "kind": "channel",
+ "sender": n.get("reason") or "",
+ "subject": f"{subj.get('type')}: {subj.get('title')}",
+ "deep_link": link,
+ "ts": _parse_iso(n.get("updated_at")),
+ "headers": {},
+ "labels": [],
+ "text": "",
+ })
elif acct["provider"] == "rss":
async with sess.get(acct["url"]) as r:
raw = await r.text()
events.extend(_parse_feed(acct, raw, cur))
else:
raise ValueError(f"unknown provider: {acct['provider']}")
- before = len(pending_events(acct["id"]))
- _append_transit(acct["id"], events)
- new = len(pending_events(acct["id"])) - before
- if events:
- cur["since"] = max([e["ts"] for e in events] + [since])
+ new = pre_recorded + _record_discovery(events)
+ ts_vals = [float(e.get("ts") or 0) for e in events if e.get("ts")]
+ if ts_vals:
+ cur["since"] = max(ts_vals + [since])
else:
- # No news: still advance modestly so a silent stream doesn't
- # re-scan the same window forever (leave 1h of overlap; the
- # transit id-dedupe absorbs re-reads).
cur["since"] = max(since, time.time() - 3600)
_save_cursor(acct["id"], cur)
update_account(acct["id"], last_poll=time.time())
+ # Already-approved threads: new messages flow. Never auto-approves.
+ new += await ingest_approved_updates(acct)
return max(0, new)
+# Tests may install these to assert body fetches / MIME parse never
+# happen too early (revocation and classification gates).
+body_fetch_hook: Any = None
+body_parse_hook: Any = None
+
+
+def _note_parse(provider: str, key: str) -> None:
+ hook = body_parse_hook
+ if hook:
+ hook(provider, key)
+
+
+async def ingest_approved_updates(
+ acct: dict[str, Any],
+ *,
+ keys: list[str] | None = None,
+ workspace: str | None = None,
+) -> int:
+ """Fetch bodies for approved threads only. Revocation wins at awaits."""
+ from . import admin_policy, comms_review
+ if not admin_policy.feature_enabled("comms_streams"):
+ return 0
+ if acct.get("provider") not in ("imap", "gmail", "msgraph"):
+ return 0
+ items = comms_review.approved_items_for_account(acct["id"])
+ allow = live_allowed_workspaces(acct)
+ n = 0
+ for raw in items:
+ key = str(raw.get("key") or "")
+ if keys is not None and key not in keys:
+ continue
+ if comms_review.is_revoked(key):
+ continue
+ if str(raw.get("content_capability") or "ok") == "fail_closed":
+ continue
+ targets = list(raw.get("approved_workspaces") or [])
+ if workspace:
+ targets = [w for w in targets if w == workspace]
+ targets = [w for w in targets if w in allow]
+ if not targets:
+ continue
+ for ws in targets:
+ result = await fetch_approved_thread(acct, key, ws)
+ if result.get("ok") and result.get("events"):
+ n += len(result["events"])
+ return n
+
+
+async def fetch_approved_thread(
+ acct: dict[str, Any],
+ key: str,
+ workspace: str,
+) -> dict[str, Any]:
+ """Production approved-content path.
+
+ Auth-only preflight (approval / allowlist / revocation / capability)
+ → fresh per-message headers → classify → body → revocation recheck
+ → MIME decode → receipted transit. Cached discovery headers never
+ override a fresh classification.
+ """
+ from . import admin_policy, comms_review
+ if not admin_policy.feature_enabled("comms_streams"):
+ return {"ok": False, "error": admin_policy.feature_error("comms_streams"), "events": []}
+ raw = comms_review.get_raw_item(key)
+ if raw is None:
+ return {"ok": False, "error": "unknown comms source", "events": []}
+ allow = live_allowed_workspaces(acct)
+ ok, reason, _pub = comms_review.authorize_content_fetch(
+ key, workspace, allowed=allow, classify=False,
+ )
+ if not ok:
+ return {"ok": False, "error": reason, "events": []}
+ if comms_review.is_revoked(key):
+ return {"ok": False, "error": "revoked", "events": []}
+ try:
+ events = await _fetch_bodies(acct, raw, workspace=workspace, allowed=allow)
+ except Exception as e: # noqa: BLE001
+ return {"ok": False, "error": str(e), "events": []}
+ await asyncio.sleep(0)
+ if comms_review.is_revoked(key):
+ return {"ok": False, "error": "revoked", "events": []}
+ allow = live_allowed_workspaces(acct)
+ ok, reason, _pub = comms_review.authorize_content_fetch(
+ key, workspace, allowed=allow, classify=False,
+ )
+ if not ok:
+ return {"ok": False, "error": reason, "events": []}
+ tagged: list[dict[str, Any]] = []
+ receipt_pairs: list[tuple[str, str]] = []
+ for ev in events:
+ if comms_review.is_revoked(key):
+ return {"ok": False, "error": "revoked", "events": []}
+ src = str(ev.get("id") or "")
+ if has_receipt(acct["id"], src, workspace):
+ continue
+ row = dict(ev)
+ row["source_event_id"] = src
+ row["id"] = f"{src}::{workspace}"
+ row["comms_key"] = key
+ row["approved_workspace"] = workspace
+ row["approved"] = True
+ tagged.append(row)
+ receipt_pairs.append((src, workspace))
+ _append_transit(acct["id"], tagged)
+ record_receipts(acct["id"], receipt_pairs)
+ return {"ok": True, "events": tagged, "error": None}
+
+
+async def _fetch_bodies(
+ acct: dict[str, Any],
+ raw: dict[str, Any],
+ *,
+ workspace: str,
+ allowed: list[str],
+) -> list[dict[str, Any]]:
+ from . import comms_review
+ key = str(raw.get("key") or "")
+ refs = [r for r in (raw.get("fetch_refs") or []) if isinstance(r, dict)]
+ hook = body_fetch_hook
+ if hook:
+ hook(acct.get("provider"), key, refs)
+ if comms_review.is_revoked(key):
+ return []
+ prov = acct.get("provider")
+ if prov == "imap":
+ return await asyncio.to_thread(
+ _imap_fetch_bodies, acct, raw, workspace, allowed,
+ )
+ if prov == "gmail":
+ return await _gmail_fetch_bodies(acct, raw, workspace=workspace, allowed=allowed)
+ if prov == "msgraph":
+ return await _graph_fetch_bodies(acct, raw, workspace=workspace, allowed=allowed)
+ return []
+
+
+def _mime_plain_from_bytes(header_bytes: bytes, body_bytes: bytes) -> str:
+ """Decode a normal MIME plain/HTML body. Skip attachment parts."""
+ raw = (header_bytes or b"") + b"\r\n" + (body_bytes or b"")
+ try:
+ msg = email.message_from_bytes(raw)
+ except Exception: # noqa: BLE001
+ return re.sub(r"\s+", " ", (body_bytes or b"").decode("utf-8", errors="replace")).strip()
+ plains: list[str] = []
+ htmls: list[str] = []
+ walked = False
+ for part in msg.walk():
+ walked = True
+ disp = str(part.get("Content-Disposition") or "").lower()
+ if "attachment" in disp:
+ continue
+ ctype = str(part.get_content_type() or "").lower()
+ if ctype not in ("text/plain", "text/html"):
+ continue
+ try:
+ payload = part.get_payload(decode=True) or b""
+ except Exception: # noqa: BLE001
+ continue
+ charset = part.get_content_charset() or "utf-8"
+ try:
+ text = payload.decode(charset, errors="replace")
+ except Exception: # noqa: BLE001
+ text = payload.decode("utf-8", errors="replace")
+ if ctype == "text/plain":
+ plains.append(text)
+ else:
+ htmls.append(_strip_html(text))
+ if plains:
+ joined = "\n".join(plains)
+ elif htmls:
+ joined = "\n".join(htmls)
+ elif not walked:
+ joined = (body_bytes or b"").decode("utf-8", errors="replace")
+ else:
+ # Simple non-multipart body with no Content-Type.
+ payload = msg.get_payload(decode=True)
+ if isinstance(payload, bytes) and payload:
+ joined = payload.decode("utf-8", errors="replace")
+ else:
+ joined = (body_bytes or b"").decode("utf-8", errors="replace")
+ return re.sub(r"\s+", " ", joined).strip()
+
+
+def _imap_fetch_bodies(
+ acct: dict[str, Any],
+ raw: dict[str, Any],
+ workspace: str,
+ allowed: list[str],
+) -> list[dict[str, Any]]:
+ """Fresh HEADER (+ UIDVALIDITY / thread id) → gate → TEXT → recheck → MIME."""
+ from . import comms_review
+ key = str(raw.get("key") or "")
+ refs = [r for r in (raw.get("fetch_refs") or []) if isinstance(r, dict)]
+ pw = secretstore.get(f"stream-secret:{acct['id']}") or ""
+ if not pw:
+ raise ValueError("not connected")
+ events: list[dict[str, Any]] = []
+ conn = imaplib.IMAP4_SSL(acct["host"], 993, timeout=45)
+ try:
+ conn.login(acct["username"], pw)
+ conn.select("INBOX", readonly=True)
+ uidvalidity = "0"
+ try:
+ _typ, dat = conn.response("UIDVALIDITY")
+ if dat and dat[0]:
+ uidvalidity = str(dat[0].decode() if isinstance(dat[0], bytes) else dat[0])
+ except Exception: # noqa: BLE001
+ uidvalidity = str(raw.get("uidvalidity") or "0")
+ expected_uv = str(raw.get("uidvalidity") or uidvalidity)
+ if expected_uv and uidvalidity != expected_uv:
+ return []
+ header_spec = "(BODY.PEEK[HEADER])"
+ for ref in refs:
+ if comms_review.is_revoked(key):
+ return []
+ uid = str(ref.get("uid") or ref.get("id") or "")
+ if not uid:
+ continue
+ src_id = f"{acct.get('provider')}:{acct.get('host')}:{uid}"
+ if has_receipt(acct["id"], src_id, workspace):
+ continue
+ typ, msg_data = conn.uid("FETCH", uid, header_spec)
+ if typ != "OK" or not msg_data:
+ continue
+ header_bytes = b""
+ for part in msg_data:
+ if not (isinstance(part, tuple) and len(part) == 2):
+ continue
+ marker = part[0] if isinstance(part[0], bytes) else str(part[0]).encode()
+ # Never treat a TEXT payload that arrived with headers as a body.
+ if b"HEADER" in marker and b"TEXT" not in marker:
+ header_bytes = part[1]
+ msg = email.message_from_bytes(header_bytes or b"")
+ headers = _header_map(msg)
+ stable = comms_review.imap_thread_stable_id(
+ message_id=msg.get("Message-ID") or "",
+ references=msg.get("References") or "",
+ in_reply_to=msg.get("In-Reply-To") or "",
+ uidvalidity=uidvalidity,
+ fallback_uid=uid,
+ )
+ if str(raw.get("stable_id") or "") and stable != str(raw.get("stable_id") or ""):
+ continue
+ ok, _reason, _pub = comms_review.authorize_content_fetch(
+ key, workspace, allowed=live_allowed_workspaces(acct),
+ headers=headers, label_ids=[],
+ )
+ if not ok:
+ if comms_review.is_revoked(key):
+ return []
+ continue
+ if comms_review.is_revoked(key):
+ return []
+ typ, msg_data = conn.uid("FETCH", uid, "(BODY.PEEK[TEXT])")
+ if comms_review.is_revoked(key):
+ return []
+ ok, _reason, _pub = comms_review.authorize_content_fetch(
+ key, workspace, allowed=live_allowed_workspaces(acct), classify=False,
+ )
+ if not ok:
+ return []
+ if typ != "OK" or not msg_data:
+ continue
+ body_bytes = b""
+ for part in msg_data:
+ if not (isinstance(part, tuple) and len(part) == 2):
+ continue
+ marker = part[0] if isinstance(part[0], bytes) else str(part[0]).encode()
+ if b"TEXT" in marker:
+ body_bytes = part[1]
+ _note_parse("imap", key)
+ preview = _mime_plain_from_bytes(header_bytes, body_bytes)
+ events.append(_ev(
+ acct, eid=f"{acct.get('host')}:{uid}", stream="inbox",
+ ts=time.time(),
+ sender=_decode_header(msg.get("From", "")),
+ subject=_decode_header(msg.get("Subject", "")),
+ text=preview[:4000],
+ deep_link="",
+ ))
+ finally:
+ try:
+ conn.logout()
+ except Exception: # noqa: BLE001
+ pass
+ return events
+
+
+async def _gmail_fetch_bodies(
+ acct: dict[str, Any],
+ raw: dict[str, Any],
+ *,
+ workspace: str,
+ allowed: list[str],
+) -> list[dict[str, Any]]:
+ from . import comms_review
+ key = str(raw.get("key") or "")
+ thread_id = str(raw.get("thread_id") or raw.get("stable_id") or "")
+ refs = [r for r in (raw.get("fetch_refs") or []) if isinstance(r, dict)]
+ ids = [str(r.get("id") or "") for r in refs if r.get("id")]
+ events: list[dict[str, Any]] = []
+ async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60)) as sess:
+ for mid in ids:
+ if comms_review.is_revoked(key):
+ return []
+ src_id = f"{acct.get('provider')}:{mid}"
+ if has_receipt(acct["id"], src_id, workspace):
+ continue
+ meta = await _api_get(
+ sess, acct,
+ f"https://gmail.googleapis.com/gmail/v1/users/me/messages/{mid}",
+ format="metadata",
+ metadataHeaders=_gmail_metadata_headers(),
+ fields=_GMAIL_METADATA_FIELDS,
+ )
+ await asyncio.sleep(0)
+ hdrs = _headers_from_list((meta.get("payload") or {}).get("headers"))
+ labels = list(meta.get("labelIds") or [])
+ ok, _reason, _pub = comms_review.authorize_content_fetch(
+ key, workspace, allowed=live_allowed_workspaces(acct),
+ headers=hdrs, label_ids=labels,
+ )
+ if not ok:
+ if comms_review.is_revoked(key):
+ return []
+ continue
+ if comms_review.is_revoked(key):
+ return []
+ full = await _api_get(
+ sess, acct,
+ f"https://gmail.googleapis.com/gmail/v1/users/me/messages/{mid}",
+ format="full",
+ )
+ await asyncio.sleep(0)
+ if comms_review.is_revoked(key):
+ return []
+ ok, _reason, _pub = comms_review.authorize_content_fetch(
+ key, workspace, allowed=live_allowed_workspaces(acct), classify=False,
+ )
+ if not ok:
+ return []
+ _note_parse("gmail", key)
+ text = _gmail_plain(full)
+ events.append(_ev(
+ acct, eid=mid, stream="inbox",
+ ts=float(full.get("internalDate") or 0) / 1000,
+ sender=hdrs.get("From") or hdrs.get("from") or "",
+ subject=hdrs.get("Subject") or hdrs.get("subject") or "",
+ text=text[:4000],
+ deep_link=f"https://mail.google.com/mail/u/0/#all/{thread_id or mid}",
+ ))
+ return events
+
+
+def _gmail_plain(msg: dict[str, Any]) -> str:
+ def walk(part: dict[str, Any]) -> tuple[list[str], list[str]]:
+ plains: list[str] = []
+ htmls: list[str] = []
+ if not isinstance(part, dict):
+ return plains, htmls
+ body = part.get("body") if isinstance(part.get("body"), dict) else {}
+ filename = str(part.get("filename") or "")
+ if filename or body.get("attachmentId"):
+ for child in part.get("parts") or []:
+ p, h = walk(child)
+ plains.extend(p)
+ htmls.extend(h)
+ return plains, htmls
+ mime = str(part.get("mimeType") or "")
+ data = body.get("data") or ""
+ if data and mime.startswith("text/plain"):
+ try:
+ plains.append(
+ base64.urlsafe_b64decode(data + "==").decode("utf-8", errors="replace")
+ )
+ except Exception: # noqa: BLE001
+ pass
+ elif data and mime.startswith("text/html"):
+ try:
+ htmls.append(_strip_html(
+ base64.urlsafe_b64decode(data + "==").decode("utf-8", errors="replace")
+ ))
+ except Exception: # noqa: BLE001
+ pass
+ for child in part.get("parts") or []:
+ p, h = walk(child)
+ plains.extend(p)
+ htmls.extend(h)
+ return plains, htmls
+ plains, htmls = walk(msg.get("payload") or {})
+ text = "\n".join(plains) if plains else "\n".join(htmls)
+ return re.sub(r"\s+", " ", text).strip()
+
+
+async def _graph_fetch_bodies(
+ acct: dict[str, Any],
+ raw: dict[str, Any],
+ *,
+ workspace: str,
+ allowed: list[str],
+) -> list[dict[str, Any]]:
+ from . import comms_review
+ key = str(raw.get("key") or "")
+ refs = [r for r in (raw.get("fetch_refs") or []) if isinstance(r, dict)]
+ events: list[dict[str, Any]] = []
+ async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60)) as sess:
+ for ref in refs:
+ mid = str(ref.get("id") or "")
+ if not mid:
+ continue
+ if comms_review.is_revoked(key):
+ return []
+ src_id = f"{acct.get('provider')}:{mid}"
+ if has_receipt(acct["id"], src_id, workspace):
+ continue
+ meta = await _api_get(
+ sess, acct,
+ f"https://graph.microsoft.com/v1.0/me/messages/{mid}",
+ **{"$select": _GRAPH_MAIL_SELECT},
+ )
+ await asyncio.sleep(0)
+ headers = _graph_headers(meta.get("internetMessageHeaders"))
+ ok, _reason, _pub = comms_review.authorize_content_fetch(
+ key, workspace, allowed=live_allowed_workspaces(acct),
+ headers=headers, label_ids=[],
+ )
+ if not ok:
+ if comms_review.is_revoked(key):
+ return []
+ continue
+ if comms_review.is_revoked(key):
+ return []
+ full = await _api_get(
+ sess, acct,
+ f"https://graph.microsoft.com/v1.0/me/messages/{mid}",
+ **{"$select": "id,subject,from,body,webLink,receivedDateTime,conversationId"},
+ )
+ await asyncio.sleep(0)
+ if comms_review.is_revoked(key):
+ return []
+ ok, _reason, _pub = comms_review.authorize_content_fetch(
+ key, workspace, allowed=live_allowed_workspaces(acct), classify=False,
+ )
+ if not ok:
+ return []
+ _note_parse("msgraph", key)
+ sender = ((full.get("from") or {}).get("emailAddress") or {})
+ text = _strip_html(((full.get("body") or {}).get("content") or ""))
+ events.append(_ev(
+ acct, eid=mid, stream="mail",
+ ts=_parse_iso(full.get("receivedDateTime")),
+ sender=sender.get("address") or "",
+ subject=full.get("subject") or "",
+ text=text[:4000],
+ deep_link=full.get("webLink") or "",
+ ))
+ return events
+
+
def _parse_iso(s: str | None) -> float:
if not s:
return 0.0
@@ -1461,7 +2319,10 @@ def curation_prompt(acct: dict[str, Any], events: list[dict[str, Any]],
f"knowledge here):\n{profile}\n"
if profile else ""
)
- + "· Extract only DURABLE, wiki-grade knowledge: decisions, "
+ + "· These sources were already explicitly approved for this "
+ "workspace. Do not ask the user to re-approve them; curate "
+ "the quoted knowledge into the wiki.\n"
+ "· Extract only DURABLE, wiki-grade knowledge: decisions, "
"facts, commitments, deadlines, project updates relevant to "
"this workspace. Routine chatter, pleasantries and one-off "
"logistics are NOT knowledge — skip them; extracting nothing "
diff --git a/src/switchbay/tabstore.py b/src/switchbay/tabstore.py
index 9817885..9d7a5ba 100644
--- a/src/switchbay/tabstore.py
+++ b/src/switchbay/tabstore.py
@@ -165,6 +165,8 @@ def strip_thread_scopes(workspace: Path) -> int:
TERMINAL_TAB_KIND = "terminal"
REPORT_TAB_KIND = "report"
REPORT_TAB_ID = "report"
+COMMS_TAB_KIND = "comms"
+COMMS_TAB_ID = "comms"
REPORT_DOC_TAB_KIND = "report-doc"
REPORT_DOC_TAB_ID = "report-doc"
LIBRARY_TAB_KIND = "library"
@@ -308,6 +310,56 @@ def remove_thrusters_tab(workspace: Path) -> bool:
return True
+def add_comms_tab(workspace: Path) -> dict[str, Any] | None:
+ """Ensure the Comms review tab exists (email/Teams/Slack queue)."""
+ path = workspace / ".workbench" / "mode.json"
+ if path.is_file():
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return None
+ else:
+ data = json.loads(json.dumps(modestore.DEFAULT_MODE))
+ if not isinstance(data, dict):
+ return None
+ tabs = data.setdefault("tabs", [])
+ if not isinstance(tabs, list):
+ return None
+ for t in tabs:
+ if isinstance(t, dict) and t.get("kind") == COMMS_TAB_KIND:
+ return t
+ tab = {
+ "id": COMMS_TAB_ID, "title": "Comms", "kind": COMMS_TAB_KIND,
+ "source": "user", "payload": {},
+ }
+ tabs.append(tab)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ atomicio.write_json_atomic(path, data)
+ return tab
+
+
+def remove_comms_tab(workspace: Path) -> bool:
+ path = workspace / ".workbench" / "mode.json"
+ if not path.is_file():
+ return False
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return False
+ tabs = data.get("tabs") if isinstance(data, dict) else None
+ if not isinstance(tabs, list):
+ return False
+ kept = [
+ t for t in tabs
+ if not (isinstance(t, dict) and t.get("kind") == COMMS_TAB_KIND)
+ ]
+ if len(kept) == len(tabs):
+ return False
+ data["tabs"] = kept
+ atomicio.write_json_atomic(path, data)
+ return True
+
+
def add_report_tab(workspace: Path) -> dict[str, Any] | None:
"""Ensure the single, reusable Report tab exists (a capable model's
`create_report` output renders here). Idempotent — one tab hosts
diff --git a/src/switchbay/tools.py b/src/switchbay/tools.py
index 1688d0c..4161bed 100644
--- a/src/switchbay/tools.py
+++ b/src/switchbay/tools.py
@@ -68,11 +68,43 @@ def register(tool: Tool) -> None:
REGISTRY[tool.name] = tool
-def execute(name: str, workspace: Path, payload: dict[str, Any]) -> dict[str, Any] | str:
+def execute(
+ name: str,
+ workspace: Path,
+ payload: dict[str, Any],
+ *,
+ consent: Any = None,
+) -> dict[str, Any] | str:
+ """Run a registry tool. Protected web egress is gated here.
+
+ ``consent`` must be ``permissions.trusted_consent()`` from this
+ process after a real card. Payload/env ``_approved`` flags are
+ stripped and never count as consent.
+ """
+ from . import permissions
+
tool = REGISTRY.get(name)
if tool is None:
raise KeyError(f"unknown tool: {name}")
- return tool.handler(workspace, payload)
+ data = permissions.strip_forged_approval(payload if isinstance(payload, dict) else {})
+ if permissions.needs_web_consent(name, data):
+ blocked = permissions.web_egress_block_reason(workspace, name, data)
+ if blocked:
+ return {"ok": False, "error": blocked}
+ approved = (
+ permissions.is_trusted_consent(consent)
+ or permissions.invocation_approved()
+ )
+ if not approved:
+ decision = permissions.request_protected_sync(workspace, name, data)
+ if decision != "approve":
+ return {"ok": False, "error": "web egress denied"}
+ blocked = permissions.web_egress_block_reason(workspace, name, data)
+ if blocked:
+ return {"ok": False, "error": blocked}
+ with permissions.approved_invocation():
+ return tool.handler(workspace, data)
+ return tool.handler(workspace, data)
# ── DuckDB starter-pill tools ────────────────────────────────────────
diff --git a/src/switchbay/updater.py b/src/switchbay/updater.py
index 70fcab8..e5db3ef 100644
--- a/src/switchbay/updater.py
+++ b/src/switchbay/updater.py
@@ -158,17 +158,11 @@ def child_env() -> dict[str, str]:
"""PATH the launchd daemon's slim environment is missing.
The installed service only sees /usr/bin + ~/.local/bin. git/npx/
- pnpm/uv live in Homebrew or the user profile; without them an
- in-app update can't fetch or rebuild.
+ pnpm/uv live in Homebrew, nvm, or the user profile; without them an
+ in-app update can't fetch or rebuild. Does not source shell profiles.
"""
- env = os.environ.copy()
- extras = [
- "/opt/homebrew/bin",
- "/usr/local/bin",
- str(Path.home() / ".local" / "bin"),
- str(Path.home() / "bin"),
- ]
- env["PATH"] = os.pathsep.join([*extras, env.get("PATH", "")])
+ from . import runtime
+ env = runtime.spawn_env()
env["GIT_TERMINAL_PROMPT"] = "0"
env["NPM_CONFIG_YES"] = "true"
return env
diff --git a/src/switchbay/verbs.py b/src/switchbay/verbs.py
index e94bc1e..62cac1b 100644
--- a/src/switchbay/verbs.py
+++ b/src/switchbay/verbs.py
@@ -444,6 +444,18 @@ def _action_stub(_: VerbContext) -> VerbResult:
return VerbResult(matches=[])
+register(Verb(
+ name="research",
+ aliases=[],
+ description=(
+ "Seat the Research desk: search the open web, fetch into the "
+ "vault, ingest, then write a cited brief. `/research stop` "
+ "quiets the DAG; `/research dismiss` tears it down."
+ ),
+ handler=_action_stub,
+))
+
+
register(Verb(
name="curate",
aliases=["curator"],
diff --git a/src/switchbay/watchfolders.py b/src/switchbay/watchfolders.py
index 78b42a7..f295efe 100644
--- a/src/switchbay/watchfolders.py
+++ b/src/switchbay/watchfolders.py
@@ -1,42 +1,55 @@
-"""Watch-folders (D5, Phase 8 stage 4): auto-ingest NEW files that
-appear in user-chosen external directories.
+"""Watch-folders: auto-ingest NEW files from user-chosen directories.
Model:
- * Config (`.workbench/watch-folders.json`, roams with the
- workspace): `[{path, enabled, added_at}]` — absolute directories.
- * Seen-index (machine-local, `statedir.workspace_state_dir()/
- watch-seen.json`, regenerable): file path → {mtime, size} at the
- moment we processed it. Machine-local because the same synced
- workspace on another machine watches that machine's folders.
+ * Config (``.workbench/watch-folders.json``, roams with the
+ workspace): ``[{path, enabled, added_at}]`` — absolute directories.
+ * Seen-index (machine-local, ``statedir.workspace_state_dir()/
+ watch-seen.json``): marked only after a file is staged and CE ingest
+ hands off accepted extracted content. Crash/download/staging failure
+ must not permanently lose the file.
+ * Pending-index (same state dir, ``watch-pending.json``): retryable
+ hydration/staging/ingest failures, including iCloud placeholders
+ and size-0 files. Surfaced on the watch-folders API.
* On ADD the folder is BASELINED — everything already inside is
- marked seen without ingesting (auto-ingest means "new files from
- now on", not "swallow this folder's history"; a whole-corpus
- import is the bulk-ingest architecture step's job).
- * Each daemon beat picks up to `MAX_PER_BEAT` unseen files per
- workspace (every new file = one background ingest agent = one
- LLM run — the cap keeps a big folder-dump from dispatching an
- agent stampede; the backlog drains on subsequent beats).
-
-Pure logic + fs here; the daemon owns the loop, the vault staging,
-and the agent dispatch.
+ marked seen without ingesting (new files from now on only).
+ * Each daemon beat picks up to ``MAX_PER_BEAT`` due files. Ready
+ local files and due cloud/retry items each get a bounded share of
+ the cap so neither side starves. iCloud download-on-demand is
+ per-file, macOS only.
+
+Deterministic CE extraction (``ce_ingest`` / ``local_ingest.py``) runs
+before any model dispatch and writes vault ``.extracted.md``. Wiki
+pages are a later Curate pass — watch ingest does not create wiki
+pages. Other extensions are skipped (marked seen, not claimed ingested).
"""
from __future__ import annotations
+import hashlib
import json
import logging
import os
+import re
+import sqlite3
+import stat as _stat
+import subprocess
+import sys
+import threading
import time
+from dataclasses import dataclass, field
from pathlib import Path
-from typing import Any
+from typing import Any, Callable, Iterable
-from . import statedir
from . import atomicio
+from . import icloud_download
+from . import ingest_prep
+from . import statedir
log = logging.getLogger(__name__)
CONFIG_FILE = "watch-folders.json"
SEEN_FILE = "watch-seen.json"
+PENDING_FILE = "watch-pending.json"
# Per-beat dispatch cap — see module docstring.
MAX_PER_BEAT = 5
@@ -44,6 +57,110 @@
MAX_FILE_BYTES = 50 * 1024 * 1024
# In-flight / junk suffixes that must never auto-ingest.
_SKIP_SUFFIXES = {".tmp", ".part", ".crdownload", ".download", ".swp", ".lock"}
+# Copy-settle: a file modified this recently may still be mid-write.
+SETTLE_SECONDS = 5.0
+HYDRATE_WAIT = icloud_download.HYDRATE_WAIT
+STAGE_TIMEOUT = 30.0
+INGEST_TIMEOUT = 90.0
+MAX_BACKOFF = 300.0
+_BACKOFF_EXP_CAP = 8 # 15 * 2**8 = 3840, then clipped to MAX_BACKOFF
+_SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
+_INDEX_LOCK = threading.RLock()
+WATCH_STAGE_REL = "vault/.watch-ingest"
+
+# Copy worker: paths arrive as argv. Refuses symlinks, enforces size cap
+# during the copy, and aborts if the source identity (size / mtime_ns /
+# inode / device) changes. Opens with O_NOFOLLOW when available so a
+# final-component symlink swap cannot replace the lstat'd file.
+_COPY_WORKER = r"""
+import os, stat, sys
+src, dst, max_b_s, exp_size_s, exp_mtime_ns_s, exp_ino_s, exp_dev_s, exp_mtime_s = sys.argv[1:9]
+max_b = int(max_b_s)
+exp_size = int(exp_size_s)
+exp_mtime_ns = int(exp_mtime_ns_s)
+exp_ino = int(exp_ino_s)
+exp_dev = int(exp_dev_s)
+exp_mtime = float(exp_mtime_s)
+
+def refuse_symlink(st):
+ if stat.S_ISLNK(st.st_mode):
+ sys.stderr.write("symlink refused\n")
+ sys.exit(2)
+
+def identity_ok(st):
+ if st.st_size != exp_size:
+ return False
+ if exp_ino != 0 and (st.st_ino != exp_ino or st.st_dev != exp_dev):
+ return False
+ if st.st_mtime_ns == exp_mtime_ns:
+ return True
+ # Float-only callers (no inode snapshot) still reject any mtime change,
+ # including sub-second, without a 1s tolerance.
+ if exp_ino == 0 and st.st_mtime == exp_mtime:
+ return True
+ return False
+
+st = os.lstat(src)
+refuse_symlink(st)
+if st.st_size != exp_size:
+ sys.stderr.write("source size changed\n")
+ sys.exit(3)
+if not identity_ok(st):
+ sys.stderr.write("source changed mid-copy\n")
+ sys.exit(4)
+if st.st_size <= 0 or st.st_size > max_b:
+ sys.stderr.write("size cap\n")
+ sys.exit(5)
+flags = os.O_RDONLY
+if hasattr(os, "O_BINARY"):
+ flags |= os.O_BINARY
+if hasattr(os, "O_CLOEXEC"):
+ flags |= os.O_CLOEXEC
+if hasattr(os, "O_NOFOLLOW"):
+ flags |= os.O_NOFOLLOW
+try:
+ fd = os.open(src, flags)
+except OSError as e:
+ sys.stderr.write("source open failed: %s\n" % e)
+ sys.exit(2)
+written = 0
+try:
+ stf = os.fstat(fd)
+ if stf.st_ino != st.st_ino or stf.st_dev != st.st_dev:
+ sys.stderr.write("source changed mid-copy\n")
+ sys.exit(4)
+ if not identity_ok(stf):
+ sys.stderr.write("source changed mid-copy\n")
+ sys.exit(4)
+ parent = os.path.dirname(dst)
+ if parent:
+ os.makedirs(parent, exist_ok=True)
+ with open(dst, "wb") as out:
+ while True:
+ chunk = os.read(fd, 1024 * 1024)
+ if not chunk:
+ break
+ written += len(chunk)
+ if written > max_b:
+ sys.stderr.write("size cap during copy\n")
+ sys.exit(5)
+ out.write(chunk)
+ st2 = os.fstat(fd)
+ if not identity_ok(st2):
+ sys.stderr.write("source changed mid-copy\n")
+ sys.exit(4)
+ out.flush()
+ os.fsync(out.fileno())
+finally:
+ os.close(fd)
+if written != exp_size:
+ sys.stderr.write("partial copy\n")
+ sys.exit(6)
+"""
+
+
+class IndexSyncError(Exception):
+ """CE/index provenance update failed after a successful extract rewrite."""
# ── Config ─────────────────────────────────────────────────────────
@@ -105,10 +222,11 @@ def add_folder(workspace: Path, raw: str) -> dict[str, Any] | str:
folders.append(rec)
_save_folders(workspace, folders)
# Baseline: mark everything currently present as seen.
- seen = _load_seen(workspace)
- for fp, st in _walk_files(d):
- seen[fp] = {"mtime": st.st_mtime, "size": st.st_size}
- _save_seen(workspace, seen)
+ with _INDEX_LOCK:
+ seen = _load_seen(workspace)
+ for cand in _iter_folder_files(d):
+ seen[cand.logical] = {"mtime": cand.mtime, "size": cand.size}
+ _save_seen(workspace, seen)
return rec
@@ -131,23 +249,48 @@ def set_enabled(workspace: Path, raw: str, enabled: bool) -> bool:
# Re-baseline on re-enable: files that arrived while
# the folder was paused are deliberately NOT ingested
# (pausing means "stop watching", not "queue up").
- seen = _load_seen(workspace)
d = Path(f["path"])
- if d.is_dir():
- for fp, st in _walk_files(d):
- seen[fp] = {"mtime": st.st_mtime, "size": st.st_size}
- _save_seen(workspace, seen)
+ with _INDEX_LOCK:
+ seen = _load_seen(workspace)
+ if d.is_dir():
+ for cand in _iter_folder_files(d):
+ seen[cand.logical] = {
+ "mtime": cand.mtime, "size": cand.size,
+ }
+ _save_seen(workspace, seen)
+ return True
+ return False
+
+
+def folder_authorized(workspace: Path, folder_path: str) -> bool:
+ """True when ``folder_path`` is still an enabled watch folder."""
+ for f in list_folders(workspace):
+ if f["path"] == folder_path and f["enabled"]:
return True
return False
-# ── Seen-index (machine-local) ─────────────────────────────────────
+def handoff_allowed(workspace: Path, folder_path: str) -> str | None:
+ """None if ingest may proceed; otherwise a skip reason."""
+ from . import admin_policy
+ if not admin_policy.feature_enabled("watch_folders"):
+ return "watch folders disabled by admin policy"
+ if not folder_authorized(workspace, folder_path):
+ return "watch folder paused or removed"
+ return None
+
+
+# ── Seen / pending (machine-local) ─────────────────────────────────
def _seen_path(workspace: Path) -> Path:
return statedir.workspace_state_dir(workspace) / SEEN_FILE
+def _pending_path(workspace: Path) -> Path:
+ return statedir.workspace_state_dir(workspace) / PENDING_FILE
+
+
def _load_seen(workspace: Path) -> dict[str, dict[str, float]]:
p = _seen_path(workspace)
if not p.is_file():
@@ -162,67 +305,960 @@ def _load_seen(workspace: Path) -> dict[str, dict[str, float]]:
def _save_seen(workspace: Path, seen: dict[str, dict[str, float]]) -> None:
p = _seen_path(workspace)
p.parent.mkdir(parents=True, exist_ok=True)
- p.write_text(json.dumps(seen), encoding="utf-8")
+ atomicio.write_json_atomic(p, seen)
+
+
+def _load_pending(workspace: Path) -> dict[str, dict[str, Any]]:
+ p = _pending_path(workspace)
+ if not p.is_file():
+ return {}
+ try:
+ raw = json.loads(p.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return {}
+ return raw if isinstance(raw, dict) else {}
+
+
+def _save_pending(workspace: Path, pending: dict[str, dict[str, Any]]) -> None:
+ p = _pending_path(workspace)
+ p.parent.mkdir(parents=True, exist_ok=True)
+ atomicio.write_json_atomic(p, pending)
+
+
+def list_pending(workspace: Path) -> list[dict[str, Any]]:
+ pending = _load_pending(workspace)
+ out: list[dict[str, Any]] = []
+ for path, rec in pending.items():
+ if not isinstance(rec, dict):
+ continue
+ out.append({
+ "path": path,
+ "state": str(rec.get("state") or "pending"),
+ "error": rec.get("error"),
+ "retryable": bool(rec.get("retryable", True)),
+ "attempts": int(rec.get("attempts") or 0),
+ "next_attempt": float(rec.get("next_attempt") or 0),
+ "updated_at": float(rec.get("updated_at") or 0),
+ })
+ out.sort(key=lambda r: r.get("updated_at") or 0, reverse=True)
+ return out
+
+
+def mark_seen(workspace: Path, logical: str, *, mtime: float, size: int) -> None:
+ with _INDEX_LOCK:
+ seen = _load_seen(workspace)
+ seen[logical] = {"mtime": mtime, "size": float(size)}
+ _save_seen(workspace, seen)
+ pending = _load_pending(workspace)
+ if logical in pending:
+ pending.pop(logical, None)
+ _save_pending(workspace, pending)
+
+
+def clear_pending(workspace: Path, logical: str) -> None:
+ with _INDEX_LOCK:
+ pending = _load_pending(workspace)
+ if logical in pending:
+ pending.pop(logical, None)
+ _save_pending(workspace, pending)
+
+
+def _backoff(attempts: int) -> float:
+ exp = min(max(0, int(attempts) - 1), _BACKOFF_EXP_CAP)
+ return min(MAX_BACKOFF, 15.0 * (2 ** exp))
+
+
+def record_pending(
+ workspace: Path,
+ logical: str,
+ *,
+ state: str,
+ error: str | None,
+ retryable: bool = True,
+) -> dict[str, Any]:
+ with _INDEX_LOCK:
+ pending = _load_pending(workspace)
+ prev = pending.get(logical) if isinstance(pending.get(logical), dict) else {}
+ attempts = int(prev.get("attempts") or 0) + 1
+ rec = {
+ "state": state,
+ "error": error,
+ "retryable": retryable,
+ "attempts": attempts,
+ "next_attempt": time.time() + (
+ _backoff(attempts) if retryable else MAX_BACKOFF
+ ),
+ "updated_at": time.time(),
+ }
+ pending[logical] = rec
+ _save_pending(workspace, pending)
+ return rec
# ── Scanning ───────────────────────────────────────────────────────
-def _walk_files(root: Path):
- """Yield (abs_path_str, stat) for every visible file under root.
- Hidden dirs/files pruned; stat errors skipped."""
- for dirpath, dirnames, filenames in os.walk(root):
+def _within(root: Path, candidate: Path) -> bool:
+ try:
+ root = root.resolve()
+ candidate = candidate.resolve()
+ except (OSError, ValueError):
+ return False
+ return candidate == root or root in candidate.parents
+
+
+def _path_in_scope(folder: Path, full: str) -> Path | None:
+ """Resolve ``full``; None if it escapes the watch folder or $HOME."""
+ try:
+ real = Path(full).resolve()
+ folder_real = folder.resolve()
+ except (OSError, ValueError):
+ return None
+ if not _within(folder_real, real):
+ return None
+ from . import workspaces
+ if not workspaces.is_within_home(real):
+ return None
+ return real
+
+
+def authorized_real_path(folder: str, path: Path) -> Path | None:
+ """Re-check ``path`` against the approved folder and $HOME.
+
+ Follows a symlink only after confirming the resolved target stays
+ inside the watch folder and the user's home. Escaping swaps after
+ scan are rejected.
+ """
+ try:
+ folder_real = Path(folder).resolve()
+ p = Path(path)
+ os.lstat(p)
+ real = p.resolve()
+ except (OSError, ValueError):
+ return None
+ if not _within(folder_real, real):
+ return None
+ from . import workspaces
+ if not workspaces.is_within_home(real):
+ return None
+ try:
+ if not real.is_file():
+ return None
+ except OSError:
+ return None
+ return real
+
+
+@dataclass
+class Candidate:
+ path: str
+ logical: str
+ folder: str
+ size: int
+ mtime: float
+ hydrate: str = "" # "", "dataless", "icloud_stub", "size0"
+ ext: str = ""
+
+
+def _logical_and_ext(name: str) -> tuple[str, str]:
+ if icloud_download.is_icloud_stub(name):
+ visible = name[1:-len(".icloud")]
+ return visible, Path(visible).suffix.lower()
+ return name, Path(name).suffix.lower()
+
+
+def _iter_folder_files(root: Path) -> Iterable[Candidate]:
+ """Yield candidates under ``root`` (icloud stubs included)."""
+ try:
+ root_real = root.resolve()
+ except OSError:
+ return
+ for dirpath, dirnames, filenames in os.walk(root_real, followlinks=False):
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
for name in filenames:
- if name.startswith("."):
+ stub = icloud_download.is_icloud_stub(name)
+ if name.startswith(".") and not stub:
continue
full = os.path.join(dirpath, name)
+ scoped = _path_in_scope(root_real, full)
+ if scoped is None:
+ continue
+ visible, ext = _logical_and_ext(name)
+ logical = str(scoped.with_name(visible)) if stub else str(scoped)
try:
- st = os.stat(full)
+ st = os.lstat(full) if stub else os.stat(full)
except OSError:
continue
- yield full, st
+ if not stub and os.path.islink(full):
+ # lstat was not used; os.stat followed. Scope already
+ # checked the real path. Skip if the link itself is the
+ # only name and the target escaped (handled above).
+ pass
+ hydrate = ""
+ size = int(getattr(st, "st_size", 0) or 0)
+ if stub:
+ hydrate = "icloud_stub"
+ elif statedir.is_dataless(scoped):
+ hydrate = "dataless"
+ elif size == 0:
+ hydrate = "size0"
+ yield Candidate(
+ path=str(scoped if not stub else Path(full)),
+ logical=logical,
+ folder=str(root_real),
+ size=size,
+ mtime=float(getattr(st, "st_mtime", 0) or 0),
+ hydrate=hydrate,
+ ext=ext,
+ )
-def _ingestable(path: str, size: int) -> bool:
- if size == 0 or size > MAX_FILE_BYTES:
+def _ingestable_ext(ext: str) -> bool:
+ if ext in _SKIP_SUFFIXES:
return False
- return Path(path).suffix.lower() not in _SKIP_SUFFIXES
+ return ext in ingest_prep.CE_DEFAULT_EXTS
-def scan_new(workspace: Path) -> tuple[list[str], int]:
- """One beat: mark-and-return up to MAX_PER_BEAT unseen files
- across the enabled watch folders. Returns (paths_to_ingest,
- backlog_count). Every unseen file is recorded as seen the moment
- it's picked (or skipped as junk) so a crash never double-
- dispatches; the backlog count is what remains for later beats."""
+def scan_candidates(
+ workspace: Path,
+ *,
+ inflight: set[str] | None = None,
+ now: float | None = None,
+) -> tuple[list[Candidate], int]:
+ """One beat: return up to MAX_PER_BEAT due files without marking seen.
+
+ Ready local files and due retries each get a bounded share of the
+ cap when both exist, so cloud hydration cannot starve locals and
+ locals cannot starve due retries.
+ """
folders = [f for f in list_folders(workspace) if f["enabled"]]
if not folders:
return [], 0
- seen = _load_seen(workspace)
- dirty = False
- fresh: list[tuple[str, os.stat_result]] = []
- for f in folders:
- d = Path(f["path"])
- if not d.is_dir():
- continue # unplugged volume / deleted dir — silently idle
- for fp, st in _walk_files(d):
- if fp in seen:
- continue
- # Settling guard: a file modified in the last 5s may
- # still be mid-copy — leave it for the next beat.
- if time.time() - st.st_mtime < 5:
+ inflight = inflight or set()
+ now = time.time() if now is None else now
+ ready: list[Candidate] = []
+ due_pending: list[Candidate] = []
+ waiting = 0
+ with _INDEX_LOCK:
+ seen = _load_seen(workspace)
+ pending = _load_pending(workspace)
+ dirty_seen = False
+ for f in folders:
+ d = Path(f["path"])
+ if not d.is_dir():
continue
- if not _ingestable(fp, st.st_size):
- seen[fp] = {"mtime": st.st_mtime, "size": st.st_size}
- dirty = True
- continue
- fresh.append((fp, st))
- fresh.sort(key=lambda t: t[1].st_mtime) # oldest first — FIFO drain
- picked = fresh[:MAX_PER_BEAT]
- for fp, st in picked:
- seen[fp] = {"mtime": st.st_mtime, "size": st.st_size}
- dirty = True
- if dirty:
- _save_seen(workspace, seen)
- return [fp for fp, _ in picked], max(0, len(fresh) - len(picked))
+ for cand in _iter_folder_files(d):
+ if cand.logical in seen or cand.logical in inflight:
+ continue
+ if cand.ext in _SKIP_SUFFIXES:
+ seen[cand.logical] = {"mtime": cand.mtime, "size": cand.size}
+ dirty_seen = True
+ continue
+ if cand.size > MAX_FILE_BYTES:
+ seen[cand.logical] = {"mtime": cand.mtime, "size": cand.size}
+ dirty_seen = True
+ continue
+ if not _ingestable_ext(cand.ext):
+ seen[cand.logical] = {"mtime": cand.mtime, "size": cand.size}
+ dirty_seen = True
+ continue
+ if (
+ not cand.hydrate
+ and now - cand.mtime < SETTLE_SECONDS
+ ):
+ waiting += 1
+ continue
+ rec = pending.get(cand.logical)
+ if isinstance(rec, dict):
+ nxt = float(rec.get("next_attempt") or 0)
+ if nxt > now:
+ waiting += 1
+ continue
+ due_pending.append(cand)
+ continue
+ if cand.hydrate:
+ due_pending.append(cand)
+ else:
+ ready.append(cand)
+ if dirty_seen:
+ _save_seen(workspace, seen)
+ ready.sort(key=lambda c: c.mtime)
+ due_pending.sort(key=lambda c: c.mtime)
+ picked = _fair_pick(ready, due_pending, MAX_PER_BEAT)
+ leftover_ready = max(0, len(ready) - sum(1 for c in picked if c in ready))
+ leftover_pending = max(
+ 0, len(due_pending) - sum(1 for c in picked if c in due_pending),
+ )
+ backlog = leftover_ready + leftover_pending + waiting
+ return picked, backlog
+
+
+def _fair_pick(
+ ready: list[Candidate], due: list[Candidate], cap: int,
+) -> list[Candidate]:
+ """Give both ready locals and due retries bounded progress."""
+ if cap <= 0:
+ return []
+ if not ready:
+ return due[:cap]
+ if not due:
+ return ready[:cap]
+ due_take = min(len(due), max(1, cap // 2))
+ ready_take = min(len(ready), cap - due_take)
+ picked = ready[:ready_take] + due[:due_take]
+ extra = cap - len(picked)
+ if extra > 0:
+ picked.extend(ready[ready_take:ready_take + extra])
+ extra = cap - len(picked)
+ if extra > 0:
+ picked.extend(due[due_take:due_take + extra])
+ return picked
+
+
+def scan_new(workspace: Path) -> tuple[list[str], int]:
+ """Compat wrapper: paths due this beat (not yet marked seen)."""
+ picked, backlog = scan_candidates(workspace)
+ return [c.path for c in picked], backlog
+
+
+# ── Staging + handoff ──────────────────────────────────────────────
+
+
+@dataclass
+class HandoffResult:
+ status: str # success | pending | skipped | error
+ retryable: bool = True
+ error: str | None = None
+ vault_rel: str | None = None
+ extracted: str | None = None
+ logical: str = ""
+ extra: dict[str, Any] = field(default_factory=dict)
+
+
+def _safe_filename(name: str) -> str:
+ return _SAFE_NAME.sub("_", name) or "file.bin"
+
+
+def _copy_bounded(
+ src: Path,
+ dest: Path,
+ *,
+ timeout: float,
+ expected_size: int,
+ expected_mtime: float,
+ max_bytes: int = MAX_FILE_BYTES,
+ expected_mtime_ns: int | None = None,
+ expected_ino: int | None = None,
+ expected_dev: int | None = None,
+) -> None:
+ """Copy ``src`` → ``dest`` via a killable child; refuse blank/partial."""
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ tmp = dest.with_name(f".{dest.name}.part")
+ try:
+ if tmp.exists():
+ tmp.unlink()
+ except OSError:
+ pass
+ if expected_mtime_ns is None:
+ expected_mtime_ns = int(round(float(expected_mtime) * 1_000_000_000))
+ try:
+ proc = subprocess.run(
+ [
+ sys.executable, "-c", _COPY_WORKER,
+ str(src), str(tmp),
+ str(int(max_bytes)), str(int(expected_size)),
+ str(int(expected_mtime_ns)),
+ str(int(expected_ino or 0)),
+ str(int(expected_dev or 0)),
+ f"{float(expected_mtime):.17g}",
+ ],
+ capture_output=True,
+ timeout=timeout,
+ check=False,
+ )
+ except subprocess.TimeoutExpired:
+ try:
+ tmp.unlink()
+ except OSError:
+ pass
+ raise TimeoutError(f"copy timed out after {int(timeout)}s") from None
+ if proc.returncode != 0:
+ try:
+ tmp.unlink()
+ except OSError:
+ pass
+ err = (proc.stderr or proc.stdout or b"").decode("utf-8", errors="replace")[-300:]
+ raise OSError(err or f"copy exited {proc.returncode}")
+ try:
+ dst_size = tmp.stat().st_size
+ except OSError as e:
+ try:
+ tmp.unlink()
+ except OSError:
+ pass
+ raise OSError(f"copy stat failed: {e}") from e
+ if dst_size == 0 or dst_size != expected_size:
+ try:
+ tmp.unlink()
+ except OSError:
+ pass
+ raise OSError("blank or partial copy refused")
+ os.replace(tmp, dest)
+
+
+def stage_file(workspace: Path, src: Path, *, timeout: float = STAGE_TIMEOUT) -> tuple[str, int]:
+ """Copy ``src`` into a unique ``vault/.watch-ingest//`` path.
+
+ Always creates a new dest so a failed retry cannot recycle (and
+ later delete) a pre-existing vault original. Returns (rel, size).
+ """
+ ws = workspace.resolve()
+ src = Path(src)
+ try:
+ st = os.lstat(src)
+ except OSError as e:
+ raise OSError(f"source stat failed: {e}") from e
+ if _stat.S_ISLNK(st.st_mode):
+ raise OSError("symlink refused")
+ safe_name = _safe_filename(src.name)
+ attempt_dir = ws / "vault" / ".watch-ingest" / f"{os.getpid()}-{time.time_ns()}"
+ dest = attempt_dir / safe_name
+ _copy_bounded(
+ src, dest, timeout=timeout,
+ expected_size=int(st.st_size), expected_mtime=float(st.st_mtime),
+ expected_mtime_ns=int(st.st_mtime_ns),
+ expected_ino=int(st.st_ino), expected_dev=int(st.st_dev),
+ )
+ size = dest.stat().st_size
+ if size == 0:
+ try:
+ dest.unlink()
+ except OSError:
+ pass
+ raise OSError("blank copy refused")
+ return str(dest.relative_to(ws)), size
+
+
+def _default_ingest(workspace: Path, rel: str, timeout: float) -> dict[str, Any]:
+ from . import ce_tools
+ return ce_tools._ce_ingest(workspace, {"path": rel, "timeout": timeout})
+
+
+def _cleanup_stage(workspace: Path, rel: str | None) -> None:
+ """Delete only this attempt's ``vault/.watch-ingest/`` file."""
+ if not rel:
+ return
+ try:
+ path = (workspace / rel).resolve()
+ root = (workspace.resolve() / "vault" / ".watch-ingest").resolve()
+ except OSError:
+ return
+ if not _within(root, path):
+ return
+ try:
+ if path.is_file():
+ path.unlink()
+ parent = path.parent
+ while parent != root and _within(root, parent):
+ if not parent.is_dir() or any(parent.iterdir()):
+ break
+ parent.rmdir()
+ parent = parent.parent
+ except OSError:
+ pass
+
+
+def _stamp_watch_source(workspace: Path, extracted: str, original: str) -> None:
+ """Record the authorized original path on the extract we just wrote.
+
+ Does not re-read the original file. CE ``source_path`` otherwise
+ points at the staged vault copy.
+ """
+ if not extracted or not original:
+ return
+ cand = Path(extracted)
+ if not cand.is_absolute():
+ cand = workspace / extracted
+ try:
+ cand = cand.resolve()
+ ws = workspace.resolve()
+ except OSError:
+ return
+ if not _within(ws, cand) or not cand.name.endswith(".extracted.md"):
+ return
+ if not cand.is_file():
+ return
+ try:
+ text = cand.read_text(encoding="utf-8")
+ except OSError:
+ return
+ if not text.startswith("---"):
+ return
+ end = text.find("\n---", 3)
+ if end < 0:
+ return
+ fm = text[3:end]
+ lines = fm.splitlines()
+ out_lines: list[str] = []
+ seen_source = False
+ seen_from = False
+ for ln in lines:
+ if ln.startswith("source_path:"):
+ out_lines.append(f"source_path: {original}")
+ seen_source = True
+ elif ln.startswith("extracted_from:"):
+ out_lines.append(f"extracted_from: {original}")
+ seen_from = True
+ else:
+ out_lines.append(ln)
+ if not seen_source:
+ out_lines.append(f"source_path: {original}")
+ if not seen_from:
+ out_lines.append(f"extracted_from: {original}")
+ new = "---" + "\n".join(out_lines) + text[end:]
+ atomicio.write_text_atomic(cand, new)
+ _sync_index_after_provenance_rewrite(workspace, cand, original)
+
+
+def extract_index_paths(workspace: Path, extracted: Path) -> list[str]:
+ """Exact vault-relative and absolute paths for one extract. No globs."""
+ out: list[str] = []
+
+ def add(raw: str) -> None:
+ if raw and raw not in out:
+ out.append(raw)
+
+ add(str(extracted))
+ add(extracted.as_posix())
+ try:
+ resolved = extracted.resolve()
+ except OSError:
+ resolved = extracted
+ add(str(resolved))
+ add(resolved.as_posix())
+ try:
+ rel = resolved.relative_to((workspace / "vault").resolve())
+ add(str(rel))
+ add(rel.as_posix())
+ except (ValueError, OSError):
+ pass
+ return out
+
+
+def _sync_index_after_provenance_rewrite(
+ workspace: Path, extracted: Path, original: str,
+) -> None:
+ """Reindex the rewritten extract, then set ``source_path`` exactly.
+
+ Prefers curiosity-engine ``vault_index.py`` so body/hash use CE
+ normalisation. Index failures raise ``IndexSyncError`` so the
+ caller can keep the extract and surface a retryable error.
+ """
+ from . import ce_tools, cebridge
+
+ paths = extract_index_paths(workspace, extracted)
+ script = cebridge.ce_root() / "scripts" / "vault_index.py"
+ if script.is_file():
+ try:
+ vault_rel = extracted.resolve().relative_to(
+ (workspace / "vault").resolve()
+ )
+ ce_path = (Path("vault") / vault_rel).as_posix()
+ except (ValueError, OSError):
+ ce_path = extracted.as_posix()
+ title = extracted.name
+ if title.endswith(".extracted.md"):
+ title = title[: -len(".extracted.md")]
+ out = ce_tools._ce_vault_index(
+ workspace, {"path": ce_path, "title": title},
+ )
+ if not isinstance(out, dict) or out.get("error") or out.get("status") == "error":
+ raise IndexSyncError(
+ str((out or {}).get("error") or (out or {}).get("status") or "vault_index failed")
+ )
+ indexed = str(out.get("path") or "")
+ if indexed:
+ paths = [indexed, *[p for p in paths if p != indexed]]
+ _set_index_source_path_exact(workspace, paths, original)
+ _assert_index_hash_matches(workspace, paths, extracted, original)
+ return
+ db = workspace / "vault" / "vault.db"
+ if not db.is_file():
+ return
+ _refresh_index_row_exact(workspace, extracted, original, paths)
+ _assert_index_hash_matches(workspace, paths, extracted, original)
+
+
+def _set_index_source_path_exact(
+ workspace: Path, paths: list[str], original: str,
+) -> None:
+ db = workspace / "vault" / "vault.db"
+ if not db.is_file() or not paths:
+ return
+ with sqlite3.connect(str(db)) as conn:
+ tables = {
+ r[0] for r in conn.execute(
+ "SELECT name FROM sqlite_master WHERE type IN ('table', 'view')"
+ )
+ }
+ if "sources" not in tables:
+ return
+ cols = [r[1] for r in conn.execute("PRAGMA table_info(sources)").fetchall()]
+ if cols and "source_path" not in cols:
+ return
+ for path in paths:
+ conn.execute(
+ "UPDATE sources SET source_path = ? WHERE path = ?",
+ (original, path),
+ )
+ conn.commit()
+
+
+def _refresh_index_row_exact(
+ workspace: Path, extracted: Path, original: str, paths: list[str],
+) -> None:
+ """CE-absent fallback: rewrite hash/body/source_path for exact paths only."""
+ db = workspace / "vault" / "vault.db"
+ text = extracted.read_text(encoding="utf-8")
+ digest = hashlib.sha256(extracted.read_bytes()).hexdigest()
+ with sqlite3.connect(str(db)) as conn:
+ tables = {
+ r[0] for r in conn.execute(
+ "SELECT name FROM sqlite_master WHERE type IN ('table', 'view')"
+ )
+ }
+ for path in paths:
+ if "sources" in tables:
+ conn.execute(
+ "UPDATE sources SET source_path = ?, body = ? WHERE path = ?",
+ (original, text, path),
+ )
+ if "source_meta" in tables:
+ conn.execute(
+ "UPDATE source_meta SET sha256 = ? WHERE path = ?",
+ (digest, path),
+ )
+ conn.commit()
+
+
+def _assert_index_hash_matches(
+ workspace: Path, paths: list[str], extracted: Path, original: str,
+) -> None:
+ db = workspace / "vault" / "vault.db"
+ if not db.is_file():
+ raise IndexSyncError("vault.db missing after index sync")
+ digest = hashlib.sha256(extracted.read_bytes()).hexdigest()
+ with sqlite3.connect(str(db)) as conn:
+ tables = {
+ r[0] for r in conn.execute(
+ "SELECT name FROM sqlite_master WHERE type IN ('table', 'view')"
+ )
+ }
+ if "source_meta" not in tables:
+ raise IndexSyncError("source_meta missing after index sync")
+ row = None
+ matched_path = None
+ for path in paths:
+ row = conn.execute(
+ "SELECT sha256 FROM source_meta WHERE path = ?", (path,),
+ ).fetchone()
+ if row:
+ matched_path = path
+ break
+ if not row:
+ raise IndexSyncError("indexed extract path not found after vault_index")
+ if row[0] != digest:
+ raise IndexSyncError("indexed hash still stale after vault_index")
+ body_row = conn.execute(
+ "SELECT body, source_path FROM sources WHERE path = ?",
+ (matched_path,),
+ ).fetchone()
+ if not body_row:
+ raise IndexSyncError("indexed extract row missing after vault_index")
+ body, source_path = body_row[0] or "", body_row[1] or ""
+ if original not in body or source_path != original:
+ raise IndexSyncError("indexed body missing rewritten provenance")
+
+
+def handoff(
+ workspace: Path,
+ cand: Candidate,
+ *,
+ hydrate: Callable[..., icloud_download.HydrateResult] | None = None,
+ ingest: Callable[..., dict[str, Any]] | None = None,
+ now: float | None = None,
+) -> HandoffResult:
+ """Hydrate (if needed), stage atomically, CE-ingest. Mark seen only on success."""
+ del now
+ hydrate = hydrate or icloud_download.hydrate_file
+ ingest = ingest or _default_ingest
+ logical = cand.logical
+ deny = handoff_allowed(workspace, cand.folder)
+ if deny:
+ return HandoffResult(
+ status="skipped", retryable=False, error=deny, logical=logical,
+ )
+
+ src = Path(cand.path)
+ if cand.hydrate:
+ try:
+ result = hydrate(src, timeout=HYDRATE_WAIT)
+ except Exception as e: # noqa: BLE001
+ record_pending(
+ workspace, logical, state="hydrating",
+ error=f"hydrate failed: {e}", retryable=True,
+ )
+ return HandoffResult(
+ status="pending", error=f"hydrate failed: {e}",
+ retryable=True, logical=logical,
+ )
+ if not result.ready:
+ record_pending(
+ workspace, logical, state="hydrating",
+ error=result.error or "waiting for local bytes",
+ retryable=result.retryable,
+ )
+ return HandoffResult(
+ status="pending",
+ error=result.error or "waiting for local bytes",
+ retryable=result.retryable, logical=logical,
+ )
+ src = Path(result.path)
+
+ deny = handoff_allowed(workspace, cand.folder)
+ if deny:
+ return HandoffResult(
+ status="skipped", retryable=False, error=deny, logical=logical,
+ )
+
+ real = authorized_real_path(cand.folder, src)
+ if real is None:
+ record_pending(
+ workspace, logical, state="error",
+ error="source escaped approved folder or home",
+ retryable=True,
+ )
+ return HandoffResult(
+ status="error", error="source escaped approved folder or home",
+ retryable=True, logical=logical,
+ )
+ src = real
+
+ try:
+ if statedir.is_dataless(src) or icloud_download.is_icloud_stub(src):
+ record_pending(
+ workspace, logical, state="hydrating",
+ error="still a cloud placeholder", retryable=True,
+ )
+ return HandoffResult(
+ status="pending", error="still a cloud placeholder",
+ retryable=True, logical=logical,
+ )
+ st = os.lstat(src)
+ except OSError as e:
+ record_pending(
+ workspace, logical, state="error",
+ error=f"source disappeared or access denied: {e}",
+ retryable=True,
+ )
+ return HandoffResult(
+ status="pending",
+ error=f"source disappeared or access denied: {e}",
+ retryable=True, logical=logical,
+ )
+ if _stat.S_ISLNK(st.st_mode):
+ record_pending(
+ workspace, logical, state="error",
+ error="source escaped approved folder or home",
+ retryable=True,
+ )
+ return HandoffResult(
+ status="error", error="source escaped approved folder or home",
+ retryable=True, logical=logical,
+ )
+ if st.st_size == 0:
+ record_pending(
+ workspace, logical, state="hydrating",
+ error="placeholder size 0", retryable=True,
+ )
+ return HandoffResult(
+ status="pending", error="placeholder size 0",
+ retryable=True, logical=logical,
+ )
+ if st.st_size > MAX_FILE_BYTES:
+ record_pending(
+ workspace, logical, state="error",
+ error="file too large", retryable=False,
+ )
+ mark_seen(workspace, logical, mtime=st.st_mtime, size=st.st_size)
+ return HandoffResult(
+ status="skipped", error="file too large",
+ retryable=False, logical=logical,
+ )
+
+ # Re-check immediately before opening the source for copy.
+ real = authorized_real_path(cand.folder, src)
+ if real is None:
+ record_pending(
+ workspace, logical, state="error",
+ error="source escaped approved folder or home",
+ retryable=True,
+ )
+ return HandoffResult(
+ status="error", error="source escaped approved folder or home",
+ retryable=True, logical=logical,
+ )
+
+ rel: str | None = None
+ try:
+ rel, size = stage_file(workspace, real, timeout=STAGE_TIMEOUT)
+ except Exception as e: # noqa: BLE001
+ record_pending(
+ workspace, logical, state="error",
+ error=f"staging failed: {e}", retryable=True,
+ )
+ return HandoffResult(
+ status="pending", error=f"staging failed: {e}",
+ retryable=True, logical=logical,
+ )
+
+ deny = handoff_allowed(workspace, cand.folder)
+ if deny:
+ _cleanup_stage(workspace, rel)
+ return HandoffResult(
+ status="skipped", retryable=False, error=deny, logical=logical,
+ )
+
+ try:
+ out = ingest(workspace, rel, INGEST_TIMEOUT)
+ except Exception as e: # noqa: BLE001
+ _cleanup_stage(workspace, rel)
+ record_pending(
+ workspace, logical, state="error",
+ error=f"ingest failed: {e}", retryable=True,
+ )
+ return HandoffResult(
+ status="error", error=f"ingest failed: {e}",
+ retryable=True, logical=logical, vault_rel=rel,
+ )
+
+ from . import ce_tools
+ if not ce_tools.ingest_is_success(out):
+ _cleanup_stage(workspace, rel)
+ err = str(
+ (out or {}).get("error")
+ or (out or {}).get("reject_reason")
+ or "extraction failed"
+ )
+ record_pending(
+ workspace, logical, state="error", error=err, retryable=True,
+ )
+ return HandoffResult(
+ status="error", error=err, retryable=True,
+ logical=logical, vault_rel=rel, extra={"ingest": out},
+ )
+
+ extracted = None
+ rows = out.get("results") if isinstance(out, dict) else None
+ if isinstance(rows, list):
+ for row in rows:
+ if isinstance(row, dict) and row.get("extracted"):
+ extracted = str(row["extracted"])
+ break
+ if isinstance(out, dict) and not extracted:
+ extracted = str(out.get("extracted") or "") or None
+ if extracted:
+ try:
+ _stamp_watch_source(workspace, extracted, logical)
+ except (IndexSyncError, OSError) as e:
+ kept_rel = _cleanup_stage_if_separate_kept(workspace, rel, out)
+ if kept_rel:
+ rel = kept_rel
+ err = f"index provenance failed: {e}"
+ record_pending(
+ workspace, logical, state="error", error=err, retryable=True,
+ )
+ return HandoffResult(
+ status="error", error=err, retryable=True,
+ logical=logical, vault_rel=rel, extracted=extracted,
+ extra={"ingest": out},
+ )
+ if isinstance(out, dict) and isinstance(out.get("results"), list):
+ for row in out["results"]:
+ if isinstance(row, dict) and row.get("extracted"):
+ row["source_path"] = logical
+ row["extracted_from"] = logical
+
+ mark_seen(workspace, logical, mtime=st.st_mtime, size=size)
+ kept_rel = _cleanup_stage_if_separate_kept(workspace, rel, out)
+ if kept_rel:
+ rel = kept_rel
+ return HandoffResult(
+ status="success", retryable=False, logical=logical,
+ vault_rel=rel, extracted=extracted,
+ extra={"ingest": out, "bytes": size, "original": logical},
+ )
+
+
+def _durable_kept_path(
+ workspace: Path, out: dict[str, Any] | None,
+) -> Path | None:
+ """CE-kept original, if it is a real file distinct from source_in_place."""
+ if not isinstance(out, dict):
+ return None
+ items: list[Any] = [out]
+ rows = out.get("results")
+ if isinstance(rows, list):
+ items.extend(rows)
+ for item in items:
+ if not isinstance(item, dict):
+ continue
+ if item.get("source_in_place") is True:
+ return None
+ kept = item.get("kept")
+ if not kept:
+ continue
+ p = Path(str(kept))
+ if not p.is_absolute():
+ p = workspace / p
+ try:
+ p = p.resolve()
+ except OSError:
+ continue
+ if p.is_file():
+ return p
+ return None
+
+
+def _cleanup_stage_if_separate_kept(
+ workspace: Path, rel: str | None, out: dict[str, Any] | None,
+) -> str | None:
+ """Drop this attempt's staging copy only when CE kept another original.
+
+ Returns the workspace-relative kept path so callers can point
+ ``vault_rel`` at a file that still exists.
+ """
+ kept = _durable_kept_path(workspace, out)
+ if not kept or not rel:
+ return None
+ try:
+ staged = (workspace / rel).resolve()
+ kept_rel = str(kept.relative_to(workspace.resolve()))
+ except (OSError, ValueError):
+ return None
+ if kept == staged:
+ return None
+ stage_root = (workspace.resolve() / "vault" / ".watch-ingest").resolve()
+ if _within(stage_root, kept):
+ return None
+ _cleanup_stage(workspace, rel)
+ return kept_rel
+
+
+def api_status(workspace: Path) -> dict[str, Any]:
+ return {
+ "folders": list_folders(workspace),
+ "pending": list_pending(workspace),
+ "cap_per_beat": MAX_PER_BEAT,
+ "icloud_download": icloud_download.supported(),
+ }
diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
index d00d9db..392ebef 100644
--- a/tests/unit/conftest.py
+++ b/tests/unit/conftest.py
@@ -27,6 +27,12 @@
import pytest
+_KEY_ENV = (
+ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "XAI_API_KEY",
+ "GEMINI_API_KEY", "GOOGLE_API_KEY", "MODEL_API_KEY", "META_API_KEY",
+)
+
+
@pytest.fixture(autouse=True)
def _isolate_user_state(tmp_path_factory, monkeypatch):
"""Point user-global config + state at throwaway dirs."""
@@ -46,6 +52,34 @@ def _isolate_user_state(tmp_path_factory, monkeypatch):
# shell doesn't flip the whole suite. Policy tests opt in.
monkeypatch.setenv("SWITCHBAY_PROFILE", "open")
monkeypatch.delenv("SWITCHBAY_ADMIN_POLICY", raising=False)
+ for key in _KEY_ENV:
+ monkeypatch.delenv(key, raising=False)
from switchbay import admin_policy
admin_policy.reset_cache()
return {"config": config, "state": state}
+
+
+@pytest.fixture(autouse=True)
+def _isolate_provider_discovery(monkeypatch):
+ """No live keychain, CLI login, or local-server probes in the suite."""
+ monkeypatch.setattr("switchbay.secrets.has", lambda *_a, **_k: False)
+ monkeypatch.setattr("switchbay.secrets.get", lambda *_a, **_k: None)
+ monkeypatch.setattr(
+ "switchbay.agents.orchestration_policy.list_keyed_providers",
+ lambda **_k: [],
+ )
+ monkeypatch.setattr(
+ "switchbay.llmgateway.ollama.has_key", lambda: False,
+ )
+ # HF cache / Ollama tag walks hang the suite when list_providers()
+ # or mlx/llamacpp.has_key run. Tests that need installed weights
+ # patch this symbol themselves.
+ monkeypatch.setattr("switchbay.local_models.list_installed", lambda: [])
+ from switchbay.agents import desk_admission as seats
+ from switchbay.agents import orchestration as orch
+ monkeypatch.setattr(orch, "WAIT_POLL_SEC", 0.05)
+ monkeypatch.setattr(orch, "IDLE_WAIT_SEC", 0.05)
+ monkeypatch.setattr(orch, "SNAPSHOT_INTERVAL_SEC", 0.05)
+ monkeypatch.setattr(orch, "cleanup_retire_tasks", True)
+ monkeypatch.setattr(seats, "ACQUIRE_WAIT_TIMEOUT", 0.05)
+ monkeypatch.setattr("switchbay.agents.fast_lookup.lookup_retire_tasks", False)
diff --git a/tests/unit/test_admin_policy.py b/tests/unit/test_admin_policy.py
index 39f9242..3a9a58d 100644
--- a/tests/unit/test_admin_policy.py
+++ b/tests/unit/test_admin_policy.py
@@ -212,6 +212,82 @@ def test_egress_allows_github_when_in_app_update(tmp_path: Path, monkeypatch, en
assert not admin_policy.egress_allowed("https://evil.example/")
+def _write_policy_pair(tmp_path: Path, monkeypatch, *, baked: dict | None, overlay: dict | None):
+ inst = tmp_path / "install"
+ inst.mkdir(exist_ok=True)
+ if baked is not None:
+ (inst / "admin.baked.json").write_text(json.dumps(baked), encoding="utf-8")
+ monkeypatch.setenv("SWITCHBAY_INSTALL_ROOT", str(inst))
+ else:
+ monkeypatch.delenv("SWITCHBAY_INSTALL_ROOT", raising=False)
+ if overlay is not None:
+ p = tmp_path / "admin.json"
+ p.write_text(json.dumps(overlay), encoding="utf-8")
+ monkeypatch.setenv("SWITCHBAY_ADMIN_POLICY", str(p))
+ else:
+ monkeypatch.delenv("SWITCHBAY_ADMIN_POLICY", raising=False)
+ admin_policy.reset_cache()
+
+
+def test_max_live_workers_malformed_does_not_crash_load(tmp_path: Path, monkeypatch):
+ monkeypatch.setenv("SWITCHBAY_PROFILE", "open")
+ for bad in ("nope", "5.5", "8.0", "", " ", "²", "9" * 5000, True, False, [4], {"n": 4}, 4.9, None):
+ _write_policy_pair(
+ tmp_path, monkeypatch,
+ baked=None,
+ overlay={"orchestration": {"max_live_workers": bad, "keep": True}},
+ )
+ data = admin_policy.load()
+ orch = data.get("orchestration") or {}
+ assert "max_live_workers" not in orch
+ assert admin_policy.max_live_workers_ceiling() is None
+ assert orch.get("keep") is True
+
+
+def test_max_live_workers_zero_negative_ignored(tmp_path: Path, monkeypatch):
+ monkeypatch.setenv("SWITCHBAY_PROFILE", "open")
+ for bad in (0, -1, "-3", "+0"):
+ _write_policy_pair(
+ tmp_path, monkeypatch,
+ baked=None,
+ overlay={"orchestration": {"max_live_workers": bad}},
+ )
+ assert admin_policy.max_live_workers_ceiling() is None
+ assert "max_live_workers" not in (admin_policy.load().get("orchestration") or {})
+
+
+def test_max_live_workers_numeric_string_and_tightening(tmp_path: Path, monkeypatch):
+ monkeypatch.setenv("SWITCHBAY_PROFILE", "open")
+ _write_policy_pair(
+ tmp_path, monkeypatch,
+ baked={"profile": "enterprise", "orchestration": {"max_live_workers": 8}},
+ overlay={"orchestration": {"max_live_workers": "5"}},
+ )
+ assert admin_policy.max_live_workers_ceiling() == 5
+ _write_policy_pair(
+ tmp_path, monkeypatch,
+ baked={"profile": "enterprise", "orchestration": {"max_live_workers": 8}},
+ overlay={"orchestration": {"max_live_workers": " 6 "}},
+ )
+ assert admin_policy.max_live_workers_ceiling() == 6
+ _write_policy_pair(
+ tmp_path, monkeypatch,
+ baked={"profile": "enterprise", "orchestration": {"max_live_workers": 4}},
+ overlay={"orchestration": {"max_live_workers": 8}},
+ )
+ assert admin_policy.max_live_workers_ceiling() == 4
+
+
+def test_max_live_workers_bad_overlay_cannot_weaken_baked(tmp_path: Path, monkeypatch):
+ monkeypatch.setenv("SWITCHBAY_PROFILE", "open")
+ baked = {"profile": "enterprise", "orchestration": {"max_live_workers": 4}}
+ for bad in (0, -1, True, False, "nope", "8.0", [8], {"n": 8}):
+ overlay = {"orchestration": {"max_live_workers": bad}}
+ _write_policy_pair(tmp_path, monkeypatch, baked=baked, overlay=overlay)
+ assert admin_policy.max_live_workers_ceiling() == 4, bad
+ assert admin_policy.load()["orchestration"]["max_live_workers"] == 4
+
+
@pytest.mark.asyncio
async def test_update_endpoint_200_when_flag_on(tmp_path: Path, monkeypatch):
p = tmp_path / "admin.json"
diff --git a/tests/unit/test_ce_global_fallback.py b/tests/unit/test_ce_global_fallback.py
new file mode 100644
index 0000000..89c31dd
--- /dev/null
+++ b/tests/unit/test_ce_global_fallback.py
@@ -0,0 +1,55 @@
+"""Global CE remains readable/executable when bundled tree is absent."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from switchbay import cebridge, skillkit
+
+
+def test_ce_root_falls_back_to_global_skill(tmp_path: Path, monkeypatch):
+ bundled = tmp_path / "missing-vendor" / "curiosity-engine"
+ global_ce = tmp_path / ".agents" / "skills" / "curiosity-engine"
+ (global_ce / "scripts").mkdir(parents=True)
+ (global_ce / "SKILL.md").write_text("# curiosity-engine\n", encoding="utf-8")
+ (global_ce / "scripts" / "planner.py").write_text("print('{}')\n", encoding="utf-8")
+ monkeypatch.setenv("SWITCHBAY_CE_ROOT", str(bundled))
+ monkeypatch.setenv("HOME", str(tmp_path))
+ monkeypatch.setattr(
+ cebridge, "_ce_root_candidates",
+ lambda: [bundled, global_ce],
+ )
+ root = cebridge.ce_root()
+ assert root == global_ce
+ assert cebridge.ce_scripts_available() is True
+
+
+def test_enterprise_setup_flag_does_not_hide_scripts(tmp_path: Path, monkeypatch):
+ from switchbay import admin_policy
+ ce = tmp_path / "ce"
+ (ce / "scripts").mkdir(parents=True)
+ (ce / "scripts" / "sweep.py").write_text("print('{}')\n", encoding="utf-8")
+ monkeypatch.setenv("SWITCHBAY_PROFILE", "enterprise")
+ monkeypatch.delenv("SWITCHBAY_ADMIN_POLICY", raising=False)
+ admin_policy.reset_cache()
+ monkeypatch.setattr(cebridge, "ce_root", lambda: ce)
+ assert not admin_policy.feature_enabled("ce_auto_setup")
+ assert cebridge.ce_scripts_available() is True
+ listed = cebridge.ce_root() / "scripts" / "sweep.py"
+ assert listed.is_file()
+ admin_policy.reset_cache()
+
+
+def test_skillkit_lists_global_ce(tmp_path: Path, monkeypatch):
+ ce = tmp_path / ".agents" / "skills" / "curiosity-engine"
+ ce.mkdir(parents=True)
+ (ce / "SKILL.md").write_text(
+ "---\nname: curiosity-engine\ndescription: wiki\n---\n# CE\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(cebridge, "ce_root", lambda: ce)
+ ws = tmp_path / "ws"
+ ws.mkdir()
+ skills = skillkit.list_skills(ws)
+ names = {s.name for s in skills}
+ assert "curiosity-engine" in names
diff --git a/tests/unit/test_ce_host.py b/tests/unit/test_ce_host.py
index dbaa672..45fc1c2 100644
--- a/tests/unit/test_ce_host.py
+++ b/tests/unit/test_ce_host.py
@@ -43,6 +43,30 @@ def test_wiki_commit_happy(tmp_path: Path):
assert out.get("committed") is True
+def test_wiki_diff_receipt_tracks_commit_and_pages(tmp_path: Path):
+ import subprocess
+ wiki = tmp_path / "wiki"
+ wiki.mkdir()
+ subprocess.run(["git", "init"], cwd=wiki, check=True, capture_output=True)
+ subprocess.run(["git", "config", "user.email", "t@t"], cwd=wiki, check=True)
+ subprocess.run(["git", "config", "user.name", "t"], cwd=wiki, check=True)
+ (wiki / "a.md").write_text("one\n", encoding="utf-8")
+ ce_host.wiki_commit(tmp_path, {"message": "curate: a"})
+ before = ce_host.wiki_work_snapshot(tmp_path)
+ fp1 = ce_host.work_availability_fingerprint(tmp_path)
+ (wiki / "b.md").write_text("two\n", encoding="utf-8")
+ ce_host.wiki_commit(tmp_path, {"message": "curate: b"})
+ rec = ce_host.wiki_diff_receipt(tmp_path, before)
+ assert rec["wiki_committed"] is True
+ assert rec["wiki_head_before"] != rec["wiki_head_after"]
+ assert any(p.endswith("b.md") for p in rec["wiki_pages_changed"])
+ assert rec["wiki_pages_landed"] >= 1
+ assert rec["wiki_commit_diff"]
+ fp2 = ce_host.work_availability_fingerprint(tmp_path)
+ assert fp1 != fp2
+ assert ce_host.work_availability_fingerprint(tmp_path) == fp2
+
+
def test_score_diff_passes_new_text_file(tmp_path: Path, monkeypatch):
seen: dict = {}
@@ -143,6 +167,31 @@ def test_wave_prime_override_mode(tmp_path: Path, monkeypatch):
out = ce_host.wave_prime(tmp_path, {"mode": "tables"})
assert out["mode"] == "multimodal-table-extract"
assert "override" in out["reason"]
+ out = ce_host.wave_prime(tmp_path, {"mode": "table-audit"})
+ assert out["mode"] == "table-audit"
+ assert "override" in out["reason"]
+
+
+def test_wave_prime_unknown_token_is_not_a_mode(tmp_path: Path, monkeypatch):
+ monkeypatch.setattr(
+ ce_host, "evolve_guard",
+ lambda *_a, **_k: {"ok": True, "stdout": "snap"},
+ )
+ monkeypatch.setattr(
+ "switchbay.ce_tools._ce_scan",
+ lambda *_a, **_k: {"ok": True},
+ )
+ monkeypatch.setattr(
+ "switchbay.ce_tools._ce_epoch_summary",
+ lambda *_a, **_k: {"ok": True},
+ )
+ monkeypatch.setattr(
+ "switchbay.ce_tools._ce_planner",
+ lambda *_a, **_k: {"mode": "repair", "reason": "default"},
+ )
+ out = ce_host.wave_prime(tmp_path, {"mode": "for"})
+ assert out["mode"] == "repair"
+ assert "override" not in (out.get("reason") or "")
def test_ce_sweep_blurb_names_numeric_review():
diff --git a/tests/unit/test_ce_workers.py b/tests/unit/test_ce_workers.py
index 54b0166..d72522a 100644
--- a/tests/unit/test_ce_workers.py
+++ b/tests/unit/test_ce_workers.py
@@ -102,7 +102,7 @@ def test_user_prompt_passes_predecessor_artifacts():
def test_ce_worker_system_is_not_investigator_json():
node = orchestration.PlanNode(
- node_id="ce-w0", kind="investigate", objective="brief",
+ node_id="ce-w1", kind="investigate", objective="brief",
role="worker", method_hint="ce:worker",
)
text = orchestration._system_for(node)
@@ -175,14 +175,17 @@ def test_cli_dispatches_land_on_the_dag_and_the_board():
)
for role in ("worker", "worker", "batch_reviewer")
]
- assert ids == ["ce-w0", "ce-w1", "ce-rev"]
+ assert len(ids) == 3 and len(set(ids)) == 3
+ assert ids[0].startswith("ce-w")
+ assert ids[1].startswith("ce-w")
+ assert ids[2].startswith("ce-rev")
orchestration._sync_parent_graph(
parent, plan, completed=completed, failed=set(), running=running,
)
view = {r["node_id"]: r for r in parent["plan_nodes"]}
- assert view["ce-w0"]["kind"] == "investigate"
- assert view["ce-rev"]["kind"] == "verify" # reviewers verify
- assert view["ce-w1"]["status"] == "running"
+ assert view[ids[0]]["kind"] == "investigate"
+ assert view[ids[2]]["kind"] == "verify" # reviewers verify
+ assert view[ids[1]]["status"] == "running"
# Each dispatch is visible on the board, which used to sit at zero
# for the whole run because this path never touched a Blackboard.
assert parent["blackboard_n"] == 3
@@ -201,7 +204,10 @@ def test_cli_dispatches_land_on_the_dag_and_the_board():
parent, plan, completed=completed, failed=set(), running=running,
)
after = {r["node_id"]: r["status"] for r in parent["plan_nodes"]}
- assert after["ce-w0"] == after["ce-w1"] == after["ce-rev"] == "done"
+ # Retired single-use workers leave the live parent graph.
+ assert ids[0] not in after
+ assert ids[1] not in after
+ assert ids[2] not in after
def test_handback_publishes_findings_or_an_excerpt():
diff --git a/tests/unit/test_comms_desks_review.py b/tests/unit/test_comms_desks_review.py
new file mode 100644
index 0000000..dafe87c
--- /dev/null
+++ b/tests/unit/test_comms_desks_review.py
@@ -0,0 +1,177 @@
+"""Independent regression probes; synthetic data only."""
+import asyncio
+import json
+
+import pytest
+
+from switchbay.agents import orchestration as orch, evidence
+from switchbay.agents import orchestration_health as health
+from switchbay.llmgateway import base
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("text", [
+ '{"findings":[{"claim":"The API uses 429 for rate limit errors; retries use backoff.","confidence":0.9}]}',
+ 'Curated failure analysis. The source says "too many requests"; this is evidence, not a transport error.',
+ 'The protocol returns HTTP 429 when a client exceeds its request allowance. Retry after the documented delay.',
+ '{"findings":[{"claim":"Documented error: rate limit exceeded. The source prescribes exponential backoff.","confidence":0.9}]}',
+ 'The source quotes the banner "You\'ve hit your weekly limit - resets Aug 26 at 11pm (Europe/Zurich)". This page documents how operators respond.',
+])
+async def test_worker_keeps_valid_failure_domain_research(tmp_path, monkeypatch, text):
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
+ monkeypatch.setenv("SWITCHBAY_STATE_DIR", str(tmp_path / "state"))
+ monkeypatch.setattr(health, "health_path", lambda: tmp_path / "health.json")
+ monkeypatch.setattr(orch, "_persist_event", lambda *a, **k: None)
+
+ class Provider:
+ ID = "openai"
+ DEFAULT_MODEL = "fixture"
+ async def chat_stream(self, req):
+ yield base.TextChunk(text=text)
+ yield base.DoneChunk(stop_reason="end_turn", input_tokens=10, output_tokens=30)
+
+ app = {"runs": {}, "run_ws": {}, "ws_clients": set()}
+ node = orch.PlanNode(node_id="evidence-1", kind="investigate", objective="Document API failure handling")
+ result = await orch._run_agent_node(
+ node, provider=Provider(), model="fixture", workspace=tmp_path,
+ parent_run_id="review-fixture", thread_id="fixture", app=app,
+ blackboard=evidence.Blackboard("independent-review"), worker_index=None,
+ )
+ for task in app.get("_orch_retire", []):
+ task.cancel()
+ await asyncio.gather(*app.get("_orch_retire", []), return_exceptions=True)
+ assert result["ok"], result
+ assert result["output"] == text
+ assert result["output_tokens"] == 30
+
+
+@pytest.mark.asyncio
+async def test_real_transport_failure_keeps_partial_output(tmp_path, monkeypatch):
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
+ monkeypatch.setenv("SWITCHBAY_STATE_DIR", str(tmp_path / "state"))
+ monkeypatch.setattr(health, "health_path", lambda: tmp_path / "health.json")
+ monkeypatch.setattr(orch, "_persist_event", lambda *a, **k: None)
+
+ class Provider:
+ ID = "openai"
+ DEFAULT_MODEL = "fixture"
+ async def chat_stream(self, req):
+ yield base.TextChunk(text="Verified source-backed partial finding: retry delay is 30 seconds.")
+ raise base.ProviderError("connection reset", code="server")
+
+ app = {"runs": {}, "run_ws": {}, "ws_clients": set()}
+ node = orch.PlanNode(node_id="partial-1", kind="investigate", objective="Document retry behavior")
+ result = await orch._run_agent_node(
+ node, provider=Provider(), model="fixture", workspace=tmp_path,
+ parent_run_id="partial-fixture", thread_id="fixture", app=app,
+ blackboard=evidence.Blackboard("independent-review"), worker_index=None,
+ )
+ for task in app.get("_orch_retire", []):
+ task.cancel()
+ await asyncio.gather(*app.get("_orch_retire", []), return_exceptions=True)
+ assert not result["ok"]
+ assert result["error"]
+ assert "retry delay is 30 seconds" in result["output"], result
+
+
+@pytest.mark.asyncio
+async def test_ce_dispatch_reaches_worker_instead_of_constructor_failure(tmp_path, monkeypatch):
+ from switchbay import ce_host, llmgateway
+ from switchbay.agents import ce_workers
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
+ monkeypatch.setenv("SWITCHBAY_STATE_DIR", str(tmp_path / "state"))
+ monkeypatch.delenv("CSWY_PROFILE", raising=False)
+ monkeypatch.setattr(ce_host, "dispatch_worker", lambda *a, **k: {"ok": True, "role": "deepener", "prompt": "Inspect fixture source"})
+ monkeypatch.setattr(ce_workers, "is_local_pid", lambda *a: False)
+ monkeypatch.setattr(ce_workers.policy, "allocate_unused", lambda *a, **k: [("openai", "fixture")])
+
+ class Provider:
+ ID = "openai"
+ DEFAULT_MODEL = "fixture"
+ def has_key(self): return True
+ monkeypatch.setattr(llmgateway, "get", lambda *a: Provider())
+
+ async def worker(*args, **kwargs):
+ assert isinstance(kwargs["blackboard"], evidence.Blackboard)
+ return {"ok": True, "output": "Preserved useful worker finding", "provider": "openai", "model": "fixture"}
+ monkeypatch.setattr(orch, "_run_agent_node", worker)
+ result = await ce_workers.run_from_tool(
+ tmp_path, {"role": "deepener"}, app={"runs": {}, "run_ws": {}, "ws_clients": set()},
+ parent_run_id="nested-fixture", thread_id="fixture", curator_pid="openai", preference=0.5,
+ )
+ assert result["ok"] and result["spawned"], result
+ assert "Preserved useful worker finding" in result["text"]
+
+
+@pytest.mark.asyncio
+async def test_gmail_discovery_requests_no_snippet_or_body(tmp_path, monkeypatch):
+ from switchbay import streams, admin_policy
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
+ monkeypatch.setenv("SWITCHBAY_STATE_DIR", str(tmp_path / "state"))
+ monkeypatch.setenv("SWITCHBAY_PROFILE", "open")
+ admin_policy.reset_cache()
+ calls = []
+ acct = {"id": "gmail-review", "provider": "gmail", "label": "Fixture", "workspaces": [str(tmp_path)]}
+ monkeypatch.setattr(streams, "get_account", lambda *a: acct)
+ monkeypatch.setattr(streams, "update_account", lambda *a, **k: acct)
+ async def api(sess, account, url, **params):
+ calls.append((url, params))
+ if url.endswith("/messages"):
+ return {"messages": [{"id": "m1", "threadId": "t1"}]}
+ if url.endswith("/labels"):
+ return {"labels": []}
+ return {"id": "m1", "threadId": "t1", "internalDate": "2000000000000", "payload": {"headers": [{"name": "Subject", "value": "Fixture thread"}, {"name": "From", "value": "fixture@example.invalid"}]}}
+ monkeypatch.setattr(streams, "_api_get", api)
+ await streams.poll_account(acct)
+ msg_calls = [params for url, params in calls if url.endswith("/messages/m1")]
+ assert msg_calls, calls
+ for params in msg_calls:
+ assert params.get("format") == "metadata", calls
+ assert params.get("fields"), "Metadata format still includes snippet unless fields projection excludes it"
+ assert not any(x in params["fields"].lower() for x in ("snippet", "body", "raw")), params
+ assert not streams.pending_events(acct["id"]), "Unapproved metadata must not enter curation transit"
+
+
+@pytest.mark.asyncio
+async def test_imap_discovery_never_fetches_text(tmp_path, monkeypatch):
+ from switchbay import streams, admin_policy
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
+ monkeypatch.setenv("SWITCHBAY_STATE_DIR", str(tmp_path / "state"))
+ monkeypatch.setenv("SWITCHBAY_PROFILE", "open")
+ admin_policy.reset_cache()
+ fetches = []
+ acct = {"id": "imap-review", "provider": "imap", "label": "Fixture", "host": "imap.example.invalid", "username": "fixture@example.invalid", "workspaces": [str(tmp_path)]}
+ class Imap:
+ def __init__(self, *a, **k): pass
+ def login(self, *a): pass
+ def select(self, *a, **k): return "OK", [b"1"]
+ def response(self, name): return "UIDVALIDITY", [b"12345"]
+ def uid(self, verb, *args):
+ if verb == "SEARCH": return "OK", [b"1"]
+ if verb == "FETCH":
+ fetches.append(str(args[-1]))
+ return "OK", [(b"1 (BODY[HEADER] {140}", b"From: fixture@example.invalid\r\nSubject: Fixture thread\r\nMessage-ID: \r\nSensitivity: normal\r\n\r\n")]
+ raise AssertionError(verb)
+ def logout(self): pass
+ monkeypatch.setattr(streams.imaplib, "IMAP4_SSL", Imap)
+ monkeypatch.setattr(streams.secretstore, "get", lambda *a: "fixture-password")
+ monkeypatch.setattr(streams, "get_account", lambda *a: acct)
+ monkeypatch.setattr(streams, "update_account", lambda *a, **k: acct)
+ await streams.poll_account(acct)
+ assert fetches
+ assert all("TEXT" not in f and "BODY.PEEK[]" not in f and "RFC822" not in f for f in fetches), fetches
+ assert not streams.pending_events(acct["id"]), "Unapproved source must not enter curation transit"
+
+
+def test_retired_thousand_workers_do_not_remain_live_roster(tmp_path, monkeypatch):
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
+ monkeypatch.setenv("SWITCHBAY_STATE_DIR", str(tmp_path / "state"))
+ nodes = [orch.PlanNode(node_id=f"old-{i}", kind="investigate", objective="Completed fixture work") for i in range(1001)]
+ current = orch.PlanNode(node_id="current", kind="investigate", objective="Current fixture work")
+ completed = {n.node_id for n in nodes}
+ plan = orch.OrchestrationPlan(orchestration_id="long-desk", strategy="parallel_investigate", objective="Continuous research", nodes=nodes + [current])
+ results = {n.node_id: {"ok": True, "output": "Persisted finding"} for n in nodes}
+ view = orch.standing_org_view(plan, completed=completed, failed=set(), results=results, running={"current"})
+ assert any(n["node_id"] == "current" for n in view["nodes"])
+ assert len(view["nodes"]) <= 8, "Historical workers still balloon standing desk graph"
+ assert len(results) == 1001, "Roster cleanup must not erase durable results"
diff --git a/tests/unit/test_comms_gate.py b/tests/unit/test_comms_gate.py
new file mode 100644
index 0000000..65d10cc
--- /dev/null
+++ b/tests/unit/test_comms_gate.py
@@ -0,0 +1,1022 @@
+"""Comms review gate: secret, revoke, workspace isolation, no auto-add."""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+import json
+import subprocess
+from pathlib import Path
+
+import pytest
+
+from switchbay import admin_policy, comms_review, streams
+from switchbay.agents import desk_admission as seats
+
+
+def test_secret_blocked_before_approval(tmp_path: Path, monkeypatch):
+ monkeypatch.setenv("SWITCHBAY_PROFILE", "open")
+ admin_policy.reset_cache()
+ rec = comms_review.upsert_discovery({
+ "provider": "imap",
+ "account_id": "a1",
+ "stable_id": "uv:abc",
+ "kind": "email_thread",
+ "subject": "War plans",
+ "sender": "x@example.invalid",
+ "headers": {"Sensitivity": "Secret"},
+ "labels": [],
+ "ts": 1.0,
+ })
+ assert rec["status"] == "blocked"
+ assert rec["subject"] == "[redacted: classified]"
+ assert "War" not in rec["subject"]
+ out = comms_review.approve(rec["key"], str(tmp_path), allowed=[str(tmp_path)])
+ assert not out["ok"]
+
+
+def test_unknown_label_enterprise_not_public(tmp_path: Path, monkeypatch):
+ monkeypatch.setenv("SWITCHBAY_PROFILE", "enterprise")
+ admin_policy.reset_cache()
+ v = comms_review.classify_metadata(headers={"Classification": "Project-X-Label"})
+ assert v["verdict"] in ("unknown", "secret")
+ v2 = comms_review.classify_metadata(headers={})
+ assert v2["verdict"] == "missing"
+
+
+def test_approve_respects_allowlist_and_revoke_wins(tmp_path: Path, monkeypatch):
+ monkeypatch.setenv("SWITCHBAY_PROFILE", "open")
+ admin_policy.reset_cache()
+ rec = comms_review.upsert_discovery({
+ "provider": "gmail",
+ "account_id": "g1",
+ "stable_id": "thread-1",
+ "kind": "email_thread",
+ "subject": "Budget notes",
+ "sender": "a@example.invalid",
+ "headers": {"Sensitivity": "normal"},
+ "labels": [],
+ "ts": 1.0,
+ })
+ other = str(tmp_path / "other")
+ denied = comms_review.approve(rec["key"], other, allowed=[str(tmp_path)])
+ assert not denied["ok"]
+ ok = comms_review.approve(rec["key"], str(tmp_path), allowed=[str(tmp_path)])
+ assert ok["ok"]
+ comms_review.revoke(rec["key"])
+ ok2, reason, _ = comms_review.authorize_content_fetch(
+ rec["key"], str(tmp_path),
+ allowed=[str(tmp_path)],
+ headers={"Sensitivity": "normal"},
+ label_ids=[],
+ )
+ assert not ok2
+ assert "revoked" in reason
+
+
+def test_later_secret_reply_blocked(tmp_path: Path, monkeypatch):
+ monkeypatch.setenv("SWITCHBAY_PROFILE", "open")
+ admin_policy.reset_cache()
+ rec = comms_review.upsert_discovery({
+ "provider": "gmail",
+ "account_id": "g1",
+ "stable_id": "thread-sec",
+ "kind": "email_thread",
+ "subject": "ok",
+ "headers": {"Sensitivity": "normal"},
+ "labels": [],
+ "ts": 1.0,
+ })
+ comms_review.approve(rec["key"], str(tmp_path), allowed=[str(tmp_path)])
+ ok, reason, _ = comms_review.authorize_content_fetch(
+ rec["key"], str(tmp_path),
+ allowed=[str(tmp_path)],
+ headers={"Sensitivity": "Top Secret"},
+ label_ids=[],
+ )
+ assert not ok
+ assert "secret" in reason.lower()
+
+
+def test_imap_thread_id_uses_references_not_subject():
+ a = comms_review.imap_thread_stable_id(
+ message_id="",
+ references=" ",
+ in_reply_to="",
+ uidvalidity="99",
+ fallback_uid="12",
+ )
+ b = comms_review.imap_thread_stable_id(
+ message_id="",
+ references="",
+ in_reply_to="",
+ uidvalidity="99",
+ fallback_uid="13",
+ )
+ assert a == b
+ assert a.startswith("99:")
+
+
+def test_quarantine_legacy_transit_does_not_parse(tmp_path, monkeypatch):
+ monkeypatch.setenv("SWITCHBAY_STATE_DIR", str(tmp_path / "state"))
+ aid = "acct-q"
+ p = streams._state_dir(aid) / "transit.jsonl"
+ p.write_text('{"id":"x","text":"BODY SECRET poison"}\n', encoding="utf-8")
+ n = streams.quarantine_legacy_transit(aid)
+ assert n >= 1
+ assert not p.is_file()
+ assert (streams._state_dir(aid) / "transit.quarantine.jsonl").is_file()
+ assert streams.pending_events(aid) == []
+
+
+def _enterprise_comms(tmp_path, monkeypatch) -> None:
+ path = tmp_path / "admin-comms.json"
+ path.write_text(json.dumps({
+ "profile": "enterprise",
+ "features": {"comms_streams": True},
+ }))
+ monkeypatch.setenv("SWITCHBAY_ADMIN_POLICY", str(path))
+ monkeypatch.setenv("SWITCHBAY_PROFILE", "enterprise")
+ admin_policy.reset_cache()
+
+
+def test_upsert_stores_headers_without_bodies(tmp_path, monkeypatch):
+ _enterprise_comms(tmp_path, monkeypatch)
+ rec = comms_review.upsert_discovery({
+ "provider": "gmail",
+ "account_id": "g-store",
+ "stable_id": "thread-store",
+ "subject": "Notes",
+ "headers": {"Sensitivity": "normal", "text": "should-drop"},
+ "fetch_ref": {"id": "m1"},
+ "body": "secret body must not persist",
+ "ts": 1.0,
+ })
+ raw = comms_review.get_raw_item(rec["key"])
+ assert raw is not None
+ assert raw.get("headers", {}).get("Sensitivity") == "normal"
+ assert "text" not in (raw.get("headers") or {})
+ assert "body" not in raw
+ assert rec.get("headers") is None or "body" not in rec
+
+
+def test_auth_only_preflight_does_not_use_cached_headers(tmp_path, monkeypatch):
+ _enterprise_comms(tmp_path, monkeypatch)
+ rec = comms_review.upsert_discovery({
+ "provider": "gmail",
+ "account_id": "g-auth",
+ "stable_id": "thread-auth",
+ "subject": "Notes",
+ "headers": {"Sensitivity": "normal"},
+ "fetch_ref": {"id": "m1"},
+ })
+ ws = str(tmp_path)
+ assert comms_review.approve(rec["key"], ws, allowed=[ws])["ok"]
+ ok, reason, _ = comms_review.authorize_content_fetch(
+ rec["key"], ws, allowed=[ws], classify=False,
+ )
+ assert ok, reason
+ ok2, reason2, _ = comms_review.authorize_content_fetch(
+ rec["key"], ws, allowed=[ws], headers={}, classify=True,
+ )
+ assert not ok2
+ assert "missing" in reason2 or "unknown" in reason2 or "not assumed" in reason2
+
+
+def _gmail_full_payload(text: str = "approved fixture body") -> dict:
+ return {
+ "id": "message1",
+ "internalDate": "2000000000000",
+ "payload": {
+ "mimeType": "text/plain",
+ "body": {"data": base64.urlsafe_b64encode(text.encode()).decode()},
+ },
+ "body": {"content": text},
+ }
+
+
+@pytest.mark.asyncio
+async def test_pipeline_discover_suggest_approve_fetch_receipt(tmp_path, monkeypatch):
+ _enterprise_comms(tmp_path, monkeypatch)
+ ws = str(tmp_path)
+ acct = {
+ "id": "gmail-pipe", "provider": "gmail", "label": "Fixture",
+ "workspaces": [ws],
+ }
+ monkeypatch.setattr(streams, "get_account", lambda *a: acct)
+ monkeypatch.setattr(streams, "update_account", lambda *a, **k: acct)
+ monkeypatch.setattr(streams, "list_accounts", lambda: [acct])
+ body_calls = []
+ metadata_calls = []
+
+ async def api(sess, account, url, **params):
+ if url.endswith("/messages"):
+ return {"messages": [{"id": "message1", "threadId": "thread-pipe"}]}
+ if params.get("format") == "full" or "body" in str(params.get("$select", "")).split(","):
+ body_calls.append((url, params))
+ return _gmail_full_payload()
+ metadata_calls.append((url, params))
+ return {
+ "id": "message1",
+ "threadId": "thread-pipe",
+ "internalDate": "2000000000000",
+ "labelIds": ["INBOX"],
+ "payload": {"headers": [
+ {"name": "Subject", "value": "Fixture"},
+ {"name": "From", "value": "fixture@example.invalid"},
+ {"name": "Sensitivity", "value": "normal"},
+ ]},
+ }
+
+ monkeypatch.setattr(streams, "_api_get", api)
+ n = await streams.poll_account(acct)
+ assert n >= 1
+ assert not body_calls
+ assert not streams.pending_events(acct["id"])
+ items = comms_review.list_items(workspace=ws)
+ assert items, "discovery must land in the review queue"
+ key = items[0]["key"]
+ assert items[0]["status"] == "pending"
+ assert ws in (items[0].get("suggested_workspaces") or [])
+ assert comms_review.approve(key, ws, allowed=[ws])["ok"]
+ result = await streams.fetch_approved_thread(acct, key, ws)
+ assert metadata_calls
+ assert body_calls, result
+ assert result.get("ok") and result.get("events"), result
+ assert result["events"][0]["text"] == "approved fixture body"
+ assert result["events"][0]["approved_workspace"] == ws
+ assert streams.has_receipt(acct["id"], result["events"][0]["source_event_id"], ws)
+ pending = streams.pending_events(acct["id"])
+ assert pending and pending[0]["approved"] is True
+ again = await streams.fetch_approved_thread(acct, key, ws)
+ assert again.get("ok")
+ assert again.get("events") == []
+
+
+@pytest.mark.asyncio
+async def test_unapproved_gmail_never_fetches_body(tmp_path, monkeypatch):
+ _enterprise_comms(tmp_path, monkeypatch)
+ ws = str(tmp_path)
+ acct = {"id": "gmail-unapp", "provider": "gmail", "label": "F", "workspaces": [ws]}
+ monkeypatch.setattr(streams, "get_account", lambda *a: acct)
+ monkeypatch.setattr(streams, "update_account", lambda *a, **k: acct)
+ monkeypatch.setattr(streams, "list_accounts", lambda: [acct])
+ body_calls = []
+
+ async def api(sess, account, url, **params):
+ if url.endswith("/messages"):
+ return {"messages": [{"id": "message1", "threadId": "t"}]}
+ if params.get("format") == "full":
+ body_calls.append((url, params))
+ return _gmail_full_payload()
+ return {
+ "id": "message1", "threadId": "t", "internalDate": "1",
+ "payload": {"headers": [{"name": "Sensitivity", "value": "normal"}]},
+ }
+
+ monkeypatch.setattr(streams, "_api_get", api)
+ await streams.poll_account(acct)
+ items = comms_review.list_items(workspace=ws)
+ key = items[0]["key"]
+ result = await streams.fetch_approved_thread(acct, key, ws)
+ assert not result.get("ok")
+ assert not body_calls
+ assert not streams.pending_events(acct["id"])
+
+
+@pytest.mark.asyncio
+async def test_revocation_during_body_request_skips_parser(tmp_path, monkeypatch):
+ _enterprise_comms(tmp_path, monkeypatch)
+ ws = str(tmp_path)
+ acct = {"id": "gmail-rev", "provider": "gmail", "label": "F", "workspaces": [ws]}
+ monkeypatch.setattr(streams, "get_account", lambda *a: acct)
+ parsed = []
+ streams.body_parse_hook = lambda *a: parsed.append(a)
+ rec = comms_review.upsert_discovery({
+ "provider": "gmail", "account_id": acct["id"], "stable_id": "thread-rev",
+ "subject": "Fixture", "headers": {"Sensitivity": "normal"},
+ "fetch_ref": {"id": "message1"}, "thread_id": "thread-rev",
+ })
+ key = rec["key"]
+ assert comms_review.approve(key, ws, allowed=[ws])["ok"]
+
+ async def api(sess, account, url, **params):
+ if params.get("format") == "full":
+ comms_review.revoke(key, reason="race")
+ return _gmail_full_payload("should-not-parse")
+ return {
+ "id": "message1", "threadId": "thread-rev",
+ "payload": {"headers": [{"name": "Sensitivity", "value": "normal"}]},
+ }
+
+ monkeypatch.setattr(streams, "_api_get", api)
+ try:
+ result = await streams.fetch_approved_thread(acct, key, ws)
+ assert not result.get("events")
+ assert not parsed
+ assert streams.pending_events(acct["id"]) == []
+ finally:
+ streams.body_parse_hook = None
+
+
+@pytest.mark.asyncio
+async def test_multi_workspace_receipts_and_restart(tmp_path, monkeypatch):
+ _enterprise_comms(tmp_path, monkeypatch)
+ ws_a = tmp_path / "a"
+ ws_b = tmp_path / "b"
+ ws_a.mkdir()
+ ws_b.mkdir()
+ acct = {
+ "id": "gmail-multi", "provider": "gmail", "label": "F",
+ "workspaces": [str(ws_a), str(ws_b)],
+ }
+ monkeypatch.setattr(streams, "get_account", lambda *a: acct)
+ rec = comms_review.upsert_discovery({
+ "provider": "gmail", "account_id": acct["id"], "stable_id": "thread-multi",
+ "subject": "Fixture", "headers": {"Sensitivity": "normal"},
+ "fetch_ref": {"id": "message1"}, "thread_id": "thread-multi",
+ })
+ key = rec["key"]
+ assert comms_review.approve(key, str(ws_a), allowed=[str(ws_a), str(ws_b)])["ok"]
+ assert comms_review.approve(key, str(ws_b), allowed=[str(ws_a), str(ws_b)])["ok"]
+
+ async def api(sess, account, url, **params):
+ if params.get("format") == "full":
+ return _gmail_full_payload("shared body")
+ return {
+ "id": "message1", "threadId": "thread-multi",
+ "payload": {"headers": [{"name": "Sensitivity", "value": "normal"}]},
+ }
+
+ monkeypatch.setattr(streams, "_api_get", api)
+ ra = await streams.fetch_approved_thread(acct, key, str(ws_a))
+ rb = await streams.fetch_approved_thread(acct, key, str(ws_b))
+ assert ra["events"] and rb["events"]
+ assert ra["events"][0]["approved_workspace"] == str(ws_a)
+ assert rb["events"][0]["approved_workspace"] == str(ws_b)
+ src = ra["events"][0]["source_event_id"]
+ assert streams.has_receipt(acct["id"], src, str(ws_a))
+ assert streams.has_receipt(acct["id"], src, str(ws_b))
+ # Restart: store still approved.
+ again = comms_review.get_item(key)
+ assert again and again["status"] == "approved"
+ assert str(ws_a) in again["approved_workspaces"]
+ comms_review.revoke(key)
+ after = comms_review.get_item(key)
+ assert after and after["status"] == "revoked"
+ denied = await streams.fetch_approved_thread(acct, key, str(ws_a))
+ assert not denied.get("events")
+
+
+@pytest.mark.asyncio
+async def test_comms_streams_false_gates_poll_and_fetch(tmp_path, monkeypatch):
+ policy = tmp_path / "admin.json"
+ policy.write_text(json.dumps({
+ "profile": "enterprise", "features": {"comms_streams": False},
+ }))
+ monkeypatch.setenv("SWITCHBAY_ADMIN_POLICY", str(policy))
+ monkeypatch.setenv("SWITCHBAY_PROFILE", "enterprise")
+ admin_policy.reset_cache()
+ ws = str(tmp_path)
+ acct = {"id": "gmail-off", "provider": "gmail", "label": "F", "workspaces": [ws]}
+ rec = comms_review.upsert_discovery({
+ "provider": "gmail", "account_id": acct["id"], "stable_id": "t",
+ "subject": "x", "headers": {"Sensitivity": "normal"},
+ "fetch_ref": {"id": "message1"},
+ })
+ comms_review.approve(rec["key"], ws, allowed=[ws])
+ with pytest.raises(ValueError, match="comms_streams"):
+ await streams.poll_account(acct)
+ out = await streams.fetch_approved_thread(acct, rec["key"], ws)
+ assert not out.get("ok")
+ assert "comms_streams" in str(out.get("error") or "")
+ assert await streams.ingest_approved_updates(acct) == 0
+
+
+@pytest.mark.asyncio
+async def test_imap_html_body_after_header_gate(tmp_path, monkeypatch):
+ _enterprise_comms(tmp_path, monkeypatch)
+ ws = str(tmp_path)
+ acct = {
+ "id": "imap-html", "provider": "imap", "label": "F",
+ "host": "imap.example.invalid", "username": "f@example.invalid",
+ "workspaces": [ws],
+ }
+ monkeypatch.setattr(streams, "get_account", lambda *a: acct)
+ stable = comms_review.imap_thread_stable_id(
+ message_id="", references="", in_reply_to="",
+ uidvalidity="123",
+ )
+ item = comms_review.upsert_discovery({
+ "provider": "imap", "account_id": acct["id"], "stable_id": stable,
+ "subject": "Fixture", "headers": {"Sensitivity": "normal"},
+ "fetch_ref": {"uid": "1", "uidvalidity": "123"},
+ "uidvalidity": "123",
+ })
+ assert comms_review.approve(item["key"], ws, allowed=[ws])["ok"]
+ fetches = []
+
+ class Imap:
+ def __init__(self, *a, **k): pass
+ def login(self, *a): pass
+ def select(self, *a, **k): return "OK", [b"1"]
+ def response(self, name): return "UIDVALIDITY", [b"123"]
+ def uid(self, verb, *args):
+ assert verb == "FETCH"
+ fetches.append(str(args[-1]))
+ if "TEXT" in str(args[-1]):
+ return "OK", [(
+ b"1 BODY[TEXT] {40}",
+ b"Content-Type: text/html\r\n\r\nhello html
",
+ )]
+ return "OK", [(
+ b"1 (BODY[HEADER] {80}",
+ b"Sensitivity: normal\r\nMessage-ID: \r\n\r\n",
+ )]
+ def logout(self): pass
+
+ monkeypatch.setattr(streams.imaplib, "IMAP4_SSL", Imap)
+ monkeypatch.setattr(streams.secretstore, "get", lambda *a: "fixture-password")
+ result = await streams.fetch_approved_thread(acct, item["key"], ws)
+ assert fetches
+ assert "TEXT" not in fetches[0]
+ assert any("TEXT" in f for f in fetches[1:])
+ assert result.get("ok") and result.get("events"), result
+ assert "hello html" in result["events"][0]["text"]
+
+
+@pytest.mark.asyncio
+async def test_teams_pages_later_channels_without_top(tmp_path, monkeypatch):
+ _enterprise_comms(tmp_path, monkeypatch)
+ acct = {
+ "id": "graph-page", "provider": "msgraph", "label": "F",
+ "workspaces": [str(tmp_path)],
+ }
+ monkeypatch.setattr(streams, "get_account", lambda *a: acct)
+ monkeypatch.setattr(streams, "update_account", lambda *a, **k: acct)
+ monkeypatch.setattr(streams, "list_accounts", lambda: [acct])
+ calls = []
+
+ async def api(sess, account, url, **params):
+ calls.append((url, params))
+ if url.endswith("/messages") or "/me/messages" in url:
+ return {"value": []}
+ if url.endswith("/joinedTeams"):
+ return {"value": [{"id": "team1", "displayName": "T"}]}
+ if url.endswith("/channels"):
+ return {
+ "value": [{"id": "c1", "displayName": "one"}],
+ "@odata.nextLink": "https://graph.microsoft.com/v1.0/teams/team1/channels/page2",
+ }
+ if url.endswith("/channels/page2"):
+ return {"value": [{"id": "c2", "displayName": "two"}]}
+ if url.endswith("/chats"):
+ return {"value": []}
+ return {"value": []}
+
+ monkeypatch.setattr(streams, "_api_get", api)
+ await streams.poll_account(acct)
+ team_calls = [p for u, p in calls if u.endswith("/joinedTeams")]
+ assert team_calls and all(not p for p in team_calls)
+ channel_calls = [(u, p) for u, p in calls if u.rstrip("/").endswith("channels") or "/channels" in u]
+ assert channel_calls
+ for _u, p in channel_calls:
+ assert "$top" not in p
+ assert set(p) <= {"$filter", "$select"}
+ items = comms_review.list_items(workspace=str(tmp_path))
+ names = {i.get("subject") for i in items if i.get("kind") == "channel"}
+ assert "one" in names
+ # Second poll resumes the nextLink so channel two is not hidden.
+ await streams.poll_account(acct)
+ items2 = comms_review.list_items(workspace=str(tmp_path))
+ names2 = {i.get("subject") for i in items2 if i.get("kind") == "channel"}
+ assert "two" in names2 or "one" in names2
+ assert not any(u.endswith("/messages") and "/channels/" in u for u, _p in calls)
+
+
+@pytest.mark.asyncio
+async def test_gmail_html_nested_without_attachment(tmp_path, monkeypatch):
+ monkeypatch.setenv("SWITCHBAY_PROFILE", "open")
+ admin_policy.reset_cache()
+ html = base64.urlsafe_b64encode(b"nested html body
").decode()
+ att = base64.urlsafe_b64encode(b"PNGDATA").decode()
+ msg = {
+ "payload": {
+ "mimeType": "multipart/mixed",
+ "parts": [
+ {
+ "mimeType": "multipart/alternative",
+ "parts": [
+ {"mimeType": "text/html", "body": {"data": html}},
+ ],
+ },
+ {
+ "mimeType": "image/png",
+ "filename": "x.png",
+ "body": {"attachmentId": "att1", "data": att},
+ },
+ ],
+ }
+ }
+ text = streams._gmail_plain(msg)
+ assert "nested html body" in text
+ assert "PNGDATA" not in text
+
+
+def test_duplicate_configured_headers_preserved(tmp_path, monkeypatch):
+ from email import message_from_bytes
+ path = tmp_path / "admin.json"
+ path.write_text(json.dumps({
+ "profile": "enterprise",
+ "comms": {"classification_headers": ["X-Tenant-Class"]},
+ }))
+ monkeypatch.setenv("SWITCHBAY_ADMIN_POLICY", str(path))
+ monkeypatch.setenv("SWITCHBAY_PROFILE", "enterprise")
+ admin_policy.reset_cache()
+ msg = message_from_bytes(
+ b"X-Tenant-Class: normal\r\nX-Tenant-Class: Secret\r\nSubject: x\r\n\r\n"
+ )
+ headers = streams._header_map(msg)
+ assert "Secret" in headers.get("X-Tenant-Class", "")
+ v = comms_review.classify_metadata(headers=headers)
+ assert v["verdict"] == "secret"
+
+
+@pytest.mark.asyncio
+async def test_legacy_transit_cannot_bypass_curation_guard(tmp_path, monkeypatch):
+ _enterprise_comms(tmp_path, monkeypatch)
+ aid = "legacy"
+ streams._append_transit(aid, [{
+ "id": "poison", "text": "BODY should never curate", "approved": False,
+ }])
+ from switchbay import daemon
+ app = {"runs": {}, "workspace": tmp_path, "ws_clients": set()}
+ acct = {"id": aid, "provider": "gmail", "label": "F", "workspaces": [str(tmp_path)]}
+ monkeypatch.setattr(streams, "allowed_workspaces", lambda a: [str(tmp_path)])
+ out = await daemon._run_stream_curation(app, acct)
+ assert out.get("curated", 0) == 0
+ assert streams.pending_events(aid) == []
+
+
+def _init_wiki(ws: Path) -> None:
+ wiki = ws / "wiki"
+ wiki.mkdir(parents=True, exist_ok=True)
+ subprocess.check_call(["git", "init"], cwd=wiki, stdout=subprocess.DEVNULL)
+ subprocess.check_call(["git", "config", "user.email", "t@example.invalid"], cwd=wiki)
+ subprocess.check_call(["git", "config", "user.name", "fixture"], cwd=wiki)
+ (wiki / "index.md").write_text("# wiki\n", encoding="utf-8")
+ subprocess.check_call(["git", "add", "-A"], cwd=wiki, stdout=subprocess.DEVNULL)
+ subprocess.check_call(["git", "commit", "-m", "init"], cwd=wiki, stdout=subprocess.DEVNULL)
+
+
+def _wiki_sha(ws: Path) -> str:
+ out = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ws / "wiki", text=True)
+ return out.strip()
+
+
+class _CommitProvider:
+ """Synthetic file-capable curator: writes a wiki page and commits."""
+
+ ID = "grok_build"
+ PROVIDER = {
+ "id": "grok_build",
+ "default_model": "grok-4.6",
+ "capabilities": {"shell": True, "file_write": True, "tools": True},
+ }
+
+ def has_key(self) -> bool:
+ return True
+
+ async def chat_stream(self, req):
+ from switchbay.llmgateway import DoneChunk
+ ws = Path(req.workspace)
+ wiki = ws / "wiki"
+ wiki.mkdir(parents=True, exist_ok=True)
+ page = wiki / "comms-note.md"
+ page.write_text("source-backed fixture knowledge\n", encoding="utf-8")
+ subprocess.check_call(["git", "add", "-A"], cwd=wiki, stdout=subprocess.DEVNULL)
+ subprocess.check_call(
+ ["git", "commit", "-m", "curate comms fixture"],
+ cwd=wiki, stdout=subprocess.DEVNULL,
+ )
+ yield DoneChunk(stop_reason="end_turn")
+
+
+class _NoopProvider:
+ ID = "grok_build"
+ PROVIDER = _CommitProvider.PROVIDER
+
+ def has_key(self) -> bool:
+ return True
+
+ async def chat_stream(self, req):
+ from switchbay.llmgateway import DoneChunk
+ yield DoneChunk(stop_reason="end_turn")
+
+
+class _EmptyCommitProvider:
+ """HEAD moves, no wiki page delta — must not consume mail."""
+
+ ID = "grok_build"
+ PROVIDER = _CommitProvider.PROVIDER
+
+ def has_key(self) -> bool:
+ return True
+
+ async def chat_stream(self, req):
+ from switchbay.llmgateway import DoneChunk
+ wiki = Path(req.workspace) / "wiki"
+ subprocess.check_call(
+ ["git", "commit", "--allow-empty", "-m", "empty"],
+ cwd=wiki, stdout=subprocess.DEVNULL,
+ )
+ yield DoneChunk(stop_reason="end_turn")
+
+
+class _HangProvider:
+ ID = "grok_build"
+ PROVIDER = _CommitProvider.PROVIDER
+
+ def has_key(self) -> bool:
+ return True
+
+ async def chat_stream(self, req):
+ await asyncio.Event().wait()
+ yield None
+
+
+def _gmail_full_payload_named(mid: str, text: str = "approved fixture body") -> dict:
+ return {
+ "id": mid,
+ "internalDate": "2000000000000",
+ "payload": {
+ "mimeType": "text/plain",
+ "body": {"data": base64.urlsafe_b64encode(text.encode()).decode()},
+ },
+ "body": {"content": text},
+ }
+
+
+@pytest.mark.asyncio
+async def test_production_handoff_commits_wiki_and_consumes(tmp_path, monkeypatch):
+ _enterprise_comms(tmp_path, monkeypatch)
+ seats.reset_for_tests()
+ _init_wiki(tmp_path)
+ before = _wiki_sha(tmp_path)
+ ws = str(tmp_path)
+ acct = {
+ "id": "gmail-handoff", "provider": "gmail", "label": "Fixture",
+ "workspaces": [ws],
+ }
+ monkeypatch.setattr(streams, "get_account", lambda *a: acct)
+ monkeypatch.setattr(streams, "update_account", lambda *a, **k: acct)
+ monkeypatch.setattr(streams, "list_accounts", lambda: [acct])
+ rec = comms_review.upsert_discovery({
+ "provider": "gmail", "account_id": acct["id"], "stable_id": "thread-h",
+ "subject": "Fixture", "headers": {"Sensitivity": "normal"},
+ "fetch_ref": {"id": "message1"}, "thread_id": "thread-h",
+ })
+ key = rec["key"]
+ comms_review.set_suggestions(key, workspaces=[ws])
+ assert comms_review.approve(key, ws, allowed=[ws])["ok"]
+
+ async def api(sess, account, url, **params):
+ if params.get("format") == "full":
+ return _gmail_full_payload_named("message1")
+ return {
+ "id": "message1", "threadId": "thread-h",
+ "payload": {"headers": [{"name": "Sensitivity", "value": "normal"}]},
+ }
+
+ from switchbay import daemon, llmgateway
+ monkeypatch.setattr(streams, "_api_get", api)
+ monkeypatch.setattr(daemon, "_comms_curation_route", lambda *a, **k: ("grok_build", "grok-4.6"))
+ monkeypatch.setattr(llmgateway, "get", lambda *a: _CommitProvider())
+ fetched = await streams.fetch_approved_thread(acct, key, ws)
+ assert fetched.get("events"), fetched
+ app = {"runs": {}, "workspace": tmp_path, "ws_clients": set()}
+ result = await daemon._run_stream_curation(app, acct)
+ assert result.get("ok") and int(result.get("curated") or 0) >= 1, result
+ after = _wiki_sha(tmp_path)
+ assert after != before
+ assert (tmp_path / "wiki" / "comms-note.md").read_text(encoding="utf-8").find("fixture") >= 0
+ assert streams.pending_events(acct["id"]) == []
+ item = comms_review.get_item(key, ws)
+ assert item and item.get("ingest_state") == "ingested"
+
+
+@pytest.mark.asyncio
+async def test_noop_curator_retains_pending_for_retry(tmp_path, monkeypatch):
+ _enterprise_comms(tmp_path, monkeypatch)
+ seats.reset_for_tests()
+ _init_wiki(tmp_path)
+ ws = str(tmp_path)
+ acct = {
+ "id": "gmail-noop", "provider": "gmail", "label": "Fixture",
+ "workspaces": [ws],
+ }
+ rec = comms_review.upsert_discovery({
+ "provider": "gmail", "account_id": acct["id"], "stable_id": "thread-n",
+ "subject": "Fixture", "headers": {"Sensitivity": "normal"},
+ "fetch_ref": {"id": "message1"}, "thread_id": "thread-n",
+ })
+ key = rec["key"]
+ assert comms_review.approve(key, ws, allowed=[ws])["ok"]
+ streams._append_transit(acct["id"], [{
+ "id": "gmail:message1::" + ws,
+ "comms_key": key,
+ "approved_workspace": ws,
+ "approved": True,
+ "text": "approved fixture body",
+ "subject": "Fixture",
+ "stream": "inbox",
+ "ts": 1,
+ "sender": "f@example.invalid",
+ "deep_link": "",
+ }])
+ from switchbay import daemon, llmgateway
+ monkeypatch.setattr(daemon, "_comms_curation_route", lambda *a, **k: ("grok_build", "grok-4.6"))
+ monkeypatch.setattr(llmgateway, "get", lambda *a: _NoopProvider())
+ monkeypatch.setattr(streams, "allowed_workspaces", lambda a: [ws])
+ app = {"runs": {}, "workspace": tmp_path, "ws_clients": set()}
+ result = await daemon._run_stream_curation(app, acct)
+ assert not result.get("ok"), result
+ pending = streams.pending_events(acct["id"])
+ assert pending, "no-op curator must leave transit for retry"
+ item = comms_review.get_item(key, ws)
+ assert item and item.get("ingest_state") == "error"
+ assert item.get("ingest_error")
+
+
+@pytest.mark.asyncio
+async def test_two_workspace_success_consumes_once_each(tmp_path, monkeypatch):
+ _enterprise_comms(tmp_path, monkeypatch)
+ seats.reset_for_tests()
+ ws_a = tmp_path / "a"
+ ws_b = tmp_path / "b"
+ ws_a.mkdir()
+ ws_b.mkdir()
+ _init_wiki(ws_a)
+ _init_wiki(ws_b)
+ acct = {
+ "id": "gmail-2ws", "provider": "gmail", "label": "Fixture",
+ "workspaces": [str(ws_a), str(ws_b)],
+ }
+ rec = comms_review.upsert_discovery({
+ "provider": "gmail", "account_id": acct["id"], "stable_id": "thread-2",
+ "subject": "Fixture", "headers": {"Sensitivity": "normal"},
+ "fetch_ref": {"id": "message1"}, "thread_id": "thread-2",
+ })
+ key = rec["key"]
+ assert comms_review.approve(key, str(ws_a), allowed=[str(ws_a), str(ws_b)])["ok"]
+ assert comms_review.approve(key, str(ws_b), allowed=[str(ws_a), str(ws_b)])["ok"]
+ for w in (ws_a, ws_b):
+ streams._append_transit(acct["id"], [{
+ "id": f"gmail:message1::{w}",
+ "comms_key": key,
+ "approved_workspace": str(w),
+ "approved": True,
+ "text": "approved fixture body",
+ "subject": "Fixture",
+ "stream": "inbox",
+ "ts": 1,
+ "sender": "f@example.invalid",
+ "deep_link": "",
+ }])
+ from switchbay import daemon, llmgateway
+ monkeypatch.setattr(daemon, "_comms_curation_route", lambda *a, **k: ("grok_build", "grok-4.6"))
+ monkeypatch.setattr(llmgateway, "get", lambda *a: _CommitProvider())
+ monkeypatch.setattr(streams, "allowed_workspaces", lambda a: [str(ws_a), str(ws_b)])
+ monkeypatch.setattr(streams, "live_allowed_workspaces", lambda a: [str(ws_a), str(ws_b)])
+ app = {"runs": {}, "workspace": tmp_path, "ws_clients": set()}
+ result = await daemon._run_stream_curation(app, acct)
+ assert result.get("ok"), result
+ assert streams.pending_events(acct["id"]) == []
+ again = await daemon._run_stream_curation(app, acct)
+ assert int(again.get("curated") or 0) == 0
+ assert (ws_a / "wiki" / "comms-note.md").is_file()
+ assert (ws_b / "wiki" / "comms-note.md").is_file()
+
+
+def test_comms_route_skips_denied_and_http_tools(tmp_path, monkeypatch):
+ monkeypatch.setenv("SWITCHBAY_PROFILE", "open")
+ admin_policy.reset_cache()
+ from switchbay import daemon, llmgateway
+ from switchbay.agents import orchestration_policy as orch_pol
+ orch_pol.set_denied_models(["claude_code", "claude_code/*"], workspace=tmp_path)
+
+ class Claude:
+ ID = "claude_code"
+ PROVIDER = {
+ "id": "claude_code",
+ "default_model": "sonnet",
+ "capabilities": {"shell": True, "file_write": True},
+ }
+ def has_key(self):
+ return True
+
+ class Grok:
+ ID = "grok_build"
+ PROVIDER = {
+ "id": "grok_build",
+ "default_model": "grok-4.6",
+ "capabilities": {"shell": True, "file_write": True},
+ }
+ def has_key(self):
+ return True
+
+ class Copilot:
+ ID = "github_copilot"
+ PROVIDER = {
+ "id": "github_copilot",
+ "default_model": "copilot-x",
+ "capabilities": {"shell": False, "file_write": False, "tools": True},
+ }
+ def has_key(self):
+ return True
+
+ fake = {
+ "claude_code": Claude(),
+ "github_copilot": Copilot(),
+ "grok_build": Grok(),
+ }
+ monkeypatch.setattr(llmgateway, "PROVIDERS", fake)
+ monkeypatch.setattr(llmgateway, "get", lambda pid: fake[pid])
+ monkeypatch.setattr(daemon, "_ce_action_provider", lambda ws: ("github_copilot", "copilot-x"))
+ monkeypatch.setattr(daemon, "_auto_roster_pair", lambda ws, **k: ("github_copilot", "copilot-x"))
+ monkeypatch.setattr(daemon, "_effective_model", lambda pid: fake[pid].PROVIDER["default_model"])
+ pid, model = daemon._comms_curation_route(tmp_path)
+ assert pid == "grok_build", (pid, model)
+ assert model == "grok-4.6"
+ assert pid != "github_copilot"
+ assert pid != "claude_code"
+
+
+@pytest.mark.asyncio
+async def test_comms_curation_waits_on_shared_curate_desk(tmp_path, monkeypatch):
+ _enterprise_comms(tmp_path, monkeypatch)
+ seats.reset_for_tests()
+ _init_wiki(tmp_path)
+ from switchbay import daemon, llmgateway
+ from switchbay.kernel.desk import DESK_CURATE
+ ws = str(tmp_path)
+ acct = {"id": "gmail-desk", "provider": "gmail", "label": "F", "workspaces": [ws]}
+ rec = comms_review.upsert_discovery({
+ "provider": "gmail", "account_id": acct["id"], "stable_id": "t",
+ "subject": "Fixture", "headers": {"Sensitivity": "normal"},
+ })
+ key = rec["key"]
+ comms_review.approve(key, ws, allowed=[ws])
+ events = [{
+ "id": "e1", "comms_key": key, "approved_workspace": ws,
+ "approved": True, "text": "body", "subject": "s", "stream": "inbox",
+ "ts": 1, "sender": "a", "deep_link": "",
+ }]
+ monkeypatch.setattr(daemon, "_comms_curation_route", lambda *a, **k: ("grok_build", "grok-4.6"))
+ monkeypatch.setattr(llmgateway, "get", lambda *a: _CommitProvider())
+ monkeypatch.setattr(streams, "live_allowed_workspaces", lambda a: [ws])
+ domain = seats.desk_domain_id(tmp_path, DESK_CURATE)
+ gate = seats.gate_for(domain, workspace=tmp_path)
+ cap = gate.cap
+ for i in range(cap):
+ await gate.acquire(f"filler-{i}", kind="worker")
+ app = {"runs": {}, "workspace": tmp_path, "ws_clients": set()}
+ task = asyncio.create_task(daemon._curate_into(app, acct, tmp_path, events))
+ await asyncio.sleep(0.05)
+ assert not task.done()
+ await gate.release_async("filler-0")
+ ok, err = await asyncio.wait_for(task, timeout=2.0)
+ assert ok, err
+ for i in range(1, cap):
+ await gate.release_async(f"filler-{i}")
+
+
+@pytest.mark.asyncio
+async def test_approve_response_includes_ingest_error(tmp_path, monkeypatch):
+ _enterprise_comms(tmp_path, monkeypatch)
+ from switchbay import daemon
+ ws = str(tmp_path)
+ acct = {
+ "id": "gmail-err", "provider": "gmail", "label": "F",
+ "workspaces": [ws],
+ }
+ rec = comms_review.upsert_discovery({
+ "provider": "gmail", "account_id": acct["id"], "stable_id": "thread-e",
+ "subject": "Fixture", "headers": {"Sensitivity": "normal"},
+ "fetch_ref": {"id": "message1"}, "thread_id": "thread-e",
+ })
+ key = rec["key"]
+ assert comms_review.approve(key, ws, allowed=[ws])["ok"]
+ async def boom(*a, **k):
+ return {"ok": False, "error": "not connected", "events": []}
+ monkeypatch.setattr(streams, "fetch_approved_thread", boom)
+ app = {"runs": {}, "workspace": tmp_path, "ws_clients": set()}
+ ingest = await daemon._ingest_after_approve(app, acct, key, ws)
+ assert ingest.get("ingest_state") == "error"
+ assert ingest.get("ingest_error")
+ assert "chat_stream" not in ingest["ingest_error"].lower()
+ item = comms_review.get_item(key, ws)
+ assert item and item["status"] == "approved"
+ assert item.get("ingest_state") == "error"
+
+
+@pytest.mark.asyncio
+async def test_empty_commit_curator_retains_transit(tmp_path, monkeypatch):
+ _enterprise_comms(tmp_path, monkeypatch)
+ seats.reset_for_tests()
+ _init_wiki(tmp_path)
+ ws = str(tmp_path)
+ acct = {
+ "id": "gmail-empty", "provider": "gmail", "label": "Fixture",
+ "workspaces": [ws],
+ }
+ rec = comms_review.upsert_discovery({
+ "provider": "gmail", "account_id": acct["id"], "stable_id": "thread-empty",
+ "subject": "Fixture", "headers": {"Sensitivity": "normal"},
+ "fetch_ref": {"id": "message1"}, "thread_id": "thread-empty",
+ })
+ key = rec["key"]
+ assert comms_review.approve(key, ws, allowed=[ws])["ok"]
+ streams._append_transit(acct["id"], [{
+ "id": "gmail:message1::" + ws,
+ "comms_key": key,
+ "approved_workspace": ws,
+ "approved": True,
+ "text": "approved fixture body",
+ "subject": "Fixture",
+ "stream": "inbox",
+ "ts": 1,
+ "sender": "f@example.invalid",
+ "deep_link": "",
+ }])
+ from switchbay import daemon, llmgateway
+ monkeypatch.setattr(daemon, "_comms_curation_route", lambda *a, **k: ("grok_build", "grok-4.6"))
+ monkeypatch.setattr(llmgateway, "get", lambda *a: _EmptyCommitProvider())
+ monkeypatch.setattr(streams, "allowed_workspaces", lambda a: [ws])
+ app = {"runs": {}, "workspace": tmp_path, "ws_clients": set()}
+ sha_before = _wiki_sha(tmp_path)
+ result = await daemon._run_stream_curation(app, acct)
+ assert not result.get("ok"), result
+ assert _wiki_sha(tmp_path) != sha_before
+ assert streams.pending_events(acct["id"]), "empty commit must not consume mail"
+
+
+@pytest.mark.asyncio
+async def test_curate_into_releases_desk_after_success_error_cancel(tmp_path, monkeypatch):
+ from switchbay import daemon, llmgateway
+ from switchbay.kernel.desk import DESK_CURATE
+ _enterprise_comms(tmp_path, monkeypatch)
+ seats.reset_for_tests()
+ _init_wiki(tmp_path)
+ ws = str(tmp_path)
+ acct = {"id": "gmail-leak", "provider": "gmail", "label": "F", "workspaces": [ws]}
+ rec = comms_review.upsert_discovery({
+ "provider": "gmail", "account_id": acct["id"], "stable_id": "leak",
+ "subject": "Fixture", "headers": {"Sensitivity": "normal"},
+ })
+ key = rec["key"]
+ comms_review.approve(key, ws, allowed=[ws])
+ events = [{
+ "id": "e1", "comms_key": key, "approved_workspace": ws,
+ "approved": True, "text": "body", "subject": "s", "stream": "inbox",
+ "ts": 1, "sender": "a", "deep_link": "",
+ }]
+ monkeypatch.setattr(daemon, "_comms_curation_route", lambda *a, **k: ("grok_build", "grok-4.6"))
+ monkeypatch.setattr(streams, "live_allowed_workspaces", lambda a: [ws])
+ domain = seats.desk_domain_id(tmp_path, DESK_CURATE)
+ app = {"runs": {}, "workspace": tmp_path, "ws_clients": set()}
+
+ def leftover() -> tuple[int, int]:
+ g = seats._GATES.get(domain)
+ if g is None:
+ return 0, 0
+ return g.live(), g._refs
+
+ monkeypatch.setattr(llmgateway, "get", lambda *a: _CommitProvider())
+ ok, err = await daemon._curate_into(app, acct, tmp_path, events)
+ assert ok, err
+ live, refs = leftover()
+ assert live == 0 and refs == 0
+
+ seats.reset_for_tests()
+ monkeypatch.setattr(llmgateway, "get", lambda *a: _NoopProvider())
+ ok, err = await daemon._curate_into(app, acct, tmp_path, events)
+ assert not ok
+ live, refs = leftover()
+ assert live == 0 and refs == 0
+
+ seats.reset_for_tests()
+ monkeypatch.setattr(llmgateway, "get", lambda *a: _HangProvider())
+ task = asyncio.create_task(daemon._curate_into(app, acct, tmp_path, events))
+ g = None
+ for _ in range(200):
+ g = seats._GATES.get(domain)
+ if g is not None and g.live() >= 1:
+ break
+ await asyncio.sleep(0.01)
+ assert g is not None and g.live() >= 1
+ task.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await task
+ live, refs = leftover()
+ assert live == 0 and refs == 0
diff --git a/tests/unit/test_curate_content_receipts.py b/tests/unit/test_curate_content_receipts.py
new file mode 100644
index 0000000..b0e256c
--- /dev/null
+++ b/tests/unit/test_curate_content_receipts.py
@@ -0,0 +1,56 @@
+"""Independent regressions: productivity requires content changes, not timestamps."""
+import os
+import subprocess
+
+from switchbay import ce_host
+from switchbay.agents import orchestration
+
+
+def _fixture(tmp_path):
+ wiki = tmp_path / 'wiki'
+ wiki.mkdir()
+ page = wiki / 'fixture.md'
+ page.write_text('# Fixture\n\nSource fact A.\n')
+ def git(*args):
+ subprocess.run(['git', '-C', str(wiki), *args], check=True, capture_output=True)
+ git('init')
+ git('config', 'user.name', 'Fixture')
+ git('config', 'user.email', 'fixture@example.invalid')
+ git('add', '.')
+ git('commit', '-m', 'Fixture baseline')
+ return page, git
+
+
+def test_unchanged_page_rewrite_is_not_productive_work(tmp_path):
+ page, _ = _fixture(tmp_path)
+ before = ce_host.wiki_work_snapshot(tmp_path)
+ original = page.read_text()
+ stamp = page.stat().st_mtime_ns
+ page.write_text(original)
+ os.utime(page, ns=(stamp + 1_000_000_000, stamp + 1_000_000_000))
+ receipt = ce_host.wiki_diff_receipt(tmp_path, before)
+ assert receipt['wiki_pages_landed'] == 0, 'mtime alone must not count as work'
+ assert not receipt['wiki_pages_changed']
+ assert not orchestration._receipt_had_work(receipt)
+
+
+def test_same_size_content_edit_survives_preserved_mtime(tmp_path):
+ page, _ = _fixture(tmp_path)
+ before = ce_host.wiki_work_snapshot(tmp_path)
+ stamp = page.stat()
+ page.write_text(page.read_text().replace('fact A', 'fact B'))
+ os.utime(page, ns=(stamp.st_atime_ns, stamp.st_mtime_ns))
+ receipt = ce_host.wiki_diff_receipt(tmp_path, before)
+ assert receipt['wiki_pages_landed'] == 1, 'Actual content changes must not be discarded'
+ assert 'wiki/fixture.md' in receipt['wiki_pages_changed']
+ assert orchestration._receipt_had_work(receipt)
+
+
+def test_empty_commit_does_not_reset_no_work_detection(tmp_path):
+ _, git = _fixture(tmp_path)
+ before = ce_host.wiki_work_snapshot(tmp_path)
+ git('commit', '--allow-empty', '-m', 'No content change')
+ receipt = ce_host.wiki_diff_receipt(tmp_path, before)
+ assert not receipt['wiki_pages_changed']
+ assert not receipt['wiki_commit_diff']
+ assert not orchestration._receipt_had_work(receipt), 'An empty commit is not useful curation'
diff --git a/tests/unit/test_curate_evidence.py b/tests/unit/test_curate_evidence.py
new file mode 100644
index 0000000..76774b8
--- /dev/null
+++ b/tests/unit/test_curate_evidence.py
@@ -0,0 +1,44 @@
+"""Curate success is grounded in work, not outage-keyword matching."""
+
+from __future__ import annotations
+
+from switchbay.agents import orchestration as orch
+from switchbay.agents import orchestration_health as health
+
+
+def test_research_rate_limit_prose_is_not_outage():
+ assert health.looks_like_outage(
+ '{"findings":[{"claim":"The API uses 429 for rate limit errors; retries use backoff.","confidence":0.9}]}'
+ ) is None
+ assert health.looks_like_outage(
+ 'Curated failure analysis. The source says "too many requests"; this is evidence, not a transport error.'
+ ) is None
+ banner = "You've hit your weekly limit - resets Aug 26 at 11pm (Europe/Zurich)"
+ assert health.looks_like_outage(banner) == "weekly_limit"
+
+
+def test_receipt_had_work_is_page_diff_not_tool_count():
+ assert orch._receipt_had_work({
+ "wiki_pages_landed": 1,
+ "wiki_pages_changed": ["wiki/concepts/x.md"],
+ "wiki_commit_diff": " x.md | 3 +++",
+ })
+ assert not orch._receipt_had_work({
+ "wiki_pages_landed": 0,
+ "wiki_tool_commits": 4,
+ "wiki_pages_changed": [],
+ "output": "called ce_wave_prime",
+ })
+ assert not orch._receipt_had_work({
+ "wiki_head_before": "aaa",
+ "wiki_head_after": "bbb",
+ "wiki_pages_landed": 0,
+ "wiki_pages_changed": [],
+ "wiki_commit_diff": "",
+ "wiki_committed": True,
+ }), "empty SHA move is not useful curation"
+ assert orch._receipt_had_work({
+ "wiki_pages_landed": 0,
+ "wiki_pages_changed": ["wiki/fixture.md"],
+ "wiki_commit_diff": " fixture.md | 1 +-",
+ })
diff --git a/tests/unit/test_curate_lifecycle.py b/tests/unit/test_curate_lifecycle.py
new file mode 100644
index 0000000..617bc68
--- /dev/null
+++ b/tests/unit/test_curate_lifecycle.py
@@ -0,0 +1,579 @@
+"""Curate desk: live-state, resume finish, duration waves, overlap."""
+
+from __future__ import annotations
+
+import asyncio
+import time
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+from switchbay.agents.orchestration import (
+ PlanNode, OrchestrationPlan, parse_duration_window,
+ _add_curate_package_wave, _add_continue_wave,
+)
+from switchbay.agents import orchestration_policy as policy
+from switchbay.kernel import (
+ DESK_CURATE, STATE_QUIET, STATE_WORKING, get, quiet, seat,
+)
+
+
+def test_parse_duration_and_continuous():
+ now = 1_000_000.0
+ repeat, until = parse_duration_window("for 10 mins", now=now)
+ assert repeat is True
+ assert until == now + 600
+ repeat, until = parse_duration_window("overnight", now=now)
+ assert repeat is True
+ assert until is None
+ repeat, until = parse_duration_window("tables", now=now)
+ assert repeat is False
+
+
+def test_curate_continue_is_package_wave_not_generic():
+ plan = OrchestrationPlan(
+ orchestration_id="run-1",
+ strategy="ce_curate",
+ objective="for 10 mins",
+ nodes=[PlanNode(
+ node_id="curate", kind="synthesize", objective="for 10 mins",
+ role="curator", tools=["ce_wave_prime", "ce_planner"],
+ )],
+ decision={"task_kind": "curation", "curate_repeat": True},
+ allow_expand=True,
+ )
+ added = _add_curate_package_wave(plan)
+ assert added
+ assert added[0].role == "curator"
+ assert added[0].kind == "synthesize"
+ assert "ce_planner" in added[0].tools or "ce_wave_prime" in added[0].tools
+ generic = _add_continue_wave(
+ OrchestrationPlan(
+ orchestration_id="run-2", strategy="investigate_verify_synthesize",
+ objective="why",
+ nodes=[PlanNode(node_id="synth", kind="synthesize", objective="why")],
+ allow_expand=True, preference=1.0,
+ ),
+ policy.ExpansionDecision(True, 1, "gap", ["more"]),
+ default_provider="anthropic",
+ default_model="opus",
+ )
+ kinds = {n.kind for n in generic}
+ assert "investigate" in kinds
+
+
+def test_quiet_ignores_newer_overlapping_run(tmp_path: Path):
+ seat(tmp_path, DESK_CURATE, chief_provider="x", chief_model="y", run_id="old")
+ seat(tmp_path, DESK_CURATE, chief_provider="x", chief_model="y", run_id="new")
+ q = quiet(tmp_path, DESK_CURATE, run_id="old")
+ assert q is not None
+ assert q.state == STATE_WORKING
+ assert q.run_id == "new"
+
+
+def test_reconcile_stale_working_without_live_run(tmp_path: Path):
+ from switchbay import daemon
+ seat(
+ tmp_path, DESK_CURATE,
+ chief_provider="x", chief_model="y", run_id="gone",
+ )
+ rec = get(tmp_path, DESK_CURATE)
+ app = {"runs": {}, "desk_launches": {}}
+ state = daemon._reconcile_desk_state(app, tmp_path, rec)
+ assert state == STATE_QUIET
+ rec2 = get(tmp_path, DESK_CURATE)
+ assert rec2 is not None
+ assert rec2.state == STATE_QUIET
+
+
+def test_stale_running_checkpoint_is_quiet(tmp_path: Path, monkeypatch):
+ from switchbay import daemon
+ seat(
+ tmp_path, DESK_CURATE,
+ chief_provider="x", chief_model="y", run_id="stale",
+ )
+ rec = get(tmp_path, DESK_CURATE)
+ monkeypatch.setattr(
+ daemon.orchestration, "load_checkpoint",
+ lambda *a: {"status": {"phase": "running"}},
+ )
+ assert daemon._reconcile_desk_state({"runs": {}}, tmp_path, rec) == STATE_QUIET
+ rec2 = get(tmp_path, DESK_CURATE)
+ assert rec2 is not None
+ assert rec2.state == STATE_QUIET
+ assert rec2.run_id == "stale"
+
+
+def test_stale_interrupted_waiting_planning_are_quiet(tmp_path: Path, monkeypatch):
+ from switchbay import daemon
+ for phase in ("interrupted", "waiting_limits", "planning"):
+ seat(
+ tmp_path, DESK_CURATE,
+ chief_provider="x", chief_model="y", run_id=f"stale-{phase}",
+ )
+ rec = get(tmp_path, DESK_CURATE)
+ monkeypatch.setattr(
+ daemon.orchestration, "load_checkpoint",
+ lambda *a, p=phase: {"status": {"phase": p}},
+ )
+ assert daemon._reconcile_desk_state(
+ {"runs": {}, "desk_launches": {}}, tmp_path, rec,
+ ) == STATE_QUIET
+ rec2 = get(tmp_path, DESK_CURATE)
+ assert rec2.run_id == f"stale-{phase}"
+
+
+def test_reconcile_live_run_and_startup_launch_race(tmp_path: Path):
+ import asyncio
+ from switchbay import daemon
+ seat(
+ tmp_path, DESK_CURATE,
+ chief_provider="x", chief_model="y", run_id="run-live",
+ )
+ rec = get(tmp_path, DESK_CURATE)
+
+ async def _run():
+ app = {
+ "runs": {
+ "run-live": {
+ "run_id": "run-live",
+ "status": "running",
+ "workspace": str(tmp_path),
+ },
+ },
+ "desk_launches": {},
+ }
+ assert daemon._reconcile_desk_state(app, tmp_path, rec) == STATE_WORKING
+ t = asyncio.create_task(asyncio.sleep(30))
+ launch_app = {"runs": {}, "desk_launches": {}}
+ daemon._track_desk_launch(launch_app, tmp_path, DESK_CURATE, t)
+ assert daemon._reconcile_desk_state(launch_app, tmp_path, rec) == STATE_WORKING
+ t.cancel()
+ try:
+ await t
+ except asyncio.CancelledError:
+ pass
+ assert daemon._reconcile_desk_state(launch_app, tmp_path, rec) == STATE_QUIET
+
+ asyncio.run(_run())
+
+
+def test_quiet_stopped_run_then_overlapping_later_run(tmp_path: Path):
+ from switchbay import daemon
+ seat(
+ tmp_path, DESK_CURATE,
+ chief_provider="x", chief_model="y", run_id="old",
+ )
+ rec = get(tmp_path, DESK_CURATE)
+ app = {"runs": {}, "desk_launches": {}}
+ assert daemon._reconcile_desk_state(app, tmp_path, rec) == STATE_QUIET
+ seat(
+ tmp_path, DESK_CURATE,
+ chief_provider="x", chief_model="y", run_id="new",
+ )
+ rec2 = get(tmp_path, DESK_CURATE)
+ app2 = {
+ "runs": {
+ "new": {
+ "run_id": "new",
+ "status": "running",
+ "workspace": str(tmp_path),
+ },
+ },
+ "desk_launches": {},
+ }
+ assert daemon._reconcile_desk_state(app2, tmp_path, rec2) == STATE_WORKING
+ assert get(tmp_path, DESK_CURATE).run_id == "new"
+
+
+def test_expired_resume_is_refused(tmp_path: Path):
+ from switchbay import daemon
+ from switchbay.agents import orchestration
+ seat(
+ tmp_path, DESK_CURATE,
+ chief_provider="x", chief_model="y", run_id="expired",
+ )
+ quiet(tmp_path, DESK_CURATE, run_id="expired", keep_run=True)
+ orchestration.persist_checkpoint(
+ tmp_path, "expired",
+ phase="quiet", completed=set(), failed=set(),
+ expansions=0, results={}, elapsed_s=1.0,
+ extra={"curate_until": time.time() - 30},
+ )
+ spec = {"desk_id": DESK_CURATE}
+ assert daemon._maybe_resume_quiet_desk({"runs": {}}, spec, tmp_path) is None
+ rec = get(tmp_path, DESK_CURATE)
+ assert rec is not None
+ assert rec.run_id == "expired"
+ assert rec.state == STATE_QUIET
+
+
+def test_reconcile_keeps_live_run_working(tmp_path: Path):
+ from switchbay import daemon
+ seat(
+ tmp_path, DESK_CURATE,
+ chief_provider="x", chief_model="y", run_id="run-live",
+ )
+ rec = get(tmp_path, DESK_CURATE)
+ app = {
+ "runs": {
+ "run-live": {
+ "run_id": "run-live",
+ "status": "running",
+ "workspace": str(tmp_path),
+ },
+ },
+ "desk_launches": {},
+ }
+ assert daemon._reconcile_desk_state(app, tmp_path, rec) == STATE_WORKING
+
+
+def test_finish_resume_protects_newer_run(tmp_path: Path):
+ from switchbay import daemon
+ seat(tmp_path, DESK_CURATE, chief_provider="x", chief_model="y", run_id="new")
+ plan = SimpleNamespace(strategy="ce_curate", decision={"task_kind": "curation"})
+ daemon._finish_resume_desk(tmp_path, plan, "old", cancelled=False)
+ rec = get(tmp_path, DESK_CURATE)
+ assert rec is not None
+ assert rec.state == STATE_WORKING
+ assert rec.run_id == "new"
+
+
+def test_track_desk_launch_cancels_previous():
+ import asyncio
+ from switchbay import daemon
+
+ async def _run():
+ app = {}
+ t1 = asyncio.create_task(asyncio.sleep(30))
+ t2 = asyncio.create_task(asyncio.sleep(30))
+ ws = Path("/tmp/ws-a")
+ daemon._track_desk_launch(app, ws, "curate", t1)
+ daemon._track_desk_launch(app, ws, "curate", t2)
+ t2.cancel()
+ for t in (t1, t2):
+ try:
+ await t
+ except asyncio.CancelledError:
+ pass
+ assert t1.cancelled()
+ assert t2.cancelled()
+
+ asyncio.run(_run())
+
+
+def _curate_plan(oid: str, *, until: float | None = None, repeat: bool = True) -> OrchestrationPlan:
+ dec = {"task_kind": "curation", "curate_repeat": repeat}
+ if until is not None:
+ dec["curate_until"] = until
+ return OrchestrationPlan(
+ orchestration_id=oid,
+ strategy="ce_curate",
+ objective="overnight" if until is None else "for 10 mins",
+ nodes=[PlanNode(
+ node_id="curate", kind="synthesize", objective="curate",
+ role="curator", tools=["ce_wave_prime"],
+ output_contract="synthesis",
+ )],
+ decision=dec,
+ allow_expand=True,
+ )
+
+
+def _app():
+ return {"ws_clients": set(), "runs": {}, "run_ws": {}}
+
+
+@pytest.mark.asyncio
+async def test_local_duration_dispatch_reaches_host_waves(tmp_path: Path, monkeypatch):
+ from switchbay import daemon
+ from switchbay.agents import orchestration
+ from switchbay.llmgateway import base
+
+ executed: list[orchestration.OrchestrationPlan] = []
+ chat_calls: list[str] = []
+
+ async def fake_execute(plan, **_kw):
+ executed.append(plan)
+ return SimpleNamespace(
+ cancelled=False, output="ok", results=[],
+ telemetry={"stop_reason": None},
+ )
+
+ async def fake_chat(*_a, **_k):
+ chat_calls.append("chat")
+ return "run-chat"
+
+ async def noop(*_a, **_k):
+ return None
+
+ class LocalProv:
+ ID = "mlx"
+ LABEL = "MLX"
+ DEFAULT_MODEL = "qwen"
+ PROVIDER = {"id": "mlx", "default_model": "qwen", "category": "local"}
+
+ def has_key(self) -> bool:
+ return True
+
+ async def chat_stream(self, req):
+ yield base.TextChunk(text="ok")
+ yield base.DoneChunk(stop_reason="end_turn")
+
+ monkeypatch.setattr(orchestration, "execute", fake_execute)
+ monkeypatch.setattr(daemon, "_dispatch_chat", fake_chat)
+ monkeypatch.setattr(daemon, "_chat_notice", noop)
+ monkeypatch.setattr(daemon, "_broadcast", noop)
+ monkeypatch.setattr(daemon, "_append_event", lambda *a, **k: None)
+ monkeypatch.setattr(daemon, "_remember_run_workspace", lambda *a, **k: None)
+ monkeypatch.setattr(daemon, "_remember_run_thread", lambda *a, **k: None)
+ monkeypatch.setattr(daemon, "_finish_desk", lambda *a, **k: None)
+ monkeypatch.setattr(daemon, "_stream_parent_reply", noop)
+ monkeypatch.setattr(daemon, "_effective_model", lambda *_a, **_k: "qwen")
+ monkeypatch.setattr("switchbay.conversations.new_thread", lambda *_a, **_k: "th-local")
+ monkeypatch.setattr("switchbay.llmgateway.get", lambda *_a, **_k: LocalProv())
+ monkeypatch.setattr(daemon, "_keyed_provider_count", lambda: 1)
+ monkeypatch.setattr(
+ "switchbay.llmgateway.list_providers",
+ lambda: [{"id": "mlx", "has_key": True, "category": "local"}],
+ )
+ monkeypatch.setattr(
+ "switchbay.agents.fanout.append_to_rail_log", lambda *a, **k: None,
+ )
+ monkeypatch.setattr("switchbay.agents.fanout.write_summary", lambda *a, **k: None)
+
+ app = {
+ "workspace": tmp_path,
+ "runs": {},
+ "run_ws": {},
+ "ws_clients": set(),
+ "thread_id": "th-local",
+ "thread_kind": "structured-agent",
+ }
+ text = "Worker-curate this workspace. Focus on: for 10 mins."
+ rid = await daemon._dispatch_auto(
+ app, None, text,
+ workspace_override=tmp_path,
+ provider_override="mlx",
+ model_override="qwen",
+ extra_system="prime",
+ command="curate",
+ task_kind="curation",
+ constrained=daemon._curate_constrained(local=True, text="for 10 mins"),
+ lock_provider=True,
+ )
+ assert not chat_calls, "local duration curate must not fall back to _dispatch_chat"
+ assert executed, "repeated local curate must reach host waves"
+ plan = executed[0]
+ assert plan.decision.get("curate_repeat") is True
+ assert plan.bounds.max_concurrency == 1
+ assert rid
+
+
+@pytest.mark.asyncio
+async def test_local_curate_deadline_cancels_worker(tmp_path: Path, monkeypatch):
+ from switchbay.agents import orchestration
+ from switchbay.llmgateway import base
+
+ monkeypatch.setattr(
+ "switchbay.modestore.resolve_for_difficulty", lambda *a, **k: (None, None),
+ )
+ cancelled: list[int] = []
+
+ class LocalBlocked:
+ ID = "mlx"
+ LABEL = "MLX"
+ DEFAULT_MODEL = "qwen"
+ PROVIDER = {"id": "mlx", "default_model": "qwen", "category": "local"}
+
+ def has_key(self) -> bool:
+ return True
+
+ async def chat_stream(self, req):
+ try:
+ await asyncio.sleep(30)
+ except asyncio.CancelledError:
+ cancelled.append(1)
+ raise
+ yield base.TextChunk(text="late")
+ yield base.DoneChunk(stop_reason="end_turn")
+
+ oid = "run-local-deadline"
+ plan = _curate_plan(oid, until=time.time() + 0.25)
+ plan.bounds = orchestration.OrchestrationBounds(max_concurrency=1).clamp()
+ app = _app()
+ app["runs"][oid] = {
+ "run_id": oid, "status": "running", "started_at": time.time(),
+ "provider": "mlx", "model": "qwen",
+ }
+ t0 = time.time()
+ result = await orchestration.execute(
+ plan, app=app, workspace=tmp_path, thread_id="th",
+ parent_run_id=oid, default_provider=LocalBlocked(), default_model="qwen",
+ )
+ elapsed = time.time() - t0
+ assert elapsed < 5, elapsed
+ assert result.telemetry.get("stop_reason") == "curate window ended"
+ assert cancelled, "local worker was not cancelled on expiry"
+
+
+@pytest.mark.asyncio
+async def test_short_deadline_cancels_blocked_worker(tmp_path: Path, monkeypatch):
+ from switchbay.agents import orchestration
+ from switchbay.llmgateway import base
+
+ monkeypatch.setattr(
+ "switchbay.modestore.resolve_for_difficulty", lambda *a, **k: (None, None),
+ )
+ cancelled = []
+
+ class Blocked:
+ ID = "openai"
+ LABEL = "OpenAI"
+ DEFAULT_MODEL = "fake"
+ PROVIDER = {"id": "openai", "default_model": "fake"}
+
+ def has_key(self) -> bool:
+ return True
+
+ async def chat_stream(self, req):
+ try:
+ await asyncio.sleep(30)
+ except asyncio.CancelledError:
+ cancelled.append(1)
+ raise
+ yield base.TextChunk(text="late")
+ yield base.DoneChunk(stop_reason="end_turn")
+
+ oid = "run-deadline"
+ plan = _curate_plan(oid, until=time.time() + 0.25)
+ app = _app()
+ app["runs"][oid] = {
+ "run_id": oid, "status": "running", "started_at": time.time(),
+ "provider": "openai", "model": "fake",
+ }
+ t0 = time.time()
+ result = await orchestration.execute(
+ plan, app=app, workspace=tmp_path, thread_id="th",
+ parent_run_id=oid, default_provider=Blocked(), default_model="fake",
+ )
+ elapsed = time.time() - t0
+ assert elapsed < 5, elapsed
+ assert result.telemetry.get("stop_reason") == "curate window ended"
+ ck = orchestration.load_checkpoint(tmp_path, oid)
+ assert ck is not None
+ assert ck["status"]["phase"] == "quiet"
+ assert cancelled, "blocked worker was not cancelled"
+
+
+@pytest.mark.asyncio
+async def test_continuous_second_wave_then_stop(tmp_path: Path, monkeypatch):
+ from switchbay.agents import orchestration
+ from switchbay.llmgateway import base
+
+ monkeypatch.setattr(
+ "switchbay.modestore.resolve_for_difficulty", lambda *a, **k: (None, None),
+ )
+ monkeypatch.setattr(
+ "switchbay.ce_host.wave_prime",
+ lambda *a, **k: {"ok": True, "pick_mode": "test"},
+ )
+
+ class Fast:
+ ID = "openai"
+ LABEL = "OpenAI"
+ DEFAULT_MODEL = "fake"
+ PROVIDER = {"id": "openai", "default_model": "fake"}
+ calls = 0
+
+ def has_key(self) -> bool:
+ return True
+
+ async def chat_stream(self, req):
+ type(self).calls += 1
+ yield base.TextChunk(text="curated")
+ yield base.DoneChunk(stop_reason="end_turn", input_tokens=1, output_tokens=1)
+
+ oid = "run-waves"
+ plan = _curate_plan(oid, until=None, repeat=True)
+ app = _app()
+ app["runs"][oid] = {
+ "run_id": oid, "status": "running", "started_at": time.time(),
+ "provider": "openai", "model": "fake",
+ }
+ task = asyncio.create_task(orchestration.execute(
+ plan, app=app, workspace=tmp_path, thread_id="th",
+ parent_run_id=oid, default_provider=Fast(), default_model="fake",
+ ))
+ saw_second = False
+ for _ in range(80):
+ await asyncio.sleep(0.05)
+ ck = orchestration.load_checkpoint(tmp_path, oid)
+ cont = int((ck or {}).get("status", {}).get("continuations") or 0)
+ nodes = list(((ck or {}).get("plan").nodes if ck and ck.get("plan") else []))
+ if cont >= 1 or len(nodes) >= 2 or Fast.calls >= 2:
+ saw_second = True
+ break
+ if task.done():
+ break
+ app["runs"][oid]["user_cancel"] = True
+ task.cancel()
+ try:
+ await asyncio.wait_for(task, timeout=5)
+ except (asyncio.CancelledError, TimeoutError):
+ pass
+ assert saw_second, f"calls={Fast.calls} ck={orchestration.load_checkpoint(tmp_path, oid)}"
+
+
+@pytest.mark.asyncio
+async def test_resume_respects_workspace_model_allowlist(tmp_path: Path, monkeypatch):
+ from switchbay.agents import orchestration, orchestration_policy as pol
+ from switchbay.llmgateway import base
+
+ monkeypatch.setattr(
+ "switchbay.modestore.resolve_for_difficulty", lambda *a, **k: (None, None),
+ )
+ pol.set_denied_models(["anthropic/opus"], workspace=tmp_path)
+
+ class Fast:
+ ID = "openai"
+ LABEL = "OpenAI"
+ DEFAULT_MODEL = "fake"
+ PROVIDER = {"id": "openai", "default_model": "fake"}
+ used: list[str] = []
+
+ def has_key(self) -> bool:
+ return True
+
+ async def chat_stream(self, req):
+ type(self).used.append(getattr(req, "model", None) or "fake")
+ yield base.TextChunk(text="ok")
+ yield base.DoneChunk(stop_reason="end_turn", input_tokens=1, output_tokens=1)
+
+ oid = "run-allow"
+ plan = OrchestrationPlan(
+ orchestration_id=oid,
+ strategy="ce_curate",
+ objective="once",
+ nodes=[PlanNode(
+ node_id="curate", kind="synthesize", objective="curate",
+ role="curator", output_contract="synthesis",
+ provider="anthropic", model="opus",
+ )],
+ decision={"task_kind": "curation"},
+ )
+ app = _app()
+ app["runs"][oid] = {
+ "run_id": oid, "status": "running", "started_at": time.time(),
+ "provider": "openai", "model": "fake",
+ }
+ result = await orchestration.execute(
+ plan, app=app, workspace=tmp_path, thread_id="th",
+ parent_run_id=oid, default_provider=Fast(), default_model="fake",
+ resume=True,
+ )
+ assert Fast.used, result
+ assert result.results
+ used_provider = result.results[0].get("provider")
+ assert used_provider == "openai"
diff --git a/tests/unit/test_curate_scheduler.py b/tests/unit/test_curate_scheduler.py
new file mode 100644
index 0000000..427dfcd
--- /dev/null
+++ b/tests/unit/test_curate_scheduler.py
@@ -0,0 +1,483 @@
+"""Fake-provider Curate scheduler: unique IDs, compact, idle, nested park.
+
+These tests are labeled fake: they do not call a live model. The isolated
+real-provider smoke is a separate script.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import time
+from pathlib import Path
+
+import pytest
+
+from switchbay import app_settings, ce_host
+from switchbay.agents import desk_admission as seats
+from switchbay.agents import orchestration as orch
+from switchbay.agents.orchestration import OrchestrationPlan, PlanNode
+from switchbay.llmgateway import base
+
+
+def _app() -> dict:
+ return {"ws_clients": set(), "runs": {}, "run_ws": {}}
+
+
+def _curate_plan(oid: str, *, repeat: bool = True, until: float | None = None) -> OrchestrationPlan:
+ dec = {"task_kind": "curation", "curate_repeat": repeat}
+ if until is not None:
+ dec["curate_until"] = until
+ return OrchestrationPlan(
+ orchestration_id=oid,
+ strategy="ce_curate",
+ objective="overnight",
+ extra_system="Role lens: keep this.",
+ nodes=[PlanNode(
+ node_id="curate", kind="synthesize", objective="curate",
+ role="curator", tools=["ce_wave_prime", "ce_dispatch_worker"],
+ output_contract="synthesis",
+ )],
+ decision=dec,
+ allow_expand=True,
+ bounds=orch.OrchestrationBounds(max_concurrency=1, max_expansions=0).clamp(),
+ )
+
+
+class ProductiveFake:
+ ID = "openai"
+ LABEL = "OpenAI"
+ DEFAULT_MODEL = "fake"
+ PROVIDER = {"id": "openai", "default_model": "fake"}
+
+ def has_key(self) -> bool:
+ return True
+
+ async def chat_stream(self, req):
+ yield base.TextChunk(text="wave")
+ yield base.DoneChunk(stop_reason="end_turn", input_tokens=1, output_tokens=1)
+
+
+@pytest.mark.asyncio
+async def test_thousand_productive_waves_unique_ids_resume(tmp_path: Path, monkeypatch):
+ """Fake scheduler: 1005+ execute() completions, unique IDs, bounded live DAG."""
+ monkeypatch.setattr(
+ "switchbay.modestore.resolve_for_difficulty", lambda *a, **k: (None, None),
+ )
+ monkeypatch.setattr(
+ "switchbay.ce_host.wave_prime",
+ lambda *a, **k: {"ok": True, "mode": "repair", "pick_mode": "test"},
+ )
+ executed: list[str] = []
+ run_ids: list[str] = []
+
+ async def fake_node(node, **kwargs):
+ executed.append(node.node_id)
+ attempt = int(kwargs.get("attempt") or 1)
+ rid = orch._node_run_id(kwargs.get("parent_run_id") or "x", node.node_id, attempt)
+ run_ids.append(rid)
+ rec = {
+ "node_id": node.node_id,
+ "kind": node.kind,
+ "run_id": rid,
+ "ok": True,
+ "error": None,
+ "output": f"did {node.node_id}",
+ "provider": "openai",
+ "model": "fake",
+ "input_tokens": 2,
+ "output_tokens": 3,
+ "wiki_pages_landed": 1,
+ "wiki_pages_changed": ["wiki/concepts/fixture.md"],
+ "wiki_committed": True,
+ "wiki_head_before": "a" * 40,
+ "wiki_head_after": "b" * 40,
+ "wiki_commit_diff": " wiki/concepts/fixture.md | 2 ++",
+ }
+ if len(executed) in (7, 19):
+ rec["ok"] = False
+ rec["error"] = "timed out after 1s"
+ rec["run_id"] = orch._node_run_id(
+ kwargs.get("parent_run_id") or "x", node.node_id, 2,
+ )
+ run_ids[-1] = rec["run_id"]
+ return rec
+
+ monkeypatch.setattr(orch, "_run_agent_node", fake_node)
+ monkeypatch.setattr(orch, "_apply_work_receipt", lambda rec, *a, **k: rec)
+ seats.reset_for_tests()
+
+ oid = "run-thousand"
+ plan = _curate_plan(oid)
+ app = _app()
+ app["runs"][oid] = {
+ "run_id": oid, "status": "running", "started_at": 0,
+ "provider": "openai", "model": "fake",
+ }
+
+ async def stop_after(n: int, current: asyncio.Task) -> None:
+ # Count productive fake_node completions on *this* execute task.
+ # Closing over the first task made the resume stopper exit immediately.
+ while not current.done():
+ if len(executed) >= n:
+ app["runs"][oid]["user_cancel"] = True
+ return
+ await asyncio.sleep(0)
+
+ def _assert_live_graph(ck, parent, *, min_results: int) -> None:
+ live_plan = ck.get("plan")
+ assert type(live_plan) is OrchestrationPlan, type(live_plan)
+ assert 1 <= len(live_plan.nodes) < 40, [n.node_id for n in live_plan.nodes]
+ parent_nodes = parent.get("plan_nodes")
+ assert type(parent_nodes) is list, type(parent_nodes)
+ assert len(parent_nodes) < 40, parent_nodes
+ ids = []
+ for row in parent_nodes:
+ assert type(row) is dict, row
+ nid = row.get("node_id")
+ assert type(nid) is str and nid, row
+ ids.append(nid)
+ assert len(ids) == len(set(ids)), ids
+ results = ck.get("results") or {}
+ assert type(results) is dict
+ assert len(results) >= min_results, len(results)
+
+ task = asyncio.create_task(orch.execute(
+ plan, app=app, workspace=tmp_path, thread_id="th",
+ parent_run_id=oid, default_provider=ProductiveFake(), default_model="fake",
+ ))
+ stopper = asyncio.create_task(stop_after(520, task))
+ result1 = await asyncio.wait_for(task, timeout=60)
+ await asyncio.wait_for(stopper, timeout=2)
+ assert stopper.done() and not stopper.cancelled()
+ assert result1.cancelled, result1.telemetry
+ assert app["runs"][oid].get("user_cancel") is True
+ mid = list(executed)
+ assert len(mid) >= 520, len(mid)
+ assert len(set(mid)) == len(mid), "node IDs reused before resume"
+ ck = orch.load_checkpoint(tmp_path, oid)
+ assert ck is not None
+ _assert_live_graph(ck, app["runs"][oid], min_results=520)
+ results_mid = dict(ck.get("results") or {})
+ mid_ids = set(mid)
+
+ app["runs"][oid]["user_cancel"] = False
+ app["runs"][oid]["status"] = "running"
+ task2 = asyncio.create_task(orch.execute(
+ plan, app=app, workspace=tmp_path, thread_id="th",
+ parent_run_id=oid, default_provider=ProductiveFake(), default_model="fake",
+ resume=True,
+ ))
+ stopper2 = asyncio.create_task(stop_after(1005, task2))
+ result2 = await asyncio.wait_for(task2, timeout=60)
+ await asyncio.wait_for(stopper2, timeout=2)
+ assert stopper2.done() and not stopper2.cancelled()
+ assert result2.cancelled, result2.telemetry
+ assert app["runs"][oid].get("user_cancel") is True
+
+ assert len(executed) >= 1005, len(executed)
+ assert len(set(executed)) == len(executed), "node IDs reused across resume"
+ assert len(set(run_ids)) == len(run_ids)
+ assert mid_ids <= set(executed)
+ ck2 = orch.load_checkpoint(tmp_path, oid)
+ assert ck2 is not None
+ _assert_live_graph(ck2, app["runs"][oid], min_results=1005)
+ live_plan = ck2.get("plan")
+ view = orch._plan_nodes_view(
+ live_plan,
+ set((ck2["status"].get("completed") or [])),
+ set((ck2["status"].get("failed") or [])),
+ set(),
+ )
+ assert len(view) < 40, view
+ results = ck2.get("results") or {}
+ for nid, rec in results_mid.items():
+ assert nid in results, nid
+ assert results[nid].get("wiki_commit_diff") == rec.get("wiki_commit_diff")
+ with_diff = [
+ r for r in results.values()
+ if type(r) is dict and r.get("wiki_commit_diff")
+ ]
+ assert len(with_diff) >= 1000, len(with_diff)
+ timeouts = [
+ r for r in results.values()
+ if type(r) is dict and "timed out" in str(r.get("error") or "")
+ ]
+ assert len(timeouts) == 2, timeouts
+
+
+@pytest.mark.asyncio
+async def test_noop_curate_waits_without_llm(tmp_path: Path, monkeypatch):
+ monkeypatch.setattr(
+ "switchbay.modestore.resolve_for_difficulty", lambda *a, **k: (None, None),
+ )
+ monkeypatch.setattr(
+ "switchbay.ce_host.wave_prime",
+ lambda *a, **k: {"ok": True, "mode": "repair", "reason": "planner stub"},
+ )
+ monkeypatch.setattr(
+ "switchbay.ce_tools._ce_planner",
+ lambda *a, **k: {"ok": True, "mode": "repair"},
+ )
+ calls = {"n": 0}
+
+ async def fake_node(node, **kwargs):
+ calls["n"] += 1
+ return {
+ "node_id": node.node_id,
+ "kind": node.kind,
+ "run_id": f"run-{node.node_id}",
+ "ok": True,
+ "output": "no wiki work this wave",
+ "wiki_pages_landed": 0,
+ "wiki_pages_changed": [],
+ "wiki_head_before": "same",
+ "wiki_head_after": "same",
+ "input_tokens": 4,
+ "output_tokens": 4,
+ }
+
+ monkeypatch.setattr(orch, "_run_agent_node", fake_node)
+ monkeypatch.setattr(orch, "_apply_work_receipt", lambda rec, *a, **k: rec)
+ seats.reset_for_tests()
+
+ oid = "run-idle"
+ plan = _curate_plan(oid)
+ app = _app()
+ app["runs"][oid] = {"run_id": oid, "status": "running", "started_at": 0}
+ task = asyncio.create_task(orch.execute(
+ plan, app=app, workspace=tmp_path, thread_id="th",
+ parent_run_id=oid, default_provider=ProductiveFake(), default_model="fake",
+ ))
+ for _ in range(80):
+ await asyncio.sleep(0.05)
+ if calls["n"] >= 2:
+ break
+ waves_while_idle = calls["n"]
+ assert 2 <= waves_while_idle <= 3, waves_while_idle
+ await asyncio.sleep(0.2)
+ assert calls["n"] == waves_while_idle, "idle wait still invoked the model"
+ fp_before = ce_host.work_availability_fingerprint(tmp_path)
+ vault = tmp_path / "vault" / "raw"
+ vault.mkdir(parents=True, exist_ok=True)
+ (vault / "new-source.md").write_text("fresh vault notes\n", encoding="utf-8")
+ fp_after = ce_host.work_availability_fingerprint(tmp_path)
+ assert fp_after != fp_before, "real source write must change wiki/vault fingerprint"
+ for _ in range(80):
+ await asyncio.sleep(0.05)
+ if calls["n"] > waves_while_idle:
+ break
+ assert calls["n"] > waves_while_idle, "new work did not wake idle curate"
+ app["runs"][oid]["user_cancel"] = True
+ result = await asyncio.wait_for(task, timeout=5)
+ assert result.cancelled, result.telemetry
+ ck = orch.load_checkpoint(tmp_path, oid)
+ assert ck is not None
+ loaded = ck.get("plan")
+ assert type(loaded) is OrchestrationPlan
+ lens = loaded.extra_system
+ assert type(lens) is str
+ assert "Role lens: keep this." in lens, lens
+ dec = loaded.decision if type(loaded.decision) is dict else {}
+ assert dec.get("_extra_system_base") == "Role lens: keep this.", dec
+
+
+@pytest.mark.asyncio
+async def test_noop_curate_deadline_stops_idle_wait(tmp_path: Path, monkeypatch):
+ monkeypatch.setattr(
+ "switchbay.modestore.resolve_for_difficulty", lambda *a, **k: (None, None),
+ )
+ monkeypatch.setattr(
+ "switchbay.ce_host.wave_prime",
+ lambda *a, **k: {"ok": True, "mode": "repair", "reason": "planner stub"},
+ )
+ monkeypatch.setattr(
+ "switchbay.ce_tools._ce_planner",
+ lambda *a, **k: {"ok": True, "mode": "repair"},
+ )
+ calls = {"n": 0}
+
+ async def fake_node(node, **kwargs):
+ calls["n"] += 1
+ return {
+ "node_id": node.node_id, "kind": node.kind, "ok": True,
+ "output": "noop", "wiki_pages_landed": 0, "wiki_pages_changed": [],
+ "run_id": node.node_id, "input_tokens": 1, "output_tokens": 1,
+ }
+
+ monkeypatch.setattr(orch, "_run_agent_node", fake_node)
+ monkeypatch.setattr(orch, "_apply_work_receipt", lambda rec, *a, **k: rec)
+ seats.reset_for_tests()
+ oid = "run-idle-deadline"
+ plan = _curate_plan(oid, until=time.time() + 0.4)
+ app = _app()
+ app["runs"][oid] = {"run_id": oid, "status": "running", "started_at": 0}
+ result = await asyncio.wait_for(orch.execute(
+ plan, app=app, workspace=tmp_path, thread_id="th",
+ parent_run_id=oid, default_provider=ProductiveFake(), default_model="fake",
+ ), timeout=5)
+ assert result.telemetry.get("stop_reason") == "curate window ended"
+ assert calls["n"] >= 1
+ assert calls["n"] <= 4, calls["n"]
+
+
+@pytest.mark.asyncio
+async def test_nested_dispatch_parks_parent_seat(tmp_path: Path, monkeypatch):
+ """Native ToolUseChunk ce_dispatch_worker at cap=4; production park."""
+ monkeypatch.setattr(
+ "switchbay.modestore.resolve_for_difficulty", lambda *a, **k: (None, None),
+ )
+ monkeypatch.setattr(
+ "switchbay.ce_host.dispatch_worker",
+ lambda *a, **k: {"ok": True, "role": "deepener", "prompt": "Inspect fixture source"},
+ )
+ monkeypatch.setattr(
+ "switchbay.agents.ce_workers.is_local_pid", lambda *a: False,
+ )
+ monkeypatch.setattr(
+ "switchbay.agents.ce_workers.policy.allocate_unused",
+ lambda *a, **k: [("openai", "fake")],
+ )
+ app_settings.set_desk_max_live_workers(4)
+ seats.reset_for_tests()
+ domain = seats.desk_domain_id(tmp_path, "curate")
+ g = seats.gate_for(domain, workspace=tmp_path)
+ assert g.cap == 4
+ await g.acquire("dummy-a", kind="worker")
+ await g.acquire("dummy-b", kind="worker")
+
+ class NestedProv:
+ ID = "openai"
+ DEFAULT_MODEL = "fake"
+ PROVIDER = {"id": "openai", "default_model": "fake"}
+
+ def has_key(self) -> bool:
+ return True
+
+ async def chat_stream(self, req):
+ yield base.TextChunk(
+ text='{"findings":[{"claim":"nested source-backed finding","confidence":0.9}]}',
+ )
+ yield base.DoneChunk(stop_reason="end_turn", input_tokens=2, output_tokens=8)
+
+ class CuratorProv:
+ ID = "openai"
+ DEFAULT_MODEL = "fake"
+ PROVIDER = {"id": "openai", "default_model": "fake"}
+
+ def has_key(self) -> bool:
+ return True
+
+ async def chat_stream(self, req):
+ blob = str(req.messages or "")
+ if "nested source-backed finding" in blob or "tool_result" in blob:
+ yield base.TextChunk(text="Curator received nested finding.\nOBJECTIVE_MET: yes")
+ yield base.DoneChunk(stop_reason="end_turn", input_tokens=3, output_tokens=4)
+ return
+ yield base.ToolUseChunk(
+ id="d1", name="ce_dispatch_worker",
+ input={"role": "deepener", "brief": "inspect fixture"},
+ )
+ yield base.DoneChunk(stop_reason="tool_use", input_tokens=1, output_tokens=1)
+
+ monkeypatch.setattr("switchbay.llmgateway.get", lambda *_a, **_k: NestedProv())
+ oid = "run-n"
+ plan = _curate_plan(oid, repeat=False)
+ plan.nodes[0].tools = ["ce_dispatch_worker"]
+ app = _app()
+ app["runs"][oid] = {
+ "run_id": oid, "status": "running", "started_at": time.time(),
+ "provider": "openai", "model": "fake",
+ }
+ result = await asyncio.wait_for(orch.execute(
+ plan, app=app, workspace=tmp_path, thread_id="th",
+ parent_run_id=oid, default_provider=CuratorProv(), default_model="fake",
+ ), timeout=5)
+ assert result.ok, result
+ parent = app["runs"][oid]
+ claims = [str(r.get("claim") or "") for r in (parent.get("blackboard_rows") or [])]
+ assert "nested source-backed finding" in " ".join(claims), (claims, parent)
+ assert "Curator received nested finding" in (result.output or ""), result.output
+ assert g.cap == 4
+ assert g.live() == 2, g.snapshot()
+ slots = g.snapshot()["slots"]
+ assert "dummy-a" in slots and "dummy-b" in slots
+ assert all(not str(s).startswith(f"{oid}:") for s in slots), slots
+ await g.release_async("dummy-a")
+ await g.release_async("dummy-b")
+
+
+@pytest.mark.asyncio
+async def test_nested_dispatch_cancel_releases_parked_parent(tmp_path: Path, monkeypatch):
+ monkeypatch.setattr(
+ "switchbay.modestore.resolve_for_difficulty", lambda *a, **k: (None, None),
+ )
+ monkeypatch.setattr(
+ "switchbay.ce_host.dispatch_worker",
+ lambda *a, **k: {"ok": True, "role": "deepener", "prompt": "Inspect fixture source"},
+ )
+ monkeypatch.setattr(
+ "switchbay.agents.ce_workers.is_local_pid", lambda *a: False,
+ )
+ monkeypatch.setattr(
+ "switchbay.agents.ce_workers.policy.allocate_unused",
+ lambda *a, **k: [("openai", "fake")],
+ )
+ app_settings.set_desk_max_live_workers(4)
+ seats.reset_for_tests()
+ domain = seats.desk_domain_id(tmp_path, "curate")
+ g = seats.gate_for(domain, workspace=tmp_path)
+ await g.acquire("dummy-a", kind="worker")
+ await g.acquire("dummy-b", kind="worker")
+ started = asyncio.Event()
+ release = asyncio.Event()
+
+ class NestedProv:
+ ID = "openai"
+ DEFAULT_MODEL = "fake"
+
+ def has_key(self) -> bool:
+ return True
+
+ async def chat_stream(self, req):
+ started.set()
+ await release.wait()
+ yield base.TextChunk(text="late nested")
+ yield base.DoneChunk(stop_reason="end_turn")
+
+ class CuratorProv:
+ ID = "openai"
+ DEFAULT_MODEL = "fake"
+
+ def has_key(self) -> bool:
+ return True
+
+ async def chat_stream(self, req):
+ yield base.ToolUseChunk(
+ id="d1", name="ce_dispatch_worker",
+ input={"role": "deepener", "brief": "inspect fixture"},
+ )
+ yield base.DoneChunk(stop_reason="tool_use")
+
+ monkeypatch.setattr("switchbay.llmgateway.get", lambda *_a, **_k: NestedProv())
+ oid = "run-n-cancel"
+ plan = _curate_plan(oid, repeat=False)
+ plan.nodes[0].tools = ["ce_dispatch_worker"]
+ app = _app()
+ app["runs"][oid] = {"run_id": oid, "status": "running", "started_at": time.time()}
+ task = asyncio.create_task(orch.execute(
+ plan, app=app, workspace=tmp_path, thread_id="th",
+ parent_run_id=oid, default_provider=CuratorProv(), default_model="fake",
+ ))
+ await asyncio.wait_for(started.wait(), timeout=2)
+ task.cancel()
+ try:
+ await asyncio.wait_for(task, timeout=5)
+ except asyncio.CancelledError:
+ pass
+ release.set()
+ await asyncio.sleep(0.05)
+ slots = g.snapshot()["slots"]
+ assert "dummy-a" in slots and "dummy-b" in slots
+ assert all(not str(s).startswith(f"{oid}:") for s in slots), slots
+ await g.release_async("dummy-a")
+ await g.release_async("dummy-b")
diff --git a/tests/unit/test_desk_admission.py b/tests/unit/test_desk_admission.py
new file mode 100644
index 0000000..e175305
--- /dev/null
+++ b/tests/unit/test_desk_admission.py
@@ -0,0 +1,228 @@
+"""Per-desk live seats: floor, admin ceiling, chief counted, nested."""
+
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+
+from switchbay import admin_policy, app_settings
+from switchbay.agents import desk_admission as seats
+from switchbay.agents import orchestration as orch
+from switchbay.agents import orchestration_policy as policy
+from switchbay.agents.orchestration import PlanNode, OrchestrationPlan
+
+
+def test_cap_floor_and_admin_tightening(tmp_path, monkeypatch):
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
+ monkeypatch.setenv("SWITCHBAY_STATE_DIR", str(tmp_path / "st"))
+ monkeypatch.setenv("SWITCHBAY_PROFILE", "open")
+ admin_policy.reset_cache()
+ seats.reset_for_tests()
+ app_settings.set_desk_max_live_workers(2)
+ assert app_settings.get_desk_max_live_workers() >= 4
+ assert seats.effective_live_cap() >= 4
+ app_settings.set_desk_max_live_workers(8)
+ policy = tmp_path / "admin.json"
+ policy.write_text('{"orchestration": {"max_live_workers": 5}}', encoding="utf-8")
+ monkeypatch.setenv("SWITCHBAY_ADMIN_POLICY", str(policy))
+ admin_policy.reset_cache()
+ assert seats.effective_live_cap() == 5
+ app_settings.set_desk_max_live_workers(8)
+ assert seats.effective_live_cap() == 5 # cannot raise above admin
+
+
+def test_baked_tightens_not_raises(tmp_path, monkeypatch):
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
+ monkeypatch.setenv("SWITCHBAY_INSTALL_ROOT", str(tmp_path / "install"))
+ inst = tmp_path / "install"
+ inst.mkdir()
+ (inst / "admin.baked.json").write_text(
+ '{"profile":"enterprise","orchestration":{"max_live_workers": 4}}',
+ encoding="utf-8",
+ )
+ overlay = tmp_path / "admin.json"
+ overlay.write_text('{"orchestration": {"max_live_workers": 8}}', encoding="utf-8")
+ monkeypatch.setenv("SWITCHBAY_ADMIN_POLICY", str(overlay))
+ admin_policy.reset_cache()
+ seats.reset_for_tests()
+ cap = seats.effective_live_cap()
+ assert cap == 4
+
+
+def test_overlay_zero_does_not_loosen_baked_floor(tmp_path, monkeypatch):
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
+ monkeypatch.setenv("SWITCHBAY_INSTALL_ROOT", str(tmp_path / "install"))
+ inst = tmp_path / "install"
+ inst.mkdir()
+ (inst / "admin.baked.json").write_text(
+ '{"profile":"enterprise","orchestration":{"max_live_workers": 4}}',
+ encoding="utf-8",
+ )
+ overlay = tmp_path / "admin.json"
+ overlay.write_text('{"orchestration": {"max_live_workers": 0}}', encoding="utf-8")
+ monkeypatch.setenv("SWITCHBAY_ADMIN_POLICY", str(overlay))
+ admin_policy.reset_cache()
+ seats.reset_for_tests()
+ app_settings.set_desk_max_live_workers(8)
+ assert admin_policy.max_live_workers_ceiling() == 4
+ assert seats.effective_live_cap() == 4
+
+
+def test_chief_counted_and_nested_share_desk():
+ seats.reset_for_tests()
+ g = seats.gate_for("desk-a", cap=4)
+ assert g.try_acquire("chief", kind="chief")
+ assert g.try_acquire("curator", kind="worker")
+ assert g.try_acquire("nested-1", kind="nested")
+ assert g.try_acquire("nested-2", kind="nested")
+ assert not g.try_acquire("nested-3", kind="nested")
+ assert g.live() == 4
+ g.release("nested-1")
+ assert g.try_acquire("nested-3", kind="nested")
+
+
+def test_multi_desk_isolation():
+ seats.reset_for_tests()
+ a = seats.gate_for("desk-a", cap=4)
+ b = seats.gate_for("desk-b", cap=4)
+ assert a.try_acquire("chief", kind="chief")
+ assert b.try_acquire("chief", kind="chief")
+ assert a.live() == 1 and b.live() == 1
+
+
+def test_continuous_progress_after_compaction():
+ nodes = [PlanNode(node_id=f"c-{i}", kind="synthesize", role="curator", objective="w") for i in range(40)]
+ plan = OrchestrationPlan(
+ orchestration_id="c1", strategy="ce_curate", objective="overnight",
+ nodes=list(nodes),
+ )
+ completed = {n.node_id for n in nodes[:-1]}
+ running = {nodes[-1].node_id}
+ dropped = orch.compact_plan_nodes(plan, completed, set(), running)
+ assert dropped >= 1
+ assert len(plan.nodes) < 40
+ live = orch.live_plan_nodes(plan, completed, set(), running)
+ assert any(n.node_id == nodes[-1].node_id for n in live)
+
+
+@pytest.mark.asyncio
+async def test_set_cap_increase_wakes_waiter():
+ seats.reset_for_tests()
+ g = seats.DeskGate("wake-cap", cap=4)
+ for name in ("chief", "a", "b", "c"):
+ await g.acquire(name)
+ waiter = asyncio.create_task(g.acquire("queued"))
+ await asyncio.sleep(0)
+ assert not waiter.done()
+ g.set_cap(5)
+ await asyncio.wait_for(waiter, timeout=0.5)
+ assert g.live() == 5
+
+
+@pytest.mark.asyncio
+async def test_set_cap_decrease_admits_no_extras():
+ seats.reset_for_tests()
+ g = seats.DeskGate("drain-cap", cap=6)
+ for name in ("chief", "a", "b", "c", "d", "e"):
+ await g.acquire(name)
+ g.set_cap(4)
+ assert g.live() == 6
+ waiter = asyncio.create_task(g.acquire("extra"))
+ await asyncio.sleep(0.05)
+ assert not waiter.done()
+ await g.release_async("e")
+ await g.release_async("d")
+ await asyncio.sleep(0.05)
+ assert not waiter.done()
+ await g.release_async("c")
+ await asyncio.wait_for(waiter, timeout=0.5)
+ assert g.live() == 4
+ waiter.cancel()
+ try:
+ await waiter
+ except asyncio.CancelledError:
+ pass
+
+
+@pytest.mark.asyncio
+async def test_refresh_cap_reads_settings(tmp_path, monkeypatch):
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
+ monkeypatch.setenv("SWITCHBAY_STATE_DIR", str(tmp_path / "st"))
+ admin_policy.reset_cache()
+ seats.reset_for_tests()
+ app_settings.set_desk_max_live_workers(4)
+ g = seats.gate_for(seats.desk_domain_id(tmp_path, "curate"), workspace=tmp_path)
+ assert g.cap == 4
+ app_settings.set_desk_max_live_workers(8)
+ g.refresh_cap(tmp_path)
+ assert g.cap == 8
+
+
+@pytest.mark.asyncio
+async def test_park_parent_unblocks_nested():
+ seats.reset_for_tests()
+ g = seats.DeskGate("nest-deadlock", cap=4)
+ await g.acquire("run:chief", kind="chief")
+ await g.acquire("run:parent-a", kind="worker")
+ await g.acquire("run:parent-b", kind="worker")
+ await g.acquire("run:parent-c", kind="worker")
+ assert g.live() == 4
+ nested = asyncio.create_task(g.acquire("run:nested", kind="nested"))
+ await asyncio.sleep(0)
+ assert not nested.done()
+ parked = await g.park("run:parent-a")
+ assert parked
+ assert g.live() == 3
+ await asyncio.wait_for(nested, timeout=0.5)
+ assert g.live() == 4
+ await g.release_async("run:nested")
+ restored = await g.unpark("run:parent-a")
+ assert restored
+ assert "run:parent-a" in g.snapshot()["slots"]
+
+
+def test_overlapping_runs_share_workspace_desk_domain(tmp_path):
+ seats.reset_for_tests()
+ a = seats.desk_domain_id(tmp_path, "curate")
+ b = seats.desk_domain_id(tmp_path, "curate")
+ c = seats.desk_domain_id(tmp_path, "auto")
+ assert a == b
+ assert a != c
+ g1 = seats.gate_for(a, cap=4, workspace=tmp_path)
+ g1.retain()
+ g2 = seats.gate_for(b, cap=4, workspace=tmp_path)
+ g2.retain()
+ assert g1 is g2
+ g1.drop_ref()
+ seats.drop_gate(a)
+ assert seats.gate_for(a) is g1
+ g2.drop_ref()
+ seats.drop_gate(a)
+
+
+def test_continue_and_expand_ids_survive_compaction():
+ plan = OrchestrationPlan(
+ orchestration_id="ids", strategy="investigate_verify_synthesize",
+ objective="x", nodes=[PlanNode(node_id="inv-0", kind="investigate", objective="a")],
+ allow_expand=True, decision={"independence": "high"},
+ )
+ seen: set[str] = {"inv-0"}
+ for i in range(40):
+ added = orch._add_expansion_nodes(
+ plan,
+ policy.ExpansionDecision(True, 1, "gap", ["more"]),
+ default_provider="openai", default_model="fake",
+ )
+ for n in added:
+ assert n.node_id not in seen, n.node_id
+ seen.add(n.node_id)
+ orch.compact_plan_nodes(plan, set(seen) - {added[-1].node_id}, set(), {added[-1].node_id})
+ cont = orch._add_continue_wave(
+ plan,
+ policy.ExpansionDecision(True, 1, "gap", ["again"]),
+ default_provider="openai", default_model="fake",
+ )
+ for n in cont:
+ assert n.node_id not in seen
+ seen.add(n.node_id)
diff --git a/tests/unit/test_icloud_download.py b/tests/unit/test_icloud_download.py
new file mode 100644
index 0000000..2d5e58c
--- /dev/null
+++ b/tests/unit/test_icloud_download.py
@@ -0,0 +1,89 @@
+"""JXA iCloud helper: local fixture, argv isolation, bounded timeout."""
+
+from __future__ import annotations
+
+import time
+from pathlib import Path
+
+import pytest
+
+from switchbay import icloud_download, statedir
+
+
+def test_helper_exists_and_argv_is_literal():
+ assert icloud_download.HELPER.is_file()
+ path = Path("/tmp/Switch Bay test; rm -rf /secret.pptx")
+ argv = icloud_download.helper_argv("probe", path)
+ assert argv[:3] == ["/usr/bin/osascript", "-l", "JavaScript"]
+ assert argv[3] == str(icloud_download.HELPER)
+ assert argv[4] == "probe"
+ assert argv[5] == str(path)
+ assert "rm -rf" not in " ".join(argv[:5])
+
+
+@pytest.mark.skipif(not icloud_download.supported(), reason="no osascript/JXA on this host")
+def test_helper_probes_harmless_local_fixture(tmp_path: Path):
+ fixture = tmp_path / "local-fixture.txt"
+ fixture.write_text("not icloud", encoding="utf-8")
+ out = icloud_download.run_helper("probe", fixture, timeout=10.0)
+ assert out.get("ok") is True, out
+ assert out.get("ubiquitous") in (False, None, 0)
+ # start on a non-ubiquitous file must not hang and must not crash.
+ start = icloud_download.run_helper("start", fixture, timeout=10.0)
+ assert "started" in start or "ok" in start
+ ready = icloud_download.hydrate_file(fixture, timeout=1.0)
+ assert ready.ready is True
+ assert Path(ready.path) == fixture.resolve() or Path(ready.path) == fixture
+
+
+@pytest.mark.skipif(not icloud_download.supported(), reason="no osascript/JXA on this host")
+def test_helper_rejects_unknown_action_without_path_interpolation(tmp_path: Path):
+ fixture = tmp_path / "x.txt"
+ fixture.write_text("x", encoding="utf-8")
+ out = icloud_download.run_helper("not-an-action", fixture, timeout=5.0)
+ assert out.get("ok") is False
+ assert "unknown action" in str(out.get("error") or "")
+
+
+def test_hydrate_timeout_is_bounded_when_dataless(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
+):
+ target = tmp_path / "cloud.pptx"
+ target.write_bytes(b"x")
+ monkeypatch.setattr(statedir, "is_dataless", lambda p: Path(p) == target)
+ monkeypatch.setattr(
+ icloud_download, "run_helper",
+ lambda *_a, **_k: {"ok": True, "ubiquitous": True, "started": True},
+ )
+ monkeypatch.setattr(icloud_download, "supported", lambda: True)
+ t0 = time.monotonic()
+ result = icloud_download.hydrate_file(target, timeout=0.3)
+ elapsed = time.monotonic() - t0
+ assert elapsed < 2.0
+ assert result.ready is False
+ assert result.retryable is True
+
+
+def test_logical_path_strips_legacy_stub():
+ stub = Path("/tmp/.Quarterly.pptx.icloud")
+ assert icloud_download.is_icloud_stub(stub)
+ assert icloud_download.logical_path(stub) == Path("/tmp/Quarterly.pptx")
+ plain = Path("/tmp/Quarterly.pptx")
+ assert not icloud_download.is_icloud_stub(plain)
+ assert icloud_download.logical_path(plain) == plain
+
+
+def test_non_icloud_cloud_hint_does_not_claim_support(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
+):
+ target = tmp_path / "OneDrive" / "doc.pptx"
+ target.parent.mkdir()
+ target.write_bytes(b"x")
+ monkeypatch.setattr(statedir, "is_dataless", lambda p: Path(p) == target)
+ monkeypatch.setattr(
+ statedir, "sync_service_hint", lambda _p: "OneDrive",
+ )
+ result = icloud_download.hydrate_file(target, timeout=0.2)
+ assert result.ready is False
+ assert result.retryable is True
+ assert "iCloud-only" in (result.error or "")
diff --git a/tests/unit/test_ingest_prep.py b/tests/unit/test_ingest_prep.py
index 9373b6a..aac1300 100644
--- a/tests/unit/test_ingest_prep.py
+++ b/tests/unit/test_ingest_prep.py
@@ -203,10 +203,12 @@ def test_ce_ingest_passes_directory_or_file_to_bridge(tmp_path: Path, monkeypatc
src.write_text("Abstract Hello lab.
")
seen: dict = {}
- def fake_run(script, args=None, *, cwd, timeout=120.0, require_json=True):
+ def fake_run(script, args=None, *, cwd, timeout=120.0, require_json=True, python=None):
seen["script"] = script
seen["args"] = list(args or [])
seen["cwd"] = Path(cwd)
+ seen["timeout"] = timeout
+ seen["python"] = python
return {"ok": True, "results": []}
monkeypatch.setattr(ce_tools.cebridge, "run_script", fake_run)
diff --git a/tests/unit/test_kernel_desk.py b/tests/unit/test_kernel_desk.py
index f0ad038..89238ef 100644
--- a/tests/unit/test_kernel_desk.py
+++ b/tests/unit/test_kernel_desk.py
@@ -286,3 +286,153 @@ def test_seat_persists_each_desk_objective(tmp_path: Path):
assert code is not None and deck is not None
assert code.objective == "implement the patch"
assert deck.objective == "make a slideshow"
+
+
+def test_curate_constrained_is_local_only():
+ from switchbay.daemon import _curate_constrained
+ assert _curate_constrained(local=True) is True
+ assert _curate_constrained(local=True, text="tables") is True
+ assert _curate_constrained(local=True, text="for 10 mins") is False
+ assert _curate_constrained(local=True, text="overnight") is False
+ assert _curate_constrained(local=False) is False
+ assert _curate_constrained(local=False, text="for 10 mins") is False
+
+
+def test_schedule_desk_launch_parses_desk_slashes():
+ from switchbay.daemon import _parse_schedule_prompt, _schedule_desk_launch
+ spec, args = _schedule_desk_launch("/curate for 10 mins")
+ assert spec is not None and spec["desk_id"] == "curate"
+ assert args == "for 10 mins"
+ spec, args = _schedule_desk_launch("/work")
+ assert spec is not None and spec["desk_id"] == "projects"
+ spec, args = _schedule_desk_launch("/code implement the parser")
+ assert spec is not None and spec["desk_id"] == "code"
+ assert args == "implement the parser"
+ spec, _ = _schedule_desk_launch("what do we know about attention")
+ assert spec is None
+ spec, _ = _schedule_desk_launch("/curate stop")
+ assert spec is None
+ spec, _ = _schedule_desk_launch("make a slideshow about Bahdanau")
+ assert spec is None
+ spec, _ = _schedule_desk_launch("/deck make slides")
+ assert spec is None
+ kind, spec, args = _parse_schedule_prompt("/curate stop")
+ assert kind == "skip" and spec is not None
+ kind, spec, args = _parse_schedule_prompt("/work dismiss")
+ assert kind == "skip"
+
+
+def test_seat_desk_now_stands_curate_before_dispatch(tmp_path: Path):
+ from switchbay.daemon import _desk_spec, _seat_desk_now
+ spec = _desk_spec("curate")
+ assert spec is not None
+ assert spec["staff"] != "/curate"
+ _seat_desk_now(
+ tmp_path, spec, "for 10 mins",
+ provider="grok-build", model="grok-4.6",
+ )
+ rec = get(tmp_path, DESK_CURATE)
+ assert rec is not None
+ assert rec.state == STATE_WORKING
+ assert rec.objective == "for 10 mins"
+ assert rec.chief_provider == "grok-build"
+ standing = {r.desk_id for r in list_standing(tmp_path)}
+ assert DESK_CURATE in standing
+
+
+def test_seat_desk_now_stands_work_and_code(tmp_path: Path):
+ from switchbay.daemon import _desk_spec, _seat_desk_now
+ work, code = _desk_spec("work"), _desk_spec("code")
+ assert work is not None and code is not None
+ _seat_desk_now(
+ tmp_path, work, "staff the plan",
+ provider="grok-build", model="grok-4.6",
+ )
+ _seat_desk_now(
+ tmp_path, code, "implement the parser",
+ provider="grok-build", model="grok-4.6",
+ )
+ standing = {r.desk_id for r in list_standing(tmp_path)}
+ assert DESK_PROJECTS in standing
+ assert DESK_CODE in standing
+
+
+def test_ws_response_disables_permessage_deflate():
+ from switchbay.daemon import _ws_response
+ ws = _ws_response()
+ assert ws._compress in (False, 0)
+
+
+def test_dispatch_error_surface_broadcasts_when_headless():
+ import asyncio
+ from switchbay.daemon import _make_dispatch_error_surface
+
+ seen: list = []
+
+ class _Task:
+ def exception(self):
+ return RuntimeError("boom")
+
+ async def _run():
+ app = {"ws_clients": set()}
+
+ async def fake_broadcast(_app, msg):
+ seen.append(msg)
+
+ import switchbay.daemon as d
+ orig = d._broadcast
+ d._broadcast = fake_broadcast # type: ignore[method-assign]
+ try:
+ cb = _make_dispatch_error_surface(app, None)
+ cb(_Task()) # type: ignore[arg-type]
+ await asyncio.sleep(0)
+ finally:
+ d._broadcast = orig
+
+ asyncio.run(_run())
+ assert seen
+ blob = str(seen[0])
+ assert "boom" in blob
+
+
+def test_ce_action_prompt_duration_is_focus_not_mode():
+ from switchbay.daemon import _ce_action_prompt
+ p = _ce_action_prompt("curate", "for 10 mins", local=False) or ""
+ assert "Focus: for 10 mins" in p
+ assert "Mode:" not in p
+
+
+def test_seat_desk_now_keeps_live_run_id(tmp_path: Path):
+ from switchbay.daemon import _desk_spec, _seat_desk_now
+ spec = _desk_spec("curate")
+ assert spec is not None
+ seat(
+ tmp_path, DESK_CURATE,
+ chief_provider="grok-build", chief_model="grok-4.6",
+ run_id="run-live", objective="old",
+ )
+ _seat_desk_now(
+ tmp_path, spec, "for 10 mins",
+ provider="grok-build", model="grok-4.6",
+ )
+ rec = get(tmp_path, DESK_CURATE)
+ assert rec is not None
+ assert rec.state == STATE_WORKING
+ assert rec.run_id == "run-live"
+ assert rec.objective == "for 10 mins"
+
+
+def test_curate_wave_prime_skips_duration_token(tmp_path: Path, monkeypatch):
+ from switchbay.daemon import _curate_wave_prime_system
+ seen: dict = {}
+
+ def fake_prime(_ws, payload=None):
+ seen["payload"] = payload
+ return {"ok": True, "mode": "repair"}
+
+ monkeypatch.setattr("switchbay.ce_host.wave_prime", fake_prime)
+ _curate_wave_prime_system(tmp_path, "for 10 mins", local=False)
+ assert seen["payload"] == {}
+ _curate_wave_prime_system(tmp_path, "tables", local=False)
+ assert seen["payload"] == {"mode": "tables"}
+ assert _curate_wave_prime_system(tmp_path, "tables", local=True) == ""
diff --git a/tests/unit/test_kernel_harness.py b/tests/unit/test_kernel_harness.py
index b0d69eb..f723bbd 100644
--- a/tests/unit/test_kernel_harness.py
+++ b/tests/unit/test_kernel_harness.py
@@ -297,8 +297,9 @@ def test_spawn_env_path_includes_homebrew(tmp_path: Path, monkeypatch):
brew.mkdir(parents=True)
env = {"PATH": os.pathsep.join(["/usr/bin", "/bin"])}
enrich_path(env, extra_dirs=(str(brew),))
- assert str(brew) in env["PATH"].split(os.pathsep)
- assert env["PATH"].split(os.pathsep)[0] == str(brew)
+ parts = env["PATH"].split(os.pathsep)
+ assert str(brew) in parts
+ assert parts[0] == "/usr/bin"
script = tmp_path / "pi"
script.write_text("#!/usr/bin/env node\nconsole.log(1)\n", encoding="utf-8")
assert shebang_wants_node(str(script)) is True
diff --git a/tests/unit/test_orchestration.py b/tests/unit/test_orchestration.py
index 8bf99fd..c7e3f06 100644
--- a/tests/unit/test_orchestration.py
+++ b/tests/unit/test_orchestration.py
@@ -1013,10 +1013,9 @@ async def chat_stream(self, req):
)
assert result.ok
parent = app["runs"]["run-h"]
- nodes = parent.get("plan_nodes") or []
- assert any(n.get("node_id") == "inv-0" for n in nodes)
- assert any(n.get("kind") == "verify" for n in nodes)
- assert "2 investigators" in (parent.get("decision_reason") or "")
+ # Completed single-use workers leave the live parent DAG; the
+ # standing org (and handoffs) keep the roster.
+ assert "2 investigators" in (parent.get("decision_reason") or "") or parent.get("arm_reason") == "bench"
assert parent.get("arm_reason") == "bench"
msgs = parent.get("orchestration_messages") or []
kinds = {m.get("kind") for m in msgs}
@@ -1799,8 +1798,9 @@ async def chat_stream(self, req):
)
assert result.ok
assert prov.synths >= 2
- synths = [n for n in result.plan.nodes if n.kind == "synthesize"]
- assert len(synths) >= 2
+ assert int(result.telemetry.get("continuations") or 0) >= 1
+ synth_results = [r for r in result.results if r.get("kind") == "synthesize"]
+ assert len(synth_results) >= 2
assert "Final." in (result.output or "")
assert "OBJECTIVE_MET" not in (result.output or "")
diff --git a/tests/unit/test_permission_scoping.py b/tests/unit/test_permission_scoping.py
index 6f5543f..ae787fa 100644
--- a/tests/unit/test_permission_scoping.py
+++ b/tests/unit/test_permission_scoping.py
@@ -123,6 +123,14 @@ def test_hook_grok_daemon_unreachable_fails_closed(tmp_path):
assert out.get("decision") == "deny"
+def test_hook_protected_unreachable_denies(tmp_path):
+ out = _run_hook(tmp_path, {
+ "tool_name": "WebSearch", "tool_input": {"query": "q"},
+ "session_id": "s1", "cwd": str(tmp_path),
+ }, port=1)
+ assert out.get("decision") == "deny"
+
+
def test_hook_empty_tool_is_passthrough(tmp_path):
# claude dialect, no tool name → {} (nothing to adjudicate).
out = _run_hook(tmp_path, {"tool_name": "", "tool_input": {}}, port=1)
diff --git a/tests/unit/test_pptx_ingest.py b/tests/unit/test_pptx_ingest.py
new file mode 100644
index 0000000..42680d1
--- /dev/null
+++ b/tests/unit/test_pptx_ingest.py
@@ -0,0 +1,344 @@
+"""Real PPTX extraction through Switch Bay ce_ingest → CE local_ingest."""
+
+from __future__ import annotations
+
+import os
+import sqlite3
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+from pptx import Presentation
+from pptx.util import Inches
+
+from switchbay import ce_tools, cebridge, tools, watchfolders
+
+PPTX_TITLE = "SBX-PPTX-Title-9f3c2a17"
+PPTX_BULLET_A = "SBX-PPTX-Bullet-ALPHA-9f3c2a17"
+PPTX_BULLET_B = "SBX-PPTX-Bullet-BETA-9f3c2a17"
+PPTX_CELL = "ZX9-TABLE-CELL-9f3c2a17"
+PPTX_METRIC = "UniqueKey-9f3c2a17"
+
+
+def _ce_ingest_script() -> Path:
+ return cebridge.ce_root() / "scripts" / "local_ingest.py"
+
+
+def _require_ce() -> Path:
+ script = _ce_ingest_script()
+ if not script.is_file():
+ pytest.skip("optional curiosity-engine installation absent")
+ return script
+
+
+def _write_pptx(path: Path) -> None:
+ prs = Presentation()
+ layout = prs.slide_layouts[1] # title + body
+ slide = prs.slides.add_slide(layout)
+ slide.shapes.title.text = PPTX_TITLE
+ body = slide.placeholders[1].text_frame
+ body.text = PPTX_BULLET_A
+ p = body.add_paragraph()
+ p.text = PPTX_BULLET_B
+ p.level = 0
+
+ blank = prs.slide_layouts[6] if len(prs.slide_layouts) > 6 else prs.slide_layouts[5]
+ table_slide = prs.slides.add_slide(blank)
+ rows, cols = 3, 2
+ table = table_slide.shapes.add_table(
+ rows, cols, Inches(0.5), Inches(1.0), Inches(8.0), Inches(2.0),
+ ).table
+ table.cell(0, 0).text = "Metric"
+ table.cell(0, 1).text = "Value"
+ table.cell(1, 0).text = PPTX_METRIC
+ table.cell(1, 1).text = PPTX_CELL
+ table.cell(2, 0).text = "Count"
+ table.cell(2, 1).text = "42"
+ path.parent.mkdir(parents=True, exist_ok=True)
+ prs.save(str(path))
+
+
+def _workspace(tmp_path: Path) -> Path:
+ ws = tmp_path / "ws"
+ (ws / "vault" / "raw").mkdir(parents=True)
+ (ws / "wiki").mkdir()
+ return ws
+
+
+def _extracted_texts(ws: Path) -> list[str]:
+ return [
+ p.read_text(encoding="utf-8", errors="replace")
+ for p in sorted((ws / "vault").rglob("*.extracted.md"))
+ ]
+
+
+def test_host_python_has_pptx_and_workspace_venv_can_lack_it(tmp_path: Path):
+ assert cebridge.host_has_module("pptx")
+ info = cebridge.host_extractor_info()
+ assert info["executable"] == sys.executable
+ assert info["modules"]["pptx"]
+ ws = _workspace(tmp_path)
+ subprocess.run(
+ [sys.executable, "-m", "venv", "--without-pip", str(ws / ".venv")],
+ check=True,
+ )
+ py = ws / ".venv" / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
+ ingest_py = cebridge.script_python_for(ws, "local_ingest.py", ext=".pptx")
+ graph_py = cebridge.script_python_for(ws, "graph.py")
+ scan_py = cebridge.script_python_for(ws, "scan.py")
+ xlsx_py = cebridge.python_for_ingest(ws, ".xlsx")
+ assert ingest_py == [sys.executable]
+ assert graph_py == [str(py)]
+ assert scan_py == [str(py)]
+ assert xlsx_py == [str(py)]
+ assert ingest_py != graph_py
+
+
+def test_real_ce_pptx_ingest_extracts_title_bullets_table(tmp_path: Path):
+ _require_ce()
+ ws = _workspace(tmp_path)
+ src = ws / "vault" / "raw" / "sbx-pptx-9f3c2a17.pptx"
+ _write_pptx(src)
+ interp = cebridge.script_python_for(ws, "local_ingest.py", ext=".pptx")
+ assert interp == [sys.executable]
+ out = tools.REGISTRY["ce_ingest"].handler(
+ ws, {"path": "vault/raw/sbx-pptx-9f3c2a17.pptx"},
+ )
+ assert ce_tools.ingest_is_success(out), out
+ texts = _extracted_texts(ws)
+ assert texts, f"no extracted.md written: {out}"
+ body = "\n".join(texts)
+ assert PPTX_TITLE in body
+ assert PPTX_BULLET_A in body
+ assert PPTX_BULLET_B in body
+ assert PPTX_CELL in body
+ assert PPTX_METRIC in body
+ assert "extraction unavailable" not in body.lower()
+ assert "python-pptx` not" not in body
+ methods = []
+ rows = out.get("results") if isinstance(out.get("results"), list) else [out]
+ for row in rows:
+ if isinstance(row, dict) and row.get("extraction_method"):
+ methods.append(row["extraction_method"])
+ assert methods, out
+ assert all(m == "python-pptx" for m in methods), methods
+ used = out.get("interpreter")
+ if isinstance(used, list) and used and isinstance(used[0], list):
+ used = used[0]
+ assert used == [sys.executable], used
+
+
+def test_real_ce_txt_ingest_still_works(tmp_path: Path):
+ _require_ce()
+ ws = _workspace(tmp_path)
+ note = ws / "vault" / "raw" / "plain-9f3c2a17.txt"
+ note.write_text("SBX-TXT-BODY-9f3c2a17\nsecond line\n", encoding="utf-8")
+ out = tools.REGISTRY["ce_ingest"].handler(
+ ws, {"path": "vault/raw/plain-9f3c2a17.txt"},
+ )
+ assert ce_tools.ingest_is_success(out), out
+ body = "\n".join(_extracted_texts(ws))
+ assert "SBX-TXT-BODY-9f3c2a17" in body
+
+
+def test_corrupt_pptx_is_retryable_failure_not_placeholder_success(tmp_path: Path):
+ _require_ce()
+ ws = _workspace(tmp_path)
+ bad = ws / "vault" / "raw" / "corrupt-9f3c2a17.pptx"
+ bad.write_bytes(b"PK\x03\x04 this is not a real presentation")
+ out = tools.REGISTRY["ce_ingest"].handler(
+ ws, {"path": "vault/raw/corrupt-9f3c2a17.pptx"},
+ )
+ assert not ce_tools.ingest_is_success(out), out
+ assert out.get("ok") is False
+ assert out.get("retryable") is True
+ extracts = list((ws / "vault").rglob("*.extracted.md"))
+ assert extracts == [], f"placeholder extract accepted: {extracts}"
+ assert bad.is_file()
+ db = ws / "vault" / "vault.db"
+ if db.is_file():
+ with sqlite3.connect(db) as conn:
+ paths = [r[0] for r in conn.execute("SELECT path FROM sources")]
+ assert not any("corrupt-9f3c2a17" in p for p in paths), paths
+
+
+def test_watch_handoff_runs_real_pptx_extract(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ _require_ce()
+ monkeypatch.setattr(
+ "switchbay.workspaces.is_within_home", lambda _p: True,
+ )
+ monkeypatch.setattr(watchfolders, "SETTLE_SECONDS", 0.0)
+ ws = _workspace(tmp_path)
+ folder = tmp_path / "watch"
+ folder.mkdir()
+ rec = watchfolders.add_folder(ws, str(folder))
+ assert isinstance(rec, dict)
+ src = folder / "watched-9f3c2a17.pptx"
+ _write_pptx(src)
+ picked, _backlog = watchfolders.scan_candidates(ws)
+ assert [c.logical for c in picked] == [str(src.resolve())]
+ seen_before = watchfolders._load_seen(ws)
+ assert str(src.resolve()) not in seen_before
+ result = watchfolders.handoff(ws, picked[0])
+ assert result.status == "success", result
+ assert result.extracted
+ seen_after = watchfolders._load_seen(ws)
+ assert str(src.resolve()) in seen_after
+ body = "\n".join(_extracted_texts(ws))
+ assert PPTX_TITLE in body
+ assert PPTX_CELL in body
+ assert "extraction unavailable" not in body.lower()
+ orig = str(src.resolve())
+ assert f"source_path: {orig}" in body
+ assert f"extracted_from: {orig}" in body
+ # Second beat does not re-hand-off.
+ picked2, _ = watchfolders.scan_candidates(ws)
+ assert picked2 == []
+
+
+FORMAT_VENV = Path("/tmp/switchbay-format-regression/.venv")
+
+
+def _write_xlsx(path: Path, py: Path) -> None:
+ subprocess.run(
+ [
+ str(py), "-c",
+ "from openpyxl import Workbook; import sys; w=Workbook(); "
+ "s=w.active; s.append(['XLSX-ROOT-MARKER', 'Value']); "
+ "s.append(['table survived', 731]); w.save(sys.argv[1])",
+ str(path),
+ ],
+ check=True,
+ )
+
+
+def _write_text_pdf(path: Path) -> None:
+ content = (
+ b"BT /F1 12 Tf 50 750 Td ("
+ + b"PDF-ROOT-MARKER "
+ + b"This synthetic document verifies ordinary readable text extraction "
+ b"and preservation of existing interpreter capabilities. " * 12
+ + b") Tj ET"
+ )
+ objects = [
+ b"<< /Type /Catalog /Pages 2 0 R >>",
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
+ b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
+ b"/Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
+ b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
+ b"<< /Length " + str(len(content)).encode() + b" >>\nstream\n"
+ + content + b"\nendstream",
+ ]
+ data = b"%PDF-1.4\n"
+ offsets = [0]
+ for n, obj in enumerate(objects, 1):
+ offsets.append(len(data))
+ data += f"{n} 0 obj\n".encode() + obj + b"\nendobj\n"
+ xref = len(data)
+ data += (
+ b"xref\n0 6\n0000000000 65535 f \n"
+ + b"".join(f"{o:010d} 00000 n \n".encode() for o in offsets[1:])
+ + f"trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode()
+ )
+ path.write_bytes(data)
+
+
+def test_xlsx_pdf_prefer_workspace_venv_that_has_extractors(tmp_path: Path):
+ assert (FORMAT_VENV / "bin" / "python").is_file(), FORMAT_VENV
+ ws = _workspace(tmp_path)
+ (ws / ".venv").symlink_to(FORMAT_VENV, target_is_directory=True)
+ ws_py = str(ws / ".venv" / "bin" / "python")
+ assert cebridge.python_for_ingest(ws, ".xlsx") == [ws_py]
+ assert cebridge.python_for_ingest(ws, ".pdf") == [ws_py]
+ assert cebridge.python_for_ingest(ws, ".pptx") == [sys.executable]
+ assert cebridge.script_python_for(ws, "scan.py") == [ws_py]
+ assert cebridge.script_python_for(ws, "graph.py") == [ws_py]
+
+
+def test_real_xlsx_pdf_and_mixed_directory_ingest(tmp_path: Path):
+ _require_ce()
+ assert (FORMAT_VENV / "bin" / "python").is_file(), FORMAT_VENV
+ ws = _workspace(tmp_path)
+ (ws / ".venv").symlink_to(FORMAT_VENV, target_is_directory=True)
+ raw = ws / "vault" / "raw"
+ _write_xlsx(raw / "probe.xlsx", FORMAT_VENV / "bin" / "python")
+ _write_text_pdf(raw / "probe.pdf")
+ _write_pptx(raw / "probe.pptx")
+ (raw / "note.txt").write_text("TXT-MIX-MARKER-9f3c2a17\n", encoding="utf-8")
+ xlsx = tools.REGISTRY["ce_ingest"].handler(ws, {"path": "vault/raw/probe.xlsx"})
+ pdf = tools.REGISTRY["ce_ingest"].handler(ws, {"path": "vault/raw/probe.pdf"})
+ assert ce_tools.ingest_is_success(xlsx), xlsx
+ assert ce_tools.ingest_is_success(pdf), pdf
+ mixed = tools.REGISTRY["ce_ingest"].handler(ws, {"path": "vault/raw"})
+ assert ce_tools.ingest_is_success(mixed), mixed
+ body = "\n".join(_extracted_texts(ws))
+ assert "XLSX-ROOT-MARKER" in body
+ assert "PDF-ROOT-MARKER" in body
+ assert PPTX_TITLE in body
+ assert "TXT-MIX-MARKER-9f3c2a17" in body
+ assert "extraction unavailable" not in body.lower()
+ methods = []
+ for row in mixed.get("results") or []:
+ if isinstance(row, dict) and row.get("extraction_method"):
+ methods.append(row["extraction_method"])
+ assert "openpyxl" in methods
+ assert any(m.startswith("pypdf") for m in methods)
+ assert "python-pptx" in methods
+
+
+def test_real_corrupt_and_good_mixed_directory_ingest(tmp_path: Path):
+ _require_ce()
+ ws = _workspace(tmp_path)
+ raw = ws / "vault" / "raw"
+ good = raw / "good.pptx"
+ bad = raw / "corrupt.pptx"
+ _write_pptx(good)
+ bad.write_bytes(b"PK\x03\x04 this is not a real presentation")
+ out = tools.REGISTRY["ce_ingest"].handler(ws, {"path": "vault/raw"})
+ assert ce_tools.ingest_is_success(out), out
+ assert out.get("ok") == 1, out
+ assert out.get("failed") == 1, out
+ assert good.is_file()
+ assert bad.is_file()
+ extracts = list((ws / "vault").rglob("*.extracted.md"))
+ bodies = [p.read_text(encoding="utf-8", errors="replace") for p in extracts]
+ assert any(PPTX_TITLE in b for b in bodies), extracts
+ assert not any("corrupt.pptx" in p.name for p in extracts)
+ db = ws / "vault" / "vault.db"
+ assert db.is_file()
+ with sqlite3.connect(db) as conn:
+ rows = conn.execute("SELECT path, body FROM sources").fetchall()
+ assert any(PPTX_TITLE in (body or "") for _path, body in rows)
+ assert not any("corrupt.pptx" in path for path, _body in rows)
+ kept_binaries = [
+ p for p in (ws / "vault").iterdir()
+ if p.is_file() and p.suffix == ".pptx"
+ ]
+ assert kept_binaries, "original/kept PPTX binaries were removed"
+
+
+def test_ce_ingest_honors_timeout_payload(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ ws = _workspace(tmp_path)
+ src = ws / "vault" / "raw" / "a.txt"
+ src.write_text("hi", encoding="utf-8")
+ seen: dict = {}
+
+ def fake_run(script, args=None, *, cwd, timeout=120.0, require_json=True, python=None):
+ seen["timeout"] = timeout
+ seen["python"] = python
+ return {
+ "ok": 1,
+ "considered": 1,
+ "results": [{
+ "ok": True, "extracted": "vault/a.txt.extracted.md",
+ "extraction_method": "utf8", "extraction_quality": "good",
+ }],
+ }
+
+ monkeypatch.setattr(ce_tools.cebridge, "run_script", fake_run)
+ (ws / "vault" / "a.txt.extracted.md").write_text("hi\n", encoding="utf-8")
+ out = ce_tools._ce_ingest(ws, {"path": "vault/raw/a.txt", "timeout": 90})
+ assert 80 <= seen["timeout"] <= 90
+ assert out.get("timeout_s") == 90
+ assert seen["timeout"] != 300
diff --git a/tests/unit/test_release_acceptance.py b/tests/unit/test_release_acceptance.py
new file mode 100644
index 0000000..3cbfda2
--- /dev/null
+++ b/tests/unit/test_release_acceptance.py
@@ -0,0 +1,315 @@
+"""Independent second-pass acceptance probes; no provider/account calls."""
+import asyncio
+import base64
+import json
+from email import message_from_bytes
+
+import pytest
+
+from switchbay import admin_policy, comms_review, streams
+from switchbay.agents import orchestration as orch, desk_admission as seats
+
+
+@pytest.fixture(autouse=True)
+def isolated(tmp_path, monkeypatch):
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
+ monkeypatch.setenv("SWITCHBAY_STATE_DIR", str(tmp_path / "state"))
+ monkeypatch.setenv("SWITCHBAY_PROFILE", "enterprise")
+ path = tmp_path / "admin.json"
+ path.write_text(json.dumps({"profile": "enterprise", "features": {"comms_streams": True}}))
+ monkeypatch.setenv("SWITCHBAY_ADMIN_POLICY", str(path))
+ monkeypatch.delenv("SWITCHBAY_INSTALL_ROOT", raising=False)
+ admin_policy.reset_cache()
+ seats.reset_for_tests()
+ yield path
+ admin_policy.reset_cache()
+ seats.reset_for_tests()
+
+
+def test_custom_headers_extend_mandatory_secret_detection(isolated):
+ isolated.write_text(json.dumps({"profile": "enterprise", "comms": {"classification_headers": ["X-Tenant-Class"], "secret_names": ["Restricted-Tenant"]}}))
+ admin_policy.reset_cache()
+ result = comms_review.classify_metadata(headers={"Sensitivity": "Secret", "X-Tenant-Class": "normal"})
+ assert result["verdict"] == "secret", result
+
+
+def test_gmail_system_labels_do_not_block_normal_classification():
+ result = comms_review.classify_metadata(headers={"Sensitivity": "normal"}, label_ids=["INBOX", "UNREAD"])
+ assert result["verdict"] == "clear", result
+
+
+def test_tenant_secret_id_embedded_in_msip_header(isolated):
+ label_id = "a3a2f242-b872-42f3-89a6-ffffeeeedddd"
+ isolated.write_text(json.dumps({"profile": "enterprise", "comms": {"tenant_label_ids": [label_id]}}))
+ admin_policy.reset_cache()
+ result = comms_review.classify_metadata(headers={"MSIP_Labels": f"MSIP_Label_{label_id}_Enabled=True; MSIP_Label_{label_id}_SiteId=tenant"})
+ assert result["verdict"] == "secret", result
+
+
+def test_duplicate_secret_header_cannot_be_hidden_by_normal_header():
+ msg = message_from_bytes(b"Sensitivity: normal\r\nSensitivity: Secret\r\nSubject: Fixture\r\n\r\n")
+ headers = streams._header_map(msg)
+ result = comms_review.classify_metadata(headers=headers)
+ assert result["verdict"] == "secret", result
+
+
+def test_thousand_curate_waves_keep_unique_ids_after_compaction():
+ plan = orch.OrchestrationPlan(orchestration_id="continuous-fixture", strategy="ce_curate", objective="Continuous fixture", nodes=[])
+ completed = set()
+ for i in range(1005):
+ new = orch._add_curate_package_wave(plan)
+ assert new, f"Wave {i} stopped prematurely"
+ for node in new:
+ assert node.node_id not in completed, f"Completed node ID reused after compaction at wave {i}: {node.node_id}"
+ completed.add(node.node_id)
+ orch.compact_plan_nodes(plan, completed, set(), set())
+ assert len(completed) == 1005
+ assert len(plan.nodes) < 40
+
+
+def test_completed_single_use_worker_leaves_actual_parent_graph():
+ old = orch.PlanNode(node_id="retired", kind="investigate", objective="Completed source work")
+ current = orch.PlanNode(node_id="current", kind="investigate", objective="Current source work")
+ plan = orch.OrchestrationPlan(orchestration_id="roster-fixture", strategy="parallel_investigate", objective="Fixture", nodes=[old, current])
+ rows = orch._plan_nodes_view(plan, {"retired"}, set(), {"current"})
+ assert [r["node_id"] for r in rows] == ["current"], rows
+
+
+@pytest.mark.asyncio
+async def test_increasing_live_cap_wakes_waiting_worker():
+ gate = seats.DeskGate("live-change-fixture", cap=4)
+ for name in ("chief", "specialist", "verifier", "synthesizer"):
+ await gate.acquire(name)
+ waiter = asyncio.create_task(gate.acquire("queued-specialist"))
+ await asyncio.sleep(0)
+ assert not waiter.done()
+ gate.set_cap(5)
+ await asyncio.wait_for(waiter, timeout=0.25)
+ assert gate.live() == 5
+
+
+@pytest.mark.asyncio
+async def test_teams_metadata_queries_use_supported_parameters_only(tmp_path, monkeypatch):
+ calls = []
+ acct = {"id": "graph-fixture", "provider": "msgraph", "label": "Graph fixture", "workspaces": [str(tmp_path)]}
+ monkeypatch.setattr(streams, "get_account", lambda *a: acct)
+ monkeypatch.setattr(streams, "update_account", lambda *a, **k: acct)
+ async def api(sess, account, url, **params):
+ calls.append((url, params))
+ if url.endswith("/joinedTeams"):
+ return {"value": [{"id": "team1", "displayName": "Fixture team"}]}
+ if url.endswith("/channels"):
+ return {"value": [{"id": "channel1", "displayName": "Fixture channel"}]}
+ return {"value": []}
+ monkeypatch.setattr(streams, "_api_get", api)
+ await streams.poll_account(acct)
+ team_calls = [p for u, p in calls if u.endswith("/joinedTeams")]
+ assert team_calls, calls
+ assert all(not p for p in team_calls), "joinedTeams does not support OData query parameters"
+ channel_calls = [p for u, p in calls if u.endswith("/channels")]
+ assert channel_calls, calls
+ assert all(set(p) <= {"$filter", "$select"} for p in channel_calls), channel_calls
+ assert not any(("/chats/" in u or "/channels/" in u) and u.endswith("/messages") for u, p in calls)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("provider", ["gmail", "msgraph"])
+@pytest.mark.parametrize("fresh_headers", [{}, {"Sensitivity": "Tenant-Unknown"}, {"Sensitivity": "Secret"}, {"Sensitivity": "normal"}])
+async def test_approved_email_unknown_fresh_classification_never_fetches_body(tmp_path, monkeypatch, provider, fresh_headers):
+ acct = {"id": f"{provider}-fresh", "provider": provider, "label": "Fixture", "workspaces": [str(tmp_path)]}
+ monkeypatch.setattr(streams, "get_account", lambda *a: acct)
+ record = {"provider": provider, "account_id": acct["id"], "stable_id": "thread-fresh", "subject": "Fixture", "headers": {"Sensitivity": "normal"}, "labels": [], "fetch_ref": {"id": "message1"}, "thread_id": "thread-fresh"}
+ item = comms_review.upsert_discovery(record)
+ assert comms_review.approve(item["key"], str(tmp_path), allowed=[str(tmp_path)])["ok"]
+ body_calls = []
+ metadata_calls = []
+ async def api(sess, account, url, **params):
+ if params.get("format") == "full" or "body" in str(params.get("$select", "")).split(","):
+ body_calls.append((url, params))
+ return {"id": "message1", "payload": {"mimeType": "text/plain", "body": {"data": base64.urlsafe_b64encode(b"approved fixture body").decode()}}, "body": {"content": "approved fixture body"}}
+ metadata_calls.append((url, params))
+ hdrs = [{"name": k, "value": v} for k, v in fresh_headers.items()]
+ return {"id": "message1", "threadId": "thread-fresh", "payload": {"headers": hdrs}, "internetMessageHeaders": hdrs}
+ monkeypatch.setattr(streams, "_api_get", api)
+ result = await streams.fetch_approved_thread(acct, item["key"], str(tmp_path))
+ assert metadata_calls, ("Approved thread must reach fresh metadata check", result)
+ if fresh_headers.get("Sensitivity") == "normal":
+ assert body_calls, "Approved, freshly clear email must be retrieved"
+ assert result.get("ok") and result.get("events"), result
+ assert result["events"][0]["text"] == "approved fixture body"
+ assert result["events"][0]["approved_workspace"] == str(tmp_path)
+ else:
+ assert not body_calls, "A cached normal label must not override protected fresh classification"
+ assert not result.get("events")
+
+
+@pytest.mark.asyncio
+async def test_approved_imap_checks_fresh_headers_before_requesting_text(tmp_path, monkeypatch):
+ acct = {"id": "imap-fresh", "provider": "imap", "label": "Fixture", "host": "imap.example.invalid", "username": "fixture@example.invalid", "workspaces": [str(tmp_path)]}
+ monkeypatch.setattr(streams, "get_account", lambda *a: acct)
+ stable = comms_review.imap_thread_stable_id(message_id="", references="", in_reply_to="", uidvalidity="123")
+ item = comms_review.upsert_discovery({"provider": "imap", "account_id": acct["id"], "stable_id": stable, "subject": "Fixture", "headers": {"Sensitivity": "normal"}, "fetch_ref": {"uid": "1", "uidvalidity": "123"}, "uidvalidity": "123"})
+ assert comms_review.approve(item["key"], str(tmp_path), allowed=[str(tmp_path)])["ok"]
+ fetches = []
+ class Imap:
+ def __init__(self, *a, **k): pass
+ def login(self, *a): pass
+ def select(self, *a, **k): return "OK", [b"1"]
+ def response(self, name): return "UIDVALIDITY", [b"123"]
+ def uid(self, verb, *args):
+ assert verb == "FETCH"
+ fetches.append(str(args[-1]))
+ return "OK", [(b"1 (BODY[HEADER] {140}", b"Sensitivity: Secret\r\nMessage-ID: \r\n\r\n"), (b"1 BODY[TEXT] {10}", b"secret body")]
+ def logout(self): pass
+ monkeypatch.setattr(streams.imaplib, "IMAP4_SSL", Imap)
+ monkeypatch.setattr(streams.secretstore, "get", lambda *a: "fixture-password")
+ result = await streams.fetch_approved_thread(acct, item["key"], str(tmp_path))
+ assert fetches
+ assert all("TEXT" not in f for f in fetches), "Fresh Secret reply body was requested together with headers"
+ assert not result.get("events")
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('change', ['revoke', 'remove_allowlist'])
+async def test_inflight_comms_authorization_change_prevents_body_parse(tmp_path, monkeypatch, change):
+ import base64
+ acct = {'id': 'race-fixture', 'provider': 'gmail', 'workspaces': [str(tmp_path)]}
+ current = dict(acct)
+ monkeypatch.setattr(streams, 'get_account', lambda *a: current)
+ item = comms_review.upsert_discovery({'provider': 'gmail', 'account_id': acct['id'], 'stable_id': 'race-thread', 'thread_id': 'race-thread', 'subject': 'Fixture', 'headers': {'Sensitivity': 'normal'}, 'fetch_ref': {'id': 'race-message'}})
+ assert comms_review.approve(item['key'], str(tmp_path), allowed=[str(tmp_path)])['ok']
+ parsed = []
+ monkeypatch.setattr(streams, '_gmail_plain', lambda *a: parsed.append(True) or 'must not parse')
+ async def api(sess, account, url, **params):
+ if params.get('format') == 'metadata':
+ return {'id': 'race-message', 'threadId': 'race-thread', 'payload': {'headers': [{'name': 'Sensitivity', 'value': 'normal'}]}}
+ assert params.get('format') == 'full'
+ if change == 'revoke':
+ comms_review.revoke(item['key'])
+ else:
+ current['workspaces'] = []
+ await asyncio.sleep(0)
+ return {'id': 'race-message', 'threadId': 'race-thread', 'payload': {'mimeType': 'text/plain', 'body': {'data': base64.urlsafe_b64encode(b'fixture body').decode()}}}
+ monkeypatch.setattr(streams, '_api_get', api)
+ result = await streams.fetch_approved_thread(acct, item['key'], str(tmp_path))
+ assert not parsed, f'{change} during fetch must prevent parsing'
+ assert not result.get('events'), result
+
+@pytest.mark.asyncio
+async def test_approval_backfills_all_discovered_messages_in_long_thread(tmp_path, monkeypatch):
+ import base64
+ acct = {'id': 'long-fixture', 'provider': 'gmail', 'workspaces': [str(tmp_path)]}
+ monkeypatch.setattr(streams, 'get_account', lambda *a: acct)
+ for i in range(105):
+ item = comms_review.upsert_discovery({'provider': 'gmail', 'account_id': acct['id'], 'stable_id': 'long-thread', 'thread_id': 'long-thread', 'subject': 'Fixture', 'headers': {'Sensitivity': 'normal'}, 'fetch_ref': {'id': f'message-{i}'}})
+ assert comms_review.approve(item['key'], str(tmp_path), allowed=[str(tmp_path)])['ok']
+ body_ids = []
+ async def api(sess, account, url, **params):
+ mid = url.rsplit('/', 1)[-1]
+ if params.get('format') == 'metadata':
+ return {'id': mid, 'threadId': 'long-thread', 'payload': {'headers': [{'name': 'Sensitivity', 'value': 'normal'}]}}
+ assert params.get('format') == 'full'
+ body_ids.append(mid)
+ return {'id': mid, 'threadId': 'long-thread', 'payload': {'mimeType': 'text/plain', 'body': {'data': base64.urlsafe_b64encode(b'fixture body').decode()}}}
+ monkeypatch.setattr(streams, '_api_get', api)
+ result = await streams.fetch_approved_thread(acct, item['key'], str(tmp_path))
+ assert set(body_ids) == {f'message-{i}' for i in range(105)}, 'Discovery must not silently discard older references before approval'
+ assert len(result.get('events', [])) == 105, result
+ repeat = await streams.fetch_approved_thread(acct, item['key'], str(tmp_path))
+ assert not repeat.get('events')
+ assert len(body_ids) == 105, 'Receipted messages must not fetch content again'
+
+@pytest.mark.asyncio
+async def test_revocation_during_curator_preparation_prevents_model_handoff(tmp_path, monkeypatch):
+ from switchbay import daemon, llmgateway
+ acct = {'id': 'handoff-fixture', 'label': 'Fixture', 'provider': 'gmail', 'workspaces': [str(tmp_path)]}
+ monkeypatch.setattr(streams, 'get_account', lambda *a: acct)
+ item = comms_review.upsert_discovery({'provider': 'gmail', 'account_id': acct['id'], 'stable_id': 'handoff-thread', 'subject': 'Fixture', 'headers': {'Sensitivity': 'normal'}})
+ assert comms_review.approve(item['key'], str(tmp_path), allowed=[str(tmp_path)])['ok']
+ events = [{'id': 'handoff-message', 'comms_key': item['key'], 'approved_workspace': str(tmp_path), 'approved': True, 'text': 'fixture body', 'subject': 'Fixture', 'stream': 'fixture', 'ts': 1, 'sender': 'fixture@example.invalid', 'deep_link': ''}]
+ calls = []
+ class Provider:
+ async def chat_stream(self, req):
+ calls.append(req)
+ yield llmgateway.DoneChunk(stop_reason='end_turn')
+ monkeypatch.setattr(daemon, '_comms_curation_route', lambda *_a, **_k: ('fixture', 'fixture'))
+ monkeypatch.setattr(llmgateway, 'get', lambda *a: Provider())
+ monkeypatch.setattr(daemon, '_effective_model', lambda *a: 'fixture')
+ monkeypatch.setattr(daemon, '_effort_for', lambda *a: None)
+ monkeypatch.setattr(daemon, '_curator_profile', lambda *a: '')
+ monkeypatch.setattr(streams, 'workspace_descriptor', lambda *a: 'fixture')
+ def head(*a):
+ comms_review.revoke(item['key'])
+ return {'sha': 'fixture'}
+ monkeypatch.setattr('switchbay.ce_host.wiki_head', head)
+ result = await daemon._curate_into({'runs': {}}, acct, tmp_path, events)
+ assert not calls, 'Revocation during preparation must stop model handoff'
+ assert not result[0]
+ assert 'revok' in str(result[1]).lower(), result
+
+@pytest.mark.asyncio
+async def test_comms_handoff_preserves_allowed_routed_model(tmp_path, monkeypatch):
+ from switchbay import daemon, llmgateway
+ acct = {'id': 'model-fixture', 'label': 'Fixture', 'provider': 'gmail', 'workspaces': [str(tmp_path)]}
+ monkeypatch.setattr(streams, 'get_account', lambda *a: acct)
+ item = comms_review.upsert_discovery({'provider': 'gmail', 'account_id': acct['id'], 'stable_id': 'model-thread', 'subject': 'Fixture', 'headers': {'Sensitivity': 'normal'}})
+ assert comms_review.approve(item['key'], str(tmp_path), allowed=[str(tmp_path)])['ok']
+ events = [{'id': 'model-message', 'comms_key': item['key'], 'approved_workspace': str(tmp_path), 'approved': True, 'text': 'fixture body', 'subject': 'Fixture', 'stream': 'fixture', 'ts': 1, 'sender': 'fixture@example.invalid', 'deep_link': ''}]
+ models = []
+ class Provider:
+ async def chat_stream(self, req):
+ models.append(req.model)
+ yield llmgateway.DoneChunk(stop_reason='end_turn')
+ monkeypatch.setattr(daemon, '_comms_curation_route', lambda *a: ('fixture', 'allowed-model'))
+ monkeypatch.setattr(llmgateway, 'get', lambda *a: Provider())
+ monkeypatch.setattr(daemon, '_effective_model', lambda *a: 'forbidden-default')
+ monkeypatch.setattr(daemon, '_effort_for', lambda *a: None)
+ monkeypatch.setattr(daemon, '_curator_profile', lambda *a: '')
+ monkeypatch.setattr(streams, 'workspace_descriptor', lambda *a: 'fixture')
+ monkeypatch.setattr('switchbay.ce_host.wiki_head', lambda *a: {'sha': 'unchanged'})
+ await asyncio.wait_for(daemon._curate_into({'runs': {}}, acct, tmp_path, events), timeout=2)
+ assert models == ['allowed-model'], 'Handoff must retain the model selected by workspace policy'
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('change', ['allowlist', 'admin_disable'])
+async def test_comms_rechecks_auth_after_waiting_for_worker_seat(tmp_path, monkeypatch, isolated, change):
+ from switchbay import daemon, llmgateway, app_settings
+ from switchbay.kernel.desk import DESK_CURATE
+ acct = {'id': 'seat-fixture', 'label': 'Fixture', 'provider': 'gmail', 'workspaces': [str(tmp_path)]}
+ current = dict(acct)
+ monkeypatch.setattr(streams, 'get_account', lambda *a: current)
+ item = comms_review.upsert_discovery({'provider': 'gmail', 'account_id': acct['id'], 'stable_id': 'seat-thread', 'subject': 'Fixture', 'headers': {'Sensitivity': 'normal'}})
+ assert comms_review.approve(item['key'], str(tmp_path), allowed=[str(tmp_path)])['ok']
+ events = [{'id': 'seat-message', 'comms_key': item['key'], 'approved_workspace': str(tmp_path), 'approved': True, 'text': 'fixture body', 'subject': 'Fixture', 'stream': 'fixture', 'ts': 1, 'sender': 'fixture@example.invalid', 'deep_link': ''}]
+ calls = []
+ class Provider:
+ async def chat_stream(self, req):
+ calls.append(req)
+ yield llmgateway.DoneChunk(stop_reason='end_turn')
+ monkeypatch.setattr(daemon, '_comms_curation_route', lambda *a: ('fixture', 'allowed-model'))
+ monkeypatch.setattr(llmgateway, 'get', lambda *a: Provider())
+ monkeypatch.setattr(daemon, '_effective_model', lambda *a: 'allowed-model')
+ monkeypatch.setattr(daemon, '_effort_for', lambda *a: None)
+ monkeypatch.setattr(daemon, '_curator_profile', lambda *a: '')
+ monkeypatch.setattr(streams, 'workspace_descriptor', lambda *a: 'fixture')
+ monkeypatch.setattr('switchbay.ce_host.wiki_head', lambda *a: {'sha': 'unchanged'})
+ app_settings.set_desk_max_live_workers(4)
+ gate = seats.gate_for(seats.desk_domain_id(tmp_path, DESK_CURATE), workspace=tmp_path)
+ for i in range(4):
+ await gate.acquire(f'fixture-{i}')
+ task = asyncio.create_task(daemon._curate_into({'runs': {}}, acct, tmp_path, events))
+ for _ in range(100):
+ if gate.snapshot().get('refs', 0):
+ break
+ await asyncio.sleep(0.01)
+ assert gate.snapshot().get('refs', 0), 'Curator must join the actual shared gate'
+ assert not task.done(), 'Curator must wait at the live cap'
+ if change == 'allowlist':
+ current['workspaces'] = []
+ else:
+ isolated.write_text(json.dumps({'profile': 'enterprise', 'features': {'comms_streams': False}}))
+ admin_policy.reset_cache()
+ await gate.release_async('fixture-0')
+ await asyncio.wait_for(task, timeout=2)
+ for i in range(1, 4):
+ await gate.release_async(f'fixture-{i}')
+ assert not calls, f'{change} while queued must block model handoff'
diff --git a/tests/unit/test_research.py b/tests/unit/test_research.py
index 7a69a1b..46ce2b1 100644
--- a/tests/unit/test_research.py
+++ b/tests/unit/test_research.py
@@ -71,6 +71,25 @@ def test_research_search_requires_query():
assert out["ok"] is False
+def test_research_handler_fail_closed_when_web_off(tmp_path: Path):
+ from switchbay import tools
+ out = tools.execute("research_search", tmp_path, {"query": "qwen"})
+ assert isinstance(out, dict)
+ assert out.get("ok") is False
+ assert "web egress" in str(out.get("error") or "").lower() or "off" in str(out.get("error") or "").lower()
+
+
+def test_research_handler_allows_when_policy_on(tmp_path: Path, monkeypatch):
+ from switchbay import permissions, tools, web_policy
+ web_policy.save(tmp_path, enabled=True)
+ monkeypatch.setattr(research, "search_web", lambda *a, **k: {"ok": True, "hits": []})
+ out = tools.execute(
+ "research_search", tmp_path, {"query": "qwen"},
+ consent=permissions.trusted_consent(),
+ )
+ assert out.get("ok") is True
+
+
def test_research_fetch_writes_vault_and_ingests(tmp_path: Path, monkeypatch):
body = b"hello paper"
diff --git a/tests/unit/test_research_desk.py b/tests/unit/test_research_desk.py
new file mode 100644
index 0000000..6c72d4e
--- /dev/null
+++ b/tests/unit/test_research_desk.py
@@ -0,0 +1,131 @@
+"""Explicit Research desk and Auto web-ingest hire the research package."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from switchbay.agents.orchestration import plan_from_decision
+from switchbay.agents import orchestration_policy as policy
+from switchbay.kernel import (
+ DESK_AUTO, DESK_RESEARCH, HireRequest, RESEARCH_ID,
+ choose_desk, decide_hire, get_package, packages_for_desk, pick_auto_hires,
+)
+from switchbay.kernel.packages import RESEARCH_TOOLS
+from switchbay.kernel.hire import pick_worker_model
+
+
+def _avail():
+ return [
+ ("anthropic", "claude-opus-4"),
+ ("gemini", "gemini-3.8-flash"),
+ ("mlx", "qwen2.5-7b"),
+ ]
+
+
+def test_research_is_a_desk_and_auto_candidate():
+ assert packages_for_desk("research") == (RESEARCH_ID,)
+ assert RESEARCH_ID in packages_for_desk("auto")
+ assert choose_desk(command="research") == DESK_RESEARCH
+ assert choose_desk(task_kind="research") == DESK_RESEARCH
+ pkg = get_package(RESEARCH_ID)
+ assert pkg is not None
+ assert set(RESEARCH_TOOLS) <= set(pkg.tools)
+ assert "vault-ingest-research" in pkg.needed_skills
+
+
+def test_auto_web_ingest_hires_research_package(tmp_path: Path):
+ hires = pick_auto_hires(
+ "search the web and ingest papers into the vault",
+ preference=0.5,
+ chief=("anthropic", "claude-opus-4"),
+ workspace=tmp_path,
+ available=_avail(),
+ denied=[],
+ chief_tools=["search_wiki"],
+ web_ingest=True,
+ )
+ assert [h.package_id for h in hires] == [RESEARCH_ID]
+ assert hires[0].accepted is True
+ assert "research_search" in (get_package(RESEARCH_ID).tools or ())
+
+
+def test_research_plan_uses_package_tools(tmp_path: Path):
+ feat = policy.extract_features("search the web for Bahdanau")
+ assert feat.web_ingest is True
+ decision = policy.decide(feat)
+ plan = plan_from_decision(
+ "search the web for Bahdanau",
+ decision, [],
+ task_kind="research",
+ workspace=tmp_path,
+ available=_avail(),
+ denied=[],
+ )
+ assert plan.strategy == "research"
+ assert any(n.role == RESEARCH_ID for n in plan.nodes)
+ node = next(n for n in plan.nodes if n.role == RESEARCH_ID)
+ assert "research_search" in node.tools
+ assert "research_fetch" in node.tools
+
+
+def test_research_hire_honours_workspace_denylist(tmp_path: Path):
+ from switchbay.agents import orchestration_policy as pol
+ pol.set_denied_models(["claude-opus-4"], workspace=tmp_path)
+ try:
+ d = decide_hire(
+ HireRequest(
+ package_id=RESEARCH_ID,
+ justification="research desk",
+ needed_tools=["research_search"],
+ desk_prior=True,
+ ),
+ preference=0.5,
+ chief=("anthropic", "claude-opus-4"),
+ org=[],
+ workspace=tmp_path,
+ available=_avail(),
+ denied=None,
+ chief_tools=["search_wiki"],
+ )
+ assert d.accepted is True
+ worker = pick_worker_model(
+ preference=0.5,
+ chief=("anthropic", "claude-opus-4"),
+ workspace=tmp_path,
+ available=_avail(),
+ )
+ assert worker != ("anthropic", "claude-opus-4")
+ assert d.model != "claude-opus-4" or d.provider != "anthropic"
+ finally:
+ pol.set_denied_models([], workspace=tmp_path)
+
+
+def test_auto_web_research_plan_includes_research_node(tmp_path: Path):
+ feat = policy.extract_features("open web search and fetch a paper into the vault")
+ decision = policy.decide(feat)
+ hires = pick_auto_hires(
+ "open web search and fetch a paper into the vault",
+ preference=decision.preference,
+ chief=("anthropic", "claude-opus-4"),
+ workspace=tmp_path,
+ available=_avail(),
+ denied=[],
+ chief_tools=["search_wiki"],
+ web_ingest=feat.web_ingest,
+ )
+ plan = plan_from_decision(
+ "open web search and fetch a paper into the vault",
+ decision, [{"description": "search"}],
+ research_hires=[h.to_dict() for h in hires],
+ workspace=tmp_path,
+ available=_avail(),
+ denied=[],
+ )
+ roles = [n.role for n in plan.nodes]
+ assert RESEARCH_ID in roles or any("research" in (n.node_id or "") for n in plan.nodes)
+ tools = []
+ for n in plan.nodes:
+ if n.role == RESEARCH_ID:
+ tools.extend(n.tools)
+ assert "research_search" in tools
+ assert choose_desk(text="open web search", research=True) == DESK_AUTO
diff --git a/tests/unit/test_runtime.py b/tests/unit/test_runtime.py
new file mode 100644
index 0000000..5ae2438
--- /dev/null
+++ b/tests/unit/test_runtime.py
@@ -0,0 +1,216 @@
+"""Shared PATH / executable discovery under launchd-minimal PATH."""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+from switchbay import runtime
+
+
+def _exe(path: Path, body: str = "#!/bin/sh\nexit 0\n") -> Path:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(body, encoding="utf-8")
+ path.chmod(0o755)
+ return path
+
+
+def test_minimal_launchd_path_finds_nvm_node(tmp_path: Path, monkeypatch):
+ home = tmp_path / "home"
+ nvm_bin = home / ".nvm" / "versions" / "node" / "v22.11.0" / "bin"
+ node = _exe(nvm_bin / "node")
+ (home / ".nvm" / "alias").mkdir(parents=True)
+ (home / ".nvm" / "alias" / "default").write_text("22.11.0\n", encoding="utf-8")
+ monkeypatch.setattr(runtime, "_SYSTEM_BIN_DIRS", ())
+ env = {
+ "PATH": "/usr/bin:/bin",
+ "HOME": str(home),
+ "NVM_DIR": str(home / ".nvm"),
+ }
+ found = runtime.resolve_node(env, home=home)
+ assert found == str(node)
+
+
+def test_empty_nvm_alias_does_not_crash(tmp_path: Path):
+ alias = tmp_path / ".nvm" / "alias" / "default"
+ alias.parent.mkdir(parents=True)
+ alias.write_text("", encoding="utf-8")
+ assert runtime._nvm_bin_dirs(home=tmp_path, environ={}) == []
+
+
+def test_nvm_major_partial_and_lts_star(tmp_path: Path, monkeypatch):
+ home = tmp_path / "home"
+ v22 = _exe(home / ".nvm" / "versions" / "node" / "v22.17.0" / "bin" / "node")
+ _exe(home / ".nvm" / "versions" / "node" / "v22.11.0" / "bin" / "node")
+ alias = home / ".nvm" / "alias"
+ alias.mkdir(parents=True)
+ (alias / "default").write_text("lts/*\n", encoding="utf-8")
+ (alias / "lts").mkdir()
+ (alias / "lts" / "*").write_text("iron\n", encoding="utf-8")
+ (alias / "lts" / "iron").write_text("22\n", encoding="utf-8")
+ monkeypatch.setattr(runtime, "_SYSTEM_BIN_DIRS", ())
+ env = {
+ "PATH": "/usr/bin:/bin",
+ "HOME": str(home),
+ "NVM_DIR": str(home / ".nvm"),
+ }
+ found = runtime.resolve_node(env, home=home)
+ assert found == str(v22)
+
+
+def test_selected_path_precedes_fallback(tmp_path: Path, monkeypatch):
+ chosen = _exe(tmp_path / "chosen" / "node")
+ fallback = _exe(tmp_path / "fallback" / "node")
+ monkeypatch.setattr(runtime, "_SYSTEM_BIN_DIRS", (str(fallback.parent),))
+ monkeypatch.setattr(runtime, "_nvm_bin_dirs", lambda **kw: [])
+ monkeypatch.setattr(runtime, "_version_manager_dirs", lambda **kw: [])
+ assert runtime.resolve_node(
+ {"PATH": str(chosen.parent)}, home=tmp_path,
+ ) == str(chosen)
+
+
+def test_missing_node_isolates_homebrew(tmp_path: Path, monkeypatch):
+ monkeypatch.setattr(runtime, "_SYSTEM_BIN_DIRS", ())
+ monkeypatch.setattr(runtime, "_nvm_bin_dirs", lambda **kw: [])
+ monkeypatch.setattr(runtime, "_version_manager_dirs", lambda **kw: [])
+ assert runtime.resolve_node(
+ {"PATH": "/usr/bin:/bin", "HOME": str(tmp_path)}, home=tmp_path,
+ ) is None
+
+
+def test_explicit_node_survives_reenrich_and_governs_env_node(tmp_path: Path, monkeypatch):
+ chosen = _exe(tmp_path / "chosen" / "node")
+ fallback = _exe(tmp_path / "fallback" / "node")
+ monkeypatch.setattr(runtime, "_SYSTEM_BIN_DIRS", (str(fallback.parent),))
+ monkeypatch.setattr(runtime, "_nvm_bin_dirs", lambda **kw: [])
+ monkeypatch.setattr(runtime, "_version_manager_dirs", lambda **kw: [])
+ env = {
+ "PATH": "/usr/bin:/bin",
+ "SWITCHBAY_NODE": str(chosen),
+ "HOME": str(tmp_path),
+ }
+ spawned = runtime.spawn_env(env, home=tmp_path)
+ parts = spawned["PATH"].split(os.pathsep)
+ assert parts[0] == str(chosen.parent)
+ assert runtime.resolve_node(spawned, home=tmp_path) == str(chosen)
+ again = runtime.enrich_env(spawned, home=tmp_path)
+ assert runtime.resolve_node(again, home=tmp_path) == str(chosen)
+
+
+def test_nvm_bin_env_wins_over_default_alias(tmp_path: Path):
+ home = tmp_path / "home"
+ active = _exe(tmp_path / "active" / "node")
+ other = _exe(home / ".nvm" / "versions" / "node" / "v20.0.0" / "bin" / "node")
+ env = {
+ "PATH": "/usr/bin:/bin",
+ "HOME": str(home),
+ "NVM_BIN": str(active.parent),
+ "NVM_DIR": str(home / ".nvm"),
+ }
+ found = runtime.resolve_node(env, home=home)
+ assert found == str(active)
+ assert found != str(other)
+
+
+def test_explicit_node_env_wins(tmp_path: Path):
+ chosen = _exe(tmp_path / "custom dir" / "node")
+ decoy = _exe(tmp_path / "opt" / "homebrew" / "bin" / "node")
+ env = {
+ "PATH": "/usr/bin:/bin",
+ "SWITCHBAY_NODE": str(chosen),
+ "HOME": str(tmp_path),
+ }
+ found = runtime.resolve_node(env, home=tmp_path, extra_dirs=(str(decoy.parent),))
+ assert found == str(chosen)
+
+
+def test_spaces_in_home_and_runtime_dir(tmp_path: Path):
+ home = tmp_path / "User Name"
+ volta = _exe(home / ".volta" / "bin" / "node")
+ env = {"PATH": "/usr/bin:/bin", "HOME": str(home), "VOLTA_HOME": str(home / ".volta")}
+ found = runtime.resolve_node(env, home=home)
+ assert found == str(volta)
+
+
+def test_pnpm_home_and_missing_non_executable(tmp_path: Path):
+ home = tmp_path / "home"
+ pnpm = _exe(home / "Library" / "pnpm" / "pnpm")
+ env = {"PATH": "/usr/bin:/bin", "HOME": str(home), "PNPM_HOME": str(pnpm.parent)}
+ assert runtime.resolve_pnpm(env, home=home) == str(pnpm)
+ missing = runtime.resolve_executable(
+ "no-such-bin-xyz-switchbay", {"PATH": "/usr/bin:/bin"}, home=home,
+ )
+ assert missing is None
+ not_exec = tmp_path / "bin" / "not-a-node"
+ not_exec.parent.mkdir(parents=True)
+ not_exec.write_text("not executable\n", encoding="utf-8")
+ not_exec.chmod(0o644)
+ assert runtime.is_executable(not_exec) is False
+ assert runtime.resolve_executable(
+ "not-a-node",
+ {"PATH": str(not_exec.parent)},
+ extra_dirs=(),
+ home=home,
+ ) is None
+
+
+def test_enrich_env_existing_path_beats_fallback(tmp_path: Path):
+ extra = tmp_path / "opt" / "homebrew" / "bin"
+ extra.mkdir(parents=True)
+ env = {"PATH": "/usr/bin:/bin", "HOME": str(tmp_path)}
+ out = runtime.enrich_env(env, extra_dirs=(str(extra),), home=tmp_path)
+ parts = out["PATH"].split(os.pathsep)
+ assert parts[0] == "/usr/bin"
+ assert str(extra) in parts
+ assert "/bin" in parts
+
+
+def test_enrich_env_prepend_wins_over_path(tmp_path: Path):
+ extra = tmp_path / "opt" / "homebrew" / "bin"
+ extra.mkdir(parents=True)
+ chosen = tmp_path / "chosen"
+ chosen.mkdir()
+ env = {"PATH": "/usr/bin:/bin", "HOME": str(tmp_path)}
+ out = runtime.enrich_env(
+ env, extra_dirs=(str(extra),), prepend=(str(chosen),), home=tmp_path,
+ )
+ parts = out["PATH"].split(os.pathsep)
+ assert parts[0] == str(chosen)
+ assert "/usr/bin" in parts
+ assert str(extra) in parts
+
+
+def test_updater_child_env_uses_runtime(tmp_path: Path, monkeypatch):
+ from switchbay import updater
+ monkeypatch.setenv("HOME", str(tmp_path))
+ monkeypatch.setenv("PATH", "/usr/bin:/bin")
+ env = updater.child_env()
+ assert "/usr/bin" in env["PATH"]
+ assert env["GIT_TERMINAL_PROMPT"] == "0"
+ assert env["NPM_CONFIG_YES"] == "true"
+
+
+def test_service_runtime_exports_custom_dirs_and_spaces(tmp_path: Path, monkeypatch):
+ node = _exe(tmp_path / "custom runtime" / "node-bin" / "node")
+ pnpm = _exe(tmp_path / "custom pnpm" / "pnpm")
+ monkeypatch.setattr(runtime, "_SYSTEM_BIN_DIRS", ())
+ monkeypatch.setattr(runtime, "_nvm_bin_dirs", lambda **kw: [])
+ monkeypatch.setattr(runtime, "_version_manager_dirs", lambda **kw: [])
+ env = {
+ "PATH": "/usr/bin:/bin",
+ "HOME": str(tmp_path),
+ "NVM_BIN": str(node.parent),
+ "PNPM_HOME": str(pnpm.parent),
+ "SWITCHBAY_NODE": str(node),
+ "SECRET_TOKEN": "do-not-copy",
+ }
+ out = runtime.service_runtime_exports(environ=env, home=tmp_path)
+ assert out["NVM_BIN"] == str(node.parent)
+ assert out["PNPM_HOME"] == str(pnpm.parent)
+ assert out["SWITCHBAY_NODE"] == str(node)
+ assert "SECRET_TOKEN" not in out
+ parts = out["PATH"].split(os.pathsep)
+ assert parts[:4] == ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]
+ assert str(node.parent) in parts
+ assert str(pnpm.parent) in parts
+ assert "/opt/homebrew/bin" not in parts
diff --git a/tests/unit/test_service_stop.py b/tests/unit/test_service_stop.py
index 1ae6e8f..4d6e1f3 100644
--- a/tests/unit/test_service_stop.py
+++ b/tests/unit/test_service_stop.py
@@ -64,6 +64,75 @@ def test_run_enterprise_user_only_on_install():
assert service.run("status", enterprise_user=True) == 2
+def test_launchd_preserves_custom_runtime_configuration(tmp_path, monkeypatch):
+ import plistlib
+ from pathlib import Path
+
+ def executable(path: Path) -> Path:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
+ path.chmod(0o755)
+ return path
+
+ node = executable(tmp_path / "custom runtime" / "node-bin" / "node")
+ pnpm = executable(tmp_path / "custom pnpm" / "pnpm")
+ py = executable(tmp_path / "repo" / ".venv" / "bin" / "python")
+ monkeypatch.setenv("NVM_BIN", str(node.parent))
+ monkeypatch.setenv("PNPM_HOME", str(pnpm.parent))
+ monkeypatch.setenv("HOME", str(tmp_path))
+ monkeypatch.setenv("PATH", "/usr/bin:/bin")
+ plist = tmp_path / "daemon.plist"
+ monkeypatch.setattr(service, "_mac_plist_path", lambda: plist)
+ monkeypatch.setattr(service, "_venv_python", lambda repo: py)
+ service._mac_write_plist(tmp_path / "repo")
+ env = plistlib.loads(plist.read_bytes())["EnvironmentVariables"]
+ assert env.get("NVM_BIN") == str(node.parent) or str(node.parent) in env.get("PATH", "").split(":"), (
+ "launchd loses custom Node runtime"
+ )
+ assert env.get("PNPM_HOME") == str(pnpm.parent) or str(pnpm.parent) in env.get("PATH", "").split(":"), (
+ "launchd loses custom pnpm runtime"
+ )
+ path_parts = env.get("PATH", "").split(":")
+ assert env["NVM_BIN"] == str(node.parent)
+ assert env["PNPM_HOME"] == str(pnpm.parent)
+ assert path_parts[:4] == ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]
+ assert str(node.parent) in path_parts
+ assert str(pnpm.parent) in path_parts
+ assert " " in str(node.parent)
+ assert "/opt/homebrew/bin" not in path_parts
+
+
+def test_systemd_unit_quotes_runtime_dirs_with_spaces(tmp_path, monkeypatch):
+ from pathlib import Path
+
+ def executable(path: Path) -> Path:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
+ path.chmod(0o755)
+ return path
+
+ node = executable(tmp_path / "custom runtime" / "node-bin" / "node")
+ pnpm = executable(tmp_path / "custom pnpm" / "pnpm")
+ py = executable(tmp_path / "repo" / ".venv" / "bin" / "python")
+ monkeypatch.setenv("NVM_BIN", str(node.parent))
+ monkeypatch.setenv("PNPM_HOME", str(pnpm.parent))
+ monkeypatch.setenv("HOME", str(tmp_path))
+ monkeypatch.setenv("PATH", "/usr/bin:/bin")
+ unit = tmp_path / "switchbay.service"
+ monkeypatch.setattr(service, "_linux_unit_path", lambda: unit)
+ monkeypatch.setattr(service, "_venv_python", lambda repo: py)
+ monkeypatch.setattr(service, "_require_built_frontend", lambda repo: None)
+ monkeypatch.setattr(service, "_systemctl", lambda *a, **k: type("R", (), {"returncode": 0})())
+ monkeypatch.setattr(service.subprocess, "run", lambda *a, **k: type("R", (), {"returncode": 0})())
+ service._linux("install", tmp_path / "repo")
+ text = unit.read_text(encoding="utf-8")
+ assert f'Environment=NVM_BIN="{node.parent}"' in text
+ assert f'Environment=PNPM_HOME="{pnpm.parent}"' in text
+ assert "Environment=PATH=" in text
+ assert str(node.parent) in text
+ assert "/usr/bin:/bin:/usr/sbin:/sbin" in text
+
+
def test_mac_plist_stdio_is_devnull(tmp_path, monkeypatch):
"""launchd must not hold the rotating daemon log fd."""
repo = tmp_path / "repo"
diff --git a/tests/unit/test_slideshow_pdf.py b/tests/unit/test_slideshow_pdf.py
index 7b9f767..f51e180 100644
--- a/tests/unit/test_slideshow_pdf.py
+++ b/tests/unit/test_slideshow_pdf.py
@@ -23,6 +23,8 @@ def test_pdf_renderer_script_exists_and_prints_16x9():
assert "printBackground" in text
assert "preferCSSPageSize" in text
assert "document.fonts.ready" in text
+ assert "@playwright/test" in text
+ assert 'require.resolve("playwright"' in text or "playwright-core" in text
@pytest.mark.asyncio
@@ -53,7 +55,8 @@ async def _broadcast(*_args, **_kwargs):
return None
monkeypatch.setattr(asyncio, "create_subprocess_exec", _exec)
- monkeypatch.setattr(daemon.shutil, "which", lambda _name: "/usr/bin/node")
+ monkeypatch.setattr("switchbay.runtime.resolve_node", lambda *_a, **_k: "/usr/bin/node")
+ monkeypatch.setattr("switchbay.runtime.spawn_env", lambda *_a, **_k: {"PATH": "/usr/bin"})
monkeypatch.setattr(daemon, "_broadcast", _broadcast)
app = web.Application()
diff --git a/tests/unit/test_watch_review.py b/tests/unit/test_watch_review.py
new file mode 100644
index 0000000..bb80bad
--- /dev/null
+++ b/tests/unit/test_watch_review.py
@@ -0,0 +1,369 @@
+"""Independent release probes: preserve data, approvals and concurrent receipts."""
+import hashlib
+import os
+import sqlite3
+import time
+from concurrent.futures import ThreadPoolExecutor
+from pathlib import Path
+
+import pytest
+from switchbay import ce_tools, watchfolders, workspaces
+
+
+@pytest.fixture
+def setup(tmp_path, monkeypatch):
+ monkeypatch.setenv('SWITCHBAY_STATE_DIR', str(tmp_path/'state'))
+ monkeypatch.setattr(workspaces, 'is_within_home', lambda p: True)
+ ws=tmp_path/'ws'; ws.mkdir()
+ folder=tmp_path/'watched'; folder.mkdir()
+ watchfolders.add_folder(ws,str(folder))
+ src=folder/'fixture.txt'; src.write_text('Synthetic source text. '*30)
+ cand=watchfolders.Candidate(path=str(src),logical=str(src),folder=str(folder),size=src.stat().st_size,mtime=time.time()-60,ext='.txt')
+ return ws,folder,src,cand
+
+def test_failed_retry_never_deletes_preexisting_vault_copy(setup):
+ ws,folder,src,cand=setup
+ rel,_=watchfolders.stage_file(ws,src)
+ existing=ws/rel; data=existing.read_bytes()
+ watchfolders.handoff(ws,cand,ingest=lambda *a:{'ok':False,'error':'fixture extraction failed'})
+ assert existing.exists(), 'Failed retry deleted a pre-existing vault source'
+ assert existing.read_bytes()==data
+
+def test_symlink_swap_after_scan_cannot_escape_authorized_folder(setup):
+ ws,folder,src,cand=setup
+ outside=folder.parent/'outside.txt'; outside.write_text('OUTSIDE-SECRET-MARKER '*20)
+ src.unlink(); src.symlink_to(outside)
+ calls=[]
+ result=watchfolders.handoff(ws,cand,ingest=lambda *a:calls.append(a) or {'ok':True})
+ assert not calls, 'File replaced with escaping symlink was handed to extractor'
+ assert result.status!='success'
+ assert not any('OUTSIDE-SECRET-MARKER' in p.read_text(errors='ignore') for p in (ws/'vault').rglob('*') if p.is_file())
+
+def test_concurrent_seen_receipts_are_not_lost(setup,monkeypatch):
+ ws,*_=setup
+ original=watchfolders._load_seen
+ def slow_load(w):
+ value=original(w); time.sleep(.003); return value
+ monkeypatch.setattr(watchfolders,'_load_seen',slow_load)
+ with ThreadPoolExecutor(max_workers=10) as pool:
+ list(pool.map(lambda n:watchfolders.mark_seen(ws,f'file-{n}',mtime=1,size=10),range(40)))
+ seen=original(ws)
+ assert len(seen)==40, f'Lost concurrent seen receipts: kept {len(seen)}/40'
+
+def test_concurrent_pending_retries_are_not_lost(setup,monkeypatch):
+ ws,*_=setup
+ original=watchfolders._load_pending
+ def slow_load(w):
+ value=original(w); time.sleep(.003); return value
+ monkeypatch.setattr(watchfolders,'_load_pending',slow_load)
+ with ThreadPoolExecutor(max_workers=10) as pool:
+ list(pool.map(lambda n:watchfolders.record_pending(ws,f'file-{n}',state='hydrating',error='waiting'),range(40)))
+ pending=original(ws)
+ assert len(pending)==40, f'Lost concurrent pending receipts: kept {len(pending)}/40'
+
+def test_valid_document_can_quote_extractor_error_messages(setup):
+ ws,*_=setup
+ page=ws/'vault'/'valid.pptx.extracted.md'
+ page.parent.mkdir(parents=True,exist_ok=True)
+ page.write_text('---\nextraction_method: python-pptx\nextraction_quality: good\n---\n\n# Troubleshooting documentation\n\nThis legitimate slide quotes the message "PPTX extraction unavailable" and explains how to fix it.\n')
+ out={'ok':1,'considered':1,'failed':0,'results':[{'ok':True,'extracted':str(page),'extraction_method':'python-pptx','extraction_quality':'good'}]}
+ result=ce_tools._reject_failed_structured_extracts(ws,out)
+ assert result.get('ok'), 'Valid research text was misclassified as an extraction error'
+ assert page.exists(), 'Valid extracted content was discarded'
+
+def test_permanent_offline_backoff_does_not_overflow():
+ delay=watchfolders._backoff(10000)
+ assert 0 < delay <= watchfolders.MAX_BACKOFF
+
+
+def test_watcher_provenance_is_preserved_in_vault_index(setup):
+ from switchbay import cebridge
+ ws, *_rest, cand = setup
+ if not (cebridge.ce_root() / 'scripts/local_ingest.py').is_file():
+ pytest.skip('optional curiosity-engine installation absent')
+ result = watchfolders.handoff(ws, cand)
+ assert result.status == 'success', result
+ assert result.vault_rel and (ws / result.vault_rel).is_file(), 'Watcher returned a deleted staging path'
+ db = ws / 'vault' / 'vault.db'
+ assert db.is_file()
+ with sqlite3.connect(db) as conn:
+ sources = [r[0] for r in conn.execute('SELECT source_path FROM sources')]
+ assert cand.logical in sources, f'Index lost original watcher provenance: {sources}'
+
+ extracted = ws / result.extracted
+ rel = str(extracted.relative_to(ws / 'vault'))
+ with sqlite3.connect(db) as conn:
+ row = conn.execute('SELECT sha256 FROM source_meta WHERE path = ?', (rel,)).fetchone()
+ assert row and row[0] == hashlib.sha256(extracted.read_bytes()).hexdigest(), 'Provenance rewrite left stale indexed hash'
+
+
+def test_staging_rejects_same_size_source_changed_within_one_second(setup):
+ ws, folder, src, cand = setup
+ before = src.stat()
+ original = src.read_bytes()
+ src.write_bytes(b'Z' * len(original))
+ os.utime(src, ns=(before.st_atime_ns, before.st_mtime_ns + 250_000_000))
+ with pytest.raises(OSError, match='changed'):
+ watchfolders._copy_bounded(src, ws / 'copied.txt', timeout=5, expected_size=before.st_size, expected_mtime=before.st_mtime)
+
+
+_QUOTE = (
+ 'This legitimate slide quotes the message "PPTX extraction unavailable" '
+ 'and explains how to fix it.\n'
+)
+
+
+@pytest.mark.parametrize("quality,method", [
+ ("good", "python-pptx"),
+ ("partial", "python-pptx"),
+ ("thin", "utf8"),
+ ("unknown", "utf8"),
+ ("", "utf8"),
+])
+def test_valid_nonfailed_quality_can_quote_extractor_errors(setup, quality, method):
+ ws, *_ = setup
+ page = ws / "vault" / f"valid-{quality or 'none'}.pptx.extracted.md"
+ page.parent.mkdir(parents=True, exist_ok=True)
+ fm = [f"extraction_method: {method}"]
+ if quality:
+ fm.append(f"extraction_quality: {quality}")
+ page.write_text("---\n" + "\n".join(fm) + "\n---\n\n# Notes\n\n" + _QUOTE)
+ row = {"ok": True, "extracted": str(page), "extraction_method": method}
+ if quality:
+ row["extraction_quality"] = quality
+ out = {"ok": 1, "considered": 1, "failed": 0, "results": [row]}
+ result = ce_tools._reject_failed_structured_extracts(ws, out)
+ assert result.get("ok"), result
+ assert page.exists(), "Valid extracted content was discarded"
+
+
+def test_valid_document_without_metadata_can_quote_extractor_errors(setup):
+ ws, *_ = setup
+ page = ws / "vault" / "notes.extracted.md"
+ page.parent.mkdir(parents=True, exist_ok=True)
+ page.write_text("# Research notes\n\n" + _QUOTE)
+ out = {
+ "ok": 1, "considered": 1, "failed": 0,
+ "results": [{"ok": True, "extracted": str(page)}],
+ }
+ result = ce_tools._reject_failed_structured_extracts(ws, out)
+ assert result.get("ok"), result
+ assert page.exists()
+
+
+def test_generated_failed_fallback_metadata_is_still_rejected(setup):
+ ws, *_ = setup
+ page = ws / "vault" / "missing.pptx.extracted.md"
+ page.parent.mkdir(parents=True, exist_ok=True)
+ page.write_text(
+ "---\nextraction_method: pptx_failed\nextraction_quality: failed\n---\n\n"
+ "(PPTX extraction unavailable — `python-pptx` not installed.)\n"
+ )
+ out = {
+ "ok": 1, "considered": 1, "failed": 0,
+ "results": [{
+ "ok": True, "extracted": str(page),
+ "extraction_method": "pptx_failed",
+ "extraction_quality": "failed",
+ }],
+ }
+ result = ce_tools._reject_failed_structured_extracts(ws, out)
+ assert result.get("ok") is False
+ assert result.get("retryable") is True
+ assert not page.exists()
+
+
+def test_rejected_extract_removed_from_index_similar_name_unchanged(setup):
+ ws, *_ = setup
+ vault = ws / "vault"
+ vault.mkdir(parents=True, exist_ok=True)
+ good = vault / "good.txt.extracted.md"
+ bad = vault / "corrupt.pptx.extracted.md"
+ decoy = vault / "also_corrupt.pptx.extracted.md"
+ good.write_text(
+ "---\nextraction_method: utf8\nextraction_quality: good\n---\n\n"
+ "GOOD-KEEP-MARKER\n"
+ )
+ bad.write_text(
+ "---\nextraction_method: pptx_failed\nextraction_quality: failed\n---\n\n"
+ "(PPTX extraction unavailable)\n"
+ )
+ decoy.write_text("UNRELATED-DECOY-BODY\n")
+ db = vault / "vault.db"
+ decoy_hash = hashlib.sha256(decoy.read_bytes()).hexdigest()
+ with sqlite3.connect(db) as conn:
+ conn.execute(
+ "CREATE VIRTUAL TABLE sources USING fts5("
+ "path, title, body, date, source_path)"
+ )
+ conn.execute(
+ "CREATE TABLE source_meta ("
+ "path TEXT PRIMARY KEY, sha256 TEXT, indexed_at TEXT)"
+ )
+ for p, src in (
+ (good, str(good)),
+ (bad, str(bad)),
+ (decoy, "/unrelated/also_corrupt.pptx"),
+ ):
+ rel = p.name
+ conn.execute(
+ "INSERT INTO sources(path, title, body, date, source_path) "
+ "VALUES(?,?,?,?,?)",
+ (rel, rel, p.read_text(encoding="utf-8"), "", src),
+ )
+ conn.execute(
+ "INSERT INTO source_meta(path, sha256, indexed_at) "
+ "VALUES(?,?,?)",
+ (rel, hashlib.sha256(p.read_bytes()).hexdigest(), "now"),
+ )
+ conn.commit()
+ original_bin = vault / "corrupt.pptx"
+ original_bin.write_bytes(b"PK\x03\x04 not a real deck")
+ out = {
+ "ok": 2, "considered": 2, "failed": 0,
+ "results": [
+ {
+ "ok": True, "extracted": str(good),
+ "extraction_method": "utf8", "extraction_quality": "good",
+ },
+ {
+ "ok": True, "extracted": str(bad),
+ "extraction_method": "pptx_failed",
+ "extraction_quality": "failed",
+ "indexed": {"path": bad.name},
+ },
+ ],
+ }
+ result = ce_tools._reject_failed_structured_extracts(ws, out)
+ assert result.get("ok") == 1, result
+ assert result.get("failed") == 1, result
+ assert len(result.get("results") or []) == 1
+ assert good.exists()
+ assert not bad.exists()
+ assert original_bin.exists()
+ with sqlite3.connect(db) as conn:
+ paths = [r[0] for r in conn.execute("SELECT path FROM sources")]
+ meta = {
+ r[0]: r[1]
+ for r in conn.execute("SELECT path, sha256 FROM source_meta")
+ }
+ bodies = {
+ r[0]: r[1]
+ for r in conn.execute("SELECT path, body FROM sources")
+ }
+ decoy_src = conn.execute(
+ "SELECT source_path FROM sources WHERE path = ?", (decoy.name,),
+ ).fetchone()
+ assert bad.name not in paths
+ assert bad.name not in meta
+ assert good.name in paths
+ assert "GOOD-KEEP-MARKER" in (bodies.get(good.name) or "")
+ assert decoy.name in paths
+ assert meta.get(decoy.name) == decoy_hash
+ assert decoy_src and decoy_src[0] == "/unrelated/also_corrupt.pptx"
+
+
+def test_provenance_update_does_not_touch_similar_index_names(setup):
+ ws, *_ = setup
+ vault = ws / "vault"
+ vault.mkdir(parents=True, exist_ok=True)
+ target = vault / "fixture.txt.extracted.md"
+ decoy = vault / "also_fixture.txt.extracted.md"
+ target.write_text("---\nsource_path: /watched/fixture.txt\n---\n\nbody\n")
+ decoy.write_text("DECOY-BODY\n")
+ db = vault / "vault.db"
+ decoy_hash = hashlib.sha256(decoy.read_bytes()).hexdigest()
+ with sqlite3.connect(db) as conn:
+ conn.execute(
+ "CREATE VIRTUAL TABLE sources USING fts5("
+ "path, title, body, date, source_path)"
+ )
+ conn.execute(
+ "CREATE TABLE source_meta ("
+ "path TEXT PRIMARY KEY, sha256 TEXT, indexed_at TEXT)"
+ )
+ for p, src in ((target, "old"), (decoy, "/unrelated/also_fixture.txt")):
+ conn.execute(
+ "INSERT INTO sources(path, title, body, date, source_path) "
+ "VALUES(?,?,?,?,?)",
+ (p.name, p.name, p.read_text(encoding="utf-8"), "", src),
+ )
+ conn.execute(
+ "INSERT INTO source_meta(path, sha256, indexed_at) "
+ "VALUES(?,?,?)",
+ (p.name, hashlib.sha256(p.read_bytes()).hexdigest(), "now"),
+ )
+ conn.commit()
+ watchfolders._set_index_source_path_exact(
+ ws, watchfolders.extract_index_paths(ws, target), "/watched/fixture.txt",
+ )
+ with sqlite3.connect(db) as conn:
+ decoy_row = conn.execute(
+ "SELECT source_path, body FROM sources WHERE path = ?",
+ (decoy.name,),
+ ).fetchone()
+ decoy_meta = conn.execute(
+ "SELECT sha256 FROM source_meta WHERE path = ?", (decoy.name,),
+ ).fetchone()
+ target_src = conn.execute(
+ "SELECT source_path FROM sources WHERE path = ?", (target.name,),
+ ).fetchone()
+ assert decoy_row == ("/unrelated/also_fixture.txt", "DECOY-BODY\n")
+ assert decoy_meta and decoy_meta[0] == decoy_hash
+ assert target_src and target_src[0] == "/watched/fixture.txt"
+
+
+def test_success_cleans_stage_only_when_ce_kept_separate_source(setup):
+ ws, folder, src, cand = setup
+ kept = ws / "vault" / "kept-original.txt"
+ kept.parent.mkdir(parents=True, exist_ok=True)
+
+ def ingest(workspace, rel, timeout):
+ del timeout
+ data = (workspace / rel).read_bytes()
+ kept.write_bytes(data)
+ extracted = ws / "vault" / "note.extracted.md"
+ extracted.write_text("payload\n", encoding="utf-8")
+ return {
+ "ok": 1,
+ "results": [{
+ "ok": True, "extracted": str(extracted),
+ "kept": str(kept),
+ "extraction_method": "utf8", "extraction_quality": "good",
+ }],
+ }
+
+ result = watchfolders.handoff(ws, cand, ingest=ingest)
+ assert result.status == "success", result
+ assert kept.is_file()
+ assert src.is_file()
+ assert result.vault_rel
+ assert (ws / result.vault_rel).is_file()
+ assert (ws / result.vault_rel).resolve() == kept.resolve()
+ stage_root = ws / "vault" / ".watch-ingest"
+ leftover = [p for p in stage_root.rglob("*") if p.is_file()] if stage_root.exists() else []
+ assert leftover == []
+
+
+def test_success_keeps_stage_when_source_is_in_place(setup):
+ ws, folder, src, cand = setup
+
+ def ingest(workspace, rel, timeout):
+ del timeout
+ extracted = ws / "vault" / "note.extracted.md"
+ extracted.parent.mkdir(parents=True, exist_ok=True)
+ extracted.write_text("payload\n", encoding="utf-8")
+ return {
+ "ok": 1,
+ "results": [{
+ "ok": True, "extracted": str(extracted),
+ "source_in_place": True,
+ "extraction_method": "utf8", "extraction_quality": "good",
+ }],
+ }
+
+ result = watchfolders.handoff(ws, cand, ingest=ingest)
+ assert result.status == "success", result
+ assert src.is_file()
+ assert result.vault_rel
+ assert (ws / result.vault_rel).is_file()
diff --git a/tests/unit/test_watchfolders.py b/tests/unit/test_watchfolders.py
new file mode 100644
index 0000000..6332e26
--- /dev/null
+++ b/tests/unit/test_watchfolders.py
@@ -0,0 +1,365 @@
+"""Watch-folder scan, hydration pending, retry, races, no shell injection."""
+
+from __future__ import annotations
+
+import os
+import time
+from pathlib import Path
+
+import pytest
+
+from switchbay import icloud_download, statedir, watchfolders
+from switchbay.icloud_download import HydrateResult
+
+
+def _watch_ws(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.setattr("switchbay.workspaces.is_within_home", lambda _p: True)
+ monkeypatch.setattr(watchfolders, "SETTLE_SECONDS", 0.0)
+ monkeypatch.setattr(watchfolders, "_backoff", lambda _n: 0.0)
+ ws = tmp_path / "ws"
+ (ws / "vault").mkdir(parents=True)
+ (ws / ".workbench").mkdir()
+ folder = tmp_path / "watch"
+ folder.mkdir()
+ rec = watchfolders.add_folder(ws, str(folder))
+ assert isinstance(rec, dict)
+ return ws, folder
+
+
+def test_baseline_ignores_preexisting_and_picks_new(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
+):
+ monkeypatch.setattr("switchbay.workspaces.is_within_home", lambda _p: True)
+ monkeypatch.setattr(watchfolders, "SETTLE_SECONDS", 0.0)
+ ws = tmp_path / "ws"
+ ws.mkdir()
+ folder = tmp_path / "watch"
+ folder.mkdir()
+ old = folder / "old.md"
+ old.write_text("old", encoding="utf-8")
+ watchfolders.add_folder(ws, str(folder))
+ picked, _ = watchfolders.scan_candidates(ws)
+ assert picked == []
+ new = folder / "new.md"
+ new.write_text("hello", encoding="utf-8")
+ picked, _ = watchfolders.scan_candidates(ws)
+ assert [c.logical for c in picked] == [str(new.resolve())]
+ seen = watchfolders._load_seen(ws)
+ assert str(new.resolve()) not in seen
+ assert str(old.resolve()) in seen
+
+
+def test_size0_and_icloud_stub_are_pending_not_seen(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
+):
+ ws, folder = _watch_ws(tmp_path, monkeypatch)
+ empty = folder / "empty.md"
+ empty.write_bytes(b"")
+ stub = folder / ".Deck.pptx.icloud"
+ stub.write_bytes(b"")
+ picked, _ = watchfolders.scan_candidates(ws)
+ kinds = {Path(c.logical).name: c.hydrate for c in picked}
+ assert kinds.get("empty.md") == "size0"
+ assert kinds.get("Deck.pptx") == "icloud_stub"
+ seen = watchfolders._load_seen(ws)
+ assert str(empty.resolve()) not in seen
+ logical_stub = str((folder / "Deck.pptx").resolve())
+ assert logical_stub not in seen
+
+
+def test_hydration_pending_then_ready_exactly_once(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
+):
+ ws, folder = _watch_ws(tmp_path, monkeypatch)
+ src = folder / "cloud.md"
+ src.write_text("payload-once", encoding="utf-8")
+ calls = {"hydrate": 0, "ingest": 0}
+ dataless = {"on": True}
+
+ def hydrate(path: Path, *, timeout: float = 8.0) -> HydrateResult:
+ del timeout
+ calls["hydrate"] += 1
+ if calls["hydrate"] < 2:
+ return HydrateResult(
+ ok=False, ready=False, path=str(path),
+ error="timeout", retryable=True, detail="timeout",
+ )
+ dataless["on"] = False
+ return HydrateResult(ok=True, ready=True, path=str(path), retryable=False)
+
+ def ingest(workspace: Path, rel: str, timeout: float):
+ del workspace, timeout
+ calls["ingest"] += 1
+ dest = ws / rel
+ assert dest.is_file() and dest.stat().st_size > 0
+ extracted = ws / "vault" / "cloud.md.extracted.md"
+ extracted.write_text("payload-once\n", encoding="utf-8")
+ return {
+ "ok": 1,
+ "results": [{
+ "ok": True,
+ "extracted": str(extracted),
+ "extraction_method": "utf8",
+ "extraction_quality": "good",
+ }],
+ }
+
+ monkeypatch.setattr(
+ statedir, "is_dataless",
+ lambda p: Path(p).name == "cloud.md" and dataless["on"],
+ )
+ picked, _ = watchfolders.scan_candidates(ws)
+ assert len(picked) == 1
+ first = watchfolders.handoff(
+ ws, picked[0], hydrate=hydrate, ingest=ingest,
+ )
+ assert first.status == "pending"
+ assert calls["ingest"] == 0
+ assert str(src.resolve()) not in watchfolders._load_seen(ws)
+
+ # Backoff is 0; file is due again.
+ picked2, _ = watchfolders.scan_candidates(ws)
+ assert len(picked2) == 1
+ second = watchfolders.handoff(
+ ws, picked2[0], hydrate=hydrate, ingest=ingest,
+ )
+ assert second.status == "success", second
+ assert calls["ingest"] == 1
+ assert str(src.resolve()) in watchfolders._load_seen(ws)
+ picked3, _ = watchfolders.scan_candidates(ws)
+ assert picked3 == []
+ assert calls["ingest"] == 1
+ assert calls["hydrate"] == 2
+
+
+def test_timeout_failure_retries_then_succeeds(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
+):
+ ws, folder = _watch_ws(tmp_path, monkeypatch)
+ src = folder / "note.md"
+ src.write_text("ok-body", encoding="utf-8")
+ ingest_calls = {"n": 0}
+
+ def ingest(workspace: Path, rel: str, timeout: float):
+ del workspace, timeout
+ ingest_calls["n"] += 1
+ if ingest_calls["n"] == 1:
+ return {"ok": False, "error": "boom", "retryable": True}
+ extracted = ws / "vault" / "note.md.extracted.md"
+ extracted.write_text("ok-body\n", encoding="utf-8")
+ return {
+ "ok": 1,
+ "results": [{
+ "ok": True, "extracted": str(extracted),
+ "extraction_method": "utf8", "extraction_quality": "good",
+ }],
+ }
+
+ picked, _ = watchfolders.scan_candidates(ws)
+ r1 = watchfolders.handoff(ws, picked[0], ingest=ingest)
+ assert r1.status == "error" and r1.retryable
+ assert str(src.resolve()) not in watchfolders._load_seen(ws)
+ picked2, _ = watchfolders.scan_candidates(ws)
+ r2 = watchfolders.handoff(ws, picked2[0], ingest=ingest)
+ assert r2.status == "success"
+ assert ingest_calls["n"] == 2
+ assert str(src.resolve()) in watchfolders._load_seen(ws)
+
+
+def test_pause_during_hydrate_does_not_mark_seen(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
+):
+ ws, folder = _watch_ws(tmp_path, monkeypatch)
+ src = folder / "x.md"
+ src.write_text("x", encoding="utf-8")
+ dataless = {"on": True}
+
+ def hydrate(path: Path, *, timeout: float = 8.0) -> HydrateResult:
+ del timeout
+ watchfolders.set_enabled(ws, str(folder.resolve()), False)
+ dataless["on"] = False
+ return HydrateResult(ok=True, ready=True, path=str(path))
+
+ monkeypatch.setattr(
+ statedir, "is_dataless",
+ lambda p: Path(p).name == "x.md" and dataless["on"],
+ )
+ picked, _ = watchfolders.scan_candidates(ws)
+ result = watchfolders.handoff(ws, picked[0], hydrate=hydrate, ingest=lambda *_a: {"ok": 1})
+ assert result.status == "skipped"
+ assert str(src.resolve()) not in watchfolders._load_seen(ws)
+
+
+def test_remove_during_stage_does_not_mark_seen(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
+):
+ ws, folder = _watch_ws(tmp_path, monkeypatch)
+ src = folder / "y.md"
+ src.write_text("yyyy", encoding="utf-8")
+ orig_stage = watchfolders.stage_file
+
+ def stage_and_remove(workspace, src_path, *, timeout=30.0):
+ rel, size = orig_stage(workspace, src_path, timeout=timeout)
+ watchfolders.remove_folder(workspace, str(folder.resolve()))
+ return rel, size
+
+ monkeypatch.setattr(watchfolders, "stage_file", stage_and_remove)
+ picked, _ = watchfolders.scan_candidates(ws)
+ result = watchfolders.handoff(
+ ws, picked[0], ingest=lambda *_a: {"ok": 1, "results": [{"ok": True, "extracted": "x"}]},
+ )
+ assert result.status == "skipped"
+ assert str(src.resolve()) not in watchfolders._load_seen(ws)
+
+
+def test_symlink_does_not_escape_folder(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
+):
+ ws, folder = _watch_ws(tmp_path, monkeypatch)
+ outside = tmp_path / "secret"
+ outside.mkdir()
+ target = outside / "leak.md"
+ target.write_text("nope", encoding="utf-8")
+ link = folder / "leak.md"
+ link.symlink_to(target)
+ picked, _ = watchfolders.scan_candidates(ws)
+ assert picked == []
+
+
+def test_ready_files_not_starved_by_pending(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
+):
+ ws, folder = _watch_ws(tmp_path, monkeypatch)
+ monkeypatch.setattr(watchfolders, "MAX_PER_BEAT", 2)
+ for i in range(3):
+ p = folder / f"pend{i}.md"
+ p.write_bytes(b"")
+ ready_a = folder / "ready-a.md"
+ ready_b = folder / "ready-b.md"
+ ready_a.write_text("a", encoding="utf-8")
+ ready_b.write_text("b", encoding="utf-8")
+ # Make pending older so FIFO would prefer them if we didn't prioritize ready.
+ older = time.time() - 100
+ for i in range(3):
+ os.utime(folder / f"pend{i}.md", (older, older))
+ picked, backlog = watchfolders.scan_candidates(ws)
+ names = [Path(c.logical).name for c in picked]
+ assert any(n.startswith("ready-") for n in names)
+ assert any(n.startswith("pend") for n in names)
+ assert backlog >= 1
+
+
+def test_inflight_skipped(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ ws, folder = _watch_ws(tmp_path, monkeypatch)
+ src = folder / "z.md"
+ src.write_text("z", encoding="utf-8")
+ picked, _ = watchfolders.scan_candidates(
+ ws, inflight={str(src.resolve())},
+ )
+ assert picked == []
+
+
+def test_unsupported_extension_marked_seen_not_ingested(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
+):
+ ws, folder = _watch_ws(tmp_path, monkeypatch)
+ dmg = folder / "Installer.dmg"
+ dmg.write_bytes(b"not-ingestible")
+ picked, _ = watchfolders.scan_candidates(ws)
+ assert picked == []
+ assert str(dmg.resolve()) in watchfolders._load_seen(ws)
+
+
+def test_helper_argv_has_no_shell_interpolation():
+ nasty = Path("/tmp/foo; rm -rf /; echo.pptx")
+ argv = icloud_download.helper_argv("start", nasty)
+ assert argv[0] == "/usr/bin/osascript"
+ assert argv[1] == "-l"
+ assert argv[2] == "JavaScript"
+ assert argv[4] == "start"
+ assert argv[5] == str(nasty)
+ joined = " ".join(argv[:-1])
+ assert "rm -rf" not in joined
+ assert "; rm" not in joined
+
+
+def test_normal_local_txt_handoff_unchanged(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
+):
+ ws, folder = _watch_ws(tmp_path, monkeypatch)
+ src = folder / "local.md"
+ src.write_text("local-body", encoding="utf-8")
+ ingest_calls = {"n": 0}
+
+ def ingest(workspace, rel, timeout):
+ del workspace, timeout
+ ingest_calls["n"] += 1
+ extracted = ws / "vault" / "local.md.extracted.md"
+ extracted.write_text("local-body\n", encoding="utf-8")
+ return {
+ "ok": 1,
+ "results": [{
+ "ok": True, "extracted": str(extracted),
+ "extraction_method": "utf8", "extraction_quality": "good",
+ }],
+ }
+
+ picked, _ = watchfolders.scan_candidates(ws)
+ result = watchfolders.handoff(ws, picked[0], ingest=ingest)
+ assert result.status == "success"
+ assert ingest_calls["n"] == 1
+ assert (ws / result.vault_rel).is_file()
+ assert (ws / result.vault_rel).stat().st_size > 0
+
+
+def test_fair_pick_gives_due_retries_a_slot_when_ready_fills_cap():
+ def cand(name: str, hydrate: str = "") -> watchfolders.Candidate:
+ return watchfolders.Candidate(
+ path=f"/tmp/{name}", logical=f"/tmp/{name}",
+ folder="/tmp", size=10, mtime=1.0, hydrate=hydrate, ext=".md",
+ )
+ ready = [cand(f"r{i}.md") for i in range(10)]
+ due = [cand(f"d{i}.md", hydrate="dataless") for i in range(10)]
+ picked = watchfolders._fair_pick(ready, due, 5)
+ names = [Path(c.logical).name for c in picked]
+ assert any(n.startswith("r") for n in names)
+ assert any(n.startswith("d") for n in names)
+ assert len(picked) == 5
+
+
+def test_backoff_exponent_is_capped():
+ a = watchfolders._backoff(1)
+ huge = watchfolders._backoff(10_000)
+ assert huge == watchfolders.MAX_BACKOFF
+ assert huge >= a
+ # Must not overflow to inf / raise.
+ assert huge < float("inf")
+
+
+@pytest.mark.asyncio
+async def test_dispatch_uses_workspace_captured_at_scan(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
+):
+ from switchbay import daemon
+ monkeypatch.setattr("switchbay.workspaces.is_within_home", lambda _p: True)
+ ws_a = tmp_path / "ws-a"
+ ws_b = tmp_path / "ws-b"
+ ws_a.mkdir()
+ ws_b.mkdir()
+ seen: list[Path] = []
+
+ def fake_handoff(workspace, cand, **_k):
+ seen.append(Path(workspace))
+ return watchfolders.HandoffResult(status="success", logical=cand.logical)
+
+ monkeypatch.setattr(watchfolders, "handoff", fake_handoff)
+ monkeypatch.setattr(daemon, "_log_event", lambda *_a, **_k: None)
+ monkeypatch.setattr(daemon, "_broadcast_files_changed_soon", lambda *_a, **_k: None)
+ app = {"workspace": ws_b, "watch_inflight": set()}
+ cand = watchfolders.Candidate(
+ path="/tmp/x.md", logical="/tmp/x.md", folder="/tmp",
+ size=1, mtime=1.0, ext=".md",
+ )
+ await daemon._dispatch_watch_ingest(app, cand, workspace=ws_a)
+ assert seen == [ws_a]
+ assert app["workspace"] == ws_b
diff --git a/tests/unit/test_web_consent.py b/tests/unit/test_web_consent.py
new file mode 100644
index 0000000..df3c7dc
--- /dev/null
+++ b/tests/unit/test_web_consent.py
@@ -0,0 +1,212 @@
+"""Protected web egress: shared execute boundary, unforgeable consent."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from urllib.error import URLError
+
+from switchbay import permissions, research, tools, web_policy
+from switchbay.llmgateway import claude_code_settings
+
+
+def test_direct_registry_call_requires_approval(tmp_path: Path, monkeypatch):
+ import urllib.request
+ web_policy.save(tmp_path, enabled=True)
+ monkeypatch.setattr(web_policy, "admin_allows", lambda: True)
+ calls = []
+ monkeypatch.setattr(research, "search_web", lambda *a, **kw: calls.append(a) or {"ok": True})
+
+ def unavailable(*a, **kw):
+ raise URLError("test: approval daemon unavailable")
+
+ monkeypatch.setattr(urllib.request, "urlopen", unavailable)
+ out = tools.execute("research_search", tmp_path, {"query": "private query"})
+ assert not calls, "search ran without per-call approval"
+ assert out.get("ok") is False
+
+
+def test_payload_approved_flag_is_ignored(tmp_path: Path, monkeypatch):
+ import urllib.request
+ web_policy.save(tmp_path, enabled=True)
+ monkeypatch.setattr(web_policy, "admin_allows", lambda: True)
+ calls = []
+ monkeypatch.setattr(research, "search_web", lambda *a, **kw: calls.append(a) or {"ok": True})
+ monkeypatch.setattr(
+ urllib.request, "urlopen",
+ lambda *a, **kw: (_ for _ in ()).throw(URLError("down")),
+ )
+ out = tools.execute(
+ "research_search", tmp_path,
+ {"query": "x", "_approved": True, "approved": True},
+ )
+ assert not calls
+ assert out.get("ok") is False
+
+
+def test_trusted_consent_runs_handler(tmp_path: Path, monkeypatch):
+ web_policy.save(tmp_path, enabled=True)
+ monkeypatch.setattr(web_policy, "admin_allows", lambda: True)
+ monkeypatch.setattr(research, "search_web", lambda *a, **kw: {"ok": True, "hits": []})
+ out = tools.execute(
+ "research_search", tmp_path, {"query": "x"},
+ consent=permissions.trusted_consent(),
+ )
+ assert out.get("ok") is True
+
+
+def test_forged_consent_object_is_rejected(tmp_path: Path, monkeypatch):
+ import urllib.request
+ web_policy.save(tmp_path, enabled=True)
+ monkeypatch.setattr(web_policy, "admin_allows", lambda: True)
+ calls = []
+ monkeypatch.setattr(research, "search_web", lambda *a, **kw: calls.append(1) or {"ok": True})
+ monkeypatch.setattr(
+ urllib.request, "urlopen",
+ lambda *a, **kw: (_ for _ in ()).throw(URLError("down")),
+ )
+ out = tools.execute(
+ "research_search", tmp_path, {"query": "x"},
+ consent=object(),
+ )
+ assert not calls
+ assert out.get("ok") is False
+
+
+def test_toggle_off_after_consent_prevents_handler(tmp_path: Path, monkeypatch):
+ web_policy.save(tmp_path, enabled=True)
+ monkeypatch.setattr(web_policy, "admin_allows", lambda: True)
+ calls = []
+ monkeypatch.setattr(research, "search_web", lambda *a, **kw: calls.append(1) or {"ok": True})
+ consent = permissions.trusted_consent()
+ web_policy.save(tmp_path, enabled=False)
+ out = tools.execute(
+ "research_search", tmp_path, {"query": "x"}, consent=consent,
+ )
+ assert not calls
+ assert out.get("ok") is False
+
+
+def test_ce_identifier_resolve_requires_consent():
+ assert permissions.needs_web_consent("ce_run", {
+ "script": "identifier_resolve.py", "args": ["run", "--yes"],
+ })
+ assert not permissions.needs_web_consent("ce_run", {
+ "script": "identifier_resolve.py", "args": ["status"],
+ })
+ assert not permissions.needs_web_consent("ce_run", {
+ "script": "identifier_resolve.py", "args": ["review"],
+ })
+
+
+def test_ce_run_url_args_require_consent(tmp_path: Path, monkeypatch):
+ import urllib.request
+ from switchbay import ce_tools
+ web_policy.save(tmp_path, enabled=True)
+ monkeypatch.setattr(web_policy, "admin_allows", lambda: True)
+ ran = []
+ monkeypatch.setattr(
+ "switchbay.cebridge.run_script",
+ lambda *a, **k: ran.append(1) or {"ok": True},
+ )
+ monkeypatch.setattr(
+ urllib.request, "urlopen",
+ lambda *a, **kw: (_ for _ in ()).throw(URLError("down")),
+ )
+ out = tools.execute(
+ "ce_run", tmp_path,
+ {"script": "sweep.py", "args": ["https://example.edu/x"]},
+ )
+ assert not ran
+ assert out.get("ok") is False
+ out2 = tools.execute(
+ "ce_run", tmp_path,
+ {"script": "sweep.py", "args": ["https://example.edu/x"]},
+ consent=permissions.trusted_consent(),
+ )
+ assert ran
+ assert "error" not in out2 or out2.get("ok") is not False
+
+
+def test_ce_ingest_local_path_does_not_need_web_card(tmp_path: Path, monkeypatch):
+ monkeypatch.setattr(
+ "switchbay.cebridge.run_script",
+ lambda *a, **k: {"ok": True},
+ )
+ monkeypatch.setattr(
+ "switchbay.ce_tools._with_ingest_prep",
+ lambda *a, **k: (["vault/raw/a.md"], {"ok": True}),
+ )
+ out = tools.execute("ce_ingest", tmp_path, {"path": "vault/raw/a.md"})
+ assert isinstance(out, dict)
+
+
+def test_ce_cli_research_requires_approval(tmp_path: Path, monkeypatch, capsys):
+ import urllib.request
+ from switchbay.ce_cli import main
+ web_policy.save(tmp_path, enabled=True)
+ monkeypatch.setattr(web_policy, "admin_allows", lambda: True)
+ calls = []
+ monkeypatch.setattr(research, "search_web", lambda *a, **kw: calls.append(a) or {"ok": True})
+ monkeypatch.setattr(
+ urllib.request, "urlopen",
+ lambda *a, **kw: (_ for _ in ()).throw(URLError("down")),
+ )
+ rc = main([
+ "--workspace", str(tmp_path),
+ "--tool", "research_search",
+ "--input", json.dumps({"query": "private"}),
+ ])
+ assert rc != 0
+ assert not calls
+
+
+def test_mcp_call_does_not_pre_card(tmp_path: Path, monkeypatch):
+ """MCP server must not card; tools.execute is the single card."""
+ from switchbay import mcp_server
+ web_policy.save(tmp_path, enabled=True)
+ monkeypatch.setattr(web_policy, "admin_allows", lambda: True)
+ cards = []
+ monkeypatch.setattr(
+ mcp_server, "_request_web_approval",
+ lambda *a, **k: cards.append(1) or "approve",
+ )
+ monkeypatch.setattr(
+ tools, "execute",
+ lambda *a, **k: {"ok": True, "hits": []},
+ )
+ out = mcp_server._call_tool(tmp_path, "research_search", {"query": "q"})
+ assert cards == []
+ assert out.get("isError") is False
+
+
+def test_mute_protected_pending_denies(tmp_path: Path):
+ rec = permissions.register(
+ workspace=tmp_path, provider="mcp", tool="research_search",
+ tool_input={"query": "q"}, run_id=None, origin="~/bench",
+ )
+ out = permissions.resolve(rec.req_id, decision="skip", remember=False)
+ assert out is not None
+ assert out.decision == "deny"
+
+
+def test_hook_mcp_research_passthrough_native_web_denies(tmp_path: Path):
+ from tests.unit.test_permission_scoping import _run_hook
+ mcp = _run_hook(tmp_path, {
+ "tool_name": "mcp__switchbay__research_search",
+ "tool_input": {"query": "q"},
+ "session_id": "s1", "cwd": str(tmp_path),
+ }, port=1)
+ assert mcp == {}
+ native = _run_hook(tmp_path, {
+ "tool_name": "WebSearch",
+ "tool_input": {"query": "q"},
+ "session_id": "s1", "cwd": str(tmp_path),
+ }, port=1)
+ assert native.get("decision") == "deny"
+
+
+def test_unrelated_tool_still_executes(tmp_path: Path):
+ out = tools.execute("list_duckdb_starters", tmp_path, {})
+ assert isinstance(out, dict)
+ assert "starters" in out
diff --git a/tests/unit/test_web_egress_policy.py b/tests/unit/test_web_egress_policy.py
new file mode 100644
index 0000000..eaa837a
--- /dev/null
+++ b/tests/unit/test_web_egress_policy.py
@@ -0,0 +1,196 @@
+"""Workspace web policy: default off, per-call, never blanket."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from switchbay import permissions, web_policy
+from switchbay.llmgateway import openai_codex
+
+
+def test_default_off(tmp_path: Path):
+ assert web_policy.is_enabled(tmp_path) is False
+ assert web_policy.effective_enabled(tmp_path) is False
+
+
+def test_toggle_persists_per_workspace(tmp_path: Path):
+ web_policy.save(tmp_path, enabled=True)
+ assert web_policy.is_enabled(tmp_path) is True
+ other = tmp_path / "other"
+ other.mkdir()
+ assert web_policy.is_enabled(other) is False
+
+
+def test_protected_never_pre_approved_even_with_mcp_wildcard(tmp_path: Path):
+ permissions.add_pattern(tmp_path, "mcp__switchbay__*")
+ permissions.add_pattern(tmp_path, "WebSearch(*)")
+ permissions.add_pattern(tmp_path, "_codex:web-search")
+ for tool, payload in (
+ ("WebSearch", {"query": "q"}),
+ ("web_search", {"query": "q"}),
+ ("research_search", {"query": "q"}),
+ ("mcp__switchbay__research_fetch", {"url": "https://example.edu/x"}),
+ ):
+ pat = permissions.pattern_for(tool, payload)
+ assert permissions.is_protected_egress(tool)
+ assert not permissions.is_pre_approved(
+ tmp_path, pat, tool=tool, tool_input=payload,
+ )
+
+
+def test_add_pattern_ignores_protected(tmp_path: Path):
+ before = permissions.list_allowed(tmp_path)
+ permissions.add_pattern(tmp_path, "_codex:web-search")
+ permissions.add_pattern(tmp_path, "WebSearch(*)")
+ permissions.add_pattern(tmp_path, "Bash(npm test*)")
+ after = permissions.list_allowed(tmp_path)
+ assert "_codex:web-search" not in after
+ assert "WebSearch(*)" not in after
+ assert "Bash(npm test*)" in after
+ assert set(before).issubset(set(after))
+
+
+def test_resolve_ignores_remember_for_protected(tmp_path: Path):
+ rec = permissions.register(
+ workspace=tmp_path, provider="claude-code", tool="WebSearch",
+ tool_input={"query": "qwen"}, run_id=None,
+ )
+ out = permissions.resolve(
+ rec.req_id, decision="approve", remember=True,
+ pattern="WebSearch(*)", session=True,
+ )
+ assert out is not None
+ assert out.remember is False
+ assert "WebSearch(*)" not in permissions.list_allowed(tmp_path)
+ session = permissions._SESSION_ALLOW.get(str(tmp_path), set())
+ assert "WebSearch(*)" not in session
+
+
+def test_codex_native_search_always_disabled(tmp_path: Path):
+ permissions.add_pattern(tmp_path, "_codex:web-search")
+ argv = openai_codex.web_search_overrides(tmp_path)
+ assert "-c" in argv
+ assert 'web_search="disabled"' in argv
+ assert "tools.web_search=false" in argv
+ assert "tools.web_search=true" not in argv
+ assert "web_search=live" not in argv
+ assert 'web_search="live"' not in argv
+
+
+def test_codex_override_beats_preexisting_live(tmp_path: Path, monkeypatch):
+ """Authoritative `-c web_search="disabled"` must appear so a user's
+ ~/.codex/config.toml `web_search = "live"` cannot win.
+
+ Codex `-c` flags parse as TOML and override the config file
+ (developers.openai.com/codex/config-basic).
+ """
+ import shutil
+ import subprocess
+
+ home = tmp_path / "codex-home"
+ home.mkdir()
+ cfg = home / "config.toml"
+ cfg.write_text('web_search = "live"\n', encoding="utf-8")
+ argv = openai_codex.web_search_overrides(tmp_path)
+ assert argv.count("-c") >= 1
+ # The disabled override must be present as a TOML assignment.
+ joined = " ".join(argv)
+ assert 'web_search="disabled"' in joined
+ assert 'web_search="live"' not in joined
+ # Later -c wins over an earlier live if both were present.
+ disabled_at = argv.index('web_search="disabled"')
+ assert argv[disabled_at - 1] == "-c"
+ binary = shutil.which("codex")
+ if binary:
+ probe = subprocess.run(
+ [binary, "-c", 'web_search="disabled"', "--help"],
+ capture_output=True, text=True, timeout=20,
+ )
+ text = (probe.stdout or "") + (probe.stderr or "")
+ assert probe.returncode == 0 or "web_search" in text.lower() or "Usage" in text or "usage" in text
+
+
+@pytest.mark.asyncio
+async def test_web_policy_post_targets_originating_workspace(tmp_path, monkeypatch):
+ from types import SimpleNamespace
+
+ from switchbay import daemon, workspaces
+
+ a = tmp_path / "alpha"
+ b = tmp_path / "beta"
+ a.mkdir()
+ b.mkdir()
+ monkeypatch.setattr(workspaces, "is_within_home", lambda p: True)
+ monkeypatch.setattr(
+ workspaces, "resolve_path",
+ lambda s, **k: Path(s).expanduser().resolve(),
+ )
+ broadcasts: list[dict] = []
+
+ async def fake_broadcast(_app, msg):
+ broadcasts.append(msg)
+
+ monkeypatch.setattr(daemon, "_broadcast", fake_broadcast)
+ web_policy.save(a, enabled=False)
+ web_policy.save(b, enabled=True)
+
+ class Req:
+ app = {"workspace": b, "ws_clients": set()}
+ rel_url = SimpleNamespace(query={})
+ headers: dict[str, str] = {}
+
+ async def json(self):
+ return {"enabled": True, "workspace": str(a)}
+
+ resp = await daemon.handle_web_policy_post(Req()) # type: ignore[arg-type]
+ body = resp.body
+ import json as _json
+ data = _json.loads(body)
+ assert data["ok"] is True
+ assert data["enabled"] is True
+ assert Path(data["workspace"]).resolve() == a.resolve()
+ assert web_policy.is_enabled(a) is True
+ assert web_policy.is_enabled(b) is True, "POST must not write the newly active workspace"
+
+
+@pytest.mark.asyncio
+async def test_web_policy_get_is_scoped_to_query_workspace(tmp_path, monkeypatch):
+ from types import SimpleNamespace
+
+ from switchbay import daemon, workspaces
+
+ a = tmp_path / "alpha"
+ b = tmp_path / "beta"
+ a.mkdir()
+ b.mkdir()
+ monkeypatch.setattr(workspaces, "is_within_home", lambda p: True)
+ monkeypatch.setattr(
+ workspaces, "resolve_path",
+ lambda s, **k: Path(s).expanduser().resolve(),
+ )
+ web_policy.save(a, enabled=False)
+ web_policy.save(b, enabled=True)
+
+ class Req:
+ app = {"workspace": b}
+ rel_url = SimpleNamespace(query={"workspace": str(a)})
+ headers: dict[str, str] = {}
+
+ resp = await daemon.handle_web_policy_get(Req()) # type: ignore[arg-type]
+ import json as _json
+ data = _json.loads(resp.body)
+ assert data["enabled"] is False
+ assert Path(data["workspace"]).resolve() == a.resolve()
+
+
+def test_block_reason_when_off(tmp_path: Path):
+ reason = permissions.web_egress_block_reason(
+ tmp_path, "research_search", {"query": "x"},
+ )
+ assert reason is not None
+ web_policy.save(tmp_path, enabled=True)
+ assert permissions.web_egress_block_reason(
+ tmp_path, "research_search", {"query": "x"},
+ ) is None
diff --git a/tests/unit/test_web_search_approval.py b/tests/unit/test_web_search_approval.py
index f1a8e21..57a10ea 100644
--- a/tests/unit/test_web_search_approval.py
+++ b/tests/unit/test_web_search_approval.py
@@ -56,10 +56,13 @@ def test_grok_hookless_disallows_web_search():
assert "web_search" not in hard
-def test_codex_disables_web_search_until_sentinel(tmp_path: Path):
+def test_codex_native_search_stays_disabled(tmp_path: Path):
argv = openai_codex.web_search_overrides(tmp_path)
- assert argv == ["-c", "tools.web_search=false"]
+ assert 'web_search="disabled"' in argv
+ assert "tools.web_search=false" in argv
permissions.add_pattern(tmp_path, permissions.CODEX_WEB_SEARCH_SENTINEL)
- assert openai_codex.web_search_overrides(tmp_path) == [
- "-c", "tools.web_search=true",
- ]
+ argv2 = openai_codex.web_search_overrides(tmp_path)
+ assert 'web_search="disabled"' in argv2
+ assert "tools.web_search=false" in argv2
+ assert "tools.web_search=true" not in argv2
+ assert 'web_search="live"' not in argv2
From ce2223a9537e6ef6bf9e266c44a4c519c3ed9839 Mon Sep 17 00:00:00 2001
From: benjsmith
Date: Fri, 18 Sep 2026 22:08:34 +0200
Subject: [PATCH 2/4] chore: prepare v0.12.19 release
Signed-off-by: benjsmith
---
CHANGELOG.md | 17 ++++++++++
docs/releases/v0.12.19.md | 68 +++++++++++++++++++++++++++++++++++++++
frontend/package.json | 2 +-
pyproject.toml | 2 +-
src/switchbay/__init__.py | 2 +-
uv.lock | 2 +-
6 files changed, 89 insertions(+), 4 deletions(-)
create mode 100644 docs/releases/v0.12.19.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 062685f..9414a25 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,23 @@
Human-curated release notes. Earlier 0.9.x notes also live on the
[GitHub releases](https://github.com/benjsmith/switchbay/releases) page.
+## 2026-09-18 — v0.12.19
+
+No wiki/vault format migration. Review Web, Comms, and watch-folder behavior changes below. After updating, run `make refresh BUILD=1` and hard-reload the PWA. No new VSIX.
+
+- **Curate:** repaired settlement, resume, expiry, and continuous package waves. Idle desks wait for new work. Content receipts preserve useful partial results; quota-related research prose no longer creates false provider failures.
+- **Desk limits:** chief-counted concurrent live-worker cap in Settings; minimum 4, default/hard maximum 8. Admin `orchestration.max_live_workers` can tighten the ceiling. Excess workers wait; completed workers leave the live DAG while findings remain available.
+- **Comms:** metadata-first review queue with explicit per-workspace approval and revocation. Relevance suggestions never auto-approve. Secret/Top Secret, and enterprise unknown/missing classifications, are refused before body retrieval. Tenant classification GUIDs are configurable. Teams/Slack remain discovery/review only; message bodies are blocked.
+- **Web/Research:** one default-off Web policy across Rail, Settings, and Zen, with per-call approvals that are never persisted. Codex native search is disabled. Auto can hire Research; workspace model allowlists remain enforced. Installed global curiosity-engine skills can be used read-only when the workspace copy is absent.
+- **Chat:** full-height docked Zen Chat with a bottom composer; floating Chat retains two columns and visible Web control. Fixed narrow-rail control overlap.
+- **Runtime/export:** launchd discovers user-managed Node/pnpm runtimes without sourcing shell profiles. Slideshow PDF export resolves executables and Playwright before spawning.
+- **Document ingestion:** PPTX can use bundled `python-pptx` when the workspace lacks it, while preserving workspace PDF/XLSX extractors. Re-import historical PPTX files whose extracts contain the old unavailable placeholder.
+- **Watch folders:** new arrivals produce deterministic vault extracts for later Curate. macOS iCloud placeholders request per-file downloads and retry while unavailable; no permanent pin or whole-tree download. Retry state, source authorization, workspace binding, and existing vault copies are protected. Other cloud providers are unsupported.
+- **CI:** added frontend Node tests, webview build, and isolated Playwright checks for Web policy, Comms, desk controls, and browser geometry.
+- Includes v0.12.17 Editor HTML extraction preview and v0.12.18 Close on vault-source tabs, previously on `main` but not yet in a published release.
+
+Cloud-state fixtures and the native download API were tested; no live iCloud placeholder was available for end-to-end verification. Comms fixture tests do not certify a real enterprise tenant. See [release notes](docs/releases/v0.12.19.md) and [enterprise configuration](docs/enterprise.md).
+
## 2026-09-13 — v0.12.18
**Migration:** none. **Breaking:** none. After pull, run
diff --git a/docs/releases/v0.12.19.md b/docs/releases/v0.12.19.md
new file mode 100644
index 0000000..26d51cb
--- /dev/null
+++ b/docs/releases/v0.12.19.md
@@ -0,0 +1,68 @@
+# v0.12.19 — 2026-09-18
+
+**Migration:** no wiki/vault format changes. Review the Web, Comms, and watch-folder behavior changes below. After pull, run `make refresh BUILD=1` (frontend + daemon; launchd/systemd unit picks up nvm/pnpm/Node for slideshow PDF export). Hard-reload the PWA tab. No new VSIX.
+
+Also ships **v0.12.17** (Editor HTML extraction preview) and **v0.12.18** (Close on vault-source Editor tabs), already on `main` but unpublished. The previous published GitHub release was **v0.12.16**.
+
+### Curate, desks, live seats
+
+`/curate for 10 mins` and overnight / continuous briefs keep the Curate desk seated and run bounded curator waves until the window ends, you Stop, or the budget expires. If there is nothing new to curate, the desk **waits** instead of burning empty waves. Start resumes a quiet desk; an older overlapping run cannot quiet a newer one.
+
+Settings → Auto orchestration → **Live workers per desk**: floor **4**, default **8**, current hard max **8**. The chief of staff **counts**. Extra workers wait — they are not dropped. Finished workers leave the live graph; their findings stay. Admin policy may only lower the ceiling:
+
+```json
+{ "orchestration": { "max_live_workers": 5 } }
+```
+
+Wiki “progress” is real page/commit content, not a touched timestamp or an empty commit. Streaming text that merely talks about quotas is not treated as a provider outage.
+
+### Web, Research, Codex
+
+Workspace Web stays **default off**. Rail, Settings, and Zen are one control. Enabling Web still shows a **once/deny card per search or fetch**; that consent is never remembered. Admin `features.web_egress` can force off (enterprise default: off).
+
+Codex CLI native search is **always disabled** for Switch Bay spawns (including `web_search = "live"` in `~/.codex/config.toml`). Use Switch Bay research tools when Web is on.
+
+Research / Auto can hire the research package for search → fetch → vault ingest, and still honour the **workspace** model allowlist. A missing vendored curiosity-engine tree no longer hides an already-installed global CE skill (read/execute only; setup flags still gate installation).
+
+### Zen + narrow rail
+
+Zen floating Chat is two columns (your prompt | the reply) with Web on/off. Docked Chat fills the pane; the composer stays at the bottom. A 377px Power rail keeps a single Web control in the header so it does not overlap the composer.
+
+### Comms
+
+The Comms tab lists mail/chat **sources**. Discovery is metadata (headers, labels, channel names). **Approve for workspace** and **Revoke** are explicit and per workspace. Suggested relevance is a hint, never an approval. Connecting Gmail/IMAP/Outlook does **not** approve any thread.
+
+Secret / Top Secret is refused **before any body fetch**. In enterprise, unknown or missing classification is refused the same way. Tenant secret MIP/Purview GUIDs go in `comms.tenant_label_ids` and match inside `MSIP_Labels` — examples in the [enterprise guide](../enterprise.md).
+
+**Teams and Slack message bodies are not ingested in this release.** You can list and review channels; Approve does not pull chat bodies. Wiki ingest of **approved Gmail / Outlook / IMAP** needs a keyed, workspace-allowed, file-capable CLI (Claude Code, Grok Build, Codex, Muse Code). Copilot and other HTTP-only providers cannot land those pages. Enterprise packages leave those CLIs off unless IT turns them on.
+
+Fixture tests use a clean tmp admin file and synthetic mail. They are not a real-tenant certification.
+
+### Watch folders, PPTX, slideshow PDF
+
+Watch folders ingest **new arrivals only** (adding a folder baselines what is already there). The watcher writes a **deterministic extract into `vault/`**. It does **not** immediately run a wiki-authoring agent; later **Curate** owns pages.
+
+On macOS, an iCloud Drive placeholder in a folder you already authorized is downloaded **that file only**, then retried if it is still a stub. Switch Bay does not pin “Keep Downloaded” or sync the whole tree. Other cloud placeholders stay pending/retryable. The download API and retry handling were tested with local files and simulated cloud states; a live iCloud placeholder was not available for end-to-end testing.
+
+PPTX ingestion can use the bundled `python-pptx` extractor even when the workspace environment lacks it. Interpreter selection preserves workspace PDF/XLSX extractors. Old vault files containing `PPTX extraction unavailable` are **not** rewritten automatically — re-import the original `.pptx`. Failed extraction remains retryable; document text that quotes an extraction error is preserved.
+
+Slideshow **Save as PDF** (HTML decks → 16:9 pages under `vault/exports/`) finds Node under launchd (nvm / Homebrew / Volta) and resolves Playwright from the frontend install. If you get 503: `pnpm --dir frontend install --frozen-lockfile` and `pnpm --dir frontend exec playwright install chromium`. That path is separate from vault PDF *document* extraction.
+
+### After pull
+
+```sh
+git pull
+make refresh BUILD=1
+```
+
+Then hard-reload the installed PWA. If slideshow PDF or CLI tools still miss Node, the rewritten service unit is what picks up nvm — `make refresh` / `make restart` rewrites it.
+
+### Validation
+
+- Local Python suite: **1,295 passed, 1 optional dependency skip**; daemon import passed.
+- **7** frontend Node checks, PWA and webview builds, and **8** isolated Playwright checks passed, including narrow Rail and Zen geometry.
+- Actual PPTX slide text and table cells survived daemon and bare-workspace ingestion. PDF/XLSX and a three-format batch passed. A good/corrupt deck batch retained the good searchable extract and rejected/deindexed only the corrupt placeholder.
+- **8** independent watcher regressions passed, including concurrency, source changes, source authorization, and index provenance/hash consistency.
+- A real short Curate run committed a 237-word page in 18.87 seconds using 10,811 provider tokens. Separate scheduler tests cover continuous waves and bounded live-worker counts.
+
+Installed curiosity-engine integration checks run when that optional dependency is present. Cloud-state fixtures and the native macOS API check do not replace a live iCloud or enterprise-tenant deployment test.
diff --git a/frontend/package.json b/frontend/package.json
index 2ad60b7..f9c86bc 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "switchbay-frontend",
"private": true,
- "version": "0.12.18",
+ "version": "0.12.19",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/pyproject.toml b/pyproject.toml
index 1a9403a..3c000c3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "switchbay"
-version = "0.12.18"
+version = "0.12.19"
description = "Local single-user workbench over knowledge bases — a driven second brain."
requires-python = ">=3.11"
authors = [{ name = "Benjamin Smith" }]
diff --git a/src/switchbay/__init__.py b/src/switchbay/__init__.py
index 6db2827..998a2e4 100644
--- a/src/switchbay/__init__.py
+++ b/src/switchbay/__init__.py
@@ -4,7 +4,7 @@
# checker both read this, so drift here makes a current install look
# stale and offers an "update" to a release it is already past.
# tests/unit/test_version_sync.py fails the build if the two diverge.
-__version__ = "0.12.18"
+__version__ = "0.12.19"
# Use the OS certificate store for HTTPS (corporate TLS proxies, custom
# CAs). Must run before any aiohttp ClientSession creates an SSL context
diff --git a/uv.lock b/uv.lock
index a1635c6..a3705a0 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2515,7 +2515,7 @@ wheels = [
[[package]]
name = "switchbay"
-version = "0.12.18"
+version = "0.12.19"
source = { virtual = "." }
dependencies = [
{ name = "aiohttp" },
From eb0bf070fcbac6619056899777def8072cdf4cc5 Mon Sep 17 00:00:00 2001
From: benjsmith
Date: Sun, 20 Sep 2026 22:00:56 +0200
Subject: [PATCH 3/4] fix: make release checks portable
Normalize Windows work receipts and executable discovery. Remove machine-local and wall-clock assumptions from release tests, and record the imported-skill parity gates.
Signed-off-by: benjsmith
---
CHANGELOG.md | 4 +-
docs/handoff/2026-09-20-skill-shell-parity.md | 46 ++++++++++++
docs/releases/v0.12.19.md | 4 +-
src/switchbay/agents/orchestration.py | 13 +++-
src/switchbay/ce_host.py | 6 +-
src/switchbay/runtime.py | 26 ++++++-
tests/unit/test_curate_scheduler.py | 50 ++++++++++++-
tests/unit/test_pptx_ingest.py | 70 +++++++++++++++++--
tests/unit/test_runtime.py | 39 +++++++----
tests/unit/test_service_stop.py | 39 ++++++++---
10 files changed, 254 insertions(+), 43 deletions(-)
create mode 100644 docs/handoff/2026-09-20-skill-shell-parity.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9414a25..6dbe696 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,7 +3,7 @@
Human-curated release notes. Earlier 0.9.x notes also live on the
[GitHub releases](https://github.com/benjsmith/switchbay/releases) page.
-## 2026-09-18 — v0.12.19
+## 2026-09-20 — v0.12.19
No wiki/vault format migration. Review Web, Comms, and watch-folder behavior changes below. After updating, run `make refresh BUILD=1` and hard-reload the PWA. No new VSIX.
@@ -18,7 +18,7 @@ No wiki/vault format migration. Review Web, Comms, and watch-folder behavior cha
- **CI:** added frontend Node tests, webview build, and isolated Playwright checks for Web policy, Comms, desk controls, and browser geometry.
- Includes v0.12.17 Editor HTML extraction preview and v0.12.18 Close on vault-source tabs, previously on `main` but not yet in a published release.
-Cloud-state fixtures and the native download API were tested; no live iCloud placeholder was available for end-to-end verification. Comms fixture tests do not certify a real enterprise tenant. See [release notes](docs/releases/v0.12.19.md) and [enterprise configuration](docs/enterprise.md).
+Cloud-state fixtures and the native download API were tested; no live iCloud placeholder was available for end-to-end verification. Comms fixture tests do not certify a real enterprise tenant. See [release notes](docs/releases/v0.12.19.md), [skill-shell parity baseline](docs/handoff/2026-09-20-skill-shell-parity.md), and [enterprise configuration](docs/enterprise.md).
## 2026-09-13 — v0.12.18
diff --git a/docs/handoff/2026-09-20-skill-shell-parity.md b/docs/handoff/2026-09-20-skill-shell-parity.md
new file mode 100644
index 0000000..1f063c9
--- /dev/null
+++ b/docs/handoff/2026-09-20-skill-shell-parity.md
@@ -0,0 +1,46 @@
+# Skill-shell parity gates — v0.12.19 release baseline
+
+**Not a claim that skill-shell rework already has parity.** This is the
+released contract for later work that **imports** curiosity-engine,
+curiosity-merge, and okstratr instead of vendoring or duplicating their
+servers. ADR-004 vocabulary (same-origin `/embed/ce` + `/embed/okstratr`,
+no iframes, okstratr owns harness/model registry) describes that shell,
+not this tag.
+
+Release notes: [v0.12.19](../releases/v0.12.19.md)
+
+**Body retrieval** = full message content beyond headers/labels. Allowed
+only after explicit per-workspace approval **and** a fresh classification
+gate. Revoke halts future body updates.
+
+**Verified paid Curate:** 18.87s, 10,811 tokens, 237-word
+committed page. **Fake stress (distinct):**
+`test_curate_scheduler.py::test_thousand_productive_waves_unique_ids_resume`
+(≥1005 `execute()` completions).
+
+**Limitations:** live iCloud placeholder not tested; Comms fixtures are
+not an enterprise-tenant certification. Integration coverage depends on
+installed CE and the optional PDF/XLSX extractor environment; report skips.
+
+| Behavior | Evidence / tests |
+|---|---|
+| Runtime + slideshow PDF/Playwright | `test_runtime.py`; `test_service_stop.py`; `test_slideshow_pdf.py` (`test_pdf_renderer_script_exists_and_prints_16x9`, `test_pdf_handler_writes_vault_export`) |
+| CE read-only fallback; workspace allowlist; Auto Research | `test_ce_global_fallback.py`; `test_fresh_install_sandbox.py`; `test_research_desk.py` (`test_auto_web_ingest_hires_research_package`, `test_research_hire_honours_workspace_denylist`); `test_curate_lifecycle.py::test_resume_respects_workspace_model_allowlist`; `test_cebridge_setup.py` |
+| Web default-off; once/deny never persisted; Codex native search off | `test_web_policy.py`; `test_web_egress_policy.py` (`test_protected_never_pre_approved_even_with_mcp_wildcard`, `test_resolve_ignores_remember_for_protected`, `test_codex_native_search_always_disabled`, `test_codex_override_beats_preexisting_live`); `test_web_consent.py`; `test_web_search_approval.py`; `frontend/tests/webPolicy.race.test.ts`; `frontend/tests/e2e/zen-web-policy.spec.ts` |
+| Docked Zen / floating Chat / 377px Rail | `zen-web-policy.spec.ts` (`zen floating chat is two columns…`, `zen docked chat pins composer…`, `377px Rail has one header Web control…`) |
+| Comms metadata-only, no auto-add; enterprise fail-closed pre-body; approve/revoke/races; Teams/Slack bodies blocked | `test_comms_desks_review.py` (`test_gmail_discovery_requests_no_snippet_or_body`, `test_imap_discovery_never_fetches_text`); `test_comms_gate.py` (`test_secret_blocked_before_approval`, `test_unknown_label_enterprise_not_public`, `test_approve_respects_allowlist_and_revoke_wins`, `test_unapproved_gmail_never_fetches_body`, `test_revocation_during_body_request_skips_parser`, `test_teams_pages_later_channels_without_top`); `test_release_acceptance.py` (`test_teams_metadata_queries_use_supported_parameters_only` — no `/messages`; `test_approved_email_unknown_fresh_classification_never_fetches_body`; `test_inflight_comms_authorization_change_prevents_body_parse`). Teams/Slack listing sets `content_capability=fail_closed` in `streams.py`; approval does not retrieve chat bodies. |
+| Live cap floor 4 / default and hard 8, chief counted, admin tightens; continuous waves; no early stop; retired workers absent from live DAG | `test_desk_admission.py` (`test_cap_floor_and_admin_tightening`, `test_baked_tightens_not_raises`, `test_chief_counted_and_nested_share_desk`, `test_continuous_progress_after_compaction`); `test_admin_policy.py` (`test_max_live_workers_*`); `test_curate_lifecycle.py` (`test_curate_continue_is_package_wave_not_generic`, `test_continuous_second_wave_then_stop`); `test_comms_desks_review.py::test_retired_thousand_workers_do_not_remain_live_roster`; `test_release_acceptance.py::test_completed_single_use_worker_leaves_actual_parent_graph` |
+| Real content receipts; partial-result preservation; idle no LLM spin | `test_curate_content_receipts.py`; `test_curate_evidence.py`; `test_curate_scheduler.py` (`test_noop_curate_waits_without_llm`, `test_noop_curate_deadline_stops_idle_wait`); `test_comms_desks_review.py::test_real_transport_failure_keeps_partial_output` |
+| PPTX fallback preserving workspace PDF/XLSX; cloud single-file hydrate/retry/auth | `test_pptx_ingest.py` (`test_host_python_has_pptx_and_workspace_venv_can_lack_it`, `test_xlsx_pdf_prefer_workspace_venv_that_has_extractors`, mixed/corrupt); `test_icloud_download.py`; `test_watchfolders.py` (`test_hydration_pending_then_ready_exactly_once`, `test_dispatch_uses_workspace_captured_at_scan`); `test_watch_review.py` |
+| Skills missing/present/read-only; versioned integration; registry ownership; no duplicate server impl | `test_skillkit_authoring.py` (`test_symlinked_bundled_skill_is_not_writable`, `test_discovers_agents_skills_without_claude_dir`); `test_ce_global_fallback.py`; `test_updater.py` (`test_local_skill_version_from_changelog_when_not_git`, `test_find_skill_dir_uses_global_roots`, `test_match_skill_release_walks_older_tags`); `test_admin_policy.py` (`test_skills_allowlist`, `test_list_providers_hides_disabled`); `test_kernel_hire.py::test_denied_model_is_not_proposed`. Rework gate: import CE/merge/okstratr; do not vendor their HTTP servers or grow a second model allowlist. |
+
+Keep these tests green (or replace with equivalent external-skill contracts)
+before deleting built-in Graph/Agents duplicates. Do not copy pre-0.12.19
+architecture.
+
+For each imported skill, record its version and verify discovery, missing-skill
+errors, its public API, and unchanged installed files. Exercise CE ingest/query,
+curiosity-merge preview/import/rollback, and okstratr desk lifecycle through the
+shell. Check workspace/model policy at those boundaries and keep a single
+registry. These are **additional rework acceptance gates**; the release tests
+above do not certify the three-skill integration.
diff --git a/docs/releases/v0.12.19.md b/docs/releases/v0.12.19.md
index 26d51cb..ce3a09a 100644
--- a/docs/releases/v0.12.19.md
+++ b/docs/releases/v0.12.19.md
@@ -1,4 +1,4 @@
-# v0.12.19 — 2026-09-18
+# v0.12.19 — 2026-09-20
**Migration:** no wiki/vault format changes. Review the Web, Comms, and watch-folder behavior changes below. After pull, run `make refresh BUILD=1` (frontend + daemon; launchd/systemd unit picks up nvm/pnpm/Node for slideshow PDF export). Hard-reload the PWA tab. No new VSIX.
@@ -66,3 +66,5 @@ Then hard-reload the installed PWA. If slideshow PDF or CLI tools still miss Nod
- A real short Curate run committed a 237-word page in 18.87 seconds using 10,811 provider tokens. Separate scheduler tests cover continuous waves and bounded live-worker counts.
Installed curiosity-engine integration checks run when that optional dependency is present. Cloud-state fixtures and the native macOS API check do not replace a live iCloud or enterprise-tenant deployment test.
+
+Skill-shell rework gates (import curiosity-engine / curiosity-merge / okstratr; do not vendor their servers) are recorded as a **release baseline**, not as already-met parity: [2026-09-20-skill-shell-parity.md](../handoff/2026-09-20-skill-shell-parity.md).
diff --git a/src/switchbay/agents/orchestration.py b/src/switchbay/agents/orchestration.py
index e782140..972f6c7 100644
--- a/src/switchbay/agents/orchestration.py
+++ b/src/switchbay/agents/orchestration.py
@@ -3458,6 +3458,15 @@ def _bind_wave_instructions(plan: OrchestrationPlan, note: str) -> None:
plan.extra_system = (base + ("\n\n" + note if note else "")).strip()
+def _posix_receipt_path(rel: Any) -> str:
+ """Workspace-relative receipt paths are POSIX, including on Windows."""
+ return str(rel or "").replace("\\", "/")
+
+
+def _posix_receipt_paths(paths: Any) -> list[str]:
+ return [_posix_receipt_path(p) for p in (paths or []) if str(p or "").strip()]
+
+
def _receipt_had_work(rec: Any) -> bool:
"""True when wiki/report *content* changed — not SHA/mtime/tool counts."""
if not isinstance(rec, dict):
@@ -3492,8 +3501,8 @@ def _apply_work_receipt(
return rec
rec["wiki_head_before"] = receipt.get("wiki_head_before") or ""
rec["wiki_head_after"] = receipt.get("wiki_head_after") or ""
- rec["wiki_pages_changed"] = list(receipt.get("wiki_pages_changed") or [])
- rec["wiki_pages_removed"] = list(receipt.get("wiki_pages_removed") or [])
+ rec["wiki_pages_changed"] = _posix_receipt_paths(receipt.get("wiki_pages_changed"))
+ rec["wiki_pages_removed"] = _posix_receipt_paths(receipt.get("wiki_pages_removed"))
rec["wiki_commit_diff"] = str(receipt.get("wiki_commit_diff") or "")
rec["wiki_committed"] = bool(receipt.get("wiki_committed"))
rec["wiki_pages_landed"] = int(receipt.get("wiki_pages_landed") or 0)
diff --git a/src/switchbay/ce_host.py b/src/switchbay/ce_host.py
index 1212836..47595f6 100644
--- a/src/switchbay/ce_host.py
+++ b/src/switchbay/ce_host.py
@@ -106,7 +106,7 @@ def _tree_entries(
if suffixes and p.suffix.lower() not in suffixes:
continue
try:
- rel = str(p.relative_to(rel_to))
+ rel = p.relative_to(rel_to).as_posix()
st = p.stat()
except OSError:
continue
@@ -152,7 +152,7 @@ def work_availability_fingerprint(workspace: Path, *, planner: bool = False) ->
if p.name.startswith(".guard") or p.suffix == ".snapshot":
continue
try:
- rel = str(p.relative_to(ws))
+ rel = p.relative_to(ws).as_posix()
st = p.stat()
except OSError:
continue
@@ -196,7 +196,7 @@ def _page_content_key(row: Any) -> tuple[str, Any] | None:
"""Identity for receipt comparison: content hash, not mtime."""
if not isinstance(row, (list, tuple)) or not row:
return None
- rel = str(row[0])
+ rel = str(row[0]).replace("\\", "/")
if len(row) >= 4 and row[3]:
return rel, ("sha", str(row[3]))
if len(row) >= 3:
diff --git a/src/switchbay/runtime.py b/src/switchbay/runtime.py
index 760368e..b93bdc1 100644
--- a/src/switchbay/runtime.py
+++ b/src/switchbay/runtime.py
@@ -38,16 +38,33 @@ def home_dir(
return Path.home()
+def _windows_pathext() -> tuple[str, ...]:
+ """PATHEXT suffixes in search order. Windows-only helper."""
+ raw = os.environ.get("PATHEXT") or ".COM;.EXE;.BAT;.CMD"
+ out: list[str] = []
+ seen: set[str] = set()
+ for ext in raw.split(os.pathsep):
+ item = ext.strip().lower()
+ if not item or item in seen:
+ continue
+ seen.add(item)
+ out.append(item)
+ return tuple(out)
+
+
def is_executable(path: Path | str) -> bool:
"""True when ``path`` is a file the process can execute.
Follows a symlink to the target. Directories, missing paths, and
- non-executable files are rejected.
+ non-executable files are rejected. Windows has no POSIX execute
+ bit — the suffix must be listed in ``PATHEXT``.
"""
try:
p = Path(path)
if not p.is_file():
return False
+ if os.name == "nt" and p.suffix.lower() not in _windows_pathext():
+ return False
return os.access(p, os.X_OK)
except OSError:
return False
@@ -367,9 +384,16 @@ def resolve_executable(
environ=enriched,
extra_dirs=tuple(extra_dirs),
):
+ # Search this directory only. shutil.which(path=d) prepends cwd
+ # on Windows, so enumerate PATHEXT suffixes here instead.
cand = Path(d) / name
if is_executable(cand):
return str(cand)
+ if os.name == "nt" and not Path(name).suffix:
+ for ext in _windows_pathext():
+ cand_ext = Path(d) / f"{name}{ext}"
+ if is_executable(cand_ext):
+ return str(cand_ext)
return None
diff --git a/tests/unit/test_curate_scheduler.py b/tests/unit/test_curate_scheduler.py
index 427dfcd..f7ac5b1 100644
--- a/tests/unit/test_curate_scheduler.py
+++ b/tests/unit/test_curate_scheduler.py
@@ -308,13 +308,57 @@ async def fake_node(node, **kwargs):
monkeypatch.setattr(orch, "_apply_work_receipt", lambda rec, *a, **k: rec)
seats.reset_for_tests()
oid = "run-idle-deadline"
- plan = _curate_plan(oid, until=time.time() + 0.4)
+ # Deadline checks in execute() use time.time(); asyncio waits use
+ # time.monotonic. Replace orch.time with a local namespace so the
+ # process-global time module stays untouched. Freeze wall time so
+ # CI startup cannot consume the 0.4s window, then expire after
+ # idle wait starts.
+ origin = time.time()
+ clock = {"now": origin}
+
+ class _OrchTime:
+ def time(self) -> float:
+ return clock["now"]
+
+ def __getattr__(self, name: str):
+ return getattr(time, name)
+
+ monkeypatch.setattr(orch, "time", _OrchTime())
+ plan = _curate_plan(oid, until=origin + 0.4)
app = _app()
app["runs"][oid] = {"run_id": oid, "status": "running", "started_at": 0}
- result = await asyncio.wait_for(orch.execute(
+ task = asyncio.create_task(orch.execute(
plan, app=app, workspace=tmp_path, thread_id="th",
parent_run_id=oid, default_provider=ProductiveFake(), default_model="fake",
- ), timeout=5)
+ ))
+
+ async def _until_idle() -> None:
+ while True:
+ rec = app["runs"][oid]
+ if calls["n"] >= 1 and rec.get("orchestration_stage") == "waiting_work":
+ return
+ if task.done():
+ exc = task.exception()
+ if exc is not None:
+ raise exc
+ raise AssertionError(
+ "execute finished before idle wait: "
+ f"calls={calls['n']} stage={rec.get('orchestration_stage')} "
+ f"telemetry={task.result().telemetry}"
+ )
+ await asyncio.sleep(0.01)
+
+ try:
+ await asyncio.wait_for(_until_idle(), timeout=5)
+ clock["now"] = origin + 1.0
+ result = await asyncio.wait_for(task, timeout=5)
+ finally:
+ if not task.done():
+ task.cancel()
+ try:
+ await task
+ except asyncio.CancelledError:
+ pass
assert result.telemetry.get("stop_reason") == "curate window ended"
assert calls["n"] >= 1
assert calls["n"] <= 4, calls["n"]
diff --git a/tests/unit/test_pptx_ingest.py b/tests/unit/test_pptx_ingest.py
index 42680d1..7062fe3 100644
--- a/tests/unit/test_pptx_ingest.py
+++ b/tests/unit/test_pptx_ingest.py
@@ -197,9 +197,37 @@ def test_watch_handoff_runs_real_pptx_extract(tmp_path: Path, monkeypatch: pytes
assert picked2 == []
+# Optional developer venv with real openpyxl/pypdf. Clean CI does not
+# have this path; capability-selection builds a stub venv instead.
FORMAT_VENV = Path("/tmp/switchbay-format-regression/.venv")
+def _venv_python(venv: Path) -> Path | None:
+ for rel in (
+ Path("bin") / "python",
+ Path("bin") / "python3",
+ Path("Scripts") / "python.exe",
+ ):
+ p = venv / rel
+ if p.is_file():
+ return p
+ return None
+
+
+def _purelib_of(py: Path) -> Path:
+ probe = subprocess.run(
+ [str(py), "-c", "import sysconfig; print(sysconfig.get_path('purelib'))"],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ lines = [ln.strip() for ln in probe.stdout.splitlines() if ln.strip()]
+ assert lines, probe.stdout
+ path = Path(lines[-1])
+ assert path.is_dir(), (path, probe.stdout, probe.stderr)
+ return path
+
+
def _write_xlsx(path: Path, py: Path) -> None:
subprocess.run(
[
@@ -245,10 +273,35 @@ def _write_text_pdf(path: Path) -> None:
def test_xlsx_pdf_prefer_workspace_venv_that_has_extractors(tmp_path: Path):
- assert (FORMAT_VENV / "bin" / "python").is_file(), FORMAT_VENV
+ """Capability selection: workspace wins for PDF/XLSX when it can import.
+
+ Builds a bare temp venv, drops importable pypdf/openpyxl stubs in its
+ own purelib, leaves pptx absent. Probes the real interpreter — no
+ extraction, no network, no developer-machine venv path.
+ """
ws = _workspace(tmp_path)
- (ws / ".venv").symlink_to(FORMAT_VENV, target_is_directory=True)
- ws_py = str(ws / ".venv" / "bin" / "python")
+ venv = ws / ".venv"
+ subprocess.run(
+ [sys.executable, "-m", "venv", "--without-pip", str(venv)],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ venv_py = _venv_python(venv)
+ assert venv_py is not None, venv
+ purelib = _purelib_of(venv_py)
+ (purelib / "pypdf.py").write_text(
+ "# capability-selection stub\n", encoding="utf-8",
+ )
+ (purelib / "openpyxl.py").write_text(
+ "# capability-selection stub\n", encoding="utf-8",
+ )
+ assert not (purelib / "pptx.py").exists()
+ assert not (purelib / "pptx").exists()
+ assert cebridge.interpreter_has_module(venv_py, "pypdf")
+ assert cebridge.interpreter_has_module(venv_py, "openpyxl")
+ assert not cebridge.interpreter_has_module(venv_py, "pptx")
+ ws_py = str(venv_py)
assert cebridge.python_for_ingest(ws, ".xlsx") == [ws_py]
assert cebridge.python_for_ingest(ws, ".pdf") == [ws_py]
assert cebridge.python_for_ingest(ws, ".pptx") == [sys.executable]
@@ -258,11 +311,16 @@ def test_xlsx_pdf_prefer_workspace_venv_that_has_extractors(tmp_path: Path):
def test_real_xlsx_pdf_and_mixed_directory_ingest(tmp_path: Path):
_require_ce()
- assert (FORMAT_VENV / "bin" / "python").is_file(), FORMAT_VENV
+ fmt_py = _venv_python(FORMAT_VENV)
+ if fmt_py is None:
+ pytest.skip("optional format extractor venv unavailable")
ws = _workspace(tmp_path)
- (ws / ".venv").symlink_to(FORMAT_VENV, target_is_directory=True)
+ try:
+ (ws / ".venv").symlink_to(FORMAT_VENV, target_is_directory=True)
+ except OSError:
+ pytest.skip("cannot link format extractor venv")
raw = ws / "vault" / "raw"
- _write_xlsx(raw / "probe.xlsx", FORMAT_VENV / "bin" / "python")
+ _write_xlsx(raw / "probe.xlsx", fmt_py)
_write_text_pdf(raw / "probe.pdf")
_write_pptx(raw / "probe.pptx")
(raw / "note.txt").write_text("TXT-MIX-MARKER-9f3c2a17\n", encoding="utf-8")
diff --git a/tests/unit/test_runtime.py b/tests/unit/test_runtime.py
index 5ae2438..0532915 100644
--- a/tests/unit/test_runtime.py
+++ b/tests/unit/test_runtime.py
@@ -7,8 +7,12 @@
from switchbay import runtime
+_MINIMAL_PATH = os.pathsep.join(("/usr/bin", "/bin"))
+
def _exe(path: Path, body: str = "#!/bin/sh\nexit 0\n") -> Path:
+ if os.name == "nt" and path.suffix == "":
+ path = path.with_suffix(".exe")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(body, encoding="utf-8")
path.chmod(0o755)
@@ -23,7 +27,7 @@ def test_minimal_launchd_path_finds_nvm_node(tmp_path: Path, monkeypatch):
(home / ".nvm" / "alias" / "default").write_text("22.11.0\n", encoding="utf-8")
monkeypatch.setattr(runtime, "_SYSTEM_BIN_DIRS", ())
env = {
- "PATH": "/usr/bin:/bin",
+ "PATH": _MINIMAL_PATH,
"HOME": str(home),
"NVM_DIR": str(home / ".nvm"),
}
@@ -44,13 +48,18 @@ def test_nvm_major_partial_and_lts_star(tmp_path: Path, monkeypatch):
_exe(home / ".nvm" / "versions" / "node" / "v22.11.0" / "bin" / "node")
alias = home / ".nvm" / "alias"
alias.mkdir(parents=True)
- (alias / "default").write_text("lts/*\n", encoding="utf-8")
(alias / "lts").mkdir()
- (alias / "lts" / "*").write_text("iron\n", encoding="utf-8")
(alias / "lts" / "iron").write_text("22\n", encoding="utf-8")
+ # nvm's current-LTS pointer is a file named '*'; Windows rejects that name.
+ try:
+ (alias / "lts" / "*").write_text("iron\n", encoding="utf-8")
+ except OSError:
+ (alias / "default").write_text("iron\n", encoding="utf-8")
+ else:
+ (alias / "default").write_text("lts/*\n", encoding="utf-8")
monkeypatch.setattr(runtime, "_SYSTEM_BIN_DIRS", ())
env = {
- "PATH": "/usr/bin:/bin",
+ "PATH": _MINIMAL_PATH,
"HOME": str(home),
"NVM_DIR": str(home / ".nvm"),
}
@@ -74,7 +83,7 @@ def test_missing_node_isolates_homebrew(tmp_path: Path, monkeypatch):
monkeypatch.setattr(runtime, "_nvm_bin_dirs", lambda **kw: [])
monkeypatch.setattr(runtime, "_version_manager_dirs", lambda **kw: [])
assert runtime.resolve_node(
- {"PATH": "/usr/bin:/bin", "HOME": str(tmp_path)}, home=tmp_path,
+ {"PATH": _MINIMAL_PATH, "HOME": str(tmp_path)}, home=tmp_path,
) is None
@@ -85,7 +94,7 @@ def test_explicit_node_survives_reenrich_and_governs_env_node(tmp_path: Path, mo
monkeypatch.setattr(runtime, "_nvm_bin_dirs", lambda **kw: [])
monkeypatch.setattr(runtime, "_version_manager_dirs", lambda **kw: [])
env = {
- "PATH": "/usr/bin:/bin",
+ "PATH": _MINIMAL_PATH,
"SWITCHBAY_NODE": str(chosen),
"HOME": str(tmp_path),
}
@@ -102,7 +111,7 @@ def test_nvm_bin_env_wins_over_default_alias(tmp_path: Path):
active = _exe(tmp_path / "active" / "node")
other = _exe(home / ".nvm" / "versions" / "node" / "v20.0.0" / "bin" / "node")
env = {
- "PATH": "/usr/bin:/bin",
+ "PATH": _MINIMAL_PATH,
"HOME": str(home),
"NVM_BIN": str(active.parent),
"NVM_DIR": str(home / ".nvm"),
@@ -116,7 +125,7 @@ def test_explicit_node_env_wins(tmp_path: Path):
chosen = _exe(tmp_path / "custom dir" / "node")
decoy = _exe(tmp_path / "opt" / "homebrew" / "bin" / "node")
env = {
- "PATH": "/usr/bin:/bin",
+ "PATH": _MINIMAL_PATH,
"SWITCHBAY_NODE": str(chosen),
"HOME": str(tmp_path),
}
@@ -127,7 +136,7 @@ def test_explicit_node_env_wins(tmp_path: Path):
def test_spaces_in_home_and_runtime_dir(tmp_path: Path):
home = tmp_path / "User Name"
volta = _exe(home / ".volta" / "bin" / "node")
- env = {"PATH": "/usr/bin:/bin", "HOME": str(home), "VOLTA_HOME": str(home / ".volta")}
+ env = {"PATH": _MINIMAL_PATH, "HOME": str(home), "VOLTA_HOME": str(home / ".volta")}
found = runtime.resolve_node(env, home=home)
assert found == str(volta)
@@ -135,10 +144,10 @@ def test_spaces_in_home_and_runtime_dir(tmp_path: Path):
def test_pnpm_home_and_missing_non_executable(tmp_path: Path):
home = tmp_path / "home"
pnpm = _exe(home / "Library" / "pnpm" / "pnpm")
- env = {"PATH": "/usr/bin:/bin", "HOME": str(home), "PNPM_HOME": str(pnpm.parent)}
+ env = {"PATH": _MINIMAL_PATH, "HOME": str(home), "PNPM_HOME": str(pnpm.parent)}
assert runtime.resolve_pnpm(env, home=home) == str(pnpm)
missing = runtime.resolve_executable(
- "no-such-bin-xyz-switchbay", {"PATH": "/usr/bin:/bin"}, home=home,
+ "no-such-bin-xyz-switchbay", {"PATH": _MINIMAL_PATH}, home=home,
)
assert missing is None
not_exec = tmp_path / "bin" / "not-a-node"
@@ -157,7 +166,7 @@ def test_pnpm_home_and_missing_non_executable(tmp_path: Path):
def test_enrich_env_existing_path_beats_fallback(tmp_path: Path):
extra = tmp_path / "opt" / "homebrew" / "bin"
extra.mkdir(parents=True)
- env = {"PATH": "/usr/bin:/bin", "HOME": str(tmp_path)}
+ env = {"PATH": _MINIMAL_PATH, "HOME": str(tmp_path)}
out = runtime.enrich_env(env, extra_dirs=(str(extra),), home=tmp_path)
parts = out["PATH"].split(os.pathsep)
assert parts[0] == "/usr/bin"
@@ -170,7 +179,7 @@ def test_enrich_env_prepend_wins_over_path(tmp_path: Path):
extra.mkdir(parents=True)
chosen = tmp_path / "chosen"
chosen.mkdir()
- env = {"PATH": "/usr/bin:/bin", "HOME": str(tmp_path)}
+ env = {"PATH": _MINIMAL_PATH, "HOME": str(tmp_path)}
out = runtime.enrich_env(
env, extra_dirs=(str(extra),), prepend=(str(chosen),), home=tmp_path,
)
@@ -183,7 +192,7 @@ def test_enrich_env_prepend_wins_over_path(tmp_path: Path):
def test_updater_child_env_uses_runtime(tmp_path: Path, monkeypatch):
from switchbay import updater
monkeypatch.setenv("HOME", str(tmp_path))
- monkeypatch.setenv("PATH", "/usr/bin:/bin")
+ monkeypatch.setenv("PATH", _MINIMAL_PATH)
env = updater.child_env()
assert "/usr/bin" in env["PATH"]
assert env["GIT_TERMINAL_PROMPT"] == "0"
@@ -197,7 +206,7 @@ def test_service_runtime_exports_custom_dirs_and_spaces(tmp_path: Path, monkeypa
monkeypatch.setattr(runtime, "_nvm_bin_dirs", lambda **kw: [])
monkeypatch.setattr(runtime, "_version_manager_dirs", lambda **kw: [])
env = {
- "PATH": "/usr/bin:/bin",
+ "PATH": _MINIMAL_PATH,
"HOME": str(tmp_path),
"NVM_BIN": str(node.parent),
"PNPM_HOME": str(pnpm.parent),
diff --git a/tests/unit/test_service_stop.py b/tests/unit/test_service_stop.py
index 4d6e1f3..1bef3fe 100644
--- a/tests/unit/test_service_stop.py
+++ b/tests/unit/test_service_stop.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import json
+import os
from switchbay import admin_policy, service
@@ -69,6 +70,8 @@ def test_launchd_preserves_custom_runtime_configuration(tmp_path, monkeypatch):
from pathlib import Path
def executable(path: Path) -> Path:
+ if os.name == "nt" and path.suffix == "":
+ path = path.with_suffix(".exe")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
path.chmod(0o755)
@@ -80,19 +83,19 @@ def executable(path: Path) -> Path:
monkeypatch.setenv("NVM_BIN", str(node.parent))
monkeypatch.setenv("PNPM_HOME", str(pnpm.parent))
monkeypatch.setenv("HOME", str(tmp_path))
- monkeypatch.setenv("PATH", "/usr/bin:/bin")
+ monkeypatch.setenv("PATH", os.pathsep.join(("/usr/bin", "/bin")))
plist = tmp_path / "daemon.plist"
monkeypatch.setattr(service, "_mac_plist_path", lambda: plist)
monkeypatch.setattr(service, "_venv_python", lambda repo: py)
service._mac_write_plist(tmp_path / "repo")
env = plistlib.loads(plist.read_bytes())["EnvironmentVariables"]
- assert env.get("NVM_BIN") == str(node.parent) or str(node.parent) in env.get("PATH", "").split(":"), (
+ assert env.get("NVM_BIN") == str(node.parent) or str(node.parent) in env.get("PATH", "").split(os.pathsep), (
"launchd loses custom Node runtime"
)
- assert env.get("PNPM_HOME") == str(pnpm.parent) or str(pnpm.parent) in env.get("PATH", "").split(":"), (
+ assert env.get("PNPM_HOME") == str(pnpm.parent) or str(pnpm.parent) in env.get("PATH", "").split(os.pathsep), (
"launchd loses custom pnpm runtime"
)
- path_parts = env.get("PATH", "").split(":")
+ path_parts = env.get("PATH", "").split(os.pathsep)
assert env["NVM_BIN"] == str(node.parent)
assert env["PNPM_HOME"] == str(pnpm.parent)
assert path_parts[:4] == ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]
@@ -106,6 +109,8 @@ def test_systemd_unit_quotes_runtime_dirs_with_spaces(tmp_path, monkeypatch):
from pathlib import Path
def executable(path: Path) -> Path:
+ if os.name == "nt" and path.suffix == "":
+ path = path.with_suffix(".exe")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
path.chmod(0o755)
@@ -117,7 +122,7 @@ def executable(path: Path) -> Path:
monkeypatch.setenv("NVM_BIN", str(node.parent))
monkeypatch.setenv("PNPM_HOME", str(pnpm.parent))
monkeypatch.setenv("HOME", str(tmp_path))
- monkeypatch.setenv("PATH", "/usr/bin:/bin")
+ monkeypatch.setenv("PATH", os.pathsep.join(("/usr/bin", "/bin")))
unit = tmp_path / "switchbay.service"
monkeypatch.setattr(service, "_linux_unit_path", lambda: unit)
monkeypatch.setattr(service, "_venv_python", lambda repo: py)
@@ -126,11 +131,25 @@ def executable(path: Path) -> Path:
monkeypatch.setattr(service.subprocess, "run", lambda *a, **k: type("R", (), {"returncode": 0})())
service._linux("install", tmp_path / "repo")
text = unit.read_text(encoding="utf-8")
- assert f'Environment=NVM_BIN="{node.parent}"' in text
- assert f'Environment=PNPM_HOME="{pnpm.parent}"' in text
- assert "Environment=PATH=" in text
- assert str(node.parent) in text
- assert "/usr/bin:/bin:/usr/sbin:/sbin" in text
+
+ def _quoted_env(key: str, value: str) -> str:
+ # systemd Environment=: quote values with whitespace / \ / ".
+ # Independent of service._systemd_env_line so a broken serializer fails.
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
+ return f'Environment={key}="{escaped}"'
+
+ nvm = str(node.parent)
+ pnpm = str(pnpm.parent)
+ assert " " in nvm and " " in pnpm
+ assert _quoted_env("NVM_BIN", nvm) in text
+ assert _quoted_env("PNPM_HOME", pnpm) in text
+ from switchbay import runtime as rt
+ path_val = rt.service_runtime_exports()["PATH"]
+ assert " " in path_val
+ assert _quoted_env("PATH", path_val) in text
+ bootstrap = os.pathsep.join(("/usr/bin", "/bin", "/usr/sbin", "/sbin"))
+ assert bootstrap in text
+ assert "custom runtime" in text
def test_mac_plist_stdio_is_devnull(tmp_path, monkeypatch):
From ff1803064d74134445ce303ac48b65ab10225e99 Mon Sep 17 00:00:00 2001
From: benjsmith
Date: Sun, 20 Sep 2026 22:09:04 +0200
Subject: [PATCH 4/4] test: compare runtime paths by platform rules
Signed-off-by: benjsmith
---
tests/unit/test_runtime.py | 32 ++++++++++++++++++++------------
1 file changed, 20 insertions(+), 12 deletions(-)
diff --git a/tests/unit/test_runtime.py b/tests/unit/test_runtime.py
index 0532915..71c3b2a 100644
--- a/tests/unit/test_runtime.py
+++ b/tests/unit/test_runtime.py
@@ -19,6 +19,13 @@ def _exe(path: Path, body: str = "#!/bin/sh\nexit 0\n") -> Path:
return path
+def _assert_exe(actual: str | None, expected: Path) -> None:
+ # shutil.which may return node.EXE (PATHEXT) vs fixture node.exe; Path
+ # equality is case-folded on Windows and exact on POSIX.
+ assert actual is not None
+ assert Path(actual) == expected
+
+
def test_minimal_launchd_path_finds_nvm_node(tmp_path: Path, monkeypatch):
home = tmp_path / "home"
nvm_bin = home / ".nvm" / "versions" / "node" / "v22.11.0" / "bin"
@@ -32,7 +39,7 @@ def test_minimal_launchd_path_finds_nvm_node(tmp_path: Path, monkeypatch):
"NVM_DIR": str(home / ".nvm"),
}
found = runtime.resolve_node(env, home=home)
- assert found == str(node)
+ _assert_exe(found, node)
def test_empty_nvm_alias_does_not_crash(tmp_path: Path):
@@ -64,7 +71,7 @@ def test_nvm_major_partial_and_lts_star(tmp_path: Path, monkeypatch):
"NVM_DIR": str(home / ".nvm"),
}
found = runtime.resolve_node(env, home=home)
- assert found == str(v22)
+ _assert_exe(found, v22)
def test_selected_path_precedes_fallback(tmp_path: Path, monkeypatch):
@@ -73,9 +80,10 @@ def test_selected_path_precedes_fallback(tmp_path: Path, monkeypatch):
monkeypatch.setattr(runtime, "_SYSTEM_BIN_DIRS", (str(fallback.parent),))
monkeypatch.setattr(runtime, "_nvm_bin_dirs", lambda **kw: [])
monkeypatch.setattr(runtime, "_version_manager_dirs", lambda **kw: [])
- assert runtime.resolve_node(
- {"PATH": str(chosen.parent)}, home=tmp_path,
- ) == str(chosen)
+ _assert_exe(
+ runtime.resolve_node({"PATH": str(chosen.parent)}, home=tmp_path),
+ chosen,
+ )
def test_missing_node_isolates_homebrew(tmp_path: Path, monkeypatch):
@@ -101,9 +109,9 @@ def test_explicit_node_survives_reenrich_and_governs_env_node(tmp_path: Path, mo
spawned = runtime.spawn_env(env, home=tmp_path)
parts = spawned["PATH"].split(os.pathsep)
assert parts[0] == str(chosen.parent)
- assert runtime.resolve_node(spawned, home=tmp_path) == str(chosen)
+ _assert_exe(runtime.resolve_node(spawned, home=tmp_path), chosen)
again = runtime.enrich_env(spawned, home=tmp_path)
- assert runtime.resolve_node(again, home=tmp_path) == str(chosen)
+ _assert_exe(runtime.resolve_node(again, home=tmp_path), chosen)
def test_nvm_bin_env_wins_over_default_alias(tmp_path: Path):
@@ -117,8 +125,8 @@ def test_nvm_bin_env_wins_over_default_alias(tmp_path: Path):
"NVM_DIR": str(home / ".nvm"),
}
found = runtime.resolve_node(env, home=home)
- assert found == str(active)
- assert found != str(other)
+ _assert_exe(found, active)
+ assert Path(found) != other
def test_explicit_node_env_wins(tmp_path: Path):
@@ -130,7 +138,7 @@ def test_explicit_node_env_wins(tmp_path: Path):
"HOME": str(tmp_path),
}
found = runtime.resolve_node(env, home=tmp_path, extra_dirs=(str(decoy.parent),))
- assert found == str(chosen)
+ _assert_exe(found, chosen)
def test_spaces_in_home_and_runtime_dir(tmp_path: Path):
@@ -138,14 +146,14 @@ def test_spaces_in_home_and_runtime_dir(tmp_path: Path):
volta = _exe(home / ".volta" / "bin" / "node")
env = {"PATH": _MINIMAL_PATH, "HOME": str(home), "VOLTA_HOME": str(home / ".volta")}
found = runtime.resolve_node(env, home=home)
- assert found == str(volta)
+ _assert_exe(found, volta)
def test_pnpm_home_and_missing_non_executable(tmp_path: Path):
home = tmp_path / "home"
pnpm = _exe(home / "Library" / "pnpm" / "pnpm")
env = {"PATH": _MINIMAL_PATH, "HOME": str(home), "PNPM_HOME": str(pnpm.parent)}
- assert runtime.resolve_pnpm(env, home=home) == str(pnpm)
+ _assert_exe(runtime.resolve_pnpm(env, home=home), pnpm)
missing = runtime.resolve_executable(
"no-such-bin-xyz-switchbay", {"PATH": _MINIMAL_PATH}, home=home,
)