v0.17.0 — Wave 3: sandbox DB, DB isolation, mobile UI, cloud auth, X account, roadmap engine (#303)

* feat(sandbox): throwaway per-agent Postgres/Redis sandbox containers

Orchestrator-provisioned sibling containers per agent spawn
(SandboxProvisioner, roboco/runtime/sandbox.py). Per-project opt-in via
projects.sandbox_services (migration 057); master switch
ROBOCO_SANDBOX_DB_ENABLED, default-off, armed in the NAS compose only.

When active, ROBOCO_TEST_DB_* / ROBOCO_TEST_REDIS_* point at the sandbox
and the prod-creds gate-env injection is suppressed (sandbox replaces,
never coexists). Sandbox lifetime tracks the agent container: teardown at
every removal path, orphan janitor at startup + each reaper tick with a
grace window for mid-flight spawns. The pre-spawn stale-clear spares the
just-provisioned sandbox; provision pre-clears stale same-named
containers from a crash-missed teardown.

Panel: per-project sandbox-service switches in the edit dialog + feature
flag card entry.

* docs: CLAUDE.md entry for the sandboxed dev DB/Redis subsystem

* feat(security): isolate prod Postgres/Redis from agent containers (roboco_data network)

Second user-defined bridge roboco_data carries postgres+redis only; the
orchestrator is multi-homed (default + data). Spawned agents and their
sandbox sidecars stay on roboco_default and can no longer resolve or
reach roboco-postgres:5432 / roboco-redis:6379 (redis has no auth —
membership is its only containment). Normal bridge, so host-published
ports (15432/16379) keep working. Applied to both build composes and
the registry compose; docker-compose.yml re-synced byte-identical with
docker-compose.yaml (it had drifted by the sandbox flag block).

ROBOCO_DB_NETWORK_ISOLATED (config default false, armed alongside the
topology) suppresses the legacy _append_gate_env prod-creds injection:
under isolation those creds dead-end, and unreachable creds are worse
than none. DB-needing projects opt into sandbox_services instead. The
flag is deliberately not a panel feature flag - it must travel with the
compose networks: stanzas.

Preserved by construction: agent<->agent A2A and orchestrator->agent SDK
polls on :9000, MCP->orchestrator on :8000, ollama reachability, docker
exec/inspect (daemon socket), host port publishing.

* feat(panel): full mobile responsiveness pass

Shared primitives: useIsMobile (useSyncExternalStore, hydration-safe,
memoized matchMedia subscribe), ResponsiveTable table->card switch below
md (single subtree mounted, no duplicated interactive rows), scrollable
snap TabsList in the base primitive (justify-center-safe so the first
tab stays reachable on overflow), persistent md:hidden bottom tab bar
(Overview/Tasks/Kanban/Chat, safe-area padded).

Applied: card lists for tasks/projects/products/work-sessions/sessions
+ the three raw metrics tables; CEO approval queue / release proposal /
playbook review action rows stack on narrow; command-center reorders
approvals above the fold on mobile; task-header metadata wraps;
Communications + A2A become URL-driven single-pane drill-downs below lg
(fixes the unconstrained-height ScrollArea bug) with dvh heights;
recharts label density/radius adapts via useIsMobile; git diff viewer
gets mobile font + wrap toggle; vh->dvh sweep; chat composers get
safe-area-inset padding; dashboard main p-4 md:p-6 + pb-20 for the bar.

Verified at 375px on the built app: bottom bar, drawer, approval-first
overview, swipeable kanban tab strip. All gates green (eslint, tsc,
vitest 249, next build 24/24 routes).

* feat(auth): cloud auth via FastAPI Users (default-off, single-user cookie session)

ROBOCO_CLOUD_AUTH_ENABLED (default off) lets the panel/API be exposed
beyond localhost without changing the CEO's local no-login flow while
off — get_agent_context and the WS gate are byte-for-byte unchanged in
off-mode. On: header-trust dies for humans — any agent-role claim (ceo
or a privileged PM/board role) with no valid HMAC token or session
cookie is 401, closing the header-spoof hole on the host-published
:8000 port for every role. The agent-fleet HMAC path and the system
self-PATCH keep working unmodified in both modes.

Single seeded CEO user (migration 058 users table, UserTable), no
registration router — idempotent env-driven upsert at startup by PK.
Cookie transport (httponly/secure/samesite=lax) + a JWTStrategy bound
to a fingerprint of the current password hash (rotating the password
invalidates every prior session). Sliding 30-day session: every
authenticated request re-mints the cookie, so an active session never
expires — no unexpected logouts.

Panel: (auth)/login page + proxy.ts (Next 16 rename of middleware; probes
/auth/status over the docker-internal URL, fails open to off) gate the
dashboard; client.ts gets withCredentials + 401->/login. nginx unchanged.

Review hardening: broadened the on-mode rejection from ceo-only to every
non-CEO role without a valid token (was only closed when
ROBOCO_AGENT_AUTH_REQUIRED was also armed); Next-16 proxy.ts rename to
clear the middleware deprecation warning.

* feat(x): RoboCo X account engine — HoM drafts, per-post CEO approval (default-off)

ROBOCO_X_ENGINE_ENABLED (default off, inert without creds). Mirrors the
ReleaseManagerEngine held-artifact shape: XEngine drafts a post when a
release publishes (via a draft_release_post seam on ReleaseProposalService
.approve) and drafts replies to meaningful mentions (dedicated poll loop,
x_seen_mentions dedup ledger, per-cycle/open caps). Drafting is
local-model-only, clamped to 280 chars. Nothing auto-posts — every tweet
is a held task (source x_post/x_reply, confirmed_by_human=False,
Secretary-owned, dispatcher-skipped) the CEO edits/approves/rejects in a
panel queue.

The four OAuth 1.0a secrets live Fernet-encrypted in a singleton
x_credentials row (migration 059, all-or-nothing, API returns only
has_credentials); decryption is server-side, agents never hold creds or
egress. Hand-rolled OAuth 1.0a HMAC-SHA1 signer, no new dependency;
NullXClient makes the unconfigured path a graceful no-op.

XPostService.approve (CEO-only) is the sole caller of post_tweet.

Review hardening: closed a double-post race — the approve path now
re-reads committed task state inside the Redis lock and commits COMPLETED
before releasing, so a concurrent approve that acquires the lock after the
winner released can't re-post (SET-NX is non-waiting, and the route-level
commit landed after the lock dropped). Added a regression test.

* feat(roadmap): board roadmap engine — PO proposes themed cycles, CEO approves per-item (default-off)

ROBOCO_ROADMAP_ENGINE_ENABLED (default off). Weekly, RoadmapEngine opens
ONE held exploration task (source=board_roadmap, confirmed_by_human=False,
Product-Owner-assigned), deduped to one open cycle. A dedicated one-shot
_dispatch_roadmap_exploration spawns the PO solo (not the two-reviewer
board path, which would also spawn HoM + fire Approve-&-Start). The PO
explores read-only (git/KB/metrics/releases/charter/web) and makes one
propose_roadmap call (PO-only content verb) authoring a themed cycle —
goal + 3-7 item drafts — persisted as a roadmap_cycle marker (no table,
no migration; head stays 059).

The CEO acts per-item in the panel roadmap queue: approve materializes a
BACKLOG task (source=roadmap, no assignee — never auto-starts), reject
records a reason; all-items-terminal completes the exploration task.
RoadmapService is idempotent per item. Dispatchers skip board_roadmap.

Includes a real SQLAlchemy dirty-check fix (deep-copy the JSON marker
before mutating, or the in-place edit + reassign compares equal to its
own baseline and the UPDATE is skipped).

Review hardening: create_task_from_draft now honors a draft-declared
source only from a {prompter, roadmap} whitelist — drafts are
LLM-authored, so an unbounded source could impersonate a privileged
origin (release_manager would even wedge that engine's dedup).

* chore(release): 0.17.0

Wave 3 — six default-off subsystems: sandboxed dev DB/Redis, prod
Postgres/Redis network isolation, full mobile UI pass, cloud auth
(FastAPI Users), the RoboCo X account engine, and the board roadmap
engine. Plus the waves 1+2 work already on master since 0.16.0.

Version bumped across the canonical set (config.py, __init__.py,
pyproject.toml, panel/package.json, uv.lock); CHANGELOG [Unreleased]
cut to [0.17.0]; docs/map delta added.

Compose: every optional feature armed :-true in the NAS composes, OFF
in the user-facing registry compose. Two opt-in exceptions default off
(CLOUD_AUTH — needs email/password/secret + TLS, would otherwise fail
startup; ROUTING_STRICT — fail-closed spawning). DB_NETWORK_ISOLATED
stays on in both (coupled to the roboco_data topology).

* chore(compose): arm cloud_auth + routing_strict ON in the NAS composes

Every feature defaults ON in the NAS composes per policy — these two
were wrongly left off. Both keep the ${VAR:-true} form so the operator
controls the real runtime via .env: cloud auth needs
ROBOCO_CLOUD_AUTH_EMAIL/_PASSWORD/_SECRET + TLS set there before a boot
(else startup fails loud), and routing_strict is fail-closed. Registry
compose keeps both off.

* fix(ci): reflow board.md prose (quality gate) + document v0.17.0 env creds

The roadmap section added hard-wrapped prose that failed the markdown
prose gate; reflowed (token-invariant). Also brought .env.example
current: cloud auth (now armed — needs SECRET or startup fails), routing
strict, the X engine (panel-entered OAuth), and web research.

* fix(ci): reduce cyclomatic complexity of five wave-3 blocks (xenon gate)

The wave-3 subagents introduced C-rank functions the CI xenon gate
rejects (my per-item reviews ran ruff/mypy/pytest but not xenon):
- sandbox.janitor_sweep -> extract _list_labeled_sandboxes /
  _list_live_agent_containers / _prune_grace
- x_client.fetch_mentions -> extract _parse_mention_items
- x_engine.run_cycle -> extract _process_mentions
- orchestrator._dispatch_pm_work -> extract the source-skip into a
  MODULE-level _is_held_ceo_source (module, not method, so the
  wholesale-mocked dispatcher unit tests exercise the real logic)
- auth/seed.ensure_seed_user -> extract _apply_seed_updates (module avg -> A)

Behavior-preserving; full suite green (11902), xenon clean.

* fix(ci): declare pyjwt + fastapi-users-db-sqlalchemy as direct deps (deptry)

The cloud-auth code imports jwt and fastapi_users_db_sqlalchemy directly
but they were only transitive deps (via fastapi-users), which deptry
(quality gate, DEP003) rejects. Declared explicitly; deptry roboco/ clean.
Missed originally because local make quality stopped at earlier gates
before reaching deptry.

* feat(x): gate mention replies behind ROBOCO_X_REPLIES_ENABLED (default off)

Per CEO decision: the X engine should only post about releases by
default. Reading mentions needs a paid X API tier, so the mention-reply
half is now a deliberate opt-in on top of release posting.

New default-off flag x_replies_enabled gates the mentions poll loop
(_x_mentions_poll_loop) and XEngine.run_cycle; release-post drafting
(the release-proposal approve hook) is unaffected and still runs when
x_engine_enabled + credentials are set. Added to FEATURE_FLAGS + the
panel card. Tests: release posting works with replies off; run_cycle +
the poll loop are no-ops with replies off.

* fix: 401 only redirects to /login when cloud auth is on; panel-token strips .env quotes

Two bugs that together dead-ended login in secure mode:
- client.ts redirected to /login on ANY 401, so a mismatched panel
  token (header-trust/secure mode, cloud auth off) bounced the user to a
  login page whose backend route isn't mounted -> 404. Now it probes
  /auth/status (bare fetch, no interceptor re-entry) and only redirects
  when cloud_auth_enabled.
- make panel-token read the .env secret with grep|cut without stripping
  surrounding quotes, so a quoted ROBOCO_AGENT_AUTH_SECRET produced a
  token signed with the quotes included — which never verifies against
  the orchestrator (docker-compose/pydantic unquote the secret). Now
  strips surrounding single/double quotes.

* fix: git-log 500 on '|' in commit message; X queue shows an empty state

- GET /api/git/log 500'd (ValueError: Invalid isoformat) when a commit
  SUBJECT contained a '|' (e.g. the 'curl|sh' lockdown commit): the
  fixed '|' field delimiter let the subject's pipe shift the split so
  author+date collapsed into one field. Switched to \x1f (Unit
  Separator), which can't appear in commit content. Regression test with
  a piped subject.
- The X Post Queue returned null when empty, so there was no visible
  place for the X drafts. It now renders a discoverable empty state
  pointing at Settings -> X credentials.

* docs: bring docs/rag + docs/map current for v0.17.0 (waves 1-3)

Agent-facing RAG corpus and codebase map updated for every feature in
the 0.17.0 span, code-verified:
- wave 3: sandbox DB, DB network isolation, cloud auth, X engine
  (+ x_replies_enabled sub-flag), board roadmap engine — new RAG
  architecture pages + role/tool/config-reference updates; new symbols,
  migrations 057-059, panel surfaces, and the get_agent_context
  dual-path across the map slices.
- waves 1-2: A2A live view + switchboard, prompter memory
  (search_past_tasks), Secretary edit access + PM-lighter scope, the
  PR-gate auto-submit turn cut (ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED).
- correctness fix: api-routes-schemas.md no longer claims the A2A admin
  routes are reachable by any authenticated agent — they carry a
  _require_ceo gate (wave 2c).

docs/internal, _front.md deltas, and the frozen _complete_map.md
snapshot untouched.

* fix(rag): atomic upsert for indexed-doc tracking (kills e2e segfault)

The indexed-document tracking write used check-then-insert in two paths
(IndexedDocumentRepository.upsert_batch and the file-source
_upsert_doc_record). Under concurrent indexing both callers saw no row
and both inserted, so the second violated uq_indexed_doc_source and
poisoned its transaction — surfacing in CI as the intermittent
_checkin_failed SIGSEGV on the failed connection's pool checkin.

Both paths now use INSERT ... ON CONFLICT DO UPDATE against the
constraint: coalesce keeps an existing title/preview when the new value
is empty (matching the old guards) and metadata is jsonb-merged. The
batch dedupes within itself first (ON CONFLICT can't touch a row twice
in one statement). expire_all after the Core upsert keeps same-session
ORM reads consistent with the merged DB row.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-03 19:24:00 +02:00
committed by GitHub
co-authored by Renn F
parent 12745352aa
commit 3ccc723cd4
179 changed files with 12734 additions and 948 deletions
+47
View File
@@ -143,8 +143,55 @@ ROBOCO_AGENT_AUTH_REQUIRED=false
# The control panel's CEO token, injected by nginx in secure mode so the human
# UI keeps working without the browser holding the signing secret. Generate it
# (after setting ROBOCO_AGENT_AUTH_SECRET above) with: make panel-token
# NOTE: leave this UNSET if you arm cloud auth (below) for real remote exposure —
# it is itself a valid CEO credential that would bypass the login page.
ROBOCO_PANEL_AGENT_TOKEN=
# =============================================================================
# Cloud auth (panel login) — ARMED ON in the NAS compose (v0.17.0)
# =============================================================================
# ROBOCO_CLOUD_AUTH_ENABLED defaults ON in docker-compose.yaml. When on, the
# orchestrator REFUSES TO START unless ROBOCO_CLOUD_AUTH_SECRET is set, the
# panel requires a login, and the session cookie is secure-only (needs TLS).
#
# To TEST-DEPLOY before you've set up creds + TLS, turn it off for that run:
# ROBOCO_CLOUD_AUTH_ENABLED=false
#
# To ARM it: set all three below + terminate TLS in front of nginx + leave
# ROBOCO_PANEL_AGENT_TOKEN unset.
# ROBOCO_CLOUD_AUTH_SECRET — session-signing key. REQUIRED when enabled.
# Generate: python -c 'import secrets; print(secrets.token_hex(32))'
# ROBOCO_CLOUD_AUTH_EMAIL / _PASSWORD — the single seeded login (no signup).
# ROBOCO_CLOUD_AUTH_SECRET=
# ROBOCO_CLOUD_AUTH_EMAIL=
# ROBOCO_CLOUD_AUTH_PASSWORD=
# =============================================================================
# Model routing strictness — ARMED ON in the NAS compose (v0.17.0)
# =============================================================================
# Fail-closed routing: a spawn whose configured provider is disabled RAISES
# instead of degrading to the legacy Claude path. Audit the model_assignments
# table first (a stale row pointing at a disabled provider crashes that spawn).
# To restore graceful degradation: ROBOCO_ROUTING_STRICT=false
# =============================================================================
# X (Twitter) engine — ARMED ON in the NAS compose (v0.17.0)
# =============================================================================
# The engine drafts release posts + mention replies HELD for per-post CEO
# approval; it is INERT until you store the four OAuth 1.0a secrets. Those are
# NOT env vars — enter them in the panel (Settings -> X credentials, stored
# Fernet-encrypted): API key, API secret, access token, access token secret,
# from an X developer app with read+write user-context access.
# Optional: pin the account's numeric user id (else it's resolved via /users/me).
# ROBOCO_X_ACCOUNT_USER_ID=
# =============================================================================
# Web research — ARMED ON in the NAS compose (v0.17.0)
# =============================================================================
# Gives Board/PM roles web_search + web_fetch. INERT (empty results) without a
# provider key — set one to make it live (Tavily / Brave / Exa, per your config).
# ROBOCO_RESEARCH_API_KEY=
# =============================================================================
# CORS (comma-separated origins)
# =============================================================================
+12
View File
@@ -6,8 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased]
## [0.17.0] - 2026-07-03
### Added
- **Sandboxed per-agent test DB/Redis.** A dev agent's gate can now run against a throwaway database instead of RoboCo's own production Postgres. When `ROBOCO_SANDBOX_DB_ENABLED` is armed, each spawn of an opted-in project (its `projects.sandbox_services` column set, migration 057) gets orchestrator-provisioned `postgres:16-alpine` / `redis:8-alpine` sibling containers with random per-sandbox credentials and a tmpfs data dir, injected as `ROBOCO_TEST_DB_*` / `ROBOCO_TEST_REDIS_*` in place of the legacy prod-creds gate-env injection. Lifetime tracks the agent container 1:1 (teardown at every removal path + an orphan janitor, grace-windowed so a sweep can't reap a mid-flight spawn's sandbox); provisioning failure fails the spawn loud, and docker-in-agent stays structurally absent (`SandboxProvisioner`).
- **Full mobile UI pass.** The control panel is now usable from a phone end to end: a `useIsMobile` hook, a shared table→card transform below `md` (tasks/projects/products/sessions/metrics), a scrollable snap `TabsList` in the base primitive, stacked action rows on the CEO approval queues, and a persistent bottom tab bar alongside the existing drawer. The Communications and A2A master-detail views become single-pane drill-downs below `lg` (fixing an unconstrained-height scroll bug), charts and the git diff viewer adapt, and `vh` heights move to `dvh` for the mobile-Safari toolbar.
- **Cloud auth via FastAPI Users (default-off).** `ROBOCO_CLOUD_AUTH_ENABLED` lets the panel/API be exposed beyond localhost without changing the CEO's local no-login flow while off. A single seeded user (no registration router; migration 058) authenticates via a cookie session that is **sliding** — every authenticated request re-mints the 30-day cookie, so an active session never expires — and a `JWTStrategy` bound to a fingerprint of the current password invalidates every prior session on a password change. When armed, header-trust dies for humans: any agent-role claim (including a privileged PM/board role) without a valid HMAC token or session cookie is 401, closing the header-spoof exposure on the host-published `:8000` port for every role; the agent-fleet HMAC path and the orchestrator's `system` self-PATCH are unchanged in both modes.
- **RoboCo X (Twitter) account (default-off).** With `ROBOCO_X_ENGINE_ENABLED`, the Head of Marketing drafts a post when a release publishes and drafts replies to meaningful mentions — but nothing auto-posts: every tweet is held in a panel queue for the CEO to edit and approve. Drafting is local-model-only, clamped to 280 chars; the four OAuth 1.0a secrets live Fernet-encrypted in a singleton row (migration 059, the API only reports `has_credentials`) and agents never hold credentials or make outbound calls. Approving posts under a Redis single-flight lock that re-reads committed state so a concurrent approve can't double-post.
- **Board roadmap engine (default-off).** With `ROBOCO_ROADMAP_ENGINE_ENABLED`, on a weekly interval the Product Owner is spawned one-shot to explore the company's repos, KB, metrics, and charter and propose a themed cycle — a one-line goal plus 37 roadmap-item drafts — via a Product-Owner-only `propose_roadmap` verb. The CEO acts on each item in a panel queue: approve materializes it as a `BACKLOG` task (never auto-started), reject records a reason. The cycle rides as a marker on the exploration task (no new table).
### Security
- **Production Postgres/Redis isolated from agent containers.** A second user-defined bridge (`roboco_data`) now carries `postgres` and `redis` only, with the orchestrator the sole multi-homed service. Spawned agent containers and their sandbox sidecars stay on `roboco_default` and can no longer resolve or reach `roboco-postgres:5432` / `roboco-redis:6379` — network membership is the containment (redis has no auth). Host-published ports and all legitimate paths (agent↔agent A2A, orchestrator→agent SDK polls, MCP→orchestrator, ollama, `docker exec`/`inspect`) are unaffected. `ROBOCO_DB_NETWORK_ISOLATED` travels with the topology and suppresses the legacy prod-creds gate-env injection.
- **The A2A switchboard — the org chart as pair cards.** `/a2a`'s desktop default is now a grid of agent-pair cards (every pair the permission matrix allows — 70 pairs across cell/PM-chain/board/cross sections, derived statically from `can_a2a_direct` at import time), each lighting up when either side messages the other (45s CSS fade, driven purely by the live `a2a.message` frames — A2A only, never verbs, by CEO ruling). Clicking a card opens the existing transcript + chime-in drawer; never-talked pairs render dimmed with an explicit empty state; the v1 list stays as the mobile/compact fallback. Backed by one CEO-gated `GET /a2a/chat/admin/pairs` route joining the static matrix against conversations in a single bulk query.
- **Secretary full task access; PM lighter editing — and a closed over-permission hole.** The Secretary's CEO-gated `edit` directive now covers the full content surface (title/description/AC/priority/team/complexity/nature plus claim-aware reassignment through the real reassign paths), and `read_task` returns full detail. Scouting the PM side found `has_higher_perms` gave PM identities UNRESTRICTED admin on `PATCH /tasks/{id}` (ASSIGN is not team-scoped) — now cell PMs hard-403 outside their team and both PM roles are capped to the content allowlist with zero status changes via that surface; CEO/Board/Auditor keep full admin.
+13 -1
View File
@@ -392,7 +392,15 @@ Agent backends are pluggable. `roboco/llm/providers/` defines an `AgentProvider`
**Organizational memory loop (default-off).** Closes the learn→reuse loop so agents stop cold-respawning blind. Three parts, all gated by `ROBOCO_ORG_MEMORY_ENABLED`: ① **capture** — at task completion `TaskService._completion_learnings_for` distills ONE high-signal lesson (Problem→Approach→Gotcha, ≤120 words) via the local model (`MemoryDistiller`, `roboco/services/memory_distiller.py`) instead of the noisy raw-notes/duration capture (flag-off keeps the legacy capture); journal indexing excludes `is_private` reflections from the shared corpus. ② **retrieve (keystone)** — on claim, `_briefing_for` injects `context_briefing["institutional_memory"]`: top-K (`ROBOCO_ORG_MEMORY_TOP_K`) relevance-floored (`ROBOCO_ORG_MEMORY_MIN_SCORE`) lessons + approved playbooks from a role-shaped query (`EvidenceRepo.similar_memory` over the LEARNINGS + PLAYBOOKS pgvector indexes); below the floor nothing is injected (no briefing bloat). ③ **playbooks** — a first-class curated procedure store: `PlaybookTable` (migration 050), the `PLAYBOOKS` OptimalService index, the `draft_playbook` content verb (delivery roles), Auditor `approve_playbook`/`reject_playbook`/`archive_playbook` curation (approval indexes it), and the panel review queue (`playbook-review-queue.tsx`; `/api/playbooks` Auditor/CEO routes). Distillation runs on the local model only — never a cloud LLM in the hot path; every step is best-effort (a failure never blocks completion or the briefing).
**Feature flags / company-in-a-box.** Env-gated, default-off subsystems toggle from the panel's Settings → Feature Flags card (`panel/src/components/settings/feature-flags-card.tsx`) instead of hand-editing env: web research (`ROBOCO_RESEARCH_ENABLED`), the strategy engine (`ROBOCO_STRATEGY_ENGINE_ENABLED`), pitch provisioning (`ROBOCO_PROVISIONING_*`), external / internal PR review, the agent-runtime toolchain match (`ROBOCO_TOOLCHAIN_MATCH_ENABLED`), the architectural-conventions standard (`ROBOCO_CONVENTIONS_ENABLED`), gateway-health recovery (`ROBOCO_GATEWAY_HEALTH_ENABLED`), multi-repo CI-watch (`ROBOCO_CI_WATCH_ENABLED`), the dependency-update bot (`ROBOCO_DEP_UPDATE_ENABLED`), the gated release manager (`ROBOCO_RELEASE_MANAGER_ENABLED`), the organizational memory loop (`ROBOCO_ORG_MEMORY_ENABLED`), and the self-heal flags above. A toggle persists in the settings store and takes effect on the next backend restart; an unset flag falls back to its environment / config default.
**Sandboxed dev DB/Redis (default-off).** Per-project opt-in (`projects.sandbox_services`, migration 057): when armed (`ROBOCO_SANDBOX_DB_ENABLED`), each opted-in project's agent spawn gets orchestrator-provisioned throwaway `postgres:16-alpine` / `redis:8-alpine` **sibling containers** (random per-sandbox creds, tmpfs pg data dir, memory/cpu-capped, labeled `roboco.sandbox=1`), injected as `ROBOCO_TEST_DB_*` / `ROBOCO_TEST_REDIS_*` **in place of** the legacy prod-creds gate-env injection (`_append_gate_env`, which points agents at RoboCo's own production Postgres under `ROBOCO_TOOLCHAIN_MATCH_ENABLED`) — sandbox replaces, never coexists with, prod creds. Lifetime tracks the agent container 1:1: teardown at every removal path plus an orphan janitor at startup + each reaper tick (grace-windowed so a sweep can't reap a sandbox whose spawn is still mid-flight; the pre-spawn stale-clear likewise spares the just-provisioned sandbox). Provisioning failure refuses the spawn (fail-loud); docker-in-agent stays structurally absent. `SandboxProvisioner` (`roboco/runtime/sandbox.py`), wired in the orchestrator spawn path.
**Cloud auth via FastAPI Users (default-off).** Lets the panel/API be safely exposed beyond localhost without touching the CEO's local no-login flow while off. Gated by `ROBOCO_CLOUD_AUTH_ENABLED` (+ `ROBOCO_CLOUD_AUTH_EMAIL` / `_PASSWORD` / `_SECRET` / `_COOKIE_MAX_AGE`; `Settings` fails loud at startup if the flag is on with no secret). Off: `get_agent_context` (`roboco/api/deps.py`) and the WS `_require_panel_token` gate (`roboco/api/websocket.py`) are byte-for-byte unchanged (header-trust). On: header-trust is dead for humans — any agent-role claim (`ceo` OR a privileged `main_pm`/`cell_pm`/board role) with no valid HMAC token or session cookie is 401, closing the header-spoof hole on the host-published `:8000` port for every role, not just `ceo` (real agents always carry a signed token, so they're unaffected); the agent-fleet HMAC path (and the orchestrator's `system` self-PATCH) keeps working unmodified in both modes; a valid session cookie authenticates as the single seeded CEO user. New `users` table (migration 058, `UserTable` in `roboco/db/tables.py`) backs FastAPI Users' `SQLAlchemyUserDatabase`; no registration router — `roboco/api/auth/seed.py` idempotently upserts exactly one row from env at startup (by primary key, so an email change renames the row instead of duplicating it). `roboco/api/auth/backend.py` wires a **cookie** transport (httponly, secure, samesite=lax) + a `JWTStrategy` subclass that binds each token to a fingerprint of the current `hashed_password`, so rotating the seeded password invalidates every prior session. Session lifetime is **sliding**: every authenticated request through `get_agent_context` re-mints + re-sets the cookie (`_slide_session_cookie`), so an active session never expires — only genuine inactivity past `cloud_auth_cookie_max_age` (default 30 days) logs out. `GET /api/auth/status` is always mounted (public); `/api/auth/login` + `/api/auth/logout` mount only when armed (`roboco/api/auth/routes.py`, mirroring `apply_guard`'s conditional mount). Panel: `(auth)/login/page.tsx` + `proxy.ts` (the Next 16 rename of `middleware.ts`; probes `/auth/status` over the docker-internal orchestrator URL, not through nginx, and fails open to "off" on any probe error/timeout) gate the `(dashboard)` group; `client.ts` adds `withCredentials` + a 401→`/login` redirect. nginx needs no changes (`/api/auth/*` rides the existing `/api/` proxy location) — but its own static `X-Agent-Token` injection (`ROBOCO_PANEL_AGENT_TOKEN`) is itself a valid HMAC credential that bypasses login when present, so a deployment arming cloud auth for real public exposure should leave that token unset (the two are alternative human-auth tiers, not layered).
**RoboCo X account (default-off).** The Head-of-Marketing voice on X (Twitter): drafts a post when a release publishes and drafts replies to meaningful mentions — but NOTHING auto-posts, every tweet is held in a panel queue for the CEO to edit/approve. Gated by `ROBOCO_X_ENGINE_ENABLED` (+ `_MENTIONS_INTERVAL_SECONDS` / `_MENTIONS_MAX_PER_CYCLE` / `_MENTIONS_MIN_ENGAGEMENT` / `_MAX_OPEN_POSTS` / `X_ACCOUNT_USER_ID`); inert without credentials regardless. Mirrors the `ReleaseManagerEngine` held-artifact shape: `XEngine` (`roboco/services/x_engine.py`) originates a held task (`source` `x_post` / `x_reply`, `confirmed_by_human=False`, Secretary-owned, skipped by every dispatcher) whose marker payload carries a **local-model-drafted** body (never a cloud LLM in the hot path), clamped to 280 chars. Release posts hook `ReleaseProposalService.approve`'s publish-success branch via a small `draft_release_post` seam; mentions ride a dedicated `_x_mentions_poll_loop` (no webhook infra exists) deduped by a `x_seen_mentions` ledger + per-cycle/open caps. The four OAuth 1.0a secrets live Fernet-encrypted in a singleton `x_credentials` row (migration 059, all-or-nothing set/clear, mirroring the git-token pattern; the API only ever returns `has_credentials`) — decryption is server-side only, agents never hold creds or egress. `XPostService.approve` (CEO-only route) is the ONLY caller of `x_client.post_tweet`: it posts under a Redis single-flight lock, **re-reads the committed task state inside the lock and commits COMPLETED before releasing** so a concurrent approve can't double-post, and is idempotent (an already-posted draft is a no-op). The hand-rolled OAuth 1.0a HMAC-SHA1 signer (`roboco/services/x_client.py`) adds no dependency; a `NullXClient` makes the unconfigured path a graceful no-op (research-engine posture). Panel: `x-post-queue.tsx` (editable draft + 280 counter, approve/reject) + `x-credentials-card.tsx` (4 write-only secret inputs).
**Board roadmap engine (default-off).** The Board originating strategic work: on a weekly interval (`ROBOCO_ROADMAP_ENGINE_ENABLED` + `_INTERVAL_SECONDS` / `_MIN_ITEMS_PER_CYCLE` / `_MAX_ITEMS_PER_CYCLE`) `RoadmapEngine` (`roboco/services/roadmap_engine.py`) opens ONE held **exploration** task (`source="board_roadmap"`, `confirmed_by_human=False`, PENDING, Product-Owner-assigned, `Team.BOARD`), deduped to one open cycle at a time. A dedicated one-shot `_dispatch_roadmap_exploration` spawns the Product Owner **solo** — deliberately NOT `_handle_board_assigned_task` (which would also spawn Head of Marketing and fire the Approve-&-Start handoff, both wrong for a PO-authored cycle) — reusing the `_board_dispatched` one-shot tracker + respawn breaker, and short-circuiting once the cycle is authored. The PO explores (read-only git, KB/RAG, metrics, releases, charter, optional web research) and makes ONE `propose_roadmap` call (a content verb gated to `product_owner` only, `_ROADMAP_ROLES`; wired through the do_server/Choreographer like `pitch`) authoring a **themed cycle** — a one-line goal + 3-7 item drafts — persisted as a `roadmap_cycle` marker on the exploration task (no table/migration). The CEO acts per-item in the panel roadmap queue (`roadmap-review-queue.tsx`; `/api/roadmap/cycles{,/items/{id}/approve,/items/{id}/reject}`, CEO-only): approve materializes that item as a BACKLOG task (`source="roadmap"`, no assignee — never auto-starts; normal PM activation picks it up) via `PrompterService.create_task_from_draft`, reject records a reason; when every item is terminal the exploration task completes (`RoadmapService`, idempotent per item). Dispatchers skip `board_roadmap` (never delivery work). `create_task_from_draft` honors a draft-declared `source` only from a `{prompter, roadmap}` whitelist — an LLM-authored draft can't impersonate a privileged origin.
**Feature flags / company-in-a-box.** Env-gated, default-off subsystems toggle from the panel's Settings → Feature Flags card (`panel/src/components/settings/feature-flags-card.tsx`) instead of hand-editing env: web research (`ROBOCO_RESEARCH_ENABLED`), the strategy engine (`ROBOCO_STRATEGY_ENGINE_ENABLED`), pitch provisioning (`ROBOCO_PROVISIONING_*`), external / internal PR review, the agent-runtime toolchain match (`ROBOCO_TOOLCHAIN_MATCH_ENABLED`), the architectural-conventions standard (`ROBOCO_CONVENTIONS_ENABLED`), gateway-health recovery (`ROBOCO_GATEWAY_HEALTH_ENABLED`), multi-repo CI-watch (`ROBOCO_CI_WATCH_ENABLED`), the dependency-update bot (`ROBOCO_DEP_UPDATE_ENABLED`), the gated release manager (`ROBOCO_RELEASE_MANAGER_ENABLED`), the organizational memory loop (`ROBOCO_ORG_MEMORY_ENABLED`), the sandboxed dev DB/Redis (`ROBOCO_SANDBOX_DB_ENABLED`), the RoboCo X account (`ROBOCO_X_ENGINE_ENABLED`), the board roadmap engine (`ROBOCO_ROADMAP_ENGINE_ENABLED`), and the self-heal flags above. Cloud auth (`ROBOCO_CLOUD_AUTH_ENABLED`) is deliberately NOT on this card — like `ROBOCO_DB_NETWORK_ISOLATED`, it's a compose/env-coupled flag a runtime toggle can't safely flip mid-session. A toggle persists in the settings store and takes effect on the next backend restart; an unset flag falls back to its environment / config default.
## Architectural Conventions Standard
@@ -496,6 +504,10 @@ The system runs as Docker Compose services. All Dockerfiles live under `docker/`
This avoids CORS since the browser sees one origin. The Next.js code uses relative URLs (`/api`, `/ws`) and lets nginx do the dispatch.
### Network topology (DB isolation)
Two user-defined bridges: `roboco_default` (the agent mesh — panel, nginx, ollama, every spawned agent container, and their sandbox DB/Redis sidecars) and `roboco_data` (postgres + redis ONLY). The orchestrator is the only multi-homed service (both networks), so agent containers cannot resolve or TCP-reach `roboco-postgres:5432` / `roboco-redis:6379` at all — network membership is the containment (redis has no auth). Agent↔agent A2A (`:9000`), orchestrator→agent SDK polls (`:9000`), MCP→orchestrator (`:8000`), and host-published ports (`15432`/`16379`/`11435`) are unaffected; `docker exec`/`inspect` paths ride the daemon socket, not the network. `ROBOCO_DB_NETWORK_ISOLATED` (config default `false`) is set `true` by the compose files that carry this topology and suppresses the legacy `_append_gate_env` prod-creds injection (unreachable creds are worse than none); DB-needing projects use the sandbox opt-in instead. The flag is deliberately NOT in the panel feature-flags card — it must travel with the compose `networks:` stanzas.
### WebSocket streams
The orchestrator exposes WebSocket endpoints under `/ws` (router in `roboco/api/websocket.py`, `ConnectionManager` + `broadcast_*` helpers):
+5 -1
View File
@@ -451,7 +451,11 @@ clean:
# Security
.PHONY: panel-token
panel-token:
@SECRET="$$(grep -E '^ROBOCO_AGENT_AUTH_SECRET=' .env 2>/dev/null | head -1 | cut -d= -f2-)"; \
@# Strip surrounding quotes from the .env value: docker-compose/pydantic
@# unquote it, so the token must be signed with the UNQUOTED secret or it
@# won't verify against the orchestrator (a quoted .env value silently
@# produced a mismatched token before this).
@SECRET="$$(grep -E '^ROBOCO_AGENT_AUTH_SECRET=' .env 2>/dev/null | head -1 | cut -d= -f2- | sed -e 's/^"//' -e 's/"$$//' -e "s/^'//" -e "s/'$$//")"; \
ROBOCO_AGENT_AUTH_SECRET="$${SECRET:-$$ROBOCO_AGENT_AUTH_SECRET}" \
uv run python -c "import sys; from roboco.agents_config import issue_panel_token; tok = issue_panel_token(); print(tok) if tok != 'UNSIGNED' else sys.exit('ERROR: ROBOCO_AGENT_AUTH_SECRET not set (in .env or environment) - the panel token would be unsigned')"
+9
View File
@@ -101,6 +101,15 @@ The Auditor has no escalation verb — every observation flows through the journ
You have `web_search` and `web_fetch` for grounding product and market calls in current external evidence — competitors, pricing, positioning, technology trends — that the knowledge base can't answer. Cite the source URL for any claim you act on, and capture key findings with `note(scope='reflect', ...)` so the team retains the source. Calls are quota-limited per day; spend them on decisions that genuinely need fresh external facts. (The Auditor does not have these tools — observe silently.)
## Roadmap exploration (Product Owner only)
When you are spawned on a `board_roadmap` task, you are not reviewing someone else's work — you are originating it, alone (Head of Marketing is not part of this cycle in v1). The task is your periodic prompt to explore and propose a themed cycle of roadmap items for the CEO's approval:
1. Explore: the company charter (already in your briefing), recent releases, metrics, and each project's current state (read-only git); check the knowledge base for open threads; optionally spend a `web_search`/`web_fetch` call on external signal if it would sharpen a call.
2. Pick ONE theme/goal that ties the cycle together — a one-line focus, not a grab-bag of unrelated ideas.
3. Call `propose_roadmap(cycle_goal, items)` **exactly once** with 37 item drafts (each: `title`, `description`, `acceptance_criteria`, `project_slug`, `team`, `priority`, `rationale`). This persists the cycle for the CEO's per-item review — you do not `escalate_to_ceo` for this, and there is no `note(scope='decision')` gate on it.
4. `i_am_idle()`. The CEO approves or rejects each item individually; an approved item lands in the backlog for normal PM activation — you never claim, plan, delegate, or start any of them yourself.
## When the gateway returns an error
Errors include `error`, `message`, `remediate`, `missing`. Read `remediate` — it tells you the literal next call. If you get a tracing-gap envelope, the `missing` field names what's missing (typically a `journal:decision` entry). Fix that one piece and retry the same verb.
@@ -0,0 +1,33 @@
"""Per-project sandbox DB/Redis opt-in column.
The sandboxed-DB provisioner (orchestrator-side sibling containers) only
participates for a project when ``sandbox_services`` is set e.g.
``["postgres", "redis"]``. Additive and nullable, so existing projects keep
today's behavior (no sandbox, the legacy ``_append_gate_env`` prod-creds
injection stays byte-for-byte unchanged for them).
Revision ID: 057_project_sandbox_services
Revises: 056_member_perf_daily
Create Date: 2026-07-03
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "057_project_sandbox_services"
down_revision = "056_member_perf_daily"
branch_labels: dict[str, str] | None = None
depends_on: dict[str, str] | None = None
def upgrade() -> None:
op.add_column(
"projects",
sa.Column("sandbox_services", sa.ARRAY(sa.String()), nullable=True),
)
def downgrade() -> None:
op.drop_column("projects", "sandbox_services")
+45
View File
@@ -0,0 +1,45 @@
"""Add the users table — the single seeded CEO login for cloud auth.
Cloud auth (default off, ROBOCO_CLOUD_AUTH_ENABLED) needs a FastAPI Users
schema to back the cookie-session login. Additive and unused while the flag
is off; the one row is idempotently upserted at startup, never via a
registration route.
Revision ID: 058_cloud_auth_users
Revises: 057_project_sandbox_services
Create Date: 2026-07-03
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "058_cloud_auth_users"
down_revision = "057_project_sandbox_services"
branch_labels: dict[str, str] | None = None
depends_on: dict[str, str] | None = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True, nullable=False),
sa.Column("email", sa.String(length=320), nullable=False),
sa.Column("hashed_password", sa.String(length=1024), nullable=False),
sa.Column(
"is_active", sa.Boolean(), nullable=False, server_default=sa.true()
),
sa.Column(
"is_superuser", sa.Boolean(), nullable=False, server_default=sa.false()
),
sa.Column(
"is_verified", sa.Boolean(), nullable=False, server_default=sa.false()
),
)
op.create_index("ix_users_email", "users", ["email"], unique=True)
def downgrade() -> None:
op.drop_index("ix_users_email", table_name="users")
op.drop_table("users")
+55
View File
@@ -0,0 +1,55 @@
"""Add the x_credentials + x_seen_mentions tables — the X (Twitter) engine.
``x_credentials`` is a singleton row holding the Fernet-encrypted OAuth 1.0a
user-context secrets (mirrors provider_configs' encrypted-token column).
``x_seen_mentions`` is the mentions-poll dedup ledger, keyed by mention id so
a mention is never turned into a second held reply proposal. Additive and
inert while ``ROBOCO_X_ENGINE_ENABLED`` is off.
Revision ID: 059_x_credentials
Revises: 058_cloud_auth_users
Create Date: 2026-07-03
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "059_x_credentials"
down_revision = "058_cloud_auth_users"
branch_labels: dict[str, str] | None = None
depends_on: dict[str, str] | None = None
def upgrade() -> None:
op.create_table(
"x_credentials",
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True, nullable=False),
sa.Column("api_key_encrypted", sa.Text(), nullable=True),
sa.Column("api_secret_encrypted", sa.Text(), nullable=True),
sa.Column("access_token_encrypted", sa.Text(), nullable=True),
sa.Column("access_token_secret_encrypted", sa.Text(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_table(
"x_seen_mentions",
sa.Column("mention_id", sa.String(length=64), primary_key=True),
sa.Column(
"seen_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
)
def downgrade() -> None:
op.drop_table("x_seen_mentions")
op.drop_table("x_credentials")
+30 -4
View File
@@ -28,6 +28,11 @@ services:
image: pgvector/pgvector:pg16
container_name: roboco-postgres
restart: unless-stopped
# data-only network: agent containers (on roboco_default) cannot reach
# the production DB; only the multi-homed orchestrator can. Host port
# publishing (15432) is unaffected — roboco_data is a normal bridge.
networks:
- data
environment:
POSTGRES_USER: roboco
POSTGRES_PASSWORD: roboco
@@ -46,6 +51,10 @@ services:
image: redis:8-alpine
container_name: roboco-redis
restart: unless-stopped
# data-only network — see postgres. Redis has no auth, so network
# membership is its ONLY containment against agent containers.
networks:
- data
command: redis-server --appendonly yes
ports:
- "16379:6379"
@@ -188,9 +197,19 @@ services:
image: ${ROBOCO_REGISTRY:-ghcr.io/rennf93}/roboco-orchestrator:${ROBOCO_VERSION:-latest}
container_name: roboco-orchestrator
restart: unless-stopped
# Multi-homed: the agent mesh (default) for spawned agents / panel /
# ollama, plus the data network for postgres/redis.
networks:
- default
- data
ports:
- "8000:8000"
environment:
# DB network isolation is LIVE in this file (postgres/redis on the
# data-only network): suppress the legacy prod-creds gate-env
# injection — agents can't reach roboco-postgres anyway. This flag
# must always travel with the networks: topology.
ROBOCO_DB_NETWORK_ISOLATED: ${ROBOCO_DB_NETWORK_ISOLATED:-true}
ROBOCO_DATABASE_HOST: roboco-postgres
ROBOCO_DATABASE_PORT: 5432
ROBOCO_DATABASE_USER: roboco
@@ -225,10 +244,10 @@ services:
ROBOCO_ENVIRONMENT: production
ROBOCO_CLAIM_STALE_SECONDS: "1800"
ROBOCO_STALE_CLAIM_REAP_SECONDS: "1800"
# External-PR review (read-only: discover inbound external/fork PRs, post
# one change-request; never runs contributor code — the supersede stays
# CEO-triggered + human-confirm gated). Matches the build compose.
ROBOCO_EXTERNAL_PR_ENABLED: ${ROBOCO_EXTERNAL_PR_ENABLED:-true}
# Every optional feature ships OFF in this user-facing compose (the build
# compose arms them for the personal deploy). External-PR review included —
# arm it (and any other subsystem) via .env or Settings → Feature Flags.
ROBOCO_EXTERNAL_PR_ENABLED: ${ROBOCO_EXTERNAL_PR_ENABLED:-false}
ROBOCO_EXTERNAL_PR_REQUIRE_HUMAN_CONFIRM: ${ROBOCO_EXTERNAL_PR_REQUIRE_HUMAN_CONFIRM:-true}
# ROBOCO_EXTERNAL_PR_POLL_INTERVAL_SECONDS: "300"
# ROBOCO_EXTERNAL_PR_AUTHOR_ALLOWLIST: '["corey"]' # empty = every external PR
@@ -306,3 +325,10 @@ services:
networks:
default:
name: roboco_default
# DB isolation: postgres/redis live ONLY here; the orchestrator is the
# only service homed on both. Spawned agent containers + sandbox sidecars
# join roboco_default (AGENT_NETWORK) and cannot resolve or reach the
# production DB/Redis. Normal bridge (not internal) so host-published
# ports keep working.
data:
name: roboco_data
+66 -2
View File
@@ -6,6 +6,11 @@ services:
image: pgvector/pgvector:pg16
container_name: roboco-postgres
restart: unless-stopped
# data-only network: agent containers (on roboco_default) cannot reach
# the production DB; only the multi-homed orchestrator can. Host port
# publishing (15432) is unaffected — roboco_data is a normal bridge.
networks:
- data
environment:
POSTGRES_USER: roboco
POSTGRES_PASSWORD: roboco
@@ -27,6 +32,10 @@ services:
image: redis:8-alpine
container_name: roboco-redis
restart: unless-stopped
# data-only network — see postgres. Redis has no auth, so network
# membership is its ONLY containment against agent containers.
networks:
- data
command: redis-server --appendonly yes
ports:
- "16379:6379"
@@ -278,6 +287,11 @@ services:
image: roboco-orchestrator
container_name: roboco-orchestrator
restart: unless-stopped
# Multi-homed: the agent mesh (default) for spawned agents / panel /
# ollama, plus the data network for postgres/redis.
networks:
- default
- data
ports:
- "8000:8000"
environment:
@@ -342,8 +356,8 @@ services:
# PROJECT_SLUG to the registered project that IS RoboCo; CI_WORKFLOW scopes
# the signal to the real CI workflow (RoboCo has several workflows, so the
# unscoped "latest run" would be unreliable).
ROBOCO_SELF_HEAL_ENABLED: ${ROBOCO_SELF_HEAL_ENABLED:-false}
ROBOCO_SELF_HEAL_ORIGINATE_ENABLED: ${ROBOCO_SELF_HEAL_ORIGINATE_ENABLED:-false}
ROBOCO_SELF_HEAL_ENABLED: ${ROBOCO_SELF_HEAL_ENABLED:-true}
ROBOCO_SELF_HEAL_ORIGINATE_ENABLED: ${ROBOCO_SELF_HEAL_ORIGINATE_ENABLED:-true}
ROBOCO_SELF_HEAL_PROJECT_SLUG: ${ROBOCO_SELF_HEAL_PROJECT_SLUG:-roboco-api}
ROBOCO_SELF_HEAL_CI_WORKFLOW: ${ROBOCO_SELF_HEAL_CI_WORKFLOW:-ci.yml}
# Agent runtime toolchain matching: provision each agent workspace with
@@ -372,15 +386,58 @@ services:
# CI (reuses SELF_HEAL_PROJECT_SLUG as the RoboCo repo).
# - Org-memory loop: distils a completion lesson + auto-injects relevant
# past lessons/playbooks into each claim's briefing (local model only).
# - X engine: drafts release-announcement + mention-reply posts, ALL
# held for per-post CEO approval in the panel; inert without stored
# X credentials (Settings -> the X card) regardless of this flag.
ROBOCO_CI_WATCH_ENABLED: ${ROBOCO_CI_WATCH_ENABLED:-true}
ROBOCO_DEP_UPDATE_ENABLED: ${ROBOCO_DEP_UPDATE_ENABLED:-true}
ROBOCO_RELEASE_MANAGER_ENABLED: ${ROBOCO_RELEASE_MANAGER_ENABLED:-true}
ROBOCO_ORG_MEMORY_ENABLED: ${ROBOCO_ORG_MEMORY_ENABLED:-true}
ROBOCO_X_ENGINE_ENABLED: ${ROBOCO_X_ENGINE_ENABLED:-true}
# - Board roadmap engine: weekly, opens ONE held exploration task for
# the Product Owner, who proposes a themed cycle of roadmap items;
# the CEO approves each item individually into the backlog.
ROBOCO_ROADMAP_ENGINE_ENABLED: ${ROBOCO_ROADMAP_ENGINE_ENABLED:-true}
# Sandboxed per-agent test DB/Redis: orchestrator-provisioned throwaway
# sibling containers per spawn, replacing the prod-creds gate-env
# injection for an opted-in project (its `sandbox_services` column set).
# Config default is OFF; enabled HERE (personal deploy), deliberately
# left OFF in docker-compose.registry.yml so the published default
# stays conservative until verified on a live run.
ROBOCO_SANDBOX_DB_ENABLED: ${ROBOCO_SANDBOX_DB_ENABLED:-true}
# DB network isolation is LIVE in this file (postgres/redis on the
# data-only network): suppress the legacy prod-creds gate-env
# injection — agents can't reach roboco-postgres anyway, and handing
# out creds that dead-end in a connect timeout is worse than none.
# This flag must always travel with the networks: topology above.
ROBOCO_DB_NETWORK_ISOLATED: ${ROBOCO_DB_NETWORK_ISOLATED:-true}
# Strategy engine (proactive strategy signals) + internal PR review — both
# default-OFF in config; ARMED here for the NAS deploy like the rest. Left
# OFF in docker-compose.registry.yml so the published default stays conservative.
ROBOCO_STRATEGY_ENGINE_ENABLED: ${ROBOCO_STRATEGY_ENGINE_ENABLED:-true}
ROBOCO_INTERNAL_PR_ENABLED: ${ROBOCO_INTERNAL_PR_ENABLED:-true}
# Remaining default-OFF capabilities, ARMED here (personal deploy): web
# research (inert without ROBOCO_RESEARCH_API_KEY → empty results), pitch
# auto-provisioning (inert until a pitch is approved + a git token is set),
# and transcript pruning (retention maintenance). All left OFF in the
# registry compose. Override any via .env.
ROBOCO_RESEARCH_ENABLED: ${ROBOCO_RESEARCH_ENABLED:-true}
ROBOCO_PROVISIONING_ENABLED: ${ROBOCO_PROVISIONING_ENABLED:-true}
ROBOCO_TRANSCRIPT_PRUNE_ENABLED: ${ROBOCO_TRANSCRIPT_PRUNE_ENABLED:-true}
# Two knobs that also default ON here (like every feature), but change
# FAILURE / AUTH behavior — the .env must be set up for each before a real
# boot, which the operator owns:
# - CLOUD_AUTH: exposes the panel behind a login. Set
# ROBOCO_CLOUD_AUTH_EMAIL / _PASSWORD / _SECRET in .env (startup fails
# loud without the secret) and terminate TLS (the session cookie is
# secure-only); leave ROBOCO_PANEL_AGENT_TOKEN unset (it bypasses
# login). To test-deploy before the creds exist, set
# ROBOCO_CLOUD_AUTH_ENABLED=false in .env for that run.
# - ROUTING_STRICT: fail-closed model routing — a spawn whose provider is
# disabled RAISES instead of degrading. Audit model_assignments first
# (a stale row pointing at a disabled provider would crash that spawn).
ROBOCO_CLOUD_AUTH_ENABLED: ${ROBOCO_CLOUD_AUTH_ENABLED:-true}
ROBOCO_ROUTING_STRICT: ${ROBOCO_ROUTING_STRICT:-true}
# Spawn preflight (token-opt Phase 3) — refuse a non-gateway delivery role
# that would respawn forever. Default-OFF in config; ARMED here (inert in
# practice: every real delivery role is gateway-enabled). OFF in registry.
@@ -490,3 +547,10 @@ services:
networks:
default:
name: roboco_default
# DB isolation: postgres/redis live ONLY here; the orchestrator is the
# only service homed on both. Spawned agent containers + sandbox sidecars
# join roboco_default (AGENT_NETWORK) and cannot resolve or reach the
# production DB/Redis. Normal bridge (not internal) so host-published
# ports keep working.
data:
name: roboco_data
+66 -2
View File
@@ -6,6 +6,11 @@ services:
image: pgvector/pgvector:pg16
container_name: roboco-postgres
restart: unless-stopped
# data-only network: agent containers (on roboco_default) cannot reach
# the production DB; only the multi-homed orchestrator can. Host port
# publishing (15432) is unaffected — roboco_data is a normal bridge.
networks:
- data
environment:
POSTGRES_USER: roboco
POSTGRES_PASSWORD: roboco
@@ -27,6 +32,10 @@ services:
image: redis:8-alpine
container_name: roboco-redis
restart: unless-stopped
# data-only network — see postgres. Redis has no auth, so network
# membership is its ONLY containment against agent containers.
networks:
- data
command: redis-server --appendonly yes
ports:
- "16379:6379"
@@ -278,6 +287,11 @@ services:
image: roboco-orchestrator
container_name: roboco-orchestrator
restart: unless-stopped
# Multi-homed: the agent mesh (default) for spawned agents / panel /
# ollama, plus the data network for postgres/redis.
networks:
- default
- data
ports:
- "8000:8000"
environment:
@@ -342,8 +356,8 @@ services:
# PROJECT_SLUG to the registered project that IS RoboCo; CI_WORKFLOW scopes
# the signal to the real CI workflow (RoboCo has several workflows, so the
# unscoped "latest run" would be unreliable).
ROBOCO_SELF_HEAL_ENABLED: ${ROBOCO_SELF_HEAL_ENABLED:-false}
ROBOCO_SELF_HEAL_ORIGINATE_ENABLED: ${ROBOCO_SELF_HEAL_ORIGINATE_ENABLED:-false}
ROBOCO_SELF_HEAL_ENABLED: ${ROBOCO_SELF_HEAL_ENABLED:-true}
ROBOCO_SELF_HEAL_ORIGINATE_ENABLED: ${ROBOCO_SELF_HEAL_ORIGINATE_ENABLED:-true}
ROBOCO_SELF_HEAL_PROJECT_SLUG: ${ROBOCO_SELF_HEAL_PROJECT_SLUG:-roboco-api}
ROBOCO_SELF_HEAL_CI_WORKFLOW: ${ROBOCO_SELF_HEAL_CI_WORKFLOW:-ci.yml}
# Agent runtime toolchain matching: provision each agent workspace with
@@ -372,15 +386,58 @@ services:
# CI (reuses SELF_HEAL_PROJECT_SLUG as the RoboCo repo).
# - Org-memory loop: distils a completion lesson + auto-injects relevant
# past lessons/playbooks into each claim's briefing (local model only).
# - X engine: drafts release-announcement + mention-reply posts, ALL
# held for per-post CEO approval in the panel; inert without stored
# X credentials (Settings -> the X card) regardless of this flag.
ROBOCO_CI_WATCH_ENABLED: ${ROBOCO_CI_WATCH_ENABLED:-true}
ROBOCO_DEP_UPDATE_ENABLED: ${ROBOCO_DEP_UPDATE_ENABLED:-true}
ROBOCO_RELEASE_MANAGER_ENABLED: ${ROBOCO_RELEASE_MANAGER_ENABLED:-true}
ROBOCO_ORG_MEMORY_ENABLED: ${ROBOCO_ORG_MEMORY_ENABLED:-true}
ROBOCO_X_ENGINE_ENABLED: ${ROBOCO_X_ENGINE_ENABLED:-true}
# - Board roadmap engine: weekly, opens ONE held exploration task for
# the Product Owner, who proposes a themed cycle of roadmap items;
# the CEO approves each item individually into the backlog.
ROBOCO_ROADMAP_ENGINE_ENABLED: ${ROBOCO_ROADMAP_ENGINE_ENABLED:-true}
# Sandboxed per-agent test DB/Redis: orchestrator-provisioned throwaway
# sibling containers per spawn, replacing the prod-creds gate-env
# injection for an opted-in project (its `sandbox_services` column set).
# Config default is OFF; enabled HERE (personal deploy), deliberately
# left OFF in docker-compose.registry.yml so the published default
# stays conservative until verified on a live run.
ROBOCO_SANDBOX_DB_ENABLED: ${ROBOCO_SANDBOX_DB_ENABLED:-true}
# DB network isolation is LIVE in this file (postgres/redis on the
# data-only network): suppress the legacy prod-creds gate-env
# injection — agents can't reach roboco-postgres anyway, and handing
# out creds that dead-end in a connect timeout is worse than none.
# This flag must always travel with the networks: topology above.
ROBOCO_DB_NETWORK_ISOLATED: ${ROBOCO_DB_NETWORK_ISOLATED:-true}
# Strategy engine (proactive strategy signals) + internal PR review — both
# default-OFF in config; ARMED here for the NAS deploy like the rest. Left
# OFF in docker-compose.registry.yml so the published default stays conservative.
ROBOCO_STRATEGY_ENGINE_ENABLED: ${ROBOCO_STRATEGY_ENGINE_ENABLED:-true}
ROBOCO_INTERNAL_PR_ENABLED: ${ROBOCO_INTERNAL_PR_ENABLED:-true}
# Remaining default-OFF capabilities, ARMED here (personal deploy): web
# research (inert without ROBOCO_RESEARCH_API_KEY → empty results), pitch
# auto-provisioning (inert until a pitch is approved + a git token is set),
# and transcript pruning (retention maintenance). All left OFF in the
# registry compose. Override any via .env.
ROBOCO_RESEARCH_ENABLED: ${ROBOCO_RESEARCH_ENABLED:-true}
ROBOCO_PROVISIONING_ENABLED: ${ROBOCO_PROVISIONING_ENABLED:-true}
ROBOCO_TRANSCRIPT_PRUNE_ENABLED: ${ROBOCO_TRANSCRIPT_PRUNE_ENABLED:-true}
# Two knobs that also default ON here (like every feature), but change
# FAILURE / AUTH behavior — the .env must be set up for each before a real
# boot, which the operator owns:
# - CLOUD_AUTH: exposes the panel behind a login. Set
# ROBOCO_CLOUD_AUTH_EMAIL / _PASSWORD / _SECRET in .env (startup fails
# loud without the secret) and terminate TLS (the session cookie is
# secure-only); leave ROBOCO_PANEL_AGENT_TOKEN unset (it bypasses
# login). To test-deploy before the creds exist, set
# ROBOCO_CLOUD_AUTH_ENABLED=false in .env for that run.
# - ROUTING_STRICT: fail-closed model routing — a spawn whose provider is
# disabled RAISES instead of degrading. Audit model_assignments first
# (a stale row pointing at a disabled provider would crash that spawn).
ROBOCO_CLOUD_AUTH_ENABLED: ${ROBOCO_CLOUD_AUTH_ENABLED:-true}
ROBOCO_ROUTING_STRICT: ${ROBOCO_ROUTING_STRICT:-true}
# Spawn preflight (token-opt Phase 3) — refuse a non-gateway delivery role
# that would respawn forever. Default-OFF in config; ARMED here (inert in
# practice: every real delivery role is gateway-enabled). OFF in registry.
@@ -490,3 +547,10 @@ services:
networks:
default:
name: roboco_default
# DB isolation: postgres/redis live ONLY here; the orchestrator is the
# only service homed on both. Spawned agent containers + sandbox sidecars
# join roboco_default (AGENT_NETWORK) and cannot resolve or reach the
# production DB/Redis. Normal bridge (not internal) so host-published
# ports keep working.
data:
name: roboco_data
+13
View File
@@ -618,3 +618,16 @@ Backend: `EventType.A2A_MESSAGE_SENT` published from `A2AService.send` (excerpt-
1. **Switchboard**: `A2A_ALLOWED_PAIRS` (agents_config, import-time; 70 pairs: 15×3 cells, 6 pm-chain, 3 board, 16 cross), `A2AService.list_admin_pairs` (bulk tuple_ IN join, latest conversation per pair), `GET /a2a/chat/admin/pairs` (CEO-gated); panel `a2a-switchboard{,-utils,-pair-card}` (pure lighting utils, rAF/CSS 45s fade, no timers), /a2a defaults to switchboard w/ v1-list toggle. FLAGGED pre-existing: `agent-utils.ts` static maps miss the per-cell pr-reviewer slugs.
2. **Task access**: Secretary `_EDITABLE_TASK_FIELDS` full content surface + enum coercion + claim-aware reassign (`reassign_active_claim` when claimed/in_progress); `read_task` full detail (progress bounded 50). PM lighter: `_pm_editor_scope`/`_enforce_pm_lighter_fields` in routes/tasks.py — closed the pre-existing PM-unrestricted-admin hole (cross-team cell PM hard-403; content allowlist; zero status via PATCH). submit_directive tool docs never mentioned `edit` (fixed — undiscoverable).
---
## Delta 2026-07-03 (5) — wave 3 → v0.17.0 (branch `feat/wave-3`, SDD/Sonnet 5, reviewed)
Six subsystems, all default-off + additive:
1. **Sandboxed dev DB/Redis** (`ROBOCO_SANDBOX_DB_ENABLED`, migration 057 `projects.sandbox_services`): `SandboxProvisioner` (`roboco/runtime/sandbox.py`) `docker run`s throwaway `postgres:16`/`redis:8` sibling containers per spawn (random creds, tmpfs, labeled), injecting `ROBOCO_TEST_DB_*`/`ROBOCO_TEST_REDIS_*` in place of the prod-creds gate-env. Container-tracked lifetime + orphan janitor (grace-windowed). REVIEW FIX: pre-spawn stale-clear must not tear down the just-provisioned sandbox (`teardown_sandbox=False`) + provision pre-clears stale + janitor grace.
2. **DB network isolation** (`ROBOCO_DB_NETWORK_ISOLATED`): second `roboco_data` compose bridge = postgres+redis only, orchestrator multi-homed; agents can't reach prod DB. Suppresses `_append_gate_env` prod-creds injection. In BOTH build+registry composes (topology-coupled).
3. **Mobile UI** (panel): `useIsMobile` (useSyncExternalStore, hydration-safe), `ResponsiveTable` table→card, snap `TabsList`, bottom tab bar, Comms/A2A single-pane drill-down below lg, vh→dvh. REVIEW FIX: `justify-center-safe` (overflow clip), memoized matchMedia subscribe.
4. **Cloud auth** (`ROBOCO_CLOUD_AUTH_ENABLED`, migration 058 `users`): FastAPI Users, single seeded user, cookie sliding 30-day session (pwd-fingerprint JWT), `get_agent_context` dual-path (deps.py). Off = byte-identical. `proxy.ts` (Next 16). REVIEW FIX: on-mode rejects EVERY non-CEO role without a token (not just ceo — closed the PM/board :8000 spoof).
5. **X engine** (`ROBOCO_X_ENGINE_ENABLED`, migration 059 Fernet `x_credentials` + `x_seen_mentions`): release-post + mention-reply drafts held for per-post CEO approval; local-model draft, hand-rolled OAuth1 signer, `XPostService.approve` sole `post_tweet` caller. REVIEW FIX: closed a double-post race (in-lock re-read + commit-before-release).
6. **Board roadmap engine** (`ROBOCO_ROADMAP_ENGINE_ENABLED`, marker-backed, no migration): weekly one-shot PO-solo explore → `propose_roadmap` verb (PO-only, 3-7 items) → CEO per-item approve→BACKLOG. `_dispatch_roadmap_exploration` bypasses the two-reviewer board path. REVIEW: builder found+fixed a SQLAlchemy dirty-check bug (deepcopy marker before mutate); I whitelisted `create_task_from_draft` source ({prompter, roadmap}).
Compose: every optional feature armed `:-true` in the NAS composes, OFF in registry; two opt-in exceptions (`CLOUD_AUTH`, `ROUTING_STRICT`) default off (change auth/failure behavior, not capability).
+27 -13
View File
@@ -5,7 +5,7 @@ This slice is the agent-to-agent communication, audit forensics, journaling, str
| Path | Role | LOC |
|---|---|---|
| roboco/services/a2a.py | A2A protocol + persistent conversation service: Agent Cards, task↔A2A conversion, legacy A2A task notifications, bidirectional response spawning, slug-keyed conversation/message CRUD, gateway send adapter | 1422 |
| roboco/services/a2a.py | A2A protocol + persistent conversation service: Agent Cards, task↔A2A conversion, legacy A2A task notifications, bidirectional response spawning, slug-keyed conversation/message CRUD, gateway send adapter, CEO admin/live-view surface (reply budget + org-wide read) | 1778 |
| roboco/services/audit.py | AuditService singleton: best-effort persist denial/lifecycle/agent events to audit_log, resolve actor role + slug→UUID at write time, tracing-gap query for respawn circuit breaker, recent-events query | 462 |
| roboco/services/journal.py | JournalService: journal/entry CRUD, gateway scope-string→JournalEntryType adapter, fire-and-forget RAG indexing (private excluded), tracing-gate existence checks, board review brief, growth analytics | 1029 |
| roboco/services/content_notes.py | Single chokepoint applying structured notes: validate via foundation ContentModel, store in notes_structured, regenerate derived TEXT mirror column (dev_notes/qa_notes/etc.) | 74 |
@@ -41,18 +41,25 @@ This slice is the agent-to-agent communication, audit forensics, journaling, str
| _canonical_pair | staticmethod | roboco/services/a2a.py:779 | Return two agent slugs in lexically-sorted order (conversation uniqueness key) |
| get_or_create_conversation | method | roboco/services/a2a.py:784 | Validate A2A access (validate_a2a_access), canonical-order lookup or create A2AConversationTable row |
| get_conversation | method | roboco/services/a2a.py:850 | Fetch conversation by ID only if agent_slug is a participant |
| get_conversation_admin | method | roboco/services/a2a.py:952 | Wave-2 CEO live view: like get_conversation but WITHOUT the participant check — returns any conversation by id for the org-wide read; None only if it truly doesn't exist |
| list_conversations | method | roboco/services/a2a.py:881 | List conversation summaries for an agent with optional status/with_agent/task_id filters; per-conv last-message preview query (N+1) |
| list_conversations_admin | method | roboco/services/a2a.py:1063 | Wave-2 CEO live view: list conversations across every agent pair (no participant filter), most-recent-first; backs GET /chat/admin/conversations |
| list_admin_pairs | method | roboco/services/a2a.py:1101 | Wave-2c switchboard: every agents_config.A2A_ALLOWED_PAIRS entry joined with its representative conversation (most-recently-updated when >1) via one bulk tuple_(agent_a,agent_b).in_() query — never N+1; backs GET /chat/admin/pairs |
| close_conversation | method | roboco/services/a2a.py:962 | Mark conversation CLOSED with optional resolution; participant-only |
| send_chat_message | method | roboco/services/a2a.py:992 | Send message in conversation; nil-UUID guard; dedup unread identical (conv,sender,kind,content); bump unread for other side; reads skill from opts and persists it on the message row (nullable) |
| get_messages | method | roboco/services/a2a.py:1100 | Paginated chronological message list for a participant |
| mark_read | method | roboco/services/a2a.py:1138 | Zero agent's per-side unread counter and bulk UPDATE read_at on inbound unread messages |
| mark_all_read | method | roboco/services/a2a.py:1179 | Agent-keyed bulk mark_read across all conversations with unread for this agent; returns count cleared |
| get_inbox_summary | method | roboco/services/a2a.py:1220 | Aggregate total unread, conversations with unread, pending + unanswered requires_response counts |
| list_pairs | method | roboco/services/a2a.py:1281 | Group conversations into unique agent pairs with rollup counts/unread/last_activity for frontend |
| _conv_to_model | method | roboco/services/a2a.py:1331 | A2AConversationTable→A2AConversation Pydantic model |
| _msg_to_model | method | roboco/services/a2a.py:1349 | A2AMessageTable→A2AChatMessage Pydantic model; now maps skill field (migration 054 adds nullable skill column on a2a_messages) |
| _resolve_slug_from_id | method | roboco/services/a2a.py:1369 | Lookup agent slug from UUID; raise ValueError if missing (gateway send adapter) |
| send | method | roboco/services/a2a.py:1379 | Gateway adapter: resolve both ends to slugs, get_or_create_conversation + send_chat_message |
| _enforce_ceo_reply_budget | method | roboco/services/a2a.py:1177 | Wave-2 reply-then-wait budget on the CEO's inbox — the one stateful gate the stateless can_a2a_direct matrix can't see. An agent may message the CEO only inside a conversation the CEO itself opened, and only up to the CEO's own message count there (rejects once agent_count >= ceo_count); no-op for CEO-authored sends or non-CEO conversations |
| send_chat_message | method | roboco/services/a2a.py:1225 | Send message in conversation; nil-UUID guard; dedup unread identical (conv,sender,kind,content); calls _enforce_ceo_reply_budget before persisting; bump unread for other side; reads skill from opts and persists it on the message row (nullable) |
| get_messages | method | roboco/services/a2a.py:1337 | Paginated chronological message list for a participant |
| get_messages_admin | method | roboco/services/a2a.py:1375 | Wave-2 CEO live view: like get_messages but WITHOUT the participant check — reads any conversation's transcript; [] only if the conversation truly doesn't exist |
| mark_read | method | roboco/services/a2a.py:1410 | Zero agent's per-side unread counter and bulk UPDATE read_at on inbound unread messages |
| mark_all_read | method | roboco/services/a2a.py:1451 | Agent-keyed bulk mark_read across all conversations with unread for this agent; returns count cleared |
| get_inbox_summary | method | roboco/services/a2a.py:1492 | Aggregate total unread, conversations with unread, pending + unanswered requires_response counts |
| list_pairs | method | roboco/services/a2a.py:1553 | Group conversations into unique agent pairs with rollup counts/unread/last_activity for frontend |
| _conv_to_model | method | roboco/services/a2a.py:1603 | A2AConversationTable→A2AConversation Pydantic model |
| _msg_to_model | method | roboco/services/a2a.py:1621 | A2AMessageTable→A2AChatMessage Pydantic model; now maps skill field (migration 054 adds nullable skill column on a2a_messages) |
| _resolve_slug_from_id | method | roboco/services/a2a.py:1642 | Lookup agent slug from UUID; raise ValueError if missing (gateway send adapter) |
| _get_conversation_for_reply_to_ceo | method | roboco/services/a2a.py:1652 | Wave-2: resolve the conversation for an agent replying to the CEO by direct lookup (bypasses get_or_create_conversation's validate-first gate, which would deny even a legitimate reply) — an existing pair conversation's mere presence proves the CEO opened it, since agents can never create one |
| send | method | roboco/services/a2a.py:1684 | Gateway adapter: resolve both ends to slugs, get_or_create_conversation (or _get_conversation_for_reply_to_ceo when replying to "ceo") + send_chat_message; publishes A2A_MESSAGE_SENT via _publish_a2a_message_sent afterward |
| _publish_a2a_message_sent | staticmethod | roboco/services/a2a.py:1742 | Wave-2: best-effort publish of A2A_MESSAGE_SENT (conversation_id/message_id/task_id/from_agent/to_agent/skill/body_excerpt/timestamp) to the event bus for the operator live view; a bus outage is logged and never rolls back the already-persisted message |
| _AuditEvent | dataclass | roboco/services/audit.py:20 | Bundled fields for one audit row write (event_type, agent_id, target, severity, details) |
| _coerce_uuid | function | roboco/services/audit.py:36 | Best-effort coerce str/UUID to UUID; returns None for slugs/invalid |
| AuditService | class | roboco/services/audit.py:48 | SingletonService for audit logging; structured log + best-effort audit_log persistence |
@@ -112,7 +119,7 @@ This slice is the agent-to-agent communication, audit forensics, journaling, str
| _get_agents_for_role_team | function | roboco/services/permissions.py:64 | All agent slugs matching a (role, team) pair from the precomputed lookup |
## Data Flow
CONTROL FLOW: (1) A2A — HTTP routes in roboco/api/routes/a2a.py construct A2AService(db) per request for card discovery, task get/list/cancel, conversation CRUD, message send, mark-read, inbox/pairs; the gateway Choreographer/content_actions use A2AService.send (UUID→slug resolved) for directed agent messaging. Legacy A2A-protocol path: create_a2a_notification requires a task_id, requires both from_agent and target_agent to be present/resolvable (raises distinct ValueError if either missing), enforces hierarchy unconditionally via validate_a2a_access (raises A2AAccessDeniedError), parses priority via foundation.policy.communications.parse_priority, then delegates to NotificationService.send_a2a_notification (which now runs the loop-prone 60s Redis re-fire guard). Bidirectional responses: update_task_from_message appends to dev_notes and, if dev_notes contains the 'A2A Request' marker, _notify_original_requester publishes a TASK_ASSIGNED event to the StreamEventBus to spawn the offline requester. (2) Audit — TaskService (log_task_event at every transition chokepoint), task routes (log_task_action_denial on 403 for action denials; log_task_creation_denial on 403 for pre-task create denials — distinct target_type="task_creation" with no task_id), the orchestrator (log_agent_event on spawn/stop + has_recent_tracing_gap for the PM-respawn circuit breaker) and the Choreographer (log_event for gateway.rejected) all call get_audit_service(); _persist opens its own session+commit so audit writes never roll back the caller's transaction. (3) Journal — gateway content_actions.note → JournalService.write_entry (scope string→type via foundation SCOPE_TO_TYPE), and write_struggle/write_decision for PM write-then-gate verbs; create_entry commits the row then _schedule_rag_index fires asyncio.create_task (strong-ref in _RAG_INDEX_TASKS) that calls OptimalService.index_journal_entry (skipped for is_private) and record_learning for LEARNING entries; tracing-gate existence checks (has_decision_for_task/latest_decision_at/has_note_for_task/...) feed the Choreographer's gate decisions. (4) content_notes — TaskService._set_structured_note and gateway content_actions handoff path call apply_structured_note(task, content_type, payload); it validates via foundation.policy.content.validate_content BEFORE mutating, reassigns notes_structured (to flag the JSON column dirty), and writes render_markdown() into the derived TEXT mirror column. (5) Extraction — app lifespan builds ExtractionPipeline(ExtractionService()); stream route process_buffer → ExtractionService.extract (regex classify) → callbacks store/broadcast messages; extract_with_llm is the optional Anthropic/TOON path. (6) Permissions — messaging/notification/task/KB routes and gateway kb_authz call PermissionService methods synchronously (no DB) from an AgentContext; has_privileged_access/is_pm_role are async DB lookups used by route deps. DATA: inputs are AsyncSession + UUIDs/slugs/payloads; outputs are Pydantic models (A2ATask, A2AConversation, Journal, JournalEntry), audit rows, structured note columns, ExtractedMessage lists, and bool permission decisions.
CONTROL FLOW: (1) A2A — HTTP routes in roboco/api/routes/a2a.py construct A2AService(db) per request for card discovery, task get/list/cancel, conversation CRUD, message send, mark-read, inbox/pairs; the gateway Choreographer/content_actions use A2AService.send (UUID→slug resolved) for directed agent messaging. Legacy A2A-protocol path: create_a2a_notification requires a task_id, requires both from_agent and target_agent to be present/resolvable (raises distinct ValueError if either missing), enforces hierarchy unconditionally via validate_a2a_access (raises A2AAccessDeniedError), parses priority via foundation.policy.communications.parse_priority, then delegates to NotificationService.send_a2a_notification (which now runs the loop-prone 60s Redis re-fire guard). Bidirectional responses: update_task_from_message appends to dev_notes and, if dev_notes contains the 'A2A Request' marker, _notify_original_requester publishes a TASK_ASSIGNED event to the StreamEventBus to spawn the offline requester. CEO live view (wave 2/2c): every send() publishes A2A_MESSAGE_SENT (_publish_a2a_message_sent) which websocket_bridge forwards to /ws/system as an a2a.message frame for the panel's /a2a switchboard; the CEO-only /chat/admin/* routes (_require_ceo) read across all conversations via get_conversation_admin/list_conversations_admin/get_messages_admin/list_admin_pairs (no participant check) and reply_as_ceo chimes in via the normal send() path, which routes a reply-to-CEO through _get_conversation_for_reply_to_ceo and gates every non-CEO send to the CEO through _enforce_ceo_reply_budget. (2) Audit — TaskService (log_task_event at every transition chokepoint), task routes (log_task_action_denial on 403 for action denials; log_task_creation_denial on 403 for pre-task create denials — distinct target_type="task_creation" with no task_id), the orchestrator (log_agent_event on spawn/stop + has_recent_tracing_gap for the PM-respawn circuit breaker) and the Choreographer (log_event for gateway.rejected) all call get_audit_service(); _persist opens its own session+commit so audit writes never roll back the caller's transaction. (3) Journal — gateway content_actions.note → JournalService.write_entry (scope string→type via foundation SCOPE_TO_TYPE), and write_struggle/write_decision for PM write-then-gate verbs; create_entry commits the row then _schedule_rag_index fires asyncio.create_task (strong-ref in _RAG_INDEX_TASKS) that calls OptimalService.index_journal_entry (skipped for is_private) and record_learning for LEARNING entries; tracing-gate existence checks (has_decision_for_task/latest_decision_at/has_note_for_task/...) feed the Choreographer's gate decisions. (4) content_notes — TaskService._set_structured_note and gateway content_actions handoff path call apply_structured_note(task, content_type, payload); it validates via foundation.policy.content.validate_content BEFORE mutating, reassigns notes_structured (to flag the JSON column dirty), and writes render_markdown() into the derived TEXT mirror column. (5) Extraction — app lifespan builds ExtractionPipeline(ExtractionService()); stream route process_buffer → ExtractionService.extract (regex classify) → callbacks store/broadcast messages; extract_with_llm is the optional Anthropic/TOON path. (6) Permissions — messaging/notification/task/KB routes and gateway kb_authz call PermissionService methods synchronously (no DB) from an AgentContext; has_privileged_access/is_pm_role are async DB lookups used by route deps. DATA: inputs are AsyncSession + UUIDs/slugs/payloads; outputs are Pydantic models (A2ATask, A2AConversation, Journal, JournalEntry), audit rows, structured note columns, ExtractedMessage lists, and bool permission decisions.
## Mermaid
```mermaid
@@ -160,7 +167,8 @@ a2a-audit-journal-permissions
│ ├── Task↔A2A conversion: task_to_a2a, get_task, list_tasks, cancel_task, _status_value_of, _apply_cancel_note
│ ├── Legacy A2A-protocol path: extract_message_text, update_task_with_message, create_a2a_notification, update_task_from_message, _notify_original_requester, _publish_a2a_response_event, resolve_creator_agent, resolve_target_agent
│ ├── Persistent conversations: get_or_create_conversation, get_conversation, list_conversations, close_conversation, _canonical_pair
│ ├── Chat messages: send_chat_message (dedup), get_messages, mark_read, mark_all_read, get_inbox_summary, list_pairs
│ ├── Chat messages: send_chat_message (dedup, _enforce_ceo_reply_budget), get_messages, mark_read, mark_all_read, get_inbox_summary, list_pairs
│ ├── CEO admin/live-view (wave 2/2c): get_conversation_admin, list_conversations_admin, list_admin_pairs, get_messages_admin, _get_conversation_for_reply_to_ceo, _publish_a2a_message_sent
│ ├── Conversions: _conv_to_model, _msg_to_model
│ └── Gateway adapter: send, _resolve_slug_from_id, get_team_from_agent
├── AuditService (audit.py)
@@ -205,6 +213,7 @@ a2a-audit-journal-permissions
| Name | File | Trigger |
|---|---|---|
| HTTP routes /api/a2a/* | roboco/api/routes/a2a.py | Agent card, task, conversation, message, inbox REST endpoints construct A2AService(db) per request |
| HTTP routes /api/a2a/chat/admin/* (CEO-only) | roboco/api/routes/a2a.py | Wave-2/2c: org-wide live view — list_admin_conversations, list_admin_pairs (switchboard), list_admin_chat_messages, reply_as_ceo — all gated by _require_ceo, delegating to A2AService.list_conversations_admin / list_admin_pairs / get_messages_admin / send |
| Gateway content_actions.note / handoff | roboco/services/gateway/content_actions.py | Agent note/say/dm verbs → JournalService.write_entry + content_type_for_role + apply_structured_note |
| Choreographer send | roboco/services/gateway/choreographer/ | Directed A2A verb → A2AService.send (UUID→slug) |
| Choreographer tracing gates | roboco/services/gateway/choreographer/ | i_will_work_on / delegate / submit gates query JournalService.has_*_for_task and latest_decision_at |
@@ -223,6 +232,9 @@ a2a-audit-journal-permissions
- a2a._notify_original_requester only fires when task.dev_notes contains the literal 'A2A Request' marker; the marker is written by the legacy A2A-protocol path (update_task_with_message), NOT by the gateway conversation path, so gateway A2A messages never trigger requester re-spawn via this path.
- a2a.list_conversations runs an N+1 query (last-message preview per conversation); fine at low volume but unbounded by the 50-row limit can cost on heavy agents.
- a2a.mark_read zeroes the conv's per-side counter and bulk-updates read_at on inbound unread messages in the SAME session; if the caller never commits, the read state is lost.
- a2a._enforce_ceo_reply_budget is the only stateful check in an otherwise-stateless access model (can_a2a_direct blocks conversation *creation* unconditionally, not individual sends); it counts messages per conversation on every send, so a very long-running CEO thread pays an extra COUNT query pair per message.
- a2a._get_conversation_for_reply_to_ceo treats conversation existence itself as proof of CEO authorization (agents can never create a CEO conversation) — if that invariant is ever broken elsewhere (e.g. a future seed/migration inserting one directly), an agent could reply into a CEO thread it was never actually invited to.
- The CEO admin/live-view routes (get_conversation_admin, list_conversations_admin, get_messages_admin) intentionally skip the participant check that every non-admin read enforces; they are safe only because the routes themselves are behind _require_ceo — a missing or misapplied _require_ceo on any new admin route would expose every agent's A2A transcript.
- audit._persist opens its OWN session and commits independently — audit writes survive caller rollback (good) but mean audit rows can exist for operations that were later rolled back (forensic skew). Failures are logged, never raised.
- audit.log_task_action_denial resolves the actor's role from agents.role at write time, overriding the caller-supplied agent_role param (DB authoritative) — a stale caller param is silently replaced, which can surprise tests asserting the supplied role.
- audit.has_recent_tracing_gap filters details->>'reason' == 'tracing_gap' via JSONB; any row whose details JSON lacks that key or uses a different reason string is invisible to the circuit breaker (it will fall back to strike counting).
@@ -264,6 +276,8 @@ a2a-audit-journal-permissions
> - **d8a5bb48** `[chore] a2a service hierarchy gate (typed, unconditional) + persist skill on message row` — a2a.py: `create_a2a_notification` hierarchy gate is now unconditional (raises distinct ValueError if from_agent missing or target unresolvable, then calls `validate_a2a_access` raising typed A2AAccessDeniedError + route_hint instead of bare ValueError); `send_chat_message` reads and persists `skill` from opts on the message row (migration 054 adds nullable skill column on a2a_messages); `_msg_to_model` maps skill field; `send()` docstring updated.
> - **5bec3ec5** `[chore] a2a-routes: authenticate send_message responder + gate cancel task (PM-only)` — a2a.py: `cancel_task` gains `agent_role` (threaded into TaskService.cancel role gate) and `actor_slug` (recorded in cancellation note) params; the route now requires PM/management auth and passes the authenticated slug.
> - **b3558d4e** `[chore] complexity: split 5 C-rank blocks to <=B for xenon gate` — a2a.py: `cancel_task` factored into helpers `_status_value_of` (line 383) and `_apply_cancel_note` (line 387); no behavior change.
> - **da563487** `Wave 2 features: A2A live view (CEO chime-in + reply budget) and prompter memory (#297)` — a2a.py grows by ~250 lines: adds the CEO admin/live-view surface (`get_conversation_admin`, `list_conversations_admin`, `get_messages_admin`, `_enforce_ceo_reply_budget`, `_get_conversation_for_reply_to_ceo`) and the `A2A_MESSAGE_SENT` publish (`_publish_a2a_message_sent`, called from `send`) for the operator's org-wide watch view; `roboco/models/events.py` adds `EventType.A2A_MESSAGE_SENT`; `websocket_bridge.py` adds `_handle_a2a_message_event` forwarding it to `/ws/system` as an `a2a.message` frame. `routes/a2a.py` adds the CEO-gated `/chat/admin/conversations`, `/chat/admin/conversations/{id}/messages`, `/chat/admin/conversations/{id}/reply` routes (`_require_ceo`).
> - **876e19b3** `A2A switchboard (pair cards), Secretary/PM task access + closed over-permission hole, MegaTask conventions fix (#298)` — a2a.py adds `list_admin_pairs` (the switchboard's one-bulk-query pair+conversation join over `agents_config.A2A_ALLOWED_PAIRS`); `routes/a2a.py` adds the CEO-gated `/chat/admin/pairs` route. This commit also tightened `roboco/api/routes/tasks.py` (`_pm_editor_scope` / `_enforce_pm_lighter_fields`, out of this slice) and gave `SecretaryService` its `edit` directive action — see `docs/map/intake-secretary.md`.
## Health
This slice is mature and internally consistent: the six services have clear separation of concerns (A2A transport/conversation, audit forensics, journal CRUD+RAG, note persistence chokepoint, stream extraction, RBAC), and the gateway/HTTP/orchestrator entry points map cleanly onto them. The code is defensive in the right places — audit._persist is best-effort with its own session, journal RAG indexing is fire-and-forget with a strong-ref guard, content_notes validates before mutating, and A2A conversation dedup prevents respawn re-emit storms. The main integrity concerns are cross-layer, not in-slice: (1) the new 60s Redis loop-prone notification re-fire guard (3aff6e04) sits between A2A's create_a2a_notification and delivery and can silently drop legitimate A2A notifications; (2) two parallel channel-permission sources (agents_config.CHANNEL_ACCESS vs foundation.policy.communications.CHANNELS) drifted further apart in 15effce0, with this slice correctly insulated but the broader system carrying latent divergence; (3) the legacy A2A-protocol path (dev_notes 'A2A Request' marker, TASK_ASSIGNED re-spawn) is undocumented in CLAUDE.md and coexists with the gateway conversation path, a known source of future confusion. No in-slice file changed since the fd10cc86 baseline, so there is no direct regression surface; the risks above are all dependency-mediated. Recommend a regression test that an A2A notification fired twice within 60s for genuinely different reasons still delivers, and a single-source-of-truth reconciliation of the two channel catalogs.
+7 -3
View File
@@ -40,7 +40,10 @@ The FastAPI application shell, request pipeline, and real-time WebSocket fan-out
| `require_panel_token` | func | deps.py:251 | CEO-HMAC gate for live-chat bridges (HTTP analog of WS gate) |
| `_resolve_agent_identity` | func | deps.py:277 | Returns `(agent_id, slug)`, special-casing `system` role |
| `_coerce_agent_role`/`_coerce_agent_team` | funcs | deps.py:298/324 | Parse role/team headers with DB fallback for role |
| `get_agent_context` | func | deps.py:333 | Builds `AgentContext` from X-Agent-* headers + token |
| `_header_trust_agent_context` | func | deps.py:340 | The original `get_agent_context` body verbatim (header-trust); the OFF-mode path, and also what a valid agent HMAC token delegates to when cloud auth is ON |
| `_slide_session_cookie` | func | deps.py:379 | Re-mints + re-sets the session cookie on every cookie-authenticated request — the sliding 30-day window (only inactivity past `cloud_auth_cookie_max_age` logs out) |
| `_cloud_auth_agent_context` | func | deps.py:390 | Dual-path enforcement when `cloud_auth_enabled`: a valid HMAC token (any role) delegates to `_header_trust_agent_context`; otherwise a non-CEO role claim is rejected outright, and the CEO must present a valid session cookie via `resolve_session_user` |
| `get_agent_context` | func | deps.py:452 | Builds `AgentContext` from X-Agent-* headers + token (+ the `roboco_session` cookie); byte-for-byte header-trust when `cloud_auth_enabled` is False, else delegates to `_cloud_auth_agent_context` |
| `require_pm_or_above`/`require_developer_or_above`/`require_cell_access` | funcs | deps.py:403/428/443 | Coarse role-gate guards (403) |
| `require_ceo_role` | func | deps.py:412 | Single CEO-check: raises 403 unless `role` is CEO; accepts `AgentRole`/`Role`/lowercase string; unifies orchestrator-router + release-handler CEO gates into one source of truth (`536bbb64`) |
| `require_channel_read`/`require_channel_write`/`require_notification_permission`/`require_task_action` | dep factories | deps.py:442/470/490/508 | PermissionService-backed dependency factories |
@@ -84,7 +87,7 @@ The FastAPI application shell, request pipeline, and real-time WebSocket fan-out
## Data Flow
**HTTP request**: nginx → ASGI `app``CorrelationIdMiddleware` (binds correlation_id + path/method to structlog) → `RequestLoggingMiddleware` (start timer) → route. Route resolves `CurrentAgentContext` via `get_agent_context` (headers + HMAC verify + identity/role/team resolution), plus service deps from `get_choreographer`/`get_content_actions`. On exception, the handler chain maps: `RequestValidationError` → 422 (scrubbed log + UUID remediation hint), `HTTPException` → standardized error code, `RobocoError` → domain status, `ServiceError` → parallel-hierarchy status, `RateLimitError` → 429 + `Retry-After`, `Exception` → 500. Response gains `X-Correlation-ID` + `X-Response-Time-Ms`. When `ROBOCO_GUARD_ENABLED` is on, `SecurityMiddleware` (mounted last in `create_app`, so outermost) runs before any of this: rate/size/WAF/custom-validator checks either block the request (enforce mode) or only log the detection (`guard_passive_mode`, the calibration posture) ahead of the correlation-id middleware; off by default, the whole path is unchanged.
**HTTP request**: nginx → ASGI `app``CorrelationIdMiddleware` (binds correlation_id + path/method to structlog) → `RequestLoggingMiddleware` (start timer) → route. Route resolves `CurrentAgentContext` via `get_agent_context` (headers + HMAC verify + identity/role/team resolution), plus service deps from `get_choreographer`/`get_content_actions`. When `ROBOCO_CLOUD_AUTH_ENABLED` is off (default) this is byte-for-byte the historical header-trust path (`_header_trust_agent_context`). When on, `_cloud_auth_agent_context` enforces a dual path: a request carrying a valid `X-Agent-Token` HMAC (any role — the agent fleet + the orchestrator's own `system` self-PATCH) is verified then delegated to the same header-trust resolution; a request with no valid token and a non-CEO role claim is rejected outright (closes the LAN header-spoof hole); the CEO alone may instead authenticate via the `roboco_session` cookie (`resolve_session_user`, `roboco.api.auth.session`), which is re-minted on every authenticated request (`_slide_session_cookie`) for a sliding 30-day window. On exception, the handler chain maps: `RequestValidationError` → 422 (scrubbed log + UUID remediation hint), `HTTPException` → standardized error code, `RobocoError` → domain status, `ServiceError` → parallel-hierarchy status, `RateLimitError` → 429 + `Retry-After`, `Exception` → 500. Response gains `X-Correlation-ID` + `X-Response-Time-Ms`. When `ROBOCO_GUARD_ENABLED` is on, `SecurityMiddleware` (mounted last in `create_app`, so outermost) runs before any of this: rate/size/WAF/custom-validator checks either block the request (enforce mode) or only log the detection (`guard_passive_mode`, the calibration posture) ahead of the correlation-id middleware; off by default, the whole path is unchanged.
**Lifespan startup**: `init_db` (alembic upgrade + create_all fallback) → `apply_persisted_feature_flags` (panel settings overlay, best-effort) → `TranscriptionService.start()` + `ExtractionPipeline``get_optimal_service()` (BLOCKS 30-90s for RAG) → `LearningPropagationService.initialize(optimal)`. `app.state.*` holds singletons. **Shutdown**: stop orchestrator (drains bg DB writes) → `close_optimal_service``close_db`. The orchestrator-stop-before-DB order is load-bearing.
@@ -203,6 +206,7 @@ roboco/api/
- `ROBOCO_AGENT_AUTH_REQUIRED` — gates HMAC token enforcement (deps.py:211, websocket.py:71, middleware docstring app.py:94). Unset → header-trust/dev mode; set `true`/`1`/`yes` → strict.
- `ROBOCO_AGENT_AUTH_SECRET` — the HMAC secret consumed by `verify_agent_token` (read inside `roboco.agents_config`).
- `ROBOCO_CLOUD_AUTH_ENABLED` (+ `_EMAIL`/`_PASSWORD`/`_SECRET`/`_COOKIE_MAX_AGE`, default off) — `deps.get_agent_context`'s dual-path switch (`_cloud_auth_agent_context` vs byte-for-byte `_header_trust_agent_context`); the login/logout FastAPI Users router is mounted by `roboco.api.auth.routes.mount_cloud_auth` only when true, but `/api/auth/status` is always mounted (public probe for the panel's `proxy.ts`).
- `ROBOCO_DATABASE_*`, `ROBOCO_REDIS_*` — read transitively via `settings` / `init_db`.
- `settings.cors_origins` / `settings.cors_allow_credentials` — CORS middleware config (app.py:218).
- `settings.app_version` / `settings.environment` / `settings.debug` — logged at startup; docs/redoc URLs are unconditional (the `if settings.debug` is commented out, app.py:207-208).
@@ -216,7 +220,7 @@ roboco/api/
- **`websocket.py` module docstring (lines 8-13) is STALE** — it claims WS "validates agent_id via query params and verify the agent exists in the database. In production, this should be enhanced with proper token-based authentication." Actual security is now the CEO HMAC panel token via `_require_panel_token`, and `validate_agent_exists` is only called on `/agents`, `/sessions`, `/notifications` (NOT `/channels`). The docstring misleads.
- **`get_choreographer` passes `stream_bus=None` when no orchestrator is set** (deps.py:557) — fine, but means the rate-limit park path is inert during the startup window before bootstrap sets the orchestrator.
- **`_check_agent_auth_token` dev mode**: a missing token is allowed; a presented-but-invalid token is rejected. The header-trust warning at app.py:94-102 is the only signal. In dev, any reachable client can act as any role (including `ceo`) by setting headers.
- **`_resolve_agent_identity` `system` role special-case** (deps.py:281) returns `UUID(x_agent_id)` with NO DB lookup. Combined with dev-mode no-auth, a caller can claim `role=system` with an arbitrary UUID and bypass agent resolution entirely.
- **`_resolve_agent_identity` `system` role special-case** (deps.py:281) returns `UUID(x_agent_id)` with NO DB lookup. Combined with dev-mode no-auth, a caller can claim `role=system` with an arbitrary UUID and bypass agent resolution entirely. `ROBOCO_CLOUD_AUTH_ENABLED` does not add a DB lookup here either — it only requires that a `system`/any-role claim carry a *verified* HMAC token first (`_cloud_auth_agent_context` rejects any non-CEO role claim without one), so the identity-bypass itself is unchanged, just gated behind a valid signature.
- **`_coerce_agent_role` falls back to the DB role when the header isn't a valid enum** (deps.py:298). The X-Agent-Role header is therefore advisory when malformed — the authoritative role is the agent row's. Good for safety, but means a caller cannot escalate by header alone (the DB role wins).
- **`request_validation_handler` returns the UNSCRUBBED body to the client** (middleware.py:434). Only the server log is scrubbed (`_scrub_secrets`); the 422 response echoes whatever the client sent, including any secret fields. By design (the client sent them), but worth knowing.
- **`_run_sender` self-cancels on send error**: on a hard send error it calls `self.disconnect(ws)` which cancels `conn.sender` — the very task currently running (websocket.py:155). It returns immediately after, so the cancellation lands on an already-returning task; harmless in practice but a subtle self-cancel.
+28 -4
View File
@@ -40,6 +40,9 @@ The FastAPI surface of RoboCo: every HTTP route under `roboco/api/routes/` (the
| roboco/api/routes/usage.py | Token usage summary/time-series/by-agent/team/model/role/sessions, cache-efficiency, spawn-waste (per-role unproductive-spawn rate + respawn strikes). |
| roboco/api/routes/system.py | System-wide info. |
| roboco/api/routes/docs.py | Project docs write/read/list/delete. |
| roboco/api/routes/x.py | X (Twitter) engine — CEO-only: list/approve/reject held draft posts + set/status OAuth 1.0a credentials. |
| roboco/api/routes/roadmap.py | Board roadmap engine — CEO-only: list open cycles + per-item approve/reject. |
| roboco/api/auth/ | Cloud auth (FastAPI Users, default off): `backend.py` (cookie transport + password-fingerprint-bound JWT strategy), `manager.py` (`UserManager` + DI chain), `session.py` (`resolve_session_user`, shared by the HTTP dual-path and the WS panel-token gate), `seed.py` (idempotent single seeded CEO login upsert), `routes.py` (always-public `/auth/status` + conditional login/logout mount). |
| roboco/api/routes/v1/_role_dep.py | Per-role HMAC guards + `envelope_to_response` helper. |
| roboco/api/routes/v1/do.py | Content verbs `/api/v1/do/*` (commit/note/say/dm/evidence/playbook...). |
| roboco/api/routes/v1/flow_dev.py | Developer flow verbs. |
@@ -62,12 +65,19 @@ The FastAPI surface of RoboCo: every HTTP route under `roboco/api/routes/` (the
| GET | /api/dashboard/ceo | dashboard.py | agent context |
| GET | /api/dashboard/metrics/{cycle-time,bottlenecks,rework,scorecard/*} | dashboard.py | agent context |
| GET/POST/PATCH/DELETE | /api/tasks, /api/tasks/{id}/{claim,start,verify,submit-qa,pass-qa,fail-qa,complete,cancel,escalate-to-ceo} | tasks.py | agent context + `require_task_action` |
| GET | /api/tasks/summary?q= (list_tasks_summary) | tasks.py | agent context — trimmed list-view rows; server-side title/description/id-prefix search via `TaskService.search_tasks` when `q` is set (wave 1, `d1cf6ecb`) |
| GET/POST | /api/orchestrator/{status,agents/{id},waiting} ; /spawn,/stop,/resolve-wait,/mark-waiting | orchestrator.py | `_require_ceo` (HMAC) |
| POST | /api/a2a/{send,send-stream} ; /chat/conversations ; /tasks/{id}/cancel | a2a.py | `require_any_authenticated_agent` |
| GET/POST | /api/a2a/chat/admin/{conversations,pairs,conversations/{id}/messages,conversations/{id}/reply} | a2a.py | `_require_ceo` (org-wide live view + reply-as-CEO; wave 2 `da563487` / wave 2c `876e19b3`) |
| POST | /api/prompter/live, /live/{id}/{stream,status,messages,stop,confirm,confirm-batch} | prompter_live.py | `require_panel_token` (CEO HMAC) |
| GET | /api/prompter/live/{id}/search-tasks | prompter_live.py | session-aliveness check (no agent identity) — intake's `search_past_tasks` tool (wave 1, `d1cf6ecb`) |
| POST | /api/secretary/live, /live/{id}/{stream,messages,stop,events} ; /api/secretary/{state,directives} | secretary*.py | panel token / agent ctx |
| GET | /api/secretary/tasks?q= (search_tasks) | secretary.py | agent ctx, Secretary or CEO role — resolve a task NAME to id(s) for a directive (wave 1, `d1cf6ecb`) |
| GET/POST | /api/release/proposal, /proposal/approve, /proposal/reject | release.py | `_require_ceo` (agent.role==CEO) |
| GET/POST | /api/playbooks, /{id}/{approve,reject,archive} | playbooks.py | agent context (Auditor/CEO) |
| GET/POST | /api/x/posts, /posts/{id}/{approve,reject}, /credentials | x.py | `require_ceo_role` (agent context) |
| GET/POST | /api/roadmap/cycles, /cycles/{id}/items/{id}/{approve,reject} | roadmap.py | `require_ceo_role` (agent context) |
| GET/POST | /api/auth/status (always), /auth/login, /auth/logout (mounted only when `cloud_auth_enabled`) | auth/routes.py | none (status) / FastAPI Users cookie login |
| GET/POST/PUT/DELETE | /api/projects, /{id}/conventions, /workspace, /sync | project.py | agent context |
| POST | /api/v1/flow/developer/{give_me_work,i_will_work_on,open_pr,i_am_done,unclaim,resume,sync_branch} | flow_dev.py | `require_dev` (role + HMAC) |
| POST | /api/v1/flow/qa/{claim_review,pass_review,fail_review} | flow_qa.py | `require_qa` |
@@ -161,6 +171,8 @@ roboco/api/
│ │ ├── release.py release proposal approve/reject
│ │ ├── playbooks.py playbook curation
│ │ ├── pitch.py pitch approve/reject
│ │ ├── x.py X engine post queue approve/reject + credentials
│ │ ├── roadmap.py board roadmap cycle item approve/reject
│ │ ├── a2a.py agent-to-agent + SSE
│ │ ├── prompter_live.py live Intake chat
│ │ ├── secretary.py company state + directives
@@ -177,6 +189,12 @@ roboco/api/
│ ├── flow_board.py board flow verbs
│ ├── flow_auditor.py auditor flow verbs
│ └── flow_pr_reviewer.py PR-reviewer flow verbs
├── auth/ (cloud auth, default off — ROBOCO_CLOUD_AUTH_ENABLED)
│ ├── backend.py cookie transport + password-fingerprint-bound JWT strategy
│ ├── manager.py UserManager + get_user_db/get_user_manager DI chain
│ ├── session.py resolve_session_user (shared HTTP + WS cookie validation)
│ ├── seed.py ensure_seed_user / ensure_seed_user_startup (single CEO row)
│ └── routes.py always-public /status + conditional login/logout mount
└── schemas/
├── *.py per-domain Pydantic models
└── v1/
@@ -188,7 +206,8 @@ roboco/api/
- FastAPI + sse-starlette (SSE), pydantic v2.
- `roboco/api/deps.py` — shared deps (DbSession, agent context, HMAC, orchestrator).
- `roboco/api/middleware.py` — exception handlers + correlation/log middleware.
- `roboco/services/*` — TaskService, GitService, OptimalService, AgentOrchestrator, Choreographer, ContentActions, ReleaseProposalService, PrompterService, SecretaryService, MetricsService, etc.
- `roboco/services/*` — TaskService, GitService, OptimalService, AgentOrchestrator, Choreographer, ContentActions, ReleaseProposalService, PrompterService, SecretaryService, MetricsService, XPostService, XCredentialsService, RoadmapService, etc.
- `roboco/api/auth/*` (`auth_backend`, `get_user_manager`, `resolve_session_user`, `mount_cloud_auth`) — cloud auth, consumed by `deps.get_agent_context`'s dual-path and the WS panel-token gate.
- `roboco/foundation/identity.py` (`Role`) + `roboco/agents_config.py` (`verify_agent_token`, `CEO_AGENT_ID`).
- `roboco/api/websocket.py` + `websocket_bridge.py` (WS event forwarding).
@@ -208,6 +227,8 @@ roboco/api/
- `StrList` BeforeValidator is load-bearing: without it the Claude SDK's XML-nested list input crashes `i_will_plan`/`delegate` with 422 (MegaTask memory Bug 3).
- `orchestrator.py` and `release.py` use two different `_require_ceo` implementations (HMAC header vs `agent.role==CEO` from context) — keep their semantics aligned.
- WS endpoints live on `/ws/*` (separate router in `websocket.py`), not under `/api`; the bridge subscribes to `StreamEventBus` and forwards per resource-id.
- `/api/tasks` PATCH is not a single admin surface: `_pm_editor_scope` (tasks.py:256) routes cell_pm/main_pm to a content-only allowlist (`_PM_LIGHTER_UPDATE_FIELDS`: title/description/acceptance_criteria/priority, zero status changes) enforced by `_enforce_pm_lighter_fields` (tasks.py:278), while CEO/Board/Auditor keep the unrestricted admin bypass; a cell_pm editing a task outside its own team 403s before the field check even runs.
- `GET /api/tasks/summary` and `GET /api/secretary/tasks` both call the same `TaskService.search_tasks` (ILIKE title/description + id-prefix) but through different auth (agent-context view-scope vs Secretary-or-CEO role check) and different response shapes (trimmed `TaskSummaryResponse` vs a hand-built dict list) — don't assume one route's pagination/limit semantics apply to the other.
## Drift from CLAUDE.md
- CLAUDE.md lists `pr_pass`/`pr_fail` under `pr_reviewer` verbs and the in-path gate; code matches (`flow_pr_reviewer.py` exposes `claim_gate_review`, `pr_pass`, `pr_fail`). No drift found.
@@ -218,15 +239,18 @@ roboco/api/
## Changes Since Baseline
`git log fd10cc86..HEAD -- roboco/api/routes/ roboco/api/schemas/`:
- `15effce0` Chore: 141 Gaps fill-in (#283) — broad route/schema hardening pass (the only logic-touching commit in range).
- `15effce0` Chore: 141 Gaps fill-in (#283) — broad route/schema hardening pass (the only logic-touching commit in range at the time this section was last refreshed).
(Baseline..HEAD contains a single sweep commit touching this slice; earlier per-fix commits predate the baseline.)
> Post-snapshot: many further commits touch this slice (536bbb64, df87fcf0, a8cb2470, 0ca9d91b, cfde4369, 0f1ed3cc, 1c87a4e4, and the three below) — only the wave-1/2/2c ones relevant to this pass are itemized; a full re-audit of the intervening route/schema history is still owed.
> - `d1cf6ecb` Wave 1 (#295) — adds `GET /api/tasks/summary?q=` search (`TaskService.search_tasks`), `GET /api/prompter/live/{id}/search-tasks` (intake memory), `GET /api/secretary/tasks?q=` (Secretary task-by-name lookup), and the Secretary `edit` directive action.
> - `da563487` Wave 2 (#297) — adds the CEO-only `/api/a2a/chat/admin/{conversations,conversations/{id}/messages,conversations/{id}/reply}` routes (`_require_ceo`) for the A2A live view + reply-as-CEO.
> - `876e19b3` Wave 2c (#298) — adds `/api/a2a/chat/admin/pairs` (the switchboard, same `_require_ceo` gate); tightens `/api/tasks` PATCH so cell/main PM roles get a content-only field allowlist instead of the unrestricted CEO/Board/Auditor admin bypass (`_pm_editor_scope` / `_enforce_pm_lighter_fields`, `roboco/api/routes/tasks.py:256,278`) — closes an over-permission hole where PM identities could edit any-team tasks via the ASSIGN-holding bypass.
## Regression Risks
| Title | File:Line | Claim | Severity |
|-------|-----------|-------|----------|
| do/a2a any-role token gate | v1/do.py:43, a2a.py:114 | `require_any_authenticated_agent` only verifies HMAC + that the agent exists; it does NOT assert the role matches the verb's intended role family — a QA-signed token could call `do/commit` or a dev could call `a2a` admin paths. Service-layer scope is the sole guard; a missed service check = privilege escape. | High |
| do/a2a any-role token gate | v1/do.py:43, a2a.py:114 | `require_any_authenticated_agent` only verifies HMAC + that the agent exists; it does NOT assert the role matches the verb's intended role family — a QA-signed token could call `do/commit`, or any agent could call the participant-scoped `a2a` routes (send/conversations) for a pair it has no policy access to (only the gateway's `can_a2a_direct`/`validate_a2a_access` matrix, a service-layer check, stops it). Service-layer scope is the sole guard on these paths; a missed service check = privilege escape. **Correction:** the `/chat/admin/*` routes (org-wide live view + reply-as-CEO) are NOT on this gate — they carry their own router-level `_require_ceo` guard, added in wave 2 (`da563487`) and extended to `/chat/admin/pairs` in wave 2c (`876e19b3`); a non-CEO agent 403s before reaching the service layer on those. | High |
| 422 response echoes secrets | middleware.py:407 | `_scrub_secrets` redacts only the **log** body; the JSON response still contains `body` with the caller's original secret fields. A 422 on `git_token`/`api_key` returns the secret back to the client (and to any MITM/log of the response). | High |
| orchestrator CEO gate vs release CEO gate divergence | orchestrator.py:37 vs release.py:32 | Two independent `_require_ceo` implementations: orchestrator uses HMAC header verification, release uses `agent.role == CEO` from `CurrentAgentContext`. If one path's HMAC/context resolution drifts, the two CEO surfaces enforce different identities. | Medium |
| SSE transport errors swallowed | prompter_live.py:122, secretary_live.py:61, a2a.py:195 | `EventSourceResponse` streams run long-lived; a Choreographer/orchestrator raise mid-stream is caught by `contextlib` suppress but can drop the stream silently without a terminal event to the panel. | Medium |
+29 -9
View File
@@ -1,7 +1,7 @@
# db-migrations slice
## Purpose
The DB layer is async SQLAlchemy 2.0 over PostgreSQL+asyncpg, with pgvector for the in-house RAG engine. Schema evolution is owned by an Alembic chain (001→054) that runs on every boot via `init_db()`; `Base.metadata.create_all` is no longer the source of truth — migration 017 reconciled the drift the other way. The ORM tables live in one fat module `roboco/db/tables.py` (~2.5k lines, 37 tables).
The DB layer is async SQLAlchemy 2.0 over PostgreSQL+asyncpg, with pgvector for the in-house RAG engine. Schema evolution is owned by an Alembic chain (001→059) that runs on every boot via `init_db()`; `Base.metadata.create_all` is no longer the source of truth — migration 017 reconciled the drift the other way. The ORM tables live in one fat module `roboco/db/tables.py` (~2.5k lines, 37 tables).
## Files
@@ -13,7 +13,7 @@ The DB layer is async SQLAlchemy 2.0 over PostgreSQL+asyncpg, with pgvector for
| `roboco/db/seed.py` | `bootstrap_database()` — runs `init_db` then seeds agents, channels, groups, initial messages. |
| `alembic/env.py` | Async Alembic env; imports `roboco.db.tables` to register metadata, overrides `sqlalchemy.url` from settings, `compare_type` + `compare_server_default` on. |
| `alembic.ini` | Standard config; `script_location=alembic`, `prepend_sys_path=.`, no URL (set in env.py). |
| `alembic/versions/` | 54 migration files 001..054 (two share number 026 — chained, not a collision). |
| `alembic/versions/` | 59 migration files 001..059 (two share number 026 — chained, not a collision). |
## Key Symbols
@@ -29,7 +29,7 @@ The DB layer is async SQLAlchemy 2.0 over PostgreSQL+asyncpg, with pgvector for
| `TaskTable` | class | tables.py:157 | Core task entity (largest table, drives lifecycle). |
| `WorkSessionTable` | class | tables.py:798 | Per-claim session; single-active enforced by 047 partial-unique index. |
| `AgentTable` | class | tables.py:95 | Agent identity, role, team, model provider assignment. |
| `ProjectTable` | class | tables.py:475 | Git repo config + CI/watch/dep-update/quality_command cols. |
| `ProjectTable` | class | tables.py:475 | Git repo config + CI/watch/dep-update/quality_command/`sandbox_services` (057) cols. |
| `AuditLogTable` | class | tables.py:1940 | Transition journey; `details` JSONB (010); composite query index (045). |
| `AgentSpawnSessionTable` | class | tables.py:2170 | Per-spawn token totals; feeds usage dashboard. |
| `ProjectConventionsCacheTable` | class | tables.py:2442 | Effective conventions map per (project, HEAD sha). |
@@ -38,6 +38,9 @@ The DB layer is async SQLAlchemy 2.0 over PostgreSQL+asyncpg, with pgvector for
| `TaskCellProjectTable` | class | tables.py:632 | Per-cell project map for a MegaTask root-subtask (052). |
| `WaitingRecordTable` | class | tables.py:1872 | Persisted dispatcher waiting records (restore at start). |
| `IndexedDocumentTable` | class | tables.py:1651 | RAG corpus docs (added to chain by 017). |
| `UserTable` | class | tables.py:2603 | Cloud-auth (FastAPI Users) single seeded CEO login row (058). |
| `XCredentialsTable` | class | tables.py:2650 | Singleton Fernet-encrypted OAuth 1.0a secrets for the X engine (059). |
| `XSeenMentionTable` | class | tables.py:2675 | X mentions-poll dedup ledger, keyed by mention id (059). |
| `run_async_migrations` | fn | env.py | Async online migration runner (NullPool). |
## Migration Chain
@@ -99,6 +102,11 @@ The DB layer is async SQLAlchemy 2.0 over PostgreSQL+asyncpg, with pgvector for
| 052 | 052_task_cell_projects.py | `task_cell_projects` (per-cell project map for MegaTask root-subtask; reuses team enum create_type=False). |
| 053 | 053_playbook_archived_attr.py | `playbooks.archived_by` (UUID) + `playbooks.archived_at` (DateTime) — distinct retirement attribution; keeps `approved_by`/`approved_at` as approval-only provenance. |
| 054 | 054_a2a_message_skill.py | `a2a_messages.skill` (String 100, nullable) — persists the capability a directed A2A message concerns; was silently dropped on send. |
| 055 | 055_spawn_session_turns_tool_calls.py | `agent_spawn_sessions.turns` + `.tool_calls` (BigInteger, DEFAULT 0) — per-stint LLM iterations + tool invocations for the granular per-member performance metrics. |
| 056 | 056_member_perf_daily.py | `member_performance_daily` — one row per (date, member_kind, agent_slug) scorecard rollup (incl. CEO as `member_kind='ceo'`). |
| 057 | 057_project_sandbox_services.py | `projects.sandbox_services` (ARRAY(String), nullable) — per-project opt-in for the sandboxed per-agent-spawn Postgres/Redis provisioner. |
| 058 | 058_cloud_auth_users.py | `users` table (FastAPI Users schema) — the single seeded CEO login for cloud auth (`ROBOCO_CLOUD_AUTH_ENABLED`, default off). |
| 059 | 059_x_credentials.py | `x_credentials` (singleton Fernet-encrypted OAuth 1.0a secrets) + `x_seen_mentions` (mentions-poll dedup ledger) — the X (Twitter) engine (`ROBOCO_X_ENGINE_ENABLED`, default off). |
## Data Flow
On boot, `init_db()` probes for application tables and `alembic_version`; if a pre-Alembic DB exists it stamps it at revision 001, then always runs `run_migrations()``alembic upgrade head` (in a thread via `asyncio.to_thread`). `env.py` imports `roboco.db.tables` so `Base.metadata` is fully populated, overrides `sqlalchemy.url` from `settings.database_url`, and runs online with an async NullPool engine. `compare_type` + `compare_server_default` are on so autogenerate drift is detectable. `tables.py` classes are the ORM mapping the migrations build; the domain layer reads them through `roboco/models/` dataclasses, not the tables directly.
@@ -113,12 +121,13 @@ graph LR
027-->028-->029-->030-->031-->032-->033-->034-->035-->036
036-->037-->038-->039-->040-->041-->042-->043-->044-->045
045-->046-->047-->048-->049-->050-->051-->052-->053-->054
054-->055-->056-->057-->058-->059
```
## Logical Tree
```
Migration chain 001..054
Migration chain 001..059
├── Initial schema
│ └── 001 initial schema (agents, tasks, work_sessions, channels, sessions, messages, notifications, journals, audit_log, a2a_*)
├── Persistence
@@ -201,8 +210,17 @@ Migration chain 001..054
│ └── 053 playbooks.archived_by + archived_at (distinct retirement attribution from approval)
├── Orchestrator runtime durability
│ └── 051 respawn_tracker (durable PM-respawn counter)
── A2A messaging
└── 054 a2a_messages.skill (nullable; persists directed-A2A capability context)
── A2A messaging
└── 054 a2a_messages.skill (nullable; persists directed-A2A capability context)
├── Per-member performance metrics
│ ├── 055 agent_spawn_sessions.turns + .tool_calls (DEFAULT 0)
│ └── 056 member_performance_daily (per date/member_kind/agent_slug rollup)
├── Sandboxed dev DB/Redis
│ └── 057 projects.sandbox_services (per-project opt-in array)
├── Cloud auth
│ └── 058 users (FastAPI Users; single seeded CEO login)
└── X (Twitter) engine
└── 059 x_credentials (singleton encrypted OAuth 1.0a) + x_seen_mentions (dedup ledger)
```
## Dependencies
@@ -231,7 +249,7 @@ Migration chain 001..054
- **017 reconciled drift the other way** — added ORM tables the chain had missed; `create_all` is no longer authoritative.
## Drift from CLAUDE.md
- CLAUDE.md says "52 migrations 001..052" — now stale; chain is 001..054 (54 files). Does not mention the two 026 files (chained, not a conflict).
- CLAUDE.md says "52 migrations 001..052" — now stale; chain is 001..059 (59 files). Does not mention the two 026 files (chained, not a conflict).
- CLAUDE.md cites migrations 043/046/047/048/049/050/051 by number in feature sections — all present and consistent.
- No factual drift found in the DB layer description.
@@ -241,7 +259,9 @@ Migration chain 001..054
(Only one commit in range touches these paths.)
> Post-snapshot updates (since 2026-06-29): `536bbb64` (Chore/all/logical gaps sweep #286) — adds migration 053 (`playbooks.archived_by`/`archived_at`), two new columns on `PlaybookTable`; `d8a5bb48` ([chore] a2a hierarchy gate + skill persist) — adds migration 054 (`a2a_messages.skill`), one new column on `A2AMessageTable`, wired through `send_chat_message` and the A2AChatMessage model. Chain head is now 054.
> Post-snapshot updates (since 2026-06-29): `536bbb64` (Chore/all/logical gaps sweep #286) — adds migration 053 (`playbooks.archived_by`/`archived_at`), two new columns on `PlaybookTable`; `d8a5bb48` ([chore] a2a hierarchy gate + skill persist) — adds migration 054 (`a2a_messages.skill`), one new column on `A2AMessageTable`, wired through `send_chat_message` and the A2AChatMessage model.
>
> Delta 2026-07-03 (v0.17.0, 5 features): `055_spawn_session_turns_tool_calls` (`agent_spawn_sessions.turns`/`.tool_calls`) + `056_member_perf_daily` (`member_performance_daily`) predate this wave but were never appended to this doc; `057_project_sandbox_services` adds `projects.sandbox_services` (sandboxed dev DB/Redis, `ROBOCO_SANDBOX_DB_ENABLED`); `058_cloud_auth_users` adds `users` (`UserTable`, cloud auth, `ROBOCO_CLOUD_AUTH_ENABLED`); `059_x_credentials` adds `x_credentials` (`XCredentialsTable`) + `x_seen_mentions` (`XSeenMentionTable`) (X engine, `ROBOCO_X_ENGINE_ENABLED`). Chain head is now 059.
## Regression Risks
@@ -257,4 +277,4 @@ Migration chain 001..054
| Single-head violation on re-apply | alembic/versions/017_reconcile_orm_schema_drift.py | 017 adds tables/columns that `create_all` had created; on a DB built by `create_all` then stamped, 017 may double-create. | Medium |
## Health
The chain is linear and complete (001→052), with `init_db` running `upgrade head` on every boot so deployed schemas stay current. The two structural risks are the `sa.Enum(create_type=False)` no-op in 001 (latent on clean re-applies) and the enum-parity gate's dependence on a populated migrated DB. New migrations consistently use the `postgresql.ENUM(create_type=False)` pattern and `ALTER TYPE ... ADD VALUE IF NOT EXISTS` for enum widening, so recent additions are safe.
The chain is linear and complete (001→059), with `init_db` running `upgrade head` on every boot so deployed schemas stay current. The two structural risks are the `sa.Enum(create_type=False)` no-op in 001 (latent on clean re-applies) and the enum-parity gate's dependence on a populated migrated DB. New migrations consistently use the `postgresql.ENUM(create_type=False)` pattern and `ALTER TYPE ... ADD VALUE IF NOT EXISTS` for enum widening, so recent additions are safe.
+9 -3
View File
@@ -5,9 +5,9 @@ This slice is the packaging, build, and runtime-tooling layer of RoboCo: the Doc
| Path | Role | LOC |
|---|---|---|
| docker-compose.yaml | Build-from-source compose: postgres/redis/ollama/ollama-init, 14 agent-*-image builders, orchestrator, panel, nginx; NAS prod env vars + volume mounts | 475 |
| docker-compose.yml | Byte-identical copy of docker-compose.yaml (kept for the canonical name compose picks up by default) | 475 |
| docker-compose.registry.yml | Pull-and-run compose using pre-built GHCR/Docker Hub images (ROBOCO_REGISTRY + ROBOCO_VERSION); infra services byte-identical to build compose, agent-* services are one-shot pre-pulls | 309 |
| docker-compose.yaml | Build-from-source compose: postgres/redis/ollama/ollama-init, 14 agent-*-image builders, orchestrator, panel, nginx, `roboco_data` DB-isolation network; NAS prod env vars + volume mounts | 556 |
| docker-compose.yml | Byte-identical copy of docker-compose.yaml (kept for the canonical name compose picks up by default) | 556 |
| docker-compose.registry.yml | Pull-and-run compose using pre-built GHCR/Docker Hub images (ROBOCO_REGISTRY + ROBOCO_VERSION); infra services byte-identical to build compose, agent-* services are one-shot pre-pulls, own `roboco_data` network | 334 |
| Makefile | Ops + quality targets: infra, dev/run/orchestrator, quality gate (ruff/mypy/pytest/xenon/radon/vulture/bandit/pip-audit/deptry/lint-imports/alembic/foundation-check), per-Python test matrix, docs, lifecycle regen | 548 |
| pyproject.toml | Project + dependency manifest: requires-python >=3.13,<3.15, deps, dev/docs extras, console scripts, ruff/mypy/pytest/coverage/vulture/bandit/radon/xenon/deptry/importlinter config | 451 |
| roboco/config.py | Pydantic Settings (env prefix ROBOCO_, cached via lru_cache); every tunable: DB, Redis, RAG, LLM/Ollama, workspaces, agent guardrails, gateway thresholds, autonomy-engine flags | 1022 |
@@ -253,6 +253,11 @@ deployment-tooling
- ROBOCO_DEP_UPDATE_ENABLED / _INTERVAL_SECONDS / _MAX_OPEN_TASKS / _MAX_PER_CYCLE
- ROBOCO_RELEASE_MANAGER_ENABLED / _MIN_COMMITS / _INTERVAL_SECONDS / _CI_WORKFLOW
- ROBOCO_ORG_MEMORY_ENABLED / _TOP_K / _MIN_SCORE
- ROBOCO_SANDBOX_DB_ENABLED — sandboxed per-agent-spawn Postgres/Redis provisioner (`roboco/runtime/sandbox.py`); a project also needs its `sandbox_services` column set
- ROBOCO_DB_NETWORK_ISOLATED — set true only by the compose topology carrying the `roboco_data` data-only network; suppresses the legacy prod-creds gate-env injection
- ROBOCO_CLOUD_AUTH_ENABLED / _EMAIL / _PASSWORD / _SECRET / _COOKIE_MAX_AGE — FastAPI Users cookie login for the single seeded CEO; `Settings` fails loud at startup if armed with no secret
- ROBOCO_X_ENGINE_ENABLED / _MENTIONS_INTERVAL_SECONDS / _MENTIONS_MAX_PER_CYCLE / _MENTIONS_MIN_ENGAGEMENT / _MAX_OPEN_POSTS / ROBOCO_X_ACCOUNT_USER_ID / _REQUEST_TIMEOUT_SECONDS — the X (Twitter) engine; inert without stored OAuth 1.0a credentials regardless of the flag
- ROBOCO_ROADMAP_ENGINE_ENABLED / _INTERVAL_SECONDS (default 604800) / _MIN_ITEMS_PER_CYCLE / _MAX_ITEMS_PER_CYCLE — the board roadmap engine
- ROBOCO_TRANSCRIPT_RETENTION_DAYS / ROBOCO_TRANSCRIPT_PRUNE_ENABLED / _INTERVAL_SECONDS
- ROBOCO_IMAGE_PRUNE_ENABLED / _INTERVAL_SECONDS
- ROBOCO_GIT_COMMAND_TIMEOUT_SECONDS / _COMMIT_TIMEOUT_SECONDS / _NETWORK_TIMEOUT_SECONDS
@@ -287,6 +292,7 @@ deployment-tooling
- pyproject [project.scripts] declares `roboco-bootstrap = roboco.bootstrap:cli` but roboco/bootstrap.py defines NO `cli` symbol (only `main`); the canonical entry is `roboco = roboco.cli:cli`. The bootstrap-script entry is dead/broken (see drift).
- The Dockerfiles COPY pyproject.toml uv.lock README.md into /app for the uv sync layer; a missing/stale uv.lock at build time breaks the --frozen sync. The Makefile's `make upgrade` re-locks but does not rebuild images.
- Both NAS composes (`docker-compose.yml`/`.yaml`) set `ROBOCO_GUARD_ENABLED=true` / `ROBOCO_GUARD_PASSIVE_MODE=true` / `ROBOCO_GUARD_FAIL_SECURE=false` — the fastapi-guard HTTP security layer runs in detect-and-log calibration mode on the NAS, never blocking; `docker-compose.registry.yml` does not set these three and stays off (`guard_enabled` default `false`). Flipping `_PASSIVE_MODE` to `false` to enforce is a deliberate later step, not part of this arming.
- Both NAS composes now also arm `ROBOCO_SANDBOX_DB_ENABLED` / `ROBOCO_DB_NETWORK_ISOLATED` / `ROBOCO_CLOUD_AUTH_ENABLED` / `ROBOCO_X_ENGINE_ENABLED` / `ROBOCO_ROADMAP_ENGINE_ENABLED` all `${VAR:-true}` (config default is `false` for every one), alongside the pre-existing `${VAR:-true}` flips for `ROBOCO_SELF_HEAL_ENABLED`, `ROBOCO_PROVISIONING_ENABLED`, `ROBOCO_TRANSCRIPT_PRUNE_ENABLED`, and `ROBOCO_ROUTING_STRICT` — this is a personal-deploy posture (override any via `.env`), not the published conservative default. `docker-compose.registry.yml` keeps `ROBOCO_SELF_HEAL_ENABLED:-false` (and does not set `SANDBOX_DB`/`CLOUD_AUTH`/`X_ENGINE`/`ROADMAP_ENGINE` at all, so they fall through to the config `False` default) — it arms only `ROBOCO_DB_NETWORK_ISOLATED:-true`, with its own `roboco_data` network in the `networks:` stanza, so the registry compose ships DB-isolated but otherwise conservative.
- Surface N (scanner honeytrap) is two-layered because nginx only proxies `/api|/ws|/health|/ready` to the orchestrator: `docker/nginx.conf` has a `location ~*` block that `return 444`s the classic root scanner paths (`/.env`, `/.git`, `/wp-login.php`, `/phpmyadmin`, `actuator`, `cgi-bin`, `vendor/`, …) at the edge before they reach the panel (anchored to scanner fingerprints; `/.well-known` + real routes untouched; always on), while `roboco/security.py`'s `_THREAT_BAN_CONFIG` gained `recon`/`sensitive_file`/`cms_probing` so `/api`-path probes that DO reach guard trip an adaptive per-IP redis auto-ban (active mode only; passive logs).
+35 -9
View File
@@ -5,9 +5,9 @@ The CEO-facing intake and chief-of-staff slice. PrompterService turns a confirme
| Path | Role | LOC |
|---|---|---|
| roboco/services/prompter.py | PrompterService: create tasks from confirmed intake drafts (single + MegaTask batch), route owning team, sequence drafts into waves; plus pure description/readiness helpers | 1066 |
| roboco/services/prompter_live.py | PrompterLiveRegistry: process-wide singleton bridging live intake/secretary chat between panel (SSE) and spawned container (HTTP turn), with open/close/park/idle-reap lifecycle | 235 |
| roboco/services/secretary.py | SecretaryService: read company state + submit/confirm/reject gated CEO directives (relay/announce/charter/pitch/task-control), persisted in secretary_directives | 266 |
| roboco/services/prompter.py | PrompterService: create tasks from confirmed intake drafts (single + MegaTask batch), route owning team, sequence drafts into waves; plus pure description/readiness helpers, the wave-1/2 prompter-memory history-digest builders, and compact task-search row rendering | 1313 |
| roboco/services/prompter_live.py | PrompterLiveRegistry: process-wide singleton bridging live intake/secretary chat between panel (SSE) and spawned container (HTTP turn), with open/close/park/idle-reap lifecycle | 234 |
| roboco/services/secretary.py | SecretaryService: read company state + submit/confirm/reject gated CEO directives (relay/announce/charter/pitch/task-control incl. wave-1 full-content `edit` + claim-aware reassignment), persisted in secretary_directives | 418 |
## Key Symbols
@@ -78,13 +78,27 @@ The CEO-facing intake and chief-of-staff slice. PrompterService turns a confirme
| SecretaryService._pending_or_raise | method | roboco/services/secretary.py:171 | Fetch directive; NotFoundError if missing, ConflictError if not PENDING |
| SecretaryService._validate_payload | staticmethod | roboco/services/secretary.py:182 | Require the per-kind payload keys from _REQUIRED_PAYLOAD else ValidationError |
| SecretaryService._run | method | roboco/services/secretary.py:188 | Execute a directive: set result, EXECUTED on success, FAILED+error message on caught domain errors |
| SecretaryService._execute | method | roboco/services/secretary.py:204 | Dispatch by kind: relay/announce post to channel, update_charter upsert, approve_pitch approve+provision, else _control_task |
| SecretaryService._control_task | method | roboco/services/secretary.py:229 | Task control: start (approve_and_start), cancel, or override status with CEO as actor |
| SecretaryService._notify_ceo_pending | method | roboco/services/secretary.py:250 | Send an ack notification to the CEO that a gated directive awaits confirmation |
| get_secretary_service | function | roboco/services/secretary.py:263 | Factory: construct SecretaryService bound to a session |
| SecretaryService._execute | method | roboco/services/secretary.py:246 | Dispatch by kind: relay/announce post to channel, update_charter upsert, approve_pitch approve+provision, else _control_task |
| SecretaryService._EDITABLE_TASK_FIELDS | ClassVar[frozenset] | roboco/services/secretary.py:278 | Wave-1: the full content-field allowlist the Secretary may edit on CEO confirmation (title/description/acceptance_criteria/priority/team/estimated_complexity/nature/assigned_to) — status is deliberately excluded (its own audited start/cancel/override path); git fields (branch/PR) are never editable |
| SecretaryService._control_task | method | roboco/services/secretary.py:302 | Task control action dispatch: **edit** (wave-1, full content surface via _edit_task), start (approve_and_start), cancel, or override status with CEO as actor |
| SecretaryService._edit_task | method | roboco/services/secretary.py:325 | Wave-1: apply the Secretary's edit — allowlisted content fields go through TaskService.update after enum coercion (team/estimated_complexity/nature); assigned_to is popped out and routed through claim-aware reassignment (_reassign_task) instead of a plain field set |
| SecretaryService._reassign_task | method | roboco/services/secretary.py:362 | Route an edit's reassignment through claim-aware paths: reassign_active_claim (reseeds heartbeat) when the task is claimed/in_progress, else the general reassign (review-state handoffs, or explicit unassign) — never a naive setattr on assigned_to |
| SecretaryService._resolve_assignee | method | roboco/services/secretary.py:384 | Resolve an edit's assigned_to to a UUID: accepts None (unassign), a UUID string, or an agent slug (same convention as the CEO chat's REST PATCH path) |
| SecretaryService._notify_ceo_pending | method | roboco/services/secretary.py:403 | Send an ack notification to the CEO that a gated directive awaits confirmation |
| get_secretary_service | function | roboco/services/secretary.py:416 | Factory: construct SecretaryService bound to a session |
| build_history_digest | function | roboco/services/prompter.py:1221 | Wave-1/2 prompter memory: render a chronological digest of recent tasks (top `limit`, reversed to oldest-first for a timeline read) into markdown bullet lines; empty input -> "" |
| project_history_digest | function | roboco/services/prompter.py:1236 | One project's rendered history digest via `TaskService.list_recent_for_project`; None if the project has no tasks |
| history_digest_layer | function | roboco/services/prompter.py:1253 | Ambient task-history-digest block for the in-scope project(s), one sub-block per project (headed by slug when >1 — the MegaTask case); None when no in-scope project has any tasks (no empty-header noise) |
| compact_task_rows | function | roboco/services/prompter.py:1286 | Render TaskTable rows into the compact id/title/status/team/priority dicts returned by the intake `search_past_tasks` HTTP route |
| TaskService.list_recent_for_project | method | roboco/services/task.py:6361 | Recent tasks for a project ordered by coalesce(completed_at, updated_at, created_at) desc — backs the prompter's per-project history digest so a just-touched task surfaces ahead of an old completed one |
| TaskService.search_tasks | method | roboco/services/task.py:6384 | Case-insensitive ILIKE search over title/description + id-prefix match; backs the panel's task search bar (GET /tasks/summary?q=), the Secretary's task-by-name lookup (GET /secretary/tasks?q=), and the intake `search_past_tasks` tool |
| search_past_tasks (route) | route | roboco/api/routes/prompter_live.py:376 | GET /live/{session}/search-tasks: session-aliveness-gated (mirrors /events' trust boundary — the intake container has no agent identity) bounded compact search calling TaskService.search_tasks + compact_task_rows |
| query_past_tasks / format_search_results | function | roboco/mcp/intake_server.py:108,145 | Shared HTTP-call + bounding + rendering logic for `search_past_tasks`, module-level so both the grok MCP tool and the Claude SDK in-process tool call the exact same implementation |
| search_past_tasks (grok MCP tool) | mcp tool | roboco/mcp/intake_server.py:161 | Grok-CLI intake's "have we done something like this before?" tool; reads ROBOCO_PROMPTER_SESSION_ID, delegates to query_past_tasks + format_search_results |
| _search_past_tasks (Claude SDK in-process tool) | tool | roboco/agent_sdk/intake_driver.py:512 | Claude SDK driver's in-process parity tool for the same feature — imports query_past_tasks/format_search_results from intake_server.py directly (one implementation, both runtimes) |
## Data Flow
Intake: the orchestrator spawns a prompter container and calls PrompterLiveRegistry.open(session_id, INTAKE_AGENT_ID); the panel SSE endpoint calls stream() and the message endpoint calls deliver() -> POST http://roboco-agent-{agent_id}:{SDK_PORT}/turn. The container driver POSTs normalized StreamChunks to the relay push() endpoint. When the agent emits a roboco-meta fence, parse_readiness extracts ReadinessTag (covered/ready/scale) used by the orchestrator to decide proposal readiness. The CEO confirms via panel: confirm_live_draft (single) or confirm_live_batch (MegaTask) -> PrompterService.create_task_from_draft -> TaskService.create (DB). For a batch, _sequence_drafts (pure SequencingService.analyze) computes waves/edges, the umbrella is created branchless via _compose_umbrella_draft, N root-subtasks are created with BatchPlacement(parent=umbrella, batch_id, sequence=wave_index), then TaskService.add_dependency wires each edge (b depends on a). preview_batch returns the same waves without creating. update_live_draft applies board feedback to an existing task (update + approve_and_start or re-board). On board review, registry.park(session_id, task_id) keeps the chat alive; find_by_task recovers it for the re-draft injection. Idle sweep: orchestrator calls idle_session_ids(threshold) and close()s abandoned chats; close_by_agent fires on forced kill. Secretary: routes /api/secretary/* call read_company_state/read_task/submit_directive/confirm_directive/reject_directive -> SecretaryService -> TaskService/CompanyGoalsService/PitchService/MessagingService/NotificationService; gated kinds (UPDATE_CHARTER/CONTROL_TASK/APPROVE_PITCH/ANNOUNCE) persist PENDING + notify CEO, then confirm_directive runs _execute with the CEO as actor; RELAY_MESSAGE runs immediately.
Intake: the orchestrator spawns a prompter container and calls PrompterLiveRegistry.open(session_id, INTAKE_AGENT_ID); the panel SSE endpoint calls stream() and the message endpoint calls deliver() -> POST http://roboco-agent-{agent_id}:{SDK_PORT}/turn. The container driver POSTs normalized StreamChunks to the relay push() endpoint. When the agent emits a roboco-meta fence, parse_readiness extracts ReadinessTag (covered/ready/scale) used by the orchestrator to decide proposal readiness. The CEO confirms via panel: confirm_live_draft (single) or confirm_live_batch (MegaTask) -> PrompterService.create_task_from_draft -> TaskService.create (DB). For a batch, _sequence_drafts (pure SequencingService.analyze) computes waves/edges, the umbrella is created branchless via _compose_umbrella_draft, N root-subtasks are created with BatchPlacement(parent=umbrella, batch_id, sequence=wave_index), then TaskService.add_dependency wires each edge (b depends on a). preview_batch returns the same waves without creating. update_live_draft applies board feedback to an existing task (update + approve_and_start or re-board). On board review, registry.park(session_id, task_id) keeps the chat alive; find_by_task recovers it for the re-draft injection. Idle sweep: orchestrator calls idle_session_ids(threshold) and close()s abandoned chats; close_by_agent fires on forced kill. Secretary: routes /api/secretary/* call read_company_state/read_task/submit_directive/confirm_directive/reject_directive -> SecretaryService -> TaskService/CompanyGoalsService/PitchService/MessagingService/NotificationService; gated kinds (UPDATE_CHARTER/CONTROL_TASK/APPROVE_PITCH/ANNOUNCE) persist PENDING + notify CEO, then confirm_directive runs _execute with the CEO as actor; RELAY_MESSAGE runs immediately. A CONTROL_TASK directive's payload["action"] fans out inside _control_task: "edit" (wave-1) is the new full-content path — _edit_task applies the allowlisted fields via TaskService.update after enum coercion, and pops assigned_to for claim-aware reassignment (_reassign_task) rather than a plain field set; "start"/"cancel"/"override" are the pre-existing status-only actions. The CEO refers to tasks by NAME in the Secretary chat, so GET /api/secretary/tasks?q= (TaskService.search_tasks) resolves a name to a concrete id before a directive targets it. Prompter memory (wave 1/2): at intake spawn, the orchestrator's `_resolve_history_digest_ambient` calls `history_digest_layer`, which fans out `project_history_digest` per in-scope project — each pulling `TaskService.list_recent_for_project` and rendering it via `build_history_digest` — into one ambient "Recent tasks" block injected into the spawn prompt; mid-conversation, the intake agent's `search_past_tasks` tool (routed through `query_past_tasks`/`format_search_results` in roboco/mcp/intake_server.py, shared byte-for-byte with the Claude SDK's in-process `_search_past_tasks` tool in roboco/agent_sdk/intake_driver.py) hits GET /live/{session}/search-tasks -> TaskService.search_tasks -> compact_task_rows.
## Mermaid
```mermaid
@@ -165,6 +179,7 @@ intake-secretary
Pure helpers
parse_readiness, compose_description, format_board_briefing, compose_redraft_message
_as_work_entry, _cell_teams, _draft_cell_map, derive_scale, _clean_list, _text, _bullets, _cell_label, _render_work_entry, _render_the_work, _section
Prompter memory (wave 1/2): build_history_digest, project_history_digest, history_digest_layer, compact_task_rows
Dataclasses: ReadinessTag, BatchPlacement
PrompterLiveRegistry (roboco/services/prompter_live.py)
LiveIntakeSession dataclass (queue, closed, task_id, last_activity)
@@ -176,6 +191,7 @@ intake-secretary
Reads: read_company_state, read_task
Directives: get_directive, list_directives, submit_directive, confirm_directive, reject_directive, to_dict
Internals: _pending_or_raise, _validate_payload, _run, _execute, _control_task, _notify_ceo_pending
Task edit (wave-1): _EDITABLE_TASK_FIELDS, _edit_task, _reassign_task (claim-aware), _resolve_assignee (uuid-or-slug)
```
## Dependencies
@@ -192,7 +208,9 @@ intake-secretary
| POST /api/prompter/live/{session}/redraft (update_live_draft) | roboco/api/routes/prompter_live.py | panel re-draft confirm -> PrompterService.update_live_draft |
| relay push/stream/deliver/is_alive endpoints | roboco/api/routes/prompter_live.py + secretary_live.py | panel SSE + message POST over PrompterLiveRegistry |
| orchestrator live-intake spawn/reap/idle hooks | roboco/runtime/orchestrator.py | _spawn_intake_container / _spawn_secretary_container / idle-reap sweep / board-review park / close_by_agent on kill |
| GET /api/prompter/live/{session}/search-tasks (search_past_tasks) | roboco/api/routes/prompter_live.py | Intake agent's `search_past_tasks` tool -> TaskService.search_tasks + compact_task_rows; session-aliveness-gated |
| POST /api/secretary/state, /task, /directive, /directive/{id}/confirm\|reject | roboco/api/routes/secretary.py | Secretary panel surface -> SecretaryService reads + directive lifecycle |
| GET /api/secretary/tasks?q= (search_tasks) | roboco/api/routes/secretary.py | Secretary or CEO resolves a task NAME to concrete id(s) -> TaskService.search_tasks, for targeting a `control_task` directive |
## Config Flags
- ROBOCO_WORKSPACE_AUTO_CLONE / ROBOCO_WORKSPACE_CLONE_TIMEOUT (intake multi-repo clone scope: _clone_intake_scope, indirectly via orchestrator)
@@ -213,6 +231,10 @@ intake-secretary
- Secretary _run catches ConflictError/NotFoundError/ValidationError/ValueError/KeyError -> FAILED with `error: {exc}` in result; any other exception propagates (no rollback of the flush).
- GATED_KINDS = {UPDATE_CHARTER, CONTROL_TASK, APPROVE_PITCH, ANNOUNCE}; only RELAY_MESSAGE runs immediately on submit_directive — ANNOUNCE is gated (needs CEO confirm), despite being a 'post a message' shape.
- compose_description falls back to the raw model description if the composed body is < _MIN_DESCRIPTION_LEN (20) chars — so a too-sparse structured draft still clears the schema minimum.
- SecretaryService._edit_task never touches status — _EDITABLE_TASK_FIELDS deliberately excludes it (status rides the separate audited start/cancel/override actions), so an "edit" directive that also needs a status change requires a second CONTROL_TASK directive.
- SecretaryService._reassign_task branches on the task's CURRENT status at call time: claimed/in_progress goes through reassign_active_claim (reseeds the heartbeat so the new assignee isn't immediately stale to the reaper), everything else falls through to the general reassign — a caller relying on one code path for both is testing the wrong branch depending on task state.
- history_digest_layer / build_history_digest return None / "" respectively on no data — a brand-new project or a board-level (no-project) spawn injects nothing into the ambient prompt (no empty "Recent tasks" header noise), which also means there is no explicit signal in the prompt that the digest was even attempted.
- search_past_tasks (both the grok MCP tool and the Claude SDK in-process tool) reads ROBOCO_PROMPTER_SESSION_ID from the environment and calls the session-scoped HTTP route — a tool call with no live session (or a session the registry has already closed) returns a plain string error, not an exception, so a stale intake container can call it silently forever without a hard failure surfacing.
## Changes Since Baseline
@@ -222,6 +244,10 @@ intake-secretary
| 15effce0 | feat(megatask): per-cell project map root-subtasks (multi-project, multi-cell) + main_pm+code impossibility + re-draft/batch hardening | Only commit touching this slice since baseline (prompter.py +228/-55; prompter_live.py and secretary.py unchanged). Adds the ad-hoc per-cell project map as a third draft target shape: _draft_cell_map, _MULTI_CELL_MIN, has_cell_projects param on _validate_draft_target, cell_projects on TaskCreateRequest, _validate_batch_scope counting per-cell pids. Adds main_pm_cannot_own_code coercion (code->planning) and switches umbrella task_type CODE->PLANNING. Extracts _validate_and_coerce_draft (coerces list fields via coerce_str_list) and _resolve_draft_assignee. Adds _as_work_entry to tolerate bare-string the_work entries. Changes _clean_list to use coerce_str_list (extracts dict-wrapped text instead of str(dict)). |
> Post-snapshot updates (since 2026-06-29): 536bbb64 (Chore/all/logical gaps sweep, PR#286, 2026-06-30) touched prompter.py only (prompter_live.py and secretary.py still unchanged). Key changes: (1) fixes Risk #1 — 1-cell map branch now conditioned on `resolved_project_id is None and resolved_product_id is None` so a top-level target is no longer silently dropped; (2) fixes Risk #2`_draft_cell_map` now raises `ValidationError` on a malformed project_id instead of silently continuing; (3) fixes Risk #4`create_task_from_draft` calls `_copy_draft` first so `_validate_and_coerce_draft` never mutates the caller's dict; (4) fixes Risk #5 — product/board routing is now checked BEFORE the multi-cell map force (multi-cell is inside the `if resolved_product_id is None:` branch); (5) extracts code->planning coercion into `_coerce_pm_code_to_planning`, extending it to cover PM assignees on any team (via the new `pm_cannot_own_code` helper imported from `roboco.foundation.policy.batch`); (6) adds `_copy_draft` module-level function. LOC grew from ~1066 to 1142.
>
> `d1cf6ecb` Wave 1: PR-gate turn cut, task search, trace timestamps, Secretary edits + e2e scenarios 23 (#295) — secretary.py gains the full `edit` action (`_EDITABLE_TASK_FIELDS`, `_edit_task`, `_reassign_task`, `_resolve_assignee`) on `_control_task`; prompter.py gains the prompter-memory digest builders (`build_history_digest`, `project_history_digest`, `history_digest_layer`, `compact_task_rows`) plus the `TaskService.list_recent_for_project` / `search_tasks` backing queries; adds the `GET /live/{session}/search-tasks` route and the `search_past_tasks` MCP tool + Claude-SDK in-process parity tool. First commit to touch secretary.py since baseline.
>
> `da563487` Wave 2 features: A2A live view (CEO chime-in + reply budget) and prompter memory (#297) / `876e19b3` A2A switchboard + Secretary/PM task access + closed over-permission hole (#298) — no further changes to prompter.py/prompter_live.py/secretary.py beyond wave 1 above; these two commits' Secretary/PM-access work landed in `roboco/api/routes/tasks.py` (`_pm_editor_scope` / `_enforce_pm_lighter_fields`, closing the PM-role unrestricted-admin hole — out of this slice, see `docs/map/api-routes-schemas.md`) and their A2A work is entirely in `docs/map/a2a-audit-journal-permissions.md`.
## Regression Risks
@@ -235,4 +261,4 @@ intake-secretary
| ~~Multi-cell map team routing precedes product/board routing~~ **RESOLVED 536bbb64** | roboco/services/prompter.py:163 | ~~_resolve_owning_team checked multi-cell before product/board.~~ Fixed: product/board routing is now checked first (`if resolved_product_id is None:` gates the multi-cell path); a product draft with a ≥2-cell the_work map stays on the board-review path as required. The representation limit (product + cell-map not simultaneously expressible) is intentional, not a bug. | ~~low~~ fixed |
## Health
The slice is coherent and well-defended. prompter_live.py and secretary.py are unchanged since baseline and read as clean, focused singletons/services with correct lifecycle semantics (idempotent open, sentinel-based stream close, park-vs-close distinction, gated-vs-direct directive split backed by GATED_KINDS). The one changed file, prompter.py, gained the per-cell MegaTask map shape and the main_pm+code->planning coercion that closes the 2026-06-27 meltdown class; its validation is stricter and coercion is robust against LLM-emitted shapes (bare-string the_work, dict-wrapped list items, word-valued priority). The main integrity concerns are two silent-collapse paths in the new cell-map handling: a 1-cell map silently drops product_id/top-level project_id, and a malformed project_id in a multi-cell map silently collapses the shape to single-cell — neither raises, so an LLM producing a slightly-off draft will create a mis-shaped task instead of a clean 400. The update_live_draft path skips the new _validate_and_coerce_draft guard, so re-drafts are not protected against empty-after-coercion AC. No drift from CLAUDE.md was found; the MegaTask umbrella is branchless/planning, ANNOUNCE is gated, and single-task intake is preserved. Overall the slice is healthy but the silent-collapse edges warrant a hardening pass to convert them into ValidationErrors.
The slice is coherent and well-defended. prompter_live.py remains unchanged since baseline and reads as a clean, focused singleton with correct lifecycle semantics (idempotent open, sentinel-based stream close, park-vs-close distinction). prompter.py and secretary.py both changed in wave 1 (`d1cf6ecb`): prompter.py gained the per-cell MegaTask map shape, the main_pm+code->planning coercion that closes the 2026-06-27 meltdown class, AND the prompter-memory digest builders (history digest + compact task search) — its validation is stricter and coercion is robust against LLM-emitted shapes (bare-string the_work, dict-wrapped list items, word-valued priority). secretary.py gained a genuinely new capability (the full-content `edit` directive action with claim-aware reassignment), its first change since baseline; the split between the allowlisted content fields and the status-only start/cancel/override actions is clean and the reassignment logic correctly branches on claim state. The main integrity concerns are two pre-existing silent-collapse paths in the cell-map handling: a 1-cell map silently drops product_id/top-level project_id, and a malformed project_id in a multi-cell map silently collapses the shape to single-cell — neither raises, so an LLM producing a slightly-off draft will create a mis-shaped task instead of a clean 400. The update_live_draft path skips the new _validate_and_coerce_draft guard, so re-drafts are not protected against empty-after-coercion AC. No drift from CLAUDE.md was found; the MegaTask umbrella is branchless/planning, ANNOUNCE is gated, and single-task intake is preserved. Overall the slice is healthy but the silent-collapse edges warrant a hardening pass to convert them into ValidationErrors.
+5 -4
View File
@@ -14,7 +14,7 @@ The `roboco/mcp` package is the agent-side MCP gateway: a set of `FastMCP` serve
| `roboco/mcp/utils.py` | Shared HTTP helpers: `_get_agent_headers`, `format_error_response`, `ApiResponse`, `ApiClient` (async httpx wrapper). Used by `optimal_server`, `docs_server`, `search_server`. | 383 |
| `roboco/mcp/schemas/__init__.py` | Pydantic input models. After Phase-4 T9 deletions only `WriteDocInput` remains. | 35 |
| `roboco/mcp/flow_server.py` | `roboco-flow` MCP server — intent verbs (lifecycle). Manifest-scoped registration, role-scoped path `/api/v1/flow/<route>/<verb>`, per-verb circuit breaker + 404-route synthesis. | 1028 |
| `roboco/mcp/do_server.py` | `roboco-do` MCP server — content tools (commit, note, say, dm, notify, evidence, progress, sessions, playbook curation, pr_update). Manifest-scoped; fixed `/api/v1/do/<verb>` path; mirror circuit breaker. | 954 |
| `roboco/mcp/do_server.py` | `roboco-do` MCP server — content tools (commit, note, pitch, propose_roadmap, say, dm, notify, evidence, progress, sessions, playbook curation, pr_update). Manifest-scoped; fixed `/api/v1/do/<verb>` path; mirror circuit breaker. | 976 |
| `roboco/mcp/optimal_server.py` | `roboco-optimal` MCP server — RAG / KB / mentor / error / decision / standards / learnings / index-mgmt / proactive-context. Factory `create_optimal_mcp_server(agent_id)`; calls `/optimal/*`. | 1102 |
| `roboco/mcp/docs_server.py` | `roboco-docs` MCP server — docs write/read/list/delete via `/docs/*`. Factory `create_docs_mcp_server(agent_id)`. RAG-based dedup on write. | 251 |
| `roboco/mcp/git_readonly.py` | `roboco-git-readonly` MCP server — four read-only git views (status/log/diff/branch list) via `/api/git/*`. No breaker, no manifest. | 123 |
@@ -52,7 +52,7 @@ The `roboco/mcp` package is the agent-side MCP gateway: a set of `FastMCP` serve
| `_load_manifest_flow_tools` | func | `flow_server.py:935` | Read `/app/tool-manifest.json` `flow_tools`; None if missing/unreadable. |
| `_register_tools` (flow) | func | `flow_server.py:965` | Raise `RuntimeError` if manifest missing and `ROBOCO_ALLOW_FULL_TOOLSET` not set; if set, registers full tool set as dev/test escape hatch. |
| `_REGISTERED_TOOLS` (flow) | var | `flow_server.py:1024` | Import-time registration side effect. |
| `_TOOLS` (do) | dict | `do_server.py:842` | Tool name → impl map (19 content tools). |
| `_TOOLS` (do) | dict | `do_server.py:863` | Tool name → impl map (21 content tools, incl. `propose_roadmap`). |
| `_load_manifest_do_tools` | func | `do_server.py:866` | Read manifest `do_tools` list. |
| `_register_tools` (do) | func | `do_server.py:896` | Manifest-scoped registration; raise `RuntimeError` if manifest missing (unless `ROBOCO_ALLOW_FULL_TOOLSET` set). |
| `give_me_work``i_am_idle` | verb funcs | `flow_server.py:491625` | Dev verbs. |
@@ -61,6 +61,7 @@ The `roboco/mcp` package is the agent-side MCP gateway: a set of `FastMCP` serve
| `i_will_plan` / `delegate` / `submit_up` / `submit_root` | verb funcs | `flow_server.py:759856` | PM coordination verbs. |
| `note` | verb func | `do_server.py:428` | Journal entry + handoff section writer (top-level `done`/`next` strings — the meltdown-#1 fix). |
| `commit` / `say` / `dm` / `notify` / `evidence` | verb funcs | `do_server.py:423598` | Core content tools. |
| `propose_roadmap` | verb func | `do_server.py:531` | Product Owner (board-roadmap-only, `_PRODUCT_OWNER_DO`): propose a themed roadmap cycle (goal + 3-7 item drafts) exactly once per exploration task. |
| `draft_playbook` / `approve_playbook` / `reject_playbook` / `archive_playbook` | verb funcs | `do_server.py:600644` | Playbook curation (delivery + Auditor). |
| `progress` / `open_session` / `link_session` / `notify_list` / `notify_get` / `notify_ack` / `channels` / `pr_update` / `read_messages` | verb funcs | `do_server.py:646840` | Wave-1 parity content tools. |
| `create_optimal_mcp_server` | factory | `optimal_server.py:1068` | Build `roboco-optimal-{agent_id}` server; registers 8 tool groups. |
@@ -172,7 +173,7 @@ roboco/mcp/
│ └── _load_manifest_flow_tools / _register_tools (fails loud if no manifest; ROBOCO_ALLOW_FULL_TOOLSET escape hatch)
├── do_server.py # roboco-do (content tools)
│ ├── mirror breaker machinery (_CIRCUIT_REJECTION_KINDS, _DICT_ERROR_CODE_MAP, _classify_*, _remediate_for_kind, _normalize_exception_envelope, _record_and_check_circuit)
│ ├── commit, note (handoff done/next), pitch, say, dm, notify, evidence
│ ├── commit, note (handoff done/next), pitch, propose_roadmap, say, dm, notify, evidence
│ ├── progress, open_session, link_session, notify_list/get/ack, channels, pr_update, read_messages
│ ├── draft_playbook, approve_playbook, reject_playbook, archive_playbook
│ └── _TOOLS / _load_manifest_do_tools / _register_tools (fails loud; ROBOCO_ALLOW_FULL_TOOLSET escape hatch)
@@ -287,7 +288,7 @@ CLAUDE.md "MCP servers running per agent container" table lists 5 servers (`robo
CLAUDE.md "roboco-optimal" row says the server exposes `roboco_ask_mentor`, `roboco_kb_search` only. The actual `optimal_server.py` registers **18** tools (search/rag/stats/index_code/index_docs/tokens_estimate/ask_mentor/search_error/record_error_solution/check_decision/record_decision/get_standards/validate_action/review_code/record_learning/search_learnings/clear_index/reindex_all/index_status/get_proactive_context). Understatement, not contradiction.
CLAUDE.md "roboco-do" row lists `commit, note, say, dm, evidence` and (in the Agent Gateway section) `draft_playbook` for delivery roles + `approve_playbook`/`reject_playbook`/`archive_playbook` for the Auditor. The actual `do_server.py` `do_tools` registry also contains `pitch`, `progress`, `open_session`, `link_session`, `notify`, `notify_list`, `notify_get`, `notify_ack`, `channels`, `pr_update`, `read_messages` — none mentioned in CLAUDE.md. Understatement.
CLAUDE.md "roboco-do" row lists `commit, note, say, dm, evidence` and (in the Agent Gateway section) `draft_playbook` for delivery roles + `approve_playbook`/`reject_playbook`/`archive_playbook` for the Auditor. The actual `do_server.py` `do_tools` registry also contains `pitch`, `propose_roadmap` (Product Owner only), `progress`, `open_session`, `link_session`, `notify`, `notify_list`, `notify_get`, `notify_ack`, `channels`, `pr_update`, `read_messages` — none mentioned in CLAUDE.md (`_TOOLS` is 21 entries, not the 5 named). Understatement.
CLAUDE.md says the `note`/journal write "returns as soon as the entry is persisted; RAG indexing runs fire-and-forget." The MCP `note` tool itself is synchronous w.r.t. the orchestrator (a single POST); the fire-and-forget behavior is server-side, not visible in this slice — consistent, not drift.
+3 -2
View File
@@ -63,7 +63,7 @@ The Pydantic/dataclass domain surface of RoboCo — the typed contract the API,
| `AgentPermissions` | Pydantic model | agent.py:45 | can_notify + channels_read/write |
| `WorkSession` | Pydantic model | work_session.py:25 | Branch/PR/merge tracking for a (project, task, agent) work episode |
| `WorkSessionStatus` | StrEnum | work_session.py:17 | active/completed/abandoned |
| `Project` | Pydantic model | project.py:28 | Git repo config + CI/dep-update opt-ins + `assigned_cell` |
| `Project` | Pydantic model | project.py:47 | Git repo config + CI/dep-update/`sandbox_services` opt-ins + `assigned_cell` |
| `BranchReason` | StrEnum | project.py:18 | feature/bug/chore/docs/hotfix (branch-name prefixes) |
| `Session` | Pydantic model | session.py:90 | Bounded message group (time/count/length); `scope` for context loading |
| `SessionScope` | StrEnum | session.py:30 | initiative/cell/task |
@@ -227,7 +227,7 @@ models/
## Config Flags
None — pure models, no flags. (The `Project` model *carries* opt-in fields `ci_watch_enabled`, `dep_update_command`, `dep_update_paths` that other layers gate on, and `llm_catalog` carries the "pure Ollama" defaults, but the models package itself reads no env / toggles nothing.)
None — pure models, no flags. (The `Project` model *carries* opt-in fields `ci_watch_enabled`, `dep_update_command`, `dep_update_paths`, `sandbox_services` (project.py:142, validated against `VALID_SANDBOX_SERVICES` — sandboxed dev DB/Redis, gated by `ROBOCO_SANDBOX_DB_ENABLED` elsewhere) that other layers gate on, and `llm_catalog` carries the "pure Ollama" defaults, but the models package itself reads no env / toggles nothing.)
## Gotchas
@@ -252,6 +252,7 @@ None — pure models, no flags. (The `Project` model *carries* opt-in fields `ci
- CLAUDE.md "A task has at most one active WorkSession" — enforced by DB partial-unique index (migration 047) + service layer, not by the `WorkSession` model itself (work_session.py has no such constraint). Consistent with CLAUDE.md's "enforced both at the service layer and by a DB partial-unique index".
- The slice prompt named `AuditEvent` and `A2AEnvelope` as landmarks; the actual symbols are `AuditEventType` (audit.py:13, no `AuditEvent` class) and there is no `A2AEnvelope` in `a2a.py` (the gateway `Envelope` lives in `services/gateway/`, not here). Listed the real landmarks instead.
- Otherwise: `TaskStatus` 15-state enum, `TaskType` 6 values, `NotificationType`/`ChannelType`/`JournalEntryType` all match CLAUDE.md verbatim.
- v0.17.0 delta: the three new ORM tables backing cloud auth + the X engine (`UserTable`, `XCredentialsTable`, `XSeenMentionTable` — migrations 058/059) have **no Pydantic counterpart in this package** — cloud auth's `UserTable` is consumed directly by `fastapi_users`/`roboco.api.auth.*` and the X engine's two tables are read/written directly off the ORM row by `roboco.services.x_*`. They are documented as ORM `Key Symbols` in `db-migrations.md`, not here, consistent with this file's own Purpose statement ("these are not the ORM tables").
## Changes Since Baseline
+26 -4
View File
@@ -1,5 +1,5 @@
## Purpose
The AgentOrchestrator is the runtime brain of RoboCo: it owns the per-agent Docker container lifecycle, the per-tick dispatcher that matches tasks to agents, the stale-claim reaper, the provider rate-limit/overload park-and-probe recovery loop, and the default-off background engines (self-heal, CI-watch, dep-update, release-manager, strategy, external-PR poll). It claims tasks on behalf of agents before spawning, injects briefings/manifests/git context at spawn time, captures per-session token usage, and persists durable runtime state (WaitingRecord, respawn_tracker) across restarts.
The AgentOrchestrator is the runtime brain of RoboCo: it owns the per-agent Docker container lifecycle, the per-tick dispatcher that matches tasks to agents, the stale-claim reaper, the provider rate-limit/overload park-and-probe recovery loop, and the default-off background engines (self-heal, CI-watch, dep-update, release-manager, strategy, external-PR poll, X-engine mentions poll, board roadmap engine). It claims tasks on behalf of agents before spawning, injects briefings/manifests/git context at spawn time, provisions a per-spawn sandbox DB/Redis when opted in, captures per-session token usage, and persists durable runtime state (WaitingRecord, respawn_tracker) across restarts.
## Files
@@ -79,10 +79,22 @@ The AgentOrchestrator is the runtime brain of RoboCo: it owns the per-agent Dock
| AgentOrchestrator._should_skip_live_reap | method | roboco/runtime/orchestrator.py:8750 | Spare a live container from reaping UNLESS wedged (grok) or gateway-broken past grace (those kill+evict). |
| AgentOrchestrator._assignee_is_provider_parked | method | roboco/runtime/orchestrator.py:8527 | True if a task's assignee is provider-parked; reaper skips it so the claim survives for probe-resume. |
| AgentOrchestrator._reap_with_service | method | roboco/runtime/orchestrator.py:8770 | Inner stale-claim reaper: skip live (unless wedged/broken), skip provider-parked, unclaim_for_reaper the rest. |
| AgentOrchestrator._maybe_provision_sandbox | method | roboco/runtime/orchestrator.py:2029 | Provision this spawn's sandbox DB/Redis via `SandboxProvisioner` when `sandbox_db_enabled` + the project's `sandbox_services` are set; None (byte-for-byte legacy path) otherwise. Fail-loud once opted in — a provisioning failure refuses the spawn. |
| AgentOrchestrator._append_sandbox_env | staticmethod | roboco/runtime/orchestrator.py:2639 | Inject `ROBOCO_TEST_DB_*`/`ROBOCO_TEST_REDIS_*` env from `config.sandbox_info`, called INSTEAD OF `_append_gate_env` whenever a sandbox was provisioned for this spawn. |
| AgentOrchestrator._sandbox_janitor_sweep | method | roboco/runtime/orchestrator.py:9426 | Best-effort: remove sandbox containers whose owner agent is gone; rides the reaper tick, error-isolated. |
| AgentOrchestrator._x_mentions_poll_loop | method | roboco/runtime/orchestrator.py:7431 | Default-off X-engine mentions-poll tick loop (`x_engine_enabled`); release-post drafts are event-driven, not from this loop. |
| AgentOrchestrator._run_x_mentions_cycle | method | roboco/runtime/orchestrator.py:7453 | One mentions-poll pass: `get_x_engine(db).run_cycle()` + commit; testable without the sleep. |
| AgentOrchestrator._roadmap_engine_loop | method | roboco/runtime/orchestrator.py:7462 | Default-off board roadmap-engine tick loop (`roadmap_engine_enabled`); opens one held exploration cycle per interval. |
| AgentOrchestrator._run_roadmap_engine_cycle | method | roboco/runtime/orchestrator.py:7484 | One roadmap-engine pass: `get_roadmap_engine(db).run_cycle()` + commit; testable without the sleep. |
| AgentOrchestrator._dispatch_roadmap_exploration | method | roboco/runtime/orchestrator.py:10284 | One-shot Product-Owner spawn to author a themed roadmap cycle; bypasses the two-reviewer board-review-pair machinery (PO-solo in v1). |
| AgentOrchestrator._dispatch_all_work | method | roboco/runtime/orchestrator.py:8814 | Reset tick-handled set, reap stale claims, enforce grok budget, run all 17 dispatchers under one httpx client with per-dispatcher isolation. |
| AgentOrchestrator._pm_respawn_should_gate | method | roboco/runtime/orchestrator.py:8915 | Per-(slug,task) respawn circuit breaker; tracing_gap rule-following resets (bounded) + durable persist; CEO notify once when tripped. |
| AgentOrchestrator._handle_pm_assigned_task | method | roboco/runtime/orchestrator.py:9084 | Spawn/respawn the PM for an assigned coordination root subject to the respawn gate. |
| AgentOrchestrator._maybe_spawn_pm_closure | method | roboco/runtime/orchestrator.py:9498 | Spawn a PM to close a paused/blocked parent whose subtasks are all terminal (debounced via _is_recently_paused). |
| AgentOrchestrator._AUTO_SUBMIT_VERB_BY_ROLE | ClassVar[dict] | roboco/runtime/orchestrator.py:10650 | Wave-1 PR-gate turn cut: maps cell_pm -> (cell_pm, submit_up) and main_pm -> (main_pm, submit_root), the flow route+verb that assembles the parent's PR for each coordinator role. |
| AgentOrchestrator._auto_submit_target | method | roboco/runtime/orchestrator.py:10655 | Resolve (role, route, verb, pm_uuid) for an auto-submittable parent; None when `pr_gate_auto_submit_enabled` is off, the parent is branchless coordination (no PR to assemble), the role has no submit verb, or no PM identity resolves. |
| AgentOrchestrator._try_auto_submit | method | roboco/runtime/orchestrator.py:10677 | Wave-1 PR-gate turn cut: run the owning PM's submit_up/submit_root verb system-side via the internal flow API (no PM spawn) when every child of an assembled parent is terminal; True on gate acceptance (fires `task.auto_submitted` audit + `_mark_task_handled`), False on ANY refusal (flag off, branchless parent, unmapped role, gate rejection, or transport error) so the caller falls back to the classic PM closure spawn. |
| AgentOrchestrator._closure_handled_without_pm | method | roboco/runtime/orchestrator.py:10740 | Recover an auto-paused/blocked parent's status first (so the next actor lands on an actionable in_progress parent), then try `_try_auto_submit`; True skips the PM closure spawn entirely. |
| AgentOrchestrator._maybe_spawn_pm_closure | method | roboco/runtime/orchestrator.py:10768 | If this parent task is ready for closure, try the system-side submit turn cut first (`_closure_handled_without_pm`); only spawn its PM when that declines (debounced via _is_recently_paused). |
| AgentOrchestrator._dispatch_dev_work | method | roboco/runtime/orchestrator.py:9733 | Fetch pending/needs_revision/in_progress/claimed code tasks and route each through _dev_dispatch_one. |
| AgentOrchestrator._dev_dispatch_one | method | roboco/runtime/orchestrator.py:9861 | Per-task dev dispatch: HITL skip, role/type mismatch guard, existing-owner respawn or pending spawn. |
| AgentOrchestrator._spawn_pending_dev | method | roboco/runtime/orchestrator.py:9800 | Validate + spawn a dev for a pre-assigned pending task; applies the per-dev lane barrier _blocked_by_earlier_lane_sibling. |
@@ -152,12 +164,13 @@ stateDiagram-v2
- Rate-limit/overload park-and-probe: `_park_provider_unavailable` (+ grok 75/78 variants) → `_rate_limit_probe_loop` (30s) → `_on_probe_success`/`_on_probe_failure``resolve_wait`
- Gateway-health: `_probe_gateway_health``_gateway_broken_past_grace``_maybe_recover_broken_gateway` (kill+evict)
- Respawn tracker: `_pm_respawn_should_gate``_persist_respawn_record` (durable upsert) + `restore_respawn_tracker` at startup
- Default-off loops: `_self_heal_loop`, `_ci_watch_loop`, `_dep_update_loop`, `_release_manager_loop`, `_strategy_engine_loop`, `_external_pr_poll_loop`
- PM closure / PR-gate turn cut (wave 1): `_maybe_spawn_pm_closure``_closure_handled_without_pm` (recover paused/blocked status) → `_try_auto_submit` (system-side submit_up/submit_root via `_AUTO_SUBMIT_VERB_BY_ROLE`, gated by `pr_gate_auto_submit_enabled`); only a gate refusal falls through to an actual PM spawn
- Default-off loops: `_self_heal_loop`, `_ci_watch_loop`, `_dep_update_loop`, `_release_manager_loop`, `_strategy_engine_loop`, `_external_pr_poll_loop`, `_x_mentions_poll_loop`, `_roadmap_engine_loop`
- Interactive: `start_intake_session` / `_spawn_intake_container` / `_spawn_secretary_container` / `_reap_idle_interactive_sessions`
- Shutdown (`stop`): cancel loops → `stop_agent(release_claim=True)` (skip provider-parked) → `_drain_bg_tasks``_stopped`
## Dependencies
- Internal: `roboco.config.settings`; `roboco.db.base.get_session_factory`; tables `RespawnTrackerTable`, `WaitingRecordTable`, `TaskTable`, `GatewayTriggerTable`, `agent_spawn_sessions`, `daily_usage_rollups`; `roboco.events.get_event_bus`; `roboco.llm.providers` (`ClaudeCodeProvider`, `GrokCliProvider`, `grok_auth`); `roboco.services.gateway` (`RateLimitStateTracker`, `role_config`, `claim_guards`); `WorkspaceService`, `TaskService`, `GitService`, `ConventionsService`, `SequencingService`, `CiWatchEngine`, `DepUpdateEngine`, `ReleaseManagerEngine`, `ReleaseReadinessService`, `MemoryDistiller`; `roboco.runtime.compose_prompt`; `AGENT_IMAGES`.
- Internal: `roboco.config.settings`; `roboco.db.base.get_session_factory`; tables `RespawnTrackerTable`, `WaitingRecordTable`, `TaskTable`, `GatewayTriggerTable`, `agent_spawn_sessions`, `daily_usage_rollups`; `roboco.events.get_event_bus`; `roboco.llm.providers` (`ClaudeCodeProvider`, `GrokCliProvider`, `grok_auth`); `roboco.services.gateway` (`RateLimitStateTracker`, `role_config`, `claim_guards`); `WorkspaceService`, `TaskService`, `GitService`, `ConventionsService`, `SequencingService`, `CiWatchEngine`, `DepUpdateEngine`, `ReleaseManagerEngine`, `ReleaseReadinessService`, `MemoryDistiller`; `roboco.runtime.sandbox.SandboxProvisioner`; `roboco.services.x_engine.get_x_engine`; `roboco.services.roadmap_engine.get_roadmap_engine`; `roboco.runtime.compose_prompt`; `AGENT_IMAGES`.
- External: Docker daemon (inspect/exec/run/rm); Redis; PostgreSQL+pgvector; Ollama; Claude Code SDK container image; grok CLI + `~/.grok/auth.json`; the orchestrator's own HTTP API (`/tasks`, `/notifications`, `/audit`) via httpx with `_system_api_headers`.
## Entry Points
@@ -179,6 +192,12 @@ stateDiagram-v2
- `ROBOCO_GROK_MAX_COST_USD` — grok budget kill-switch; `_GROK_RATE_LIMIT_EXIT_CODE=75`, `_GROK_AUTH_EXIT_CODE=78`, `_PROBE_GIVE_UP_THRESHOLD=30`.
- `ROBOCO_CLAUDE_STUCK_KILL_SECONDS` (default 3600, min 600) — heartbeat-stale kill threshold for non-GROK agents; controls `_maybe_kill_stuck_claude`.
- `ROBOCO_DISPATCHER_INTERVAL_SECONDS` (30), `ROBOCO_INTERACTIVE_IDLE_REAP_SECONDS`, `ROBOCO_GROK_*` backoff constants.
- `ROBOCO_SANDBOX_DB_ENABLED` (default off) — master switch for the sandboxed per-agent-spawn Postgres/Redis provisioner (`_maybe_provision_sandbox`/`_append_sandbox_env`/`_sandbox_janitor_sweep`); a project participates only when its `sandbox_services` column is also set.
- `ROBOCO_DB_NETWORK_ISOLATED` (default off; set by the compose topology that carries the `roboco_data` network) — suppresses the legacy `_append_gate_env` prod-creds injection when postgres/redis are unreachable from the agent mesh.
- `ROBOCO_CLOUD_AUTH_ENABLED` (+ `_EMAIL`/`_PASSWORD`/`_SECRET`/`_COOKIE_MAX_AGE`, default off) — cloud auth master switch; read by `roboco.api.deps.get_agent_context`/`roboco.api.auth.*`, not the orchestrator itself, but gates whether a spawned agent's own HMAC-token identity path is the sole non-CEO auth route.
- `ROBOCO_X_ENGINE_ENABLED` (+ `_mentions_interval_seconds` / `_mentions_max_per_cycle` / `_mentions_min_engagement` / `_max_open_posts` / `_account_user_id` / `_request_timeout_seconds`, default off) — gates `_x_mentions_poll_loop`.
- `ROBOCO_ROADMAP_ENGINE_ENABLED` (+ `_interval_seconds` default 604800 / `_min_items_per_cycle` / `_max_items_per_cycle`, default off) — gates `_roadmap_engine_loop`.
- `ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED` (`pr_gate_auto_submit_enabled`, default **True**) — wave-1 PR-gate turn cut: when every child of an assembled parent is terminal, `_try_auto_submit` runs the owning PM's submit_up/submit_root gate system-side instead of spawning the PM for that turn; a gate rejection (freshness/integrity) falls back to the classic PM closure spawn.
## Gotchas
- Respawn-tracker rows are restored at startup and re-stamped to live values; terminal/missing-task rows are evicted, so a stale row can't gate a fresh task. The upsert is race-free but fire-and-forget persists are ordered by `_respawn_persist_lock` acquisition (= schedule order); a stale persist resolving after a fresh one would otherwise re-burn the strike threshold on restart.
@@ -189,6 +208,8 @@ stateDiagram-v2
- Fire-and-forget `_bg_tasks` (respawn_tracker upserts, audit rows) are drained at shutdown under `_SHUTDOWN_DRAIN_TIMEOUT_SECONDS`; past the deadline they're cancelled, so a cancelled persist degrades to in-memory-only (can only suppress a spawn, never manufacture one).
- Budget-kill (`_enforce_grok_cost_budget`) finalizes the spawn session BEFORE popping the instance so captured usage/cost isn't lost; the reaper then releases the freed claim.
- `_should_skip_live_reap` short-circuits like the original `and`: when not live, none of the three kill checks is awaited. The three kill paths are: `_maybe_kill_wedged_grok` (grok idle TTL), `_maybe_kill_stuck_claude` (non-GROK agent stuck past `claude_stuck_kill_seconds`, default 3600s), and `_maybe_recover_broken_gateway`. A Claude agent stuck in a genuine verb loop (still firing gateway verbs, so heartbeat advances) remains spared — the stuck-claude TTL only catches heartbeat-stale containers.
- `_try_auto_submit` posts to the internal flow API AS the owning PM (`X-Agent-ID`/`X-Agent-Role` headers set to the PM's own identity) — it is not a privilege escalation since the PM already owns that verb, but it means an `auto_submitted` gate action is indistinguishable in the PM's own audit trail from one it issued itself; the `task.auto_submitted` audit event (fired only from `_try_auto_submit`) is the sole marker that the PM turn was skipped.
- `_auto_submit_target` requires BOTH `branch_name` and `project_id` on the parent — a MegaTask umbrella (branchless coordination) always fails this check and falls through to the classic PM closure spawn, which is correct (an umbrella assembles no PR) but means the turn cut never applies to the top of a MegaTask tree, only its root-subtasks.
## Drift from CLAUDE.md
- None material. The orchestrator runs the six default-off loops CLAUDE.md lists (self-heal, CI-watch, dep-update, release-manager, strategy, external-PR poll); `ROBOCO_ORG_MEMORY_ENABLED` has no orchestrator loop (capture/retrieval live in `TaskService`/`EvidenceRepo`, by design). Respawn-tracker durability (migration 051), `_instances` reconciled-from-Docker, and the park-and-probe shape all match the prose.
@@ -203,6 +224,7 @@ stateDiagram-v2
> - `d34bc1a7` ci-watch/dep-update dedupe: `_repo_key` normalizes git_url (strip `.git`, trailing slash, lowercase) and empty-string workflow treated as default so the DB dedupe and the orchestrator poll-set collapse agree.
> - `7be10057` Agent image: stop baking `VIRTUAL_ENV=/app/.venv` — comment in `_generate_mcp_config` updated to drop the stale VIRTUAL_ENV reference.
> - `6b441e42` Converters: `InvalidIdentifierError` now caught explicitly in `_release_stopped_agent_claim` with a structured warning log instead of a silent broad-except return.
> - `d1cf6ecb` Wave 1: PR-gate turn cut, task search, trace timestamps, Secretary edits + e2e scenarios 23 (#295) — adds `config.pr_gate_auto_submit_enabled` (default True) + `_AUTO_SUBMIT_VERB_BY_ROLE` / `_auto_submit_target` / `_try_auto_submit` / `_closure_handled_without_pm`, wired into `_maybe_spawn_pm_closure` so an assembled, all-children-terminal parent is submitted to the PR gate system-side instead of always spawning the PM for that turn; fires a new `task.auto_submitted` audit event.
## Regression Risks
+49 -7
View File
@@ -15,20 +15,25 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| `panel/src/app/(dashboard)/tasks/page.tsx` + `tasks/[taskId]/page.tsx` | Task list + task detail (tabbed) |
| `panel/src/app/(dashboard)/kanban/page.tsx` | Operator kanban (dev/qa/pm/pr-review views) |
| `panel/src/app/(dashboard)/prompter/page.tsx` | Intake chat (single + MegaTask batch scope) |
| `panel/src/app/(dashboard)/a2a/page.tsx` | A2A Live: org-wide switchboard/list + transcript + CEO reply composer, live via `/ws/system` `a2a.message` frames |
| `panel/src/app/(dashboard)/settings/page.tsx` + `settings/ai-providers/page.tsx` | Settings: feature flags, AI routing, transcript retention, self-hosted |
| `panel/src/app/(dashboard)/{agents,projects,products,business,journals,communications,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx` | Per-domain pages |
| `panel/src/app/(auth)/login/page.tsx` | Cloud-auth login form (email/password → `useLogin``/auth/login`); only reachable/relevant once `proxy.ts` starts gating the `(dashboard)` group |
| `panel/src/proxy.ts` | Next 16's rename of `middleware.ts`: probes `/auth/status` (docker-internal orchestrator URL, fails open to "off" on any error/timeout) and redirects to `/login` when cloud auth is on and no session cookie is present |
| `panel/src/app/(dashboard)/communications/[sessionId]/page.tsx` | Per-session chat view: live transcript (useSessionStream on `/ws/sessions/{id}`), closed-session read-only notice, redirect toast on stale-send |
| `panel/src/components/dashboard/` | Overview cards: command-center, key-metrics, release-proposal, playbook-review-queue, ceo-approval-queue, pr-review-queue, usage-overview, team-health, active-blockers, auditor-alerts, strategy-signals, quick-actions, recent-activity |
| `panel/src/components/dashboard/` | Overview cards: command-center, key-metrics, release-proposal, playbook-review-queue, ceo-approval-queue, pr-review-queue, usage-overview, team-health, active-blockers, auditor-alerts, strategy-signals, quick-actions, recent-activity, `x-post-queue.tsx`, `roadmap-review-queue.tsx` |
| `panel/src/components/metrics/` | delivery-tab, usage-time-series-chart, agent/team-usage-chart, model-usage-donut, sessions-table |
| `panel/src/components/kanban/{core,shared,views}/` | core: kanban-board/column/card + bypass-preconditions; views: dev/qa/pm/pr-review kanban |
| `panel/src/components/prompter/` | intake-form, chat-messages, chat-composer, draft-proposal-card, batch-review-card, success-card, board-review-sent-card |
| `panel/src/components/a2a/` | a2a-switchboard (org-chart pair cards, 45s pulse fade) + a2a-switchboard-utils (pairKey/grouping/pulse), a2a-pair-card, a2a-conversation-list (classic fallback), a2a-transcript, a2a-reply-composer (CEO chime-in), a2a-utils |
| `panel/src/components/tasks/` + `tasks/task-detail/` | task-table, create/edit-task-dialog, task-filters, acceptance-criteria-editor, dependency-selector, task-detail tabs (overview/plan/progress/commits/sessions/notes/dependencies) |
| `panel/src/components/settings/` | feature-flags-card, ai-routing-card, transcript-retention-card, self-hosted-section |
| `panel/src/components/settings/` | feature-flags-card, ai-routing-card, transcript-retention-card, self-hosted-section, `x-credentials-card.tsx` (write-only OAuth 1.0a secrets, mounted in `settings/page.tsx`) |
| `panel/src/components/conventions/conventions-tab.tsx` | Per-project architecture map + health (in edit-project dialog) |
| `panel/src/components/projects/`, `agents/`, `business/`, `auditor/`, `knowledge-base/`, `communications/`, `git/`, `journals/`, `work-sessions/`, `notifications/`, `rate-limit/`, `layout/`, `ui/` | Per-domain component groups; `ui/` = Radix-based primitives (dialog, table, tabs, select, switch, required-notes-dialog, sonner toaster, markdown) |
| `panel/src/hooks/use-websocket.ts` | Shared `useWebSocket<T>(path, handlers?, isSystem?)` hook (auto-reconnect, heartbeat) |
| `panel/src/hooks/use-{tasks,agents,projects,products,usage,prompter,secretary,dashboard,git,journals,channels,notifications,knowledge-base,observability,work-sessions,providers,rate-limit-{sync,websocket}}.ts` | TanStack Query + zustand data hooks |
| `panel/src/lib/api/*.ts` | Per-domain axios clients (`client.ts` shared instance; `release.ts`, `playbooks.ts`, `prompter-live.ts`, `tasks.ts`, `settings.ts`, `usage.ts`, `cockpit.ts`, `a2a.ts`, …) |
| `panel/src/hooks/use-a2a-live.ts` | `useA2AConversations` / `useA2AAdminPairs` / `useA2AMessages` (TanStack Query over `a2aApi`) + `useReplyAsCeo` mutation; `a2aLiveKeys` query-key namespace |
| `panel/src/lib/api/*.ts` | Per-domain axios clients (`client.ts` shared instance; `release.ts`, `playbooks.ts`, `prompter-live.ts`, `tasks.ts`, `settings.ts`, `usage.ts`, `cockpit.ts`, `a2a.ts`, `auth.ts` (status/login/logout), `x.ts` (post queue + credentials), `roadmap.ts` (cycles + item approve/reject), …) |
| `panel/src/lib/websocket/connection.ts` | `WebSocketConnection` class + `getWebSocketUrl` |
| `panel/src/store/{rate-limit-store,notifications-store,usage-store,ui-store}.ts` + `lib/stores/` | zustand stores (`lib/stores/` now exports `scroll-restoration-store` only; `ui-store` is sole-canonical under `src/store/`) |
| `panel/src/types/` | Shared TS types (index, rate-limits, git) |
@@ -46,8 +51,11 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| Playbook Review Queue | `components/dashboard/playbook-review-queue.tsx` | Auditor/CEO approve/reject drafted playbooks; hidden when no drafts |
| CEO Approval Queue | `components/dashboard/ceo-approval-queue.tsx` | Tasks in `awaiting_ceo_approval` awaiting CEO verdict |
| PR Review Queue | `components/dashboard/pr-review-queue.tsx` | Inbound external/fork PRs + in-path gate PRs for the reviewer |
| X Post Queue | `components/dashboard/x-post-queue.tsx` | CEO edit/approve (posts to X)/reject on held release-post + mention-reply drafts; hidden when empty |
| Roadmap Review Queue | `components/dashboard/roadmap-review-queue.tsx` | CEO per-item approve (materializes BACKLOG task)/reject on the Product Owner's held roadmap cycle; hidden until authored |
| Feature Flags | `components/settings/feature-flags-card.tsx` | Toggles persisted to settings store; takes effect on next backend restart |
| Intake / MegaTask | `app/(dashboard)/prompter/page.tsx` + `components/prompter/*` | Live SSE chat with spawned Claude/Grok intake agent; single-project, product, or multi-project (`project_ids`) MegaTask → `propose_batch``confirm-batch` |
| A2A Live (switchboard + reply) | `app/(dashboard)/a2a/page.tsx` + `components/a2a/*` | CEO watches every agent-to-agent conversation live: default org-chart switchboard (pair cards grouped by cell/PM-chain/board, pulsing on fresh `a2a.message` frames) or the classic conversation list; drill-in shows the transcript + a reply composer that lets the CEO chime into the thread as itself (task-linked conversations only) |
| Project Settings / Conventions | `components/projects/edit-project-dialog.tsx` + `components/conventions/conventions-tab.tsx` | Per-project `.roboco/conventions.yml` map + health; Save / Restore via PR |
| Usage Dashboard | `components/dashboard/usage-overview-panel.tsx` + `hooks/use-usage.ts` | Token/cost totals; live WS snapshot with HTTP-polling fallback |
| Kanban | `components/kanban/{core,views}/*` | dnd-kit drag board; dev/qa/pm/pr-review views; drag routes through admin status-override with bypass-precondition prompt |
@@ -63,20 +71,32 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| `WebSocketConnection` | class | `lib/websocket/connection.ts` | Low-level WS lifecycle; `getWebSocketUrl` builds `/ws/<path>` |
| `api` (axios instance) | const | `lib/api/client.ts` | Shared client; baseURL `API_URL`, injects `X-Agent-ID/Role=CEO`, rate-limit retry (3) |
| `releaseApi` | module | `lib/api/release.ts` | `getProposal/approve/reject`; 404→null, non-404 rethrow |
| `authApi` | module | `lib/api/auth.ts` | `status/login/logout`; `status` always available (public probe), `login` posts an OAuth2 form body (FastAPI Users cookie route, not JSON) |
| `useLogin`/`useAuthStatus`/`useLogout` | hooks | `hooks/use-auth.ts` | TanStack Query wrappers over `authApi`; login page + `proxy.ts`-gated flows |
| `xApi` | module | `lib/api/x.ts` | `listPosts/approve/reject/getCredentialsStatus/setCredentials`; credentials are write-only (never returned) |
| `roadmapApi` | module | `lib/api/roadmap.ts` | `listCycles/approveItem/rejectItem` |
| `prompterLiveApi` | module | `lib/api/prompter-live.ts` | `start/streamUrl/messages/confirm/confirmBatch`; EventSource SSE |
| `usePrompter` | hook | `hooks/use-prompter.ts` | Intake state machine: SSE refs, draft/batch extraction, turn lifecycle |
| `useRateLimitWebsocket` | hook | `hooks/use-rate-limit-websocket.ts` | Single `/ws/system` subscriber; dispatches RATE_LIMIT_* + USAGE_SNAPSHOT; clears usage on disconnect |
| `useA2ALiveStream` | hook | `hooks/use-websocket.ts` | Second `/ws/system` consumer (same shared connection): filters `a2a.message` frames, exposes `lastMessage`/`a2aMessages`/`isConnected` for the A2A page's invalidate-on-frame + switchboard pulses |
| `useA2AAdminPairs` / `useA2AConversations` / `useA2AMessages` | hooks | `hooks/use-a2a-live.ts` | TanStack Query wrappers over `a2aApi.listAdminPairs/listAdminConversations/listAdminMessages`; 30s `staleTime`, invalidated by `a2a.message` frames |
| `useReplyAsCeo` | hook | `hooks/use-a2a-live.ts` | Mutation wrapping `a2aApi.replyAsCeo`; invalidates the conversation list + the watched transcript's messages on success |
| `A2ASwitchboard` / `A2APairCard` | comp | `components/a2a/a2a-switchboard.tsx` + `a2a-pair-card.tsx` | Org-chart pair cards grouped into sections (cell/PM-chain/board/cross-team) via `groupPairsBySection`; each card pulses for `PAIR_PULSE_FADE_MS` (45s) after a matching live frame |
| `A2AReplyComposer` | comp | `components/a2a/a2a-reply-composer.tsx` | CEO chime-in box on a selected conversation; disabled when the conversation has no linked task (A2A sends require one) |
| `useUsageStore` | store | `store/usage-store.ts` | zustand: live usage snapshot, wsState, polling fallback |
| `skippedPreconditions` | fn | `components/kanban/core/bypass-preconditions.ts` | Lists material lifecycle preconditions a drag would skip (PR/docs/subtasks-terminal) |
| `KanbanBoard` | comp | `components/kanban/core/kanban-board.tsx` | dnd-kit board; routes drag→`useUpdateTask` (admin override) or in-band lifecycle verb; notes dialog for pass-qa/fail-qa/complete |
| `ReleaseProposalCard` | comp | `components/dashboard/release-proposal-card.tsx` | Approve/reject-with-changes dialog; ≥10 char reject reason |
| `PlaybookReviewQueue` | comp | `components/dashboard/playbook-review-queue.tsx` | Approve/reject-with-reason (≥4 char) drafts |
| `XPostQueue` | comp | `components/dashboard/x-post-queue.tsx` | Editable draft body + 280-char counter; approve (posts), reject-with-reason (≥4 char); hidden when empty |
| `RoadmapReviewQueue` | comp | `components/dashboard/roadmap-review-queue.tsx` | Per-item approve/reject within a held cycle card; reject requires ≥4 char reason |
| `XCredentialsCard` | comp | `components/settings/x-credentials-card.tsx` | Write-only entry of the 4 OAuth 1.0a secrets; set-all-4 or clear-all-4 |
| `RequiredNotesDialog` | comp | `components/ui/required-notes-dialog.tsx` | Reusable notes-gated confirm; submit disabled on empty/whitespace |
| `CommandCenter` | comp | `components/dashboard/command-center.tsx` | Overview page body; composes all dashboard cards |
| `DeliveryTabContent` | comp | `components/metrics/delivery-tab.tsx` | Cycle-time/bottleneck/rework/scorecard panels |
## Data Flow
Browser → nginx :3000 → (panel Next.js server for pages; `/api/*` and `/ws/*` proxied to `orchestrator:8000`). All client calls use relative URLs: `API_URL="/api"` (axios `baseURL`) and `WS_URL="/ws"` (`getWebSocketUrl`) — no CORS because the browser sees one origin. The shared axios client injects `X-Agent-ID=<CEO_AGENT_ID>` + `X-Agent-Role=CEO_ROLE` headers for API authorization. Live events flow: orchestrator `StreamEventBus``websocket_bridge` → per-resource `/ws/{agents,channels,sessions,notifications,system}` sockets → panel `useWebSocket` hooks → zustand stores / TanStack Query cache. Usage snapshots (`USAGE_SNAPSHOT`) and rate-limit lifecycle (`RATE_LIMIT_HIT/LIFTED`) arrive on the single shared `/ws/system` stream mounted in providers; on any non-`connected` state the usage store clears its snapshot so the panel falls back to HTTP-polling summary until a fresh frame lands.
Browser → nginx :3000 → (panel Next.js server for pages; `/api/*` and `/ws/*` proxied to `orchestrator:8000`). All client calls use relative URLs: `API_URL="/api"` (axios `baseURL`) and `WS_URL="/ws"` (`getWebSocketUrl`) — no CORS because the browser sees one origin. When cloud auth is armed (`ROBOCO_CLOUD_AUTH_ENABLED`), every navigation to a `(dashboard)` route first runs `proxy.ts` (Next 16's rename of `middleware.ts`), which probes `/auth/status` directly against the docker-internal orchestrator URL (not through nginx) and redirects to `/login` when no `roboco_session` cookie is present; a probe failure/timeout fails OPEN to "cloud auth off" so a slow/unreachable backend never blocks navigation. The login page (`(auth)/login/page.tsx`) posts credentials via `authApi.login` (OAuth2 form body, FastAPI Users' cookie route) and the session cookie rides back on the response. The shared axios client injects `X-Agent-ID=<CEO_AGENT_ID>` + `X-Agent-Role=CEO_ROLE` headers for API authorization. Live events flow: orchestrator `StreamEventBus``websocket_bridge` → per-resource `/ws/{agents,channels,sessions,notifications,system}` sockets → panel `useWebSocket` hooks → zustand stores / TanStack Query cache. Usage snapshots (`USAGE_SNAPSHOT`) and rate-limit lifecycle (`RATE_LIMIT_HIT/LIFTED`) arrive on the single shared `/ws/system` stream mounted in providers; on any non-`connected` state the usage store clears its snapshot so the panel falls back to HTTP-polling summary until a fresh frame lands. The A2A page's `useA2ALiveStream` is a second, independent consumer of that same shared `/ws/system` connection (not a new socket): every persisted A2A message publishes an `a2a.message` frame, which the page uses purely to invalidate-on-frame (REST via `a2aApi` stays the source of truth for full message bodies, since the frame's excerpt is capped) and to drive the switchboard's 45s pulse fade on the matching pair card.
## Mermaid
```mermaid
@@ -88,6 +108,7 @@ graph TD
Routes --> Tasks["tasks + tasks/[id]"]
Routes --> Kanban["kanban (dev/qa/pm/pr-review)"]
Routes --> Prompter["prompter (single + MegaTask)"]
Routes --> A2ALive["a2a (switchboard + reply)"]
Routes --> Settings["settings + ai-providers"]
Routes --> Domain["agents/projects/products/business/journals/communications/git/kb/auditor/work-sessions/notifications"]
Overview --> Dash["components/dashboard/*"]
@@ -95,6 +116,8 @@ graph TD
Dash --> Playbook["PlaybookReviewQueue"]
Dash --> Ceo["CEOApprovalQueue"]
Dash --> Pr["PRReviewQueue"]
Dash --> XQ["XPostQueue"]
Dash --> Rd["RoadmapReviewQueue"]
Dash --> Usage["UsageOverviewPanel"]
Metrics --> MComp["components/metrics/*"]
Kanban --> KCore["kanban/core (board + bypass)"]
@@ -110,6 +133,7 @@ graph TD
panel/ (Next.js 16, package roboco-panel v0.14.0)
├── src/app/
│ ├── layout.tsx (root layout: providers, theme, fonts)
│ ├── (auth)/login/page.tsx (cloud-auth login form; gated by proxy.ts)
│ └── (dashboard)/
│ ├── layout.tsx (dashboard shell: sidebar + header + connection status)
│ ├── overview/page.tsx (→ <CommandCenter/>)
@@ -117,19 +141,21 @@ panel/ (Next.js 16, package roboco-panel v0.14.0)
│ ├── tasks/page.tsx + tasks/[taskId]/page.tsx
│ ├── kanban/page.tsx (dev/qa/pm/pr-review views)
│ ├── prompter/page.tsx (intake chat: single + MegaTask batch)
│ ├── a2a/page.tsx (A2A Live: switchboard/list + transcript + CEO reply)
│ ├── settings/page.tsx + settings/ai-providers/page.tsx
│ ├── {agents,projects,products,business,journals,communications,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx
│ └── communications/[sessionId]/page.tsx (per-session chat view; live via useSessionStream)
├── src/components/
│ ├── dashboard/ (command-center, key-metrics, release-proposal, playbook-review-queue, ceo-approval-queue, pr-review-queue, usage-overview, team-health, active-blockers, auditor-alerts, strategy-signals, quick-actions, recent-activity)
│ ├── dashboard/ (command-center, key-metrics, release-proposal, playbook-review-queue, ceo-approval-queue, pr-review-queue, x-post-queue, roadmap-review-queue, usage-overview, team-health, active-blockers, auditor-alerts, strategy-signals, quick-actions, recent-activity)
│ ├── metrics/ (delivery-tab, usage-time-series-chart, agent/team-usage-chart, model-usage-donut, sessions-table)
│ ├── kanban/
│ │ ├── core/ (kanban-board/column/card + bypass-preconditions)
│ │ ├── shared/
│ │ └── views/ (dev/qa/pm/pr-review kanban)
│ ├── prompter/ (intake-form, chat-messages, chat-composer, draft-proposal-card, batch-review-card, success-card, board-review-sent-card)
│ ├── a2a/ (a2a-switchboard + a2a-switchboard-utils, a2a-pair-card, a2a-conversation-list, a2a-transcript, a2a-reply-composer, a2a-utils)
│ ├── tasks/ + tasks/task-detail/ (task-table, create/edit-task-dialog, task-filters, acceptance-criteria-editor, dependency-selector; detail tabs: overview/plan/progress/commits/sessions/notes/dependencies)
│ ├── settings/ (feature-flags-card, ai-routing-card, transcript-retention-card, self-hosted-section)
│ ├── settings/ (feature-flags-card, ai-routing-card, transcript-retention-card, self-hosted-section, x-credentials-card)
│ ├── conventions/conventions-tab.tsx (per-project architecture map + health)
│ ├── projects/ agents/ business/ auditor/ knowledge-base/ communications/ git/ journals/ work-sessions/ notifications/ rate-limit/ layout/
│ └── ui/ (Radix-based primitives: dialog, table, tabs, select, switch, required-notes-dialog, sonner toaster, markdown)
@@ -137,10 +163,11 @@ panel/ (Next.js 16, package roboco-panel v0.14.0)
│ ├── use-websocket.ts (shared useWebSocket<T>: auto-reconnect, heartbeat)
│ └── use-{tasks,agents,projects,products,usage,prompter,secretary,dashboard,git,journals,channels,notifications,knowledge-base,observability,work-sessions,providers,rate-limit-{sync,websocket}}.ts
├── src/lib/
│ ├── api/*.ts (per-domain axios clients; client.ts shared instance; release, playbooks, prompter-live, tasks, settings, usage, cockpit, a2a, …)
│ ├── api/*.ts (per-domain axios clients; client.ts shared instance; release, playbooks, prompter-live, tasks, settings, usage, cockpit, a2a, auth, x, roadmap, …)
│ ├── websocket/connection.ts (WebSocketConnection + getWebSocketUrl)
│ ├── stores/ (scroll-restoration-store only; ui-store is sole-canonical in src/store/)
│ └── {constants,utils,agent-definitions,agent-utils,repo-url,mock-data}.ts
├── src/proxy.ts (Next 16 rename of middleware.ts: gates (dashboard) behind cloud auth)
├── src/store/ (rate-limit-store, notifications-store, usage-store, ui-store)
├── src/types/ (shared TS types: index, rate-limits, git)
├── vitest.config.ts + src/test/setup.ts (Vitest + jsdom; coverage via @vitest/coverage-v8)
@@ -177,6 +204,12 @@ panel/ (Next.js 16, package roboco-panel v0.14.0)
- `dep_update_enabled` — dependency-update bot
- `release_manager_enabled` — gated release manager
- `org_memory_enabled` — organizational memory loop
- `sandbox_db_enabled` — sandboxed per-agent test DB/Redis
- `x_engine_enabled` — X (Twitter) engine (release-post + mention-reply drafts, all CEO-held)
- `roadmap_engine_enabled` — board roadmap engine (weekly Product-Owner-authored cycle)
- `routing_strict` — fail-closed model routing (refuse to silently downgrade to the legacy Anthropic path on a disabled provider)
Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime toggle): `ROBOCO_CLOUD_AUTH_ENABLED` and `ROBOCO_DB_NETWORK_ISOLATED`.
## Gotchas
- **Relative URLs only** (`/api`, `/ws`); overriding `NEXT_PUBLIC_API_URL`/`NEXT_PUBLIC_WS_URL` to an absolute URL reintroduces CORS — leave defaults.
@@ -187,6 +220,12 @@ panel/ (Next.js 16, package roboco-panel v0.14.0)
- **MegaTask intake card** historically had crash/disappears bugs (list[str] nest, depth ValueError→500); confirm-batch path is the multi-project branch (`project_ids`).
- **Kanban drag = admin status-override** which bypasses the in-band lifecycle validator; `skippedPreconditions` only warns on what the panel can detect (PR/docs/subtasks-terminal) — precision over recall, an empty list does NOT mean the move is safe, only that nothing detectable is skipped.
- ~~`ui-store` exists under both `store/ui-store.ts` and `lib/stores/ui-store.ts`~~**FIXED** (536bbb64): `lib/stores/ui-store.ts` was removed and replaced with `scroll-restoration-store.ts`; `store/ui-store.ts` is now the sole canonical location.
- **`proxy.ts` is Next.js 16's renamed `middleware.ts`** — same file-convention contract (default export + `config.matcher`), just relocated/renamed terminology (it never ran in true Edge middleware). A reader searching the repo for `middleware.ts` will find nothing; the gate lives at `src/proxy.ts`.
- **`proxy.ts` fails OPEN, not closed** — a slow/unreachable orchestrator on the `/auth/status` probe (1500ms timeout) is treated as "cloud auth off," so the dashboard stays reachable rather than the CEO getting locked out by a transient backend hiccup. This is the deliberately safe default (off is what every deploy starts on) but means a genuinely-armed deployment with a flaky orchestrator could intermittently skip the login gate.
- **X Post Queue / Roadmap Review Queue hide when empty**, mirroring the release-proposal + playbook queues — a CEO who doesn't see the card has no signal that the underlying engine is even armed; both need `refetchInterval: 30000` to surface a newly-originated draft/cycle without a manual refresh.
- **A2A page activity is A2A-only by design**: `latestPulseTimestamps` (switchboard-utils) derives pulses purely from `a2a.message` frames on `/ws/system`, never from the verb/flow traffic sharing that same stream — a CEO ruling, not an oversight, so don't "fix" the switchboard to also light up on ordinary gateway verbs.
- **A2A reply composer is read-only on a task-less conversation**: the backend's `reply_as_ceo` route 400s exactly when the watched conversation has no `task_id` (A2A sends always ride the gateway `send` path, which requires one) — the panel pre-empts that bounce with an explanatory message instead of letting the POST fail. Conversation `status` does NOT gate the composer; the CEO's reply lands in its own direct thread with the participant, not into the watched conversation.
- **Switchboard "peeked pair" state**: a pair with `conversation_id: null` (never talked) has nothing to select via `?conversation=`, so `page.tsx` tracks it separately (`peekedPair`) and renders its own empty state — don't conflate this with the ordinary `selectedId` empty-state path when touching the drill-in panel.
## Drift from CLAUDE.md
- CLAUDE.md says panel lives at `roboco/panel/` inside this repo — confirmed (no longer a separate `roboco-panel` project). No drift.
@@ -208,6 +247,9 @@ panel/ (Next.js 16, package roboco-panel v0.14.0)
> - `2da72f3f` chat: closed-session guard + reply_to validation — `communications/[sessionId]/page.tsx` renders read-only notice for closed sessions; stale-send toasts rather than silently vanishing.
> - `5cb4e85f` secretary: stuck-spinner + mid-reply + reload hardening (`use-secretary.ts`, `secretary-tab.tsx`); adds `use-secretary.test.tsx`.
> - `a1127daf` chat: `linkTask`/`unlinkTask` corrected to real backend routes; phantom `updateTaskLink` removed from `sessions.ts`.
> - `da563487` Wave 2: A2A live view (#297) — new `app/(dashboard)/a2a/page.tsx` (classic list view + transcript + `A2AReplyComposer`), `hooks/use-a2a-live.ts`, `lib/api/a2a.ts` admin client, `useA2ALiveStream` added to `use-websocket.ts`. Backend pairs with `EventType.A2A_MESSAGE_SENT` + `websocket_bridge._handle_a2a_message_event`.
> - `876e19b3` A2A switchboard (#298) — `page.tsx` gains the switchboard/list view toggle (default switchboard) + `peekedPair` state; new `components/a2a/{a2a-switchboard,a2a-switchboard-utils,a2a-pair-card}.tsx`; `useA2AAdminPairs` added to `use-a2a-live.ts`.
> - `a7147702` feat(panel): full mobile responsiveness pass — touches the A2A page's single-visible-pane layout (`h-dvh`, back affordance) among other routes.
## Regression Risks
+44 -6
View File
@@ -17,6 +17,9 @@ The product / strategy / research / pitch slice covers the "company layer" above
| `roboco/services/research_quota.py` | Per-agent UTC-daily Redis quota counter for research calls (fail-open) | 78 |
| `roboco/services/pitch.py` | Board pitch CRUD + CEO approve → provision repos/Projects(+Product) + seed Main-PM task | 274 |
| `roboco/services/github_provisioning.py` | The only service that CREATES GitHub repos (POST `/orgs/{org}/repos`) | 174 |
| `roboco/services/roadmap_engine.py` | Dormant weekly engine: originates ONE held roadmap-exploration task for the Product Owner (default off) | 111 |
| `roboco/services/roadmap_service.py` | CEO's per-item approve/reject glue over a held roadmap cycle; approve materializes a BACKLOG task | 211 |
| `roboco/api/routes/roadmap.py` | CEO-only routes: list open cycles, approve/reject one item | 124 |
## Key Symbols
@@ -74,6 +77,15 @@ The product / strategy / research / pitch slice covers the "company layer" above
| `GitHubProvisioningService._fetch_existing_repo` | method | github_provisioning.py:140 | GET `org/name` and reconstruct `ProvisionedRepo` — called on 422 to reuse an orphaned repo from a rolled-back prior approval |
| `_GITHUB_REPO_EXISTS_STATUS` | constant | github_provisioning.py:42 | `422` — GitHub's "name already exists" status sentinel |
| `ProvisionedRepo` / `ProvisioningError` / `ProvisioningDisabledError` | dataclass/exc | github_provisioning.py:32 / 23 / 27 | Result + error types |
| `RoadmapEngine` | class | roadmap_engine.py:49 | Dormant "engine 3": mirrors the release-manager "detect → originate a CEO-gated artifact → hold" shape, but the artifact is a cycle the PO *authors*, not a report the engine assembles |
| `RoadmapEngine.run_cycle` | method | roadmap_engine.py:54 | No-op unless `roadmap_engine_enabled`, a cycle is already open (`list_open_roadmap_cycles`), or the RoboCo project isn't resolvable; else opens ONE held PENDING exploration task assigned to the Product Owner |
| `RoadmapService` | class | roadmap_service.py:50 | List / approve / reject items within the open roadmap cycle(s) |
| `RoadmapService.approve_item` | method | roadmap_service.py:59 | Materialize one proposed item as a BACKLOG task via `PrompterService.create_task_from_draft`; idempotent per item |
| `RoadmapService.reject_item` | method | roadmap_service.py:108 | Record the CEO's reason; idempotent; an already-approved item cannot be rejected |
| `RoadmapService._find_item` | method | roadmap_service.py:146 | Resolve (exploration task, deep-copied cycle payload, one item) — deep copy so mutation doesn't poison SQLAlchemy's dirty-check before `markers.set_roadmap_cycle` reassigns |
| `RoadmapService._maybe_complete_cycle` | staticmethod | roadmap_service.py:202 | Completes the exploration task once every item on it is terminal (approved/rejected) |
| `RoadmapItemResult` | dataclass | roadmap_service.py:37 | Outcome of one approve/reject call (status/item_id/materialized_task_id/detail) |
| `get_roadmap_engine` / `get_roadmap_service` | factory | roadmap_engine.py:109 / roadmap_service.py:209 | Session-bound constructors |
## Data Flow
@@ -87,6 +99,8 @@ Two distinct flows originate work into the delivery lifecycle:
**Routing flow (runtime keystone).** `ProductService.project_for(product_id, team)` is called from the gateway delegate path to resolve which Project a cell works on within a product; None falls back to the parent task's project.
**Roadmap flow (dormant weekly originator, default off).** `Orchestrator._roadmap_engine_loop` returns immediately unless `roadmap_engine_enabled`; otherwise each `roadmap_interval_seconds` (default weekly) it opens a DB context and calls `RoadmapEngine.run_cycle`, which no-ops if a roadmap-source task is already open or the RoboCo project isn't resolvable, else opens ONE held PENDING exploration task (`source=board_roadmap`, `confirmed_by_human=False`) assigned to the Product Owner. The normal board one-shot dispatch (`_dispatch_roadmap_exploration`) spawns the PO, who explores the charter/releases/metrics/projects and calls the `propose_roadmap` do-tool exactly once with a themed goal + 3-7 item drafts (persisted as an `orchestration_markers` payload). The CEO reviews the cycle in the panel's Roadmap Review Queue and approves/rejects each item individually via `/api/roadmap/cycles/{id}/items/{id}/{approve,reject}``RoadmapService`; an approved item materializes as a BACKLOG task (`source=roadmap`) through `PrompterService.create_task_from_draft` — nothing auto-starts, normal PM activation takes it from BACKLOG. Once every item is terminal, the exploration task itself completes.
**Read-only views.** `KanbanService` builds role-specific boards from `TaskTable` queries on demand for the kanban API; `CompanyGoalsService.get` is read by the briefing injector into every agent's `context_briefing`.
## Mermaid
@@ -121,6 +135,15 @@ flowchart TD
RS --> Prov2{"Tavily|Brave|Exa|Null"}
Prov2 -->|provider API| Web[(Web)]
end
subgraph RoadmapLoop[dormant — roadmap_engine_enabled]
RLoop[Orchestrator._roadmap_engine_loop] -->|interval, default weekly| RCycle[RoadmapEngine.run_cycle]
RCycle -->|held PENDING task| PO[Product Owner spawn]
PO -->|propose_roadmap do-tool| Payload[(orchestration_markers cycle payload)]
CEO -->|approve/reject per item /api/roadmap| RSvc[RoadmapService]
RSvc -->|approve| Backlog[BACKLOG task via PrompterService]
RSvc -->|all items terminal| Complete[exploration task completes]
end
```
## Logical Tree
@@ -167,9 +190,16 @@ product-strategy-research-pitch
│ ├── _provision_repos (idempotent on re-approval)
│ ├── _register_topology (Product vs seed-project)
│ └── _seed_main_pm_task (PENDING Main-PM task)
── github_provisioning.py — GitHubProvisioningService
├── enabled (master+token+org)
└── create_repo (POST /orgs/{org}/repos, auto_init)
── github_provisioning.py — GitHubProvisioningService
├── enabled (master+token+org)
└── create_repo (POST /orgs/{org}/repos, auto_init)
├── roadmap_engine.py — RoadmapEngine (dormant, roadmap_engine_enabled)
│ └── run_cycle (one held exploration task for the Product Owner; one-open-cycle dedup)
└── roadmap_service.py — RoadmapService
├── list_open_cycles
├── approve_item (materialize BACKLOG task, idempotent)
├── reject_item (record reason, idempotent)
└── _maybe_complete_cycle (completes exploration task once all items terminal)
```
## Dependencies
@@ -185,12 +215,15 @@ product-strategy-research-pitch
- `roboco.services.conventions` — lazy-imported in `ProjectService._maybe_scaffold_conventions`.
- `roboco.services.work_session` — lazy in `ProjectService.delete`.
- `roboco.services.workspace` — lazy in `ProjectService.delete` (delete_workspaces).
- `roboco.services.task``StrategyEngine` (`list_in_progress_or_claimed`, `list_long_running_blocked`), `PitchService._seed_main_pm_task`.
- `roboco.services.task``StrategyEngine` (`list_in_progress_or_claimed`, `list_long_running_blocked`), `PitchService._seed_main_pm_task`; `RoadmapEngine`/`RoadmapService` (`ROADMAP_SOURCE`/`ROADMAP_ITEM_SOURCE`, `list_open_roadmap_cycles`, `TaskCreateRequest`).
- `roboco.services.prompter``RoadmapService._materialize` lazy-imports `get_prompter_service` (`create_task_from_draft`, the same confirmed-by-CEO-approval path pitch items use).
- `roboco.foundation.policy.content.markers``RoadmapService`/`api/routes/roadmap.py` (`get_roadmap_cycle`/`set_roadmap_cycle`, the cycle payload persisted on `orchestration_markers`).
- `roboco.foundation.identity``RoadmapEngine._originate` (`AGENTS["product-owner"]`/`AGENTS["system"]`).
- `roboco.services.agent``PitchService` (`get_by_slug("main-pm")`).
- `roboco.services.notification``StrategyEngine.run_cycle`.
- `roboco.services.github_provisioning``PitchService.approve`.
- `roboco.services.project` / `product``PitchService`.
- `roboco.runtime.orchestrator` — runs `_strategy_engine_loop`; mounts `roboco-search` MCP when `research_enabled`.
- `roboco.runtime.orchestrator` — runs `_strategy_engine_loop` + `_roadmap_engine_loop`/`_dispatch_roadmap_exploration`; mounts `roboco-search` MCP when `research_enabled`.
**External:**
- `sqlalchemy` (async ext) — all DB-backed services.
@@ -210,7 +243,8 @@ product-strategy-research-pitch
- `research.py``POST /api/research/search`, `/fetch``get_research_service` + module-level `ResearchQuotaTracker`.
- `prompter_live.py``get_project_service` for project lookup during intake.
- `dashboard.py``get_product_service` / `get_project_service` for dashboard views.
- **Orchestrator loop tick:** `_strategy_engine_loop` (orchestrator.py:6360) — created at `start()` (line 1010), cancelled in shutdown (line 1075); ticks every `strategy_engine_interval_seconds`, calls `StrategyEngine.run_cycle`.
- `roadmap.py``GET /api/roadmap/cycles`, `POST /cycles/{id}/items/{id}/{approve,reject}` (CEO-only) → `get_roadmap_service`.
- **Orchestrator loop tick:** `_strategy_engine_loop` (orchestrator.py:6360) — created at `start()` (line 1010), cancelled in shutdown (line 1075); ticks every `strategy_engine_interval_seconds`, calls `StrategyEngine.run_cycle`. `_roadmap_engine_loop` (orchestrator.py:7462) — same lifecycle shape, ticks every `roadmap_interval_seconds` (default weekly), calls `RoadmapEngine.run_cycle`; `_dispatch_roadmap_exploration` (orchestrator.py:10284) spawns the Product Owner once per open exploration task.
- **MCP mount (orchestrator spawn):** `roboco-search` MCP mounted into Board/PM agent containers only when `research_enabled` (orchestrator.py:2914); the MCP server calls the `/api/research/*` routes.
- **Service-to-service:** `ProjectService` called by `WorkspaceService`, `GitService`, `PitchService`, `task`, `docs`, `cockpit`, `secretary`, gateway choreographer; `ProductService.project_for` called from gateway delegate path; `CompanyGoalsService.get` called by briefing injector.
- **No CLI / lifespan entry points** for this slice.
@@ -238,6 +272,10 @@ product-strategy-research-pitch
| `ROBOCO_STRATEGY_STRANDED_BLOCKED_MINUTES` | `120` | config.py:360 | Blocked-task threshold for "stranded" observation |
| `ROBOCO_PROTECTED_GIT_URLS` | `[]` | config.py:770 | Denylist — `ProjectService` rejects `git_url` containing any entry |
| `ROBOCO_ENCRYPTION_KEY` | `""` | config.py:295 | Fernet key for git-token encrypt/decrypt |
| `ROBOCO_ROADMAP_ENGINE_ENABLED` | `False` | config.py:865 | Master switch — `_roadmap_engine_loop` never opens an exploration cycle when off |
| `ROBOCO_ROADMAP_INTERVAL_SECONDS` | `604800` | config.py:875 | Seconds between roadmap-exploration cycles (default weekly) |
| `ROBOCO_ROADMAP_MIN_ITEMS_PER_CYCLE` | `3` | config.py:880 | Minimum item drafts `propose_roadmap` must submit for a themed cycle |
| `ROBOCO_ROADMAP_MAX_ITEMS_PER_CYCLE` | `7` | config.py:885 | Maximum item drafts per cycle |
## Gotchas
+2
View File
@@ -9,6 +9,7 @@ This slice is the agent-runtime + LLM-provider seam plus the in-container agent
| roboco/runtime/spawn_manifest.py | Builds the per-role /app/tool-manifest.json (allowed verbs/tools, env) from role_config | 85 |
| roboco/runtime/streaming.py | Global reasoning-stream callback holder+setter for live UI streaming | 53 |
| roboco/runtime/transcript_retention.py | Pure selector of agent-owned old Claude transcripts to prune (never operator dirs) | 74 |
| roboco/runtime/sandbox.py | `SandboxProvisioner` — throwaway per-agent-spawn Postgres/Redis sibling containers (orchestrator-side, never docker-in-agent); provision/teardown/janitor_sweep, standalone + unit-testable via an injected `DockerRunner` | 344 |
| roboco/llm/__init__.py | Re-exports ToonAdapter/ToonMetrics singletons | 17 |
| roboco/llm/metrics.py | Singleton holder for TOON token-savings metrics | 21 |
| roboco/llm/toon_adapter.py | TOON serialization adapter for token-efficient LLM communication (JSON fallback) | 188 |
@@ -46,6 +47,7 @@ This slice is the agent-runtime + LLM-provider seam plus the in-container agent
| stream_reasoning | function | roboco/runtime/streaming.py:39 | Stream a reasoning chunk to the registered callback if any |
| is_agent_owned_dir | function | roboco/runtime/transcript_retention.py:23 | True if a ~/.claude/projects subdir was written by a spawned agent (-app or encoded workspaces root prefix, boundary-aware) |
| select_prunable_transcripts | function | roboco/runtime/transcript_retention.py:59 | Pure selector of agent-owned *.jsonl transcripts older than cutoff_epoch (never operator dirs) |
| SandboxProvisioner | class | roboco/runtime/sandbox.py:90 | Per-agent-spawn throwaway Postgres/Redis provisioner; `provision`/`teardown`/`janitor_sweep`, docker plumbing is an injected `DockerRunner` callable |
| ToonAdapter | class | roboco/llm/toon_adapter.py:33 | TOON serialization adapter: encode/decode with JSON fallback, prompt formatting, token-savings estimate |
| get_toon_adapter | function | roboco/llm/toon_adapter.py:184 | Singleton ToonAdapter accessor |
| SpawnResult | dataclass | roboco/llm/providers/base.py:26 | Provider spawn result: instance_id, initial agent_state, extra metadata |
+5
View File
@@ -269,6 +269,11 @@ Panel-tunable flags defined in `services/settings.py:46` `FEATURE_FLAGS` (stored
| `dep_update_enabled` | Dependency-update bot | `ROBOCO_DEP_UPDATE_ENABLED` |
| `release_manager_enabled` | Gated release manager | `ROBOCO_RELEASE_MANAGER_ENABLED` |
| `org_memory_enabled` | Organizational memory loop | `ROBOCO_ORG_MEMORY_ENABLED` |
| `sandbox_db_enabled` | Sandboxed per-agent test DB/Redis | `ROBOCO_SANDBOX_DB_ENABLED` |
| `x_engine_enabled` | X (Twitter) engine | `ROBOCO_X_ENGINE_ENABLED` |
| `roadmap_engine_enabled` | Board roadmap engine | `ROBOCO_ROADMAP_ENGINE_ENABLED` |
Cloud auth (`ROBOCO_CLOUD_AUTH_ENABLED`) and DB network isolation (`ROBOCO_DB_NETWORK_ISOLATED`) are deliberately **not** in `FEATURE_FLAGS` — both are compose/env-coupled (cookie/TLS posture and the `networks:` topology respectively) and unsafe for a runtime toggle to flip mid-session; they stay pure env vars, not panel-tunable settings.
Other settings read here: `transcript_retention_days` (int, ≥1; read by orchestrator at `runtime/orchestrator.py:5910`). Non-flag config consumed: `settings.redis_url` (`health`, `stream_bus`), `settings.encryption_key` (`crypto`).
+43
View File
@@ -0,0 +1,43 @@
# Cloud Auth
## What It Is
A cookie-session login for the **single human CEO user**, built on FastAPI Users, that lets the panel/API be safely exposed beyond localhost. Implemented in `roboco/api/auth/` (`backend.py`, `session.py`, `manager.py`, `seed.py`, `routes.py`). It is **not a panel feature flag** — it is env-only, deliberately absent from `roboco/services/settings.py`'s `FEATURE_FLAGS` tuple, so it can't be toggled on for a deployment that isn't already behind TLS.
## Enable/Disable
| Variable | Default | Effect |
|----------|---------|--------|
| `ROBOCO_CLOUD_AUTH_ENABLED` | `false` | Master switch. Off: `get_agent_context` (the single dependency every API route resolves its caller through) behaves byte-for-byte as today's header-trust. On: no registration router is mounted — exactly one user, seeded from `ROBOCO_CLOUD_AUTH_EMAIL` / `_PASSWORD`. |
| `ROBOCO_CLOUD_AUTH_EMAIL` | (unset) | Email for the single seeded user. |
| `ROBOCO_CLOUD_AUTH_PASSWORD` | (unset) | Password for the single seeded user. Hashed at startup (bcrypt via `PasswordHelper`), never stored in plain text. |
| `ROBOCO_CLOUD_AUTH_SECRET` | (unset) | JWT signing secret for the session cookie. **Fails loud at startup**`Settings._validate_cloud_auth` raises before the app boots — if `cloud_auth_enabled=true` without this set. |
| `ROBOCO_CLOUD_AUTH_COOKIE_MAX_AGE` | `2592000` (30 days) | Sliding session lifetime in seconds. |
## Single seeded user, no registration
`ensure_seed_user_startup()` runs at lifespan startup: if `cloud_auth_enabled` and both email/password are set, it upserts **exactly one** `UserTable` row, looked up by primary key (not email) so changing the email renames the existing row instead of creating a second one. There is no registration route — the only way to get a login is this seed. If cloud auth is on but email/password are unset, startup logs a warning and every login attempt is rejected (fail-safe, not fail-open).
## Sliding session, password-fingerprint JWT
The session cookie (`roboco_session`, `SESSION_COOKIE_NAME` in `backend.py`) is **secure-only** (`cookie_secure=True`), `httponly`, `samesite=lax`**arm this only behind TLS**, or browsers silently refuse to send the cookie and login appears to fail with no clear error.
Every authenticated request re-mints and re-sets the cookie (`_slide_session_cookie` in `roboco/api/deps.py`), so an in-use session never lapses — only genuine inactivity past `cloud_auth_cookie_max_age` logs out. The JWT itself binds a **password fingerprint** (`_password_fingerprint` — first 16 hex chars of a SHA-256 of the current `hashed_password`) as a `pwd_fp` claim; `_SlidingSessionStrategy.read_token` rejects any token whose fingerprint no longer matches. A JWT is otherwise stateless and can't be revoked by user id alone — this makes rotating the seeded password (env change + restart) invalidate every previously-issued cookie.
## Dual-path `get_agent_context`
`roboco/api/deps.py`'s `get_agent_context` is the single dependency every route resolves its caller through, and it branches on the flag:
- **Off** (default): delegates straight to `_header_trust_agent_context` — the historical `X-Agent-ID` / `X-Agent-Role` / `X-Agent-Team` / `X-Agent-Token` header path, unchanged.
- **On**: `_cloud_auth_agent_context` enforces dual-path. A caller presenting a **valid HMAC `X-Agent-Token`** (the agent fleet, and the orchestrator's `system` self-PATCH) is accepted exactly like today — delegated to the header-trust path once the token verifies. Without a valid token, any agent-role claim in the headers is treated as an unauthenticated spoof and rejected **regardless of role** — this closes the LAN header-spoof hole on the published API port for every privileged role, not just `ceo` (the pre-cloud-auth surface only ever worried about spoofing the CEO). The sole remaining legitimate caller without a token is the human CEO with a **valid session cookie**, resolved via `resolve_session_user`.
Agent HMAC auth and the orchestrator's `system` self-PATCH are untouched in both modes — cloud auth only closes the *unauthenticated header* path, never the *signed token* path.
## Panel wiring
`panel/src/proxy.ts` (the Next.js middleware entry) probes `GET /api/auth/status` on every non-API, non-static request; if `cloud_auth_enabled` is true and the `roboco_session` cookie is absent, it redirects to `/login`. The probe fails open (treats a slow/unreachable backend as "cloud auth off") within a 1.5s timeout — a stuck backend must never turn into a stuck redirect loop.
## Related
- `docs/rag/architecture/config-reference.md` — full env var table
- `docs/rag/architecture/db-network-isolation.md` — a separate, unrelated hardening (network topology, not auth) that ships alongside cloud auth on the NAS composes
+18 -4
View File
@@ -1,6 +1,6 @@
# Company Layer (Goals, Pitches, Strategy)
The **company layer** sits above day-to-day delivery: the CEO's charter, the pitch pipeline, and a background strategy watcher. The charter is always available (empty until set); the research, provisioning, and strategy-engine pieces are **opt-in and default-off** — the org runs fine without any of them.
The **company layer** sits above day-to-day delivery: the CEO's charter, the pitch pipeline, and a background strategy watcher. The charter is always available (empty until set); research and provisioning ship default-**on** (but degrade gracefully until configured), while the strategy engine and the roadmap engine are **opt-in and default-off** — the org runs fine without any of them.
## The Charter (Company Goals)
@@ -38,6 +38,19 @@ The Strategy Engine is a **notify-only** background watcher (`ROBOCO_STRATEGY_EN
Those observations are the "needs your attention" signals shown on the Dashboard, served by `GET /api/cockpit/signals`.
## Board Roadmap Engine
The **roadmap engine** (`ROBOCO_ROADMAP_ENGINE_ENABLED`, default off) is a weekly counterpart to the pitch pipeline: instead of a one-off product proposal, the Product Owner explores the company's projects, charter, recent releases, and metrics, then proposes one themed **cycle** of 3-7 roadmap item drafts.
Mechanically it mirrors the release manager's "detect → originate a CEO-gated artifact → hold" shape:
1. Weekly, `RoadmapEngine.run_cycle()` opens ONE held, PENDING exploration task assigned to the Product Owner (`source=board_roadmap`, `confirmed_by_human=False`) — only when no cycle is already open.
2. The board dispatcher one-shot-spawns the Product Owner for it, who explores and calls `propose_roadmap(cycle_goal, items)` **exactly once**, persisting the goal + item drafts as a marker on the task (no dedicated table).
3. The CEO reviews the authored cycle in the roadmap queue and approves or rejects each item **individually** (`GET /api/roadmap/cycles`, `POST /api/roadmap/cycles/{task_id}/items/{item_id}/{approve,reject}`, CEO-only).
4. An approved item materializes as a real BACKLOG task (`source=roadmap`) via the same `create_task_from_draft` path pitches use — nothing here auto-starts it; normal PM activation takes it from there. The exploration task itself completes once every item is terminal (approved or rejected).
Like every company-layer engine, it never authors work outside this held/approved chain, and it never starts anything itself.
## The Secretary
The CEO's chief-of-staff reads this layer (`read_company_state` returns the charter, task counts, pending pitches, and any directives awaiting confirmation) and acts on it via gated directives. See `docs/rag/roles/secretary.md`.
@@ -46,8 +59,9 @@ The CEO's chief-of-staff reads this layer (`read_company_state` returns the char
| Env | Default | Enables |
|-----|---------|---------|
| `ROBOCO_RESEARCH_ENABLED` | off | Board / PM web research |
| `ROBOCO_RESEARCH_ENABLED` | **on** | Board / PM web research |
| `ROBOCO_STRATEGY_ENGINE_ENABLED` | off | The strategy watcher loop |
| `ROBOCO_PROVISIONING_ENABLED` | off | Pitch → auto-provisioned repos |
| `ROBOCO_PROVISIONING_ENABLED` | on (inert without a token/org) | Pitch → auto-provisioned repos |
| `ROBOCO_ROADMAP_ENGINE_ENABLED` | off | The weekly board roadmap engine above |
All are additive: with every toggle off, the company layer is just the charter plus the pitch record.
With every toggle off, the company layer is just the charter plus the pitch record — research and provisioning ship on by default but degrade gracefully (no key/token configured) rather than doing anything until set up.
+81 -3
View File
@@ -76,17 +76,35 @@ Agents whose provider is `GROK` run xAI's official `grok` CLI. Auth is the host
## Feature flags
Env-gated subsystems, default-off except the overload break. Each takes effect on the next backend restart; the panel's Settings → Feature Flags card toggles them without hand-editing env.
Env-gated subsystems. Most are default-off; `ROBOCO_OVERLOAD_BREAK_ENABLED`, `ROBOCO_RESEARCH_ENABLED`, `ROBOCO_PROVISIONING_ENABLED`, and `ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED` ship default-**on**. Each takes effect on the next backend restart; the panel's Settings → Feature Flags card toggles the panel-exposed ones (`roboco/services/settings.py`'s `FEATURE_FLAGS`) without hand-editing env — a few security/topology flags below are env-only by design and are called out as such.
| Variable | Default | Description |
|----------|---------|-------------|
| `ROBOCO_CONVENTIONS_ENABLED` | `false` | Architectural Conventions Standard: auto-scaffold `.roboco/conventions.yml`, inject the architecture map, attach baseline constraints, and block `i_am_done` / `pr_pass` on block-level placement and hygiene violations. Off = fully inert. |
| `ROBOCO_TOOLCHAIN_MATCH_ENABLED` | `false` | Provision each agent workspace with the target project's Python (resolved from its `requires-python` / `.python-version`) and block delivery gates when the suite cannot be executed under it. Off = today's behavior. |
| `ROBOCO_OVERLOAD_BREAK_ENABLED` | `true` | Park a provider on a persistent model-API overload (HTTP 529 / 500 / 503) the same way a 429 is parked — queue its spawns and probe until it recovers — instead of crash-retrying into the overload. Off = crash-retry behavior. |
| `ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED` | `true` | PR-gate turn cut: when every child of an assembled parent is terminal, run the real `submit_up` / `submit_root` system-side as the owning PM (`_try_auto_submit`) instead of spawning the PM for that turn — the submit's substance (freshness rebase, integrity check, PR open) is deterministic gate code. A gate rejection falls back to the classic PM closure spawn; the PM keeps the judgment turns (merge, revision). Each auto-submit leaves a `task.auto_submitted` audit row. Off = every closure spawns the PM to submit. |
| `ROBOCO_SPAWN_PREFLIGHT_ENABLED` | `false` | Refuse to spawn a non-human delivery role absent from `GATEWAY_ENABLED_ROLES` (no manifest → can never claim → would respawn on the same task forever); refuse + alert the overseer once instead. Inert in practice (all delivery roles are gateway-enabled). Armed on the NAS composes. |
| `ROBOCO_NOTIFICATION_SPAWN_COOLDOWN_SECONDS` | `600` | Cross-tick damper for notification-triggered spawns (escalation/approval/audit/a2a — task-less, so the readiness gate and respawn breaker never see them): one spawn per (agent, notification) per window; the notification stays pending so the next window retries. `0` = legacy every-tick respawn. |
| `ROBOCO_SANDBOX_DB_ENABLED` | `false` | Sandboxed per-agent-spawn test DB/Redis: throwaway `postgres:16-alpine` / `redis:8-alpine` sibling containers, per-project opt-in. See "Sandboxed Dev DB/Redis" below and `docs/rag/architecture/sandbox-db.md`. |
| `ROBOCO_X_ENGINE_ENABLED` | `false` | The X (Twitter) engine: draft release/mention posts, ALL held for per-post CEO approval. See "X (Twitter) Engine" below and `docs/rag/architecture/x-engine.md`. |
| `ROBOCO_ROADMAP_ENGINE_ENABLED` | `false` | The board roadmap engine: weekly Product-Owner-authored cycle, CEO approves each item individually into BACKLOG. See "Board Roadmap Engine" below. |
The company-in-a-box subsystems toggle the same way and are all default-off: web research (`ROBOCO_RESEARCH_ENABLED`), the strategy engine (`ROBOCO_STRATEGY_ENGINE_ENABLED`), and pitch provisioning (`ROBOCO_PROVISIONING_ENABLED`).
The company-in-a-box subsystems toggle the same way: web research (`ROBOCO_RESEARCH_ENABLED`, default **on** — see "Web Research" below), the strategy engine (`ROBOCO_STRATEGY_ENGINE_ENABLED`, default off), and pitch provisioning (`ROBOCO_PROVISIONING_ENABLED`, default on but inert without a token/org configured).
## Web Research
Pluggable external search/fetch for the Board + PM roles (`cell_pm`, `main_pm`, `product_owner`, `head_marketing`). Calls flow agent → `roboco-search` MCP → `/api/research/*``ResearchService` → provider; the provider key lives only in the server-side process — it is never injected into an agent container, and agents never egress (the provider's own API does). See `docs/rag/tools/research-tools.md` for the `web_search` / `web_fetch` tool contract.
| Variable | Default | Description |
|----------|---------|-------------|
| `ROBOCO_RESEARCH_ENABLED` | `true` | Master switch. Ships default-**on** (unlike most feature flags): the `roboco-search` MCP server is mounted for the four research roles unless explicitly disabled. Panel-toggleable. Both NAS composes also set `ROBOCO_RESEARCH_ENABLED:-true` explicitly (redundant with the code default, but keeps the deploy's env self-documenting). |
| `ROBOCO_RESEARCH_PROVIDER` | `tavily` | Adapter: `tavily` (LLM-native cited results + extract), `brave` (independent index, no fetch endpoint), `exa` (neural search + contents), or `null` (always-empty stub). Swapping providers is a config change only. |
| `ROBOCO_RESEARCH_API_KEY` | (unset) | API key for the selected provider. Server-side only. Unset ⇒ graceful `NullProvider` (empty results, never a hard fail). |
| `ROBOCO_RESEARCH_MAX_RESULTS` | `5` | Hard cap (1-20) on `web_search` results per call. |
| `ROBOCO_RESEARCH_FETCH_MAX_CHARS` | `20000` | Hard cap on characters `web_fetch` returns; content past this is truncated. |
| `ROBOCO_RESEARCH_TIMEOUT_SECONDS` | `15.0` | Per-request timeout for outbound provider HTTP calls. |
| `ROBOCO_RESEARCH_DAILY_QUOTA_PER_AGENT` | `50` | Max `web_search` + `web_fetch` calls per agent per UTC day. Tracked in Redis; fails open (allows the call) if Redis is unreachable. |
## Self-Healing CI loop
@@ -126,11 +144,71 @@ The fan-out generalizations of self-heal — they watch any opted-in project, no
| `ROBOCO_IMAGE_PRUNE_ENABLED` | `true` | Background sweep prunes dangling (`<none>`) Docker images from agent-image rebuilds (only dangling; ~6h throttle). Always-on safety net, not a feature flag |
| `ROBOCO_IMAGE_PRUNE_INTERVAL_SECONDS` | `21600` | Minimum seconds between dangling-image prune passes |
## Sandboxed Dev DB/Redis
Per-agent-spawn throwaway Postgres/Redis, replacing (never coexisting with) the legacy prod-creds gate-env injection for an opted-in project. Default-off; see `docs/rag/architecture/sandbox-db.md`.
| Variable | Default | Description |
|----------|---------|-------------|
| `ROBOCO_SANDBOX_DB_ENABLED` | `false` | Master switch. Off = spawning behaves exactly as today (the legacy `_append_gate_env` prod-creds injection, itself gated by `ROBOCO_TOOLCHAIN_MATCH_ENABLED`). Only projects with their `sandbox_services` column set (migration `057`) participate even when on. |
## X (Twitter) Engine
Drafts release-announcement and mention-reply posts, ALL held for per-post CEO approval — nothing ever posts automatically. Default-off; see `docs/rag/architecture/x-engine.md`.
| Variable | Default | Description |
|----------|---------|-------------|
| `ROBOCO_X_ENGINE_ENABLED` | `false` | Master switch. Off = no draft is originated and no X API call is ever made. Even on, posting requires stored credentials (panel-entered) AND an explicit per-post CEO approval. |
| `ROBOCO_X_REPLIES_ENABLED` | `false` | Sub-switch for the mention-reply half. Off (even with the engine on) = the engine only drafts release-announcement posts; it never polls mentions or drafts replies. Reading mentions needs a paid X API tier, so replies are a deliberate opt-in on top of release posting. |
| `ROBOCO_X_MENTIONS_INTERVAL_SECONDS` | `1800` | Seconds between mentions-poll passes (only when `X_REPLIES_ENABLED`). |
| `ROBOCO_X_MENTIONS_MAX_PER_CYCLE` | `5` | Max held reply proposals the mentions poll may originate in one cycle. |
| `ROBOCO_X_MENTIONS_MIN_ENGAGEMENT` | `0` | Minimum like+reply+retweet count for a mention to count as "meaningful" (the engagement half of the mention filter; the other half rejects bare retweets and near-empty text). |
| `ROBOCO_X_MAX_OPEN_POSTS` | `10` | Rolling cap on concurrently-open held X posts/replies (both sources combined); the engine originates nothing more past it. |
| `ROBOCO_X_ACCOUNT_USER_ID` | (empty) | Numeric X user id of the account's own account. Empty resolves it once per mentions cycle via `GET /2/users/me` (one extra call). |
| `ROBOCO_X_REQUEST_TIMEOUT_SECONDS` | `15.0` | Per-request timeout for outbound X API HTTP calls. |
## Board Roadmap Engine
Weekly, the Product Owner explores the company's projects and proposes a themed cycle of roadmap item drafts; the CEO approves or rejects each one individually. Default-off; approved items land in BACKLOG and nothing auto-starts. See `docs/rag/architecture/company-layer.md`.
| Variable | Default | Description |
|----------|---------|-------------|
| `ROBOCO_ROADMAP_ENGINE_ENABLED` | `false` | Master switch. Off = no exploration cycle is originated and the Product Owner is never spawned for this. |
| `ROBOCO_ROADMAP_INTERVAL_SECONDS` | `604800` | Seconds between roadmap-exploration cycles (default weekly). |
| `ROBOCO_ROADMAP_MIN_ITEMS_PER_CYCLE` | `3` | Minimum roadmap item drafts a themed cycle must propose. |
| `ROBOCO_ROADMAP_MAX_ITEMS_PER_CYCLE` | `7` | Maximum roadmap item drafts a themed cycle may propose. |
No dedicated migration — a cycle is marker-backed (`orchestration_markers` on the held exploration task), not a new table.
## Cloud Auth
**Not a panel feature flag** — unlike the flags above, `ROBOCO_CLOUD_AUTH_ENABLED` is env-only (deliberately absent from `roboco/services/settings.py`'s `FEATURE_FLAGS`, so it can't be flipped on for a deployment that isn't behind TLS). Lets the panel/API be exposed beyond localhost without changing the CEO's local no-login flow while off. See `docs/rag/architecture/cloud-auth.md`.
| Variable | Default | Description |
|----------|---------|-------------|
| `ROBOCO_CLOUD_AUTH_ENABLED` | `false` | Master switch. Off: `get_agent_context` behaves byte-for-byte as today (header-trust). On: no registration router is mounted — exactly one user, seeded from `cloud_auth_email` / `cloud_auth_password`. **Fails loud at startup** (raises before the app boots) if `true` without `ROBOCO_CLOUD_AUTH_SECRET` set. |
| `ROBOCO_CLOUD_AUTH_EMAIL` | (unset) | Email for the single seeded CEO login user. |
| `ROBOCO_CLOUD_AUTH_PASSWORD` | (unset) | Password for the single seeded user. Hashed at startup; never stored in plain text. |
| `ROBOCO_CLOUD_AUTH_SECRET` | (unset) | Session-signing secret for the login cookie's JWT. Required when enabled — generate with `python -c 'import secrets; print(secrets.token_hex(32))'`. |
| `ROBOCO_CLOUD_AUTH_COOKIE_MAX_AGE` | `2592000` | Session cookie lifetime in seconds (30 days). Sliding: every authenticated request re-mints + re-sets the cookie, so an active session never expires — only genuine inactivity past this window logs out. |
The session cookie is `secure`-only (`cookie_secure=True` in `roboco/api/auth/backend.py`) — arm this flag only behind TLS, or the browser will silently refuse to send the cookie and login will appear to fail.
## DB Network Isolation
**Not a panel feature flag** — `ROBOCO_DB_NETWORK_ISOLATED` must travel with the compose file's `networks:` stanzas (it describes topology, not a runtime-toggleable behavior), so it is env-only like `ROBOCO_CLOUD_AUTH_ENABLED`.
| Variable | Default | Description |
|----------|---------|-------------|
| `ROBOCO_DB_NETWORK_ISOLATED` | `false` | Set `true` by the compose files that put postgres/redis on the data-only `roboco_data` network agents never join. Suppresses the legacy `_append_gate_env` prod-creds injection (unreachable creds are worse than none) — DB-needing projects use the sandbox opt-in instead. |
## Security
| Variable | Default | Description |
|----------|---------|-------------|
| `ROBOCO_ENCRYPTION_KEY` | (required) | Fernet key that encrypts secrets at rest (e.g. per-project git tokens). Generate with `Fernet.generate_key()`. |
| `ROBOCO_ENCRYPTION_KEY` | (required) | Fernet key that encrypts secrets at rest (e.g. per-project git tokens, the `x_credentials` OAuth 1.0a singleton row). Generate with `Fernet.generate_key()`. |
| `ROBOCO_AGENT_AUTH_SECRET` | (required) | HMAC secret the orchestrator signs each agent's `X-Agent-Token` with. |
| `ROBOCO_AGENT_AUTH_REQUIRED` | `false` | When `true`, every request must carry a valid agent token (secure mode). |
| `ROBOCO_PANEL_AGENT_TOKEN` | (unset) | The control panel's CEO token, injected by nginx in secure mode. Get it with `make panel-token`. |
`ROBOCO_ENCRYPTION_KEY` also encrypts the `x_credentials` row (the four X/Twitter OAuth 1.0a secrets) — the same key, one more consumer.
@@ -0,0 +1,25 @@
# DB Network Isolation
## What It Is
A compose-topology hardening: two user-defined Docker bridges instead of one. `roboco_default` carries the agent mesh (panel, nginx, ollama, every spawned agent container, and their sandbox DB/Redis sidecars — see `docs/rag/architecture/sandbox-db.md`). `roboco_data` carries **only** postgres + redis. The orchestrator is the sole multi-homed service (both networks) — every agent container structurally cannot resolve or TCP-reach `roboco-postgres:5432` / `roboco-redis:6379` at all. This matters because redis has no auth in this deployment: network membership *is* the containment, not a password.
## Enable/Disable
| Variable | Default | Effect |
|----------|---------|--------|
| `ROBOCO_DB_NETWORK_ISOLATED` | `false` | **Not a panel feature flag** — it must travel with the compose file's `networks:` stanzas (it describes topology, not a runtime-toggleable behavior), so it is deliberately absent from `roboco/services/settings.py`'s `FEATURE_FLAGS`. Set `true` only by the compose files that actually carry the two-bridge topology. |
## What flipping it changes
`ROBOCO_DB_NETWORK_ISOLATED=true` suppresses the legacy `_append_gate_env` prod-creds injection (`roboco/runtime/orchestrator.py`) — the one that would otherwise hand an agent `ROBOCO_TEST_DB_HOST=roboco-postgres` credentials for a host it cannot reach. A connect timeout is worse than no credentials at all (the test suite's DB-reachability check skips cleanly on a fast refusal, but hangs on a dead-end timeout), so the flag makes that injection a no-op rather than let it happen and fail slow. Projects that need a real DB for their gate opt into the sandboxed dev DB/Redis instead (`docs/rag/architecture/sandbox-db.md`) — sandbox replaces, never coexists with, the prod-creds path.
## What is unaffected
Agent↔agent A2A (`:9000`), orchestrator→agent SDK polls, MCP→orchestrator (`:8000`), and host-published ports (`15432`/`16379`/`11435` in this topology) are unaffected — those don't route through `roboco_data`. `docker exec` / `docker inspect` paths ride the daemon socket, not the network, so they're untouched too.
## Related
- `docs/rag/architecture/config-reference.md` — env var table
- `docs/rag/architecture/sandbox-db.md` — the alternative for a DB-needing project under this topology
- `docs/rag/architecture/cloud-auth.md` — a separate, unrelated hardening that ships alongside this on the NAS composes
+2
View File
@@ -77,6 +77,8 @@ These roles can create/assign tasks (`create_subtask`, `delegate` — PM-only pe
The Board (`product_owner`, `head_marketing`), the Auditor, and the CEO do NOT create or assign tasks through the gateway.
**Footnote — `propose_roadmap` is not an exception to this.** The Product Owner's `propose_roadmap` content verb authors a themed cycle of roadmap item **drafts** onto its own held exploration task — it does not call `delegate` and creates nothing directly. A draft only becomes a real BACKLOG task via a distinct code path, `RoadmapService.approve_item` (the CEO's per-item approval in the roadmap queue), which itself calls the same `create_task_from_draft` helper the pitch-approval flow uses. So the Board still never creates or assigns a task on its own authority — it authors proposals; only the CEO's explicit per-item approval materializes one.
## Cancellation Roles
These roles can cancel tasks from most non-terminal states (`cancel` action in `lifecycle.py`):
+39
View File
@@ -0,0 +1,39 @@
# Sandboxed Dev DB/Redis
## What It Is
A per-agent-spawn throwaway Postgres/Redis pair, provisioned as **sibling containers** to the agent container (never docker-in-agent — the docker socket/CLI stay structurally absent from agent images). Implemented in `roboco/runtime/sandbox.py` (`SandboxProvisioner`), wired into the orchestrator's spawn path.
It replaces — never coexists with — the legacy `_append_gate_env` behavior that hands an agent RoboCo's own production Postgres credentials so its `make quality` gate can run the DB-backed test suite instead of a hollow unit-only subset.
## Enable/Disable
| Variable | Default | Effect |
|----------|---------|--------|
| `ROBOCO_SANDBOX_DB_ENABLED` | `false` | Master switch. Off = spawning behaves exactly as today (the legacy prod-creds gate-env injection, itself gated by `ROBOCO_TOOLCHAIN_MATCH_ENABLED`). Panel-toggleable (Settings → Feature Flags). |
A second, per-project gate applies even when the flag is on: only a project with its `sandbox_services` column set (e.g. `["postgres", "redis"]`; migration `057`, nullable/additive) participates. Every other project's spawns are byte-for-byte unaffected.
## Provisioning
For an opted-in project's spawn, the orchestrator provisions before `docker run`:
- **Postgres**: `postgres:16-alpine`, named `roboco-sandbox-pg-{agent_id}`, `--tmpfs /var/lib/postgresql/data` (no disk persistence), `--memory 512m --cpus 1`, a random 32-hex-char password (`secrets.token_hex(16)`), user/db both `sandbox`. Readiness polled via `pg_isready` up to 60s.
- **Redis**: `redis:8-alpine`, named `roboco-sandbox-redis-{agent_id}`, same memory/cpu caps, `--requirepass` with its own random password. Readiness polled via `redis-cli ping` up to 15s.
Both are labeled `roboco.sandbox=1` plus an owner label (`roboco.sandbox.owner=roboco-agent-{agent_id}`) so the janitor can find them. A provisioning failure is **fail-loud**: the spawn is refused (`AgentReadinessError`) rather than starting an agent whose gate can't run against a broken DB. A stale same-named sandbox left by a crash-missed teardown is pre-cleared before provisioning, so a leftover container can't collide with a fresh `docker run`.
## Injected environment
Instead of the legacy `ROBOCO_TEST_DB_*` pointing at RoboCo's own production Postgres, the sandbox's own host/port/user/password are injected under the **same** `ROBOCO_TEST_DB_*` names (so an existing project's conftest needs no change) plus new `ROBOCO_TEST_REDIS_*` names. `_append_sandbox_env` runs **instead of** `_append_gate_env` whenever a sandbox was provisioned for that spawn.
## Lifetime and teardown
A sandbox's lifetime tracks its owning agent container 1:1: torn down (`stop``kill` fallback → `rm -f`, all best-effort and idempotent) at every container-removal path. An **orphan janitor** also runs at orchestrator startup and on each reaper tick: it lists every `roboco.sandbox=1` container, cross-references live agent containers, and removes any sandbox whose owner is gone.
The janitor has a **grace window** (`_JANITOR_GRACE_SECONDS`, 180s): a sandbox is provisioned *before* its agent container exists, so a sweep racing a mid-flight spawn would otherwise see "owner not live yet" and reap a fresh sandbox out from under a spawn still starting up. Owners provisioned within the grace window are skipped by that pass. The pre-spawn stale-clear (above) likewise never touches a just-provisioned sandbox.
## Related
- `docs/rag/architecture/config-reference.md` — full env var table
- `docs/rag/architecture/db-network-isolation.md` — the network-topology change this pairs with in a NAS deploy (agents structurally can't reach production Postgres/Redis at all; sandbox is the DB-needing project's alternative)
+14 -3
View File
@@ -9,6 +9,7 @@ Agents call gateway verbs through up to five MCP servers, scoped per role:
| `roboco-flow` | Lifecycle verbs (give_me_work, i_will_work_on, open_pr, complete, …) |
| `roboco-do` | Content/write verbs (commit, note, say, dm, notify, evidence) |
| `roboco-git-readonly` | Read-only git inspection (status, log, diff, branch_list) |
| `roboco-search` | Web research (`web_search`, `web_fetch`) — `cell_pm`/`main_pm`/`product_owner`/`head_marketing` only, and only when `ROBOCO_RESEARCH_ENABLED` (default on) |
| `roboco-optimal` | RAG (`roboco_ask_mentor`, `roboco_kb_search`) |
| `roboco-docs` | Project docs file management (selected roles) |
@@ -54,6 +55,8 @@ The canonical source of role → verb mapping is `roboco/services/gateway/role_c
**Read-only git:** all 4
**Web research (conditional):** `roboco-search`'s `web_search` / `web_fetch`, when `ROBOCO_RESEARCH_ENABLED` (default on).
**Workspace writes:** none.
## Main PM
@@ -64,15 +67,23 @@ The canonical source of role → verb mapping is `roboco/services/gateway/role_c
**Read-only git:** all 4
**Web research (conditional):** `roboco-search`'s `web_search` / `web_fetch`, when `ROBOCO_RESEARCH_ENABLED` (default on).
**Workspace writes:** none. `submit_root` on a root parent task opens the root→master PR (entering the `awaiting_pr_review` gate); after the main reviewer `pr_pass`es it, `complete` escalates to the CEO. The Main PM never merges to master — only the CEO does.
## Board (Product Owner, Head of Marketing)
**Flow verbs:** `triage`, `escalate_to_ceo`, `i_am_idle`
Both share the same flow verbs and read-only git (none), but their content verbs now diverge — the Product Owner is the sole author of the board roadmap engine's cycles.
**Content verbs:** `note`, `say`, `dm`, `notify`, `evidence`
**Flow verbs (both):** `triage`, `escalate_to_ceo`, `i_am_idle`
**Read-only git:** none.
**Content verbs — Product Owner:** `note`, `pitch`, `propose_roadmap`, `say`, `dm`, `notify`, `evidence`, `open_session`
**Content verbs — Head of Marketing:** `note`, `pitch`, `say`, `dm`, `notify`, `evidence`, `open_session` (no `propose_roadmap`)
**Read-only git (both):** none.
**Web research (both, conditional):** `roboco-search`'s `web_search` / `web_fetch`, mounted only when `ROBOCO_RESEARCH_ENABLED` (default on).
## Auditor
+53
View File
@@ -0,0 +1,53 @@
# X (Twitter) Engine
## What It Is
RoboCo can draft posts for the company's X (Twitter) account — release announcements and mention replies — implemented in `roboco/services/x_engine.py` (`XEngine`) and `roboco/services/x_post_service.py` (`XPostService`). Every draft is HELD for an explicit, per-post CEO approval; nothing is ever posted automatically. It mirrors the `ReleaseManagerEngine` "detect → originate a CEO-gated artifact → hold" shape.
## Enable/Disable
| Variable | Default | Effect |
|----------|---------|--------|
| `ROBOCO_X_ENGINE_ENABLED` | `false` | Master switch. Off = no draft is originated and no X API call is ever made — the release-post hook and the mentions poll are both no-ops. Panel-toggleable (Settings → Feature Flags). |
Even when enabled, the engine only drafts once stored credentials are present AND acts only through the CEO's explicit per-post approval — two independent gates beyond the flag.
## Two draft sources
**Release posts** (event-driven). `XEngine.draft_release_post(version, highlights)` is called from `ReleaseProposalService.approve()`'s publish-success branch — a release post is only ever drafted for a release that actually shipped. Dedup by version: a retry never drafts twice for the same `version`.
**Mention replies** (periodic poll). `XEngine.run_cycle()` fetches mentions via the X API, filters for "meaningful" ones (not a bare retweet, real text, and an engagement floor — `like + reply + retweet counts >= ROBOCO_X_MENTIONS_MIN_ENGAGEMENT`), and dedups against `x_seen_mentions` (migration `059`) so a mention is never turned into a second held reply.
Both are bounded by `ROBOCO_X_MAX_OPEN_POSTS` (rolling cap on concurrently-open held drafts, both sources combined) and the mentions poll additionally by `ROBOCO_X_MENTIONS_MAX_PER_CYCLE` (per-cycle origination cap).
## Drafting
Draft bodies are written by a **local-model** chat call (`_chat`, hitting `ROBOCO_LOCAL_LLM_BASE_URL` — never a cloud LLM in the hot path), in a fixed Head-of-Marketing voice prompt, then hard-clamped to 280 characters (`_clamp_tweet`). A local-model failure falls back to a plain template body rather than failing the draft outright. Drafting is **not** an agent spawn — no agent (including Head of Marketing) is spawned to write these; see `docs/rag/roles/head-marketing.md` for why the HoM's tool surface doesn't change.
## Ownership and the CEO gate
Every held draft is a real task: `team=main_pm`, `assigned_to=secretary-1`, `source` one of `X_POST_SOURCE` / `X_REPLY_SOURCE`, `confirmed_by_human=False` (HELD — skipped by every dispatcher, never delivered to an agent). The body lives on an `orchestration_markers` marker, editable up to the point of posting.
The CEO acts through panel-only REST, CEO-role-gated (`require_ceo_role`), never a gateway verb:
| Endpoint | Effect |
|----------|--------|
| `GET /api/x/posts` | List every held draft (both sources) awaiting decision. |
| `POST /api/x/posts/{task_id}/approve` | Post to X (optionally with an edited body, still 280-char clamped). Idempotent — approving an already-posted draft returns `already_posted` without a second API call. |
| `POST /api/x/posts/{task_id}/reject` | Cancel the draft with a reason. Terminal — a rejected draft is never posted; a fresh one originates on the next cycle/release if still relevant. |
| `GET /api/x/credentials` | Whether all four OAuth secrets are stored (`has_credentials` boolean — never the secrets). |
| `POST /api/x/credentials` | Set (or, passing all four empty, clear) the four secrets. All-or-nothing — a partial set raises a validation error. |
Approval runs under a Redis single-flight lock (`roboco:x_post:{task_id}`, plain `SET NX`, 60s TTL) so a double-click can't double-post; the task is marked `COMPLETED` under the same lock before it releases.
## Credentials and signing
Credentials are entered in the **panel only** — never in `.env` or an agent-visible setting. The four OAuth 1.0a user-context secrets (`api_key`, `api_secret`, `access_token`, `access_token_secret`) are Fernet-encrypted (`ROBOCO_ENCRYPTION_KEY`) in the singleton `x_credentials` table (migration `059`); `get_decrypted()` is called only server-side, by `x_post_service` / `x_engine` — the API surface is write-only (`has_credentials` boolean, matching the `has_git_token` pattern for per-project git tokens).
Requests are signed with a **hand-rolled OAuth 1.0a HMAC-SHA1** signer (`roboco/services/x_client.py`, no new dependency) — no library does this signing for X's v2 API in the project's existing dependency set. Without credentials, `build_x_client` returns a `NullXClient` that never raises and never egresses, exactly like the research `NullProvider`.
## Related
- `docs/rag/architecture/config-reference.md` — full env var table
- `docs/rag/roles/head-marketing.md` — why the HoM's tool surface is unchanged
- `docs/rag/roles/ceo.md` — the CEO approval queues
+4
View File
@@ -34,6 +34,7 @@
- Write code or commit → devs / documenters only (`commit` is in their manifest, not yours)
- Open or merge the master PR → the Main PM's `submit_root` opens the root→master PR and only the CEO merges it to `master`
- Run shell git — blocked by the bash-guard hook
- Get unrestricted task admin on the REST `PATCH /tasks/{id}` surface — cell_pm/main_pm are capped to a **content-only allowlist** (`title`, `description`, `acceptance_criteria`, `priority`; no status changes, no structural/ownership fields) — the "PM lighter" scope (`_pm_editor_scope` / `_enforce_pm_lighter_fields`, `roboco/api/routes/tasks.py`). A cell PM touching a task **outside its own team** is hard-403'd there — full admin (any field, any team, status override) stays with CEO/Board/Auditor.
## Task Flow (gateway verbs)
@@ -72,6 +73,7 @@ unclaim(task_id) / resume(task_id) / i_am_idle()
| `roboco-flow` | `give_me_work`, `i_will_plan`, `delegate`, `submit_up`, `triage`, `unblock`, `reassign`, `complete`, `escalate_up`, `unclaim`, `resume`, `i_am_idle` |
| `roboco-do` | `note`, `say`, `dm`, `notify`, `evidence` (no `commit`) |
| `roboco-git-readonly` | `roboco_git_status`, `roboco_git_log`, `roboco_git_diff`, `roboco_git_branch_list` |
| `roboco-search` | `web_search`, `web_fetch` (only when `ROBOCO_RESEARCH_ENABLED`, default on) |
| `roboco-optimal` | `roboco_ask_mentor`, `roboco_kb_search` |
| `roboco-docs` | project doc file ops |
@@ -154,6 +156,8 @@ When every subtask of your cell-scoped parent is terminal (each leaf PR merged i
Re-`submit_up` is refused if the assembled PR is **unchanged** since the last `pr_fail` (no new commits on it) — it stops a re-submit-the-same-PR loop. Fix the issues and commit before re-submitting.
**You may never even see this turn.** When every subtask is terminal, the orchestrator's closure dispatcher first tries `_try_auto_submit`: if `ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED` (default on) and the parent has a branch + project, it runs the real `submit_up` system-side as you, skipping your spawn for that turn — the submit's substance (freshness rebase, integrity check, PR open) is deterministic gate code, not judgment. A gate rejection (freshness/integrity) falls back to spawning you for the classic closure turn instead. Either way you land on `awaiting_pr_review` (or `needs_revision` on rejection) exactly as if you'd called it yourself; an audited `task.auto_submitted` event marks the cut.
You merge your own cell→root PR — the Main PM does **not** merge your cell branch. The Main PM owns the **root** task: once every cell's parent is terminal, it runs the same gate one level up (`submit_root` → main reviewer → escalate to CEO) and only the CEO merges to `master`. You never open or merge a master PR yourself.
`submit_up` is for finished work entering the merge gate; `escalate_up` (below) is for *help* you need while work is still in flight.
+9
View File
@@ -35,6 +35,15 @@ When a Main PM or Board member escalates a major task via `escalate_to_ceo`, it
Both are panel actions; the agent that escalated simply idles until the CEO decides.
### X posts and roadmap items (panel-only, not gateway verbs)
Two more CEO-only approval queues, both plain REST endpoints on the orchestrator (not lifecycle transitions, not agent-callable verbs):
- **X (Twitter) posts**`GET/POST /api/x/posts{,/{id}/approve,/reject}`. Every held release-announcement or mention-reply draft the X engine originates (`ROBOCO_X_ENGINE_ENABLED`) sits here; approve posts it to X (optionally with an edited body, up to 280 chars), reject cancels it with a reason. Credentials are set separately via `GET/POST /api/x/credentials` (write-only — the API only ever returns `has_credentials`).
- **Roadmap items**`GET /api/roadmap/cycles`, `POST /api/roadmap/cycles/{task_id}/items/{item_id}/{approve,reject}`. Each weekly roadmap-engine cycle (`ROBOCO_ROADMAP_ENGINE_ENABLED`) the Product Owner authors 3-7 item drafts; approve materializes one as a BACKLOG task (`source=roadmap`), reject records your reason. Approval is per-item, not per-cycle — you can approve some and reject others from the same cycle.
Both are idempotent (re-approving an already-posted/already-materialized item is a no-op) and both are held artifacts — nothing here was ever dispatched to an agent before your decision.
## Escalation
The CEO is the final escalation target:
+9 -2
View File
@@ -19,7 +19,9 @@
- Escalate tasks to the CEO via `escalate_to_ceo(task_id, reason)`
- Communicate: `say` (channel), `dm` (A2A), `notify` (ack-required signal)
- Open strategic sessions via `open_session`
- Propose a product via `pitch(title, slug, problem, proposed_solution, target_cells)` — queues for CEO approval, then auto-provisions
- Read project docs via `roboco_docs_read` / `roboco_docs_list`
- Research the market via `web_search` / `web_fetch` (when `ROBOCO_RESEARCH_ENABLED`)
- Search the knowledge base via `roboco_ask_mentor` / `roboco_kb_search`
## What You CANNOT Do
@@ -35,12 +37,17 @@
| MCP server | Verbs you can call |
|-----------------------|--------------------|
| `roboco-flow` | `triage`, `escalate_to_ceo`, `i_am_idle` |
| `roboco-do` | `note`, `say`, `dm`, `notify`, `evidence`, `open_session` |
| `roboco-do` | `note`, `pitch`, `say`, `dm`, `notify`, `evidence`, `open_session` |
| `roboco-docs` | `roboco_docs_read`, `roboco_docs_list` |
| `roboco-git-readonly` | `roboco_git_status`, `roboco_git_log`, `roboco_git_diff`, `roboco_git_branch_list` |
| `roboco-search` | `web_search`, `web_fetch` (only when `ROBOCO_RESEARCH_ENABLED`) |
| `roboco-optimal` | `roboco_ask_mentor`, `roboco_kb_search` |
Your flow surface is deliberately narrow: the Board steers and approves, it does not claim, create, or complete tasks.
Your flow surface is deliberately narrow: the Board steers and approves, it does not claim, create, or complete tasks. Unlike the Product Owner, you do **not** get `propose_roadmap` — that tool is Product-Owner-only.
## X (Twitter) Engine — Not a New Tool for You
The X engine (`ROBOCO_X_ENGINE_ENABLED`, default off) drafts release-announcement and mention-reply posts for the company's X account, in your marketing voice — but it does **not** add anything to your tool surface above. Drafting is done by a direct local-model call from `XEngine` (`roboco/services/x_engine.py`), not by spawning you as an agent; every draft lands as a held task **owned by the Secretary** (`assigned_to=secretary-1`, `team=main_pm`), never assigned to you. The CEO reviews and approves/rejects each draft in the panel (`GET/POST /api/x/posts{,/{id}/approve,/reject}`, CEO-only) — nothing posts without that explicit per-post approval. If you want to review or influence a draft's content, ask the CEO directly (via `dm` or the escalation chain below) rather than expecting a task in your queue.
## Escalation
+6
View File
@@ -84,10 +84,15 @@ channels() # discover the pm-all channel, then read its history
| `roboco-do` | `note`, `say`, `dm`, `notify`, `evidence`, `open_session`, `link_session`, `pr_update` |
| `roboco-docs` | `roboco_docs_write`, `roboco_docs_read`, `roboco_docs_list` |
| `roboco-git-readonly` | `roboco_git_status`, `roboco_git_log`, `roboco_git_diff`, `roboco_git_branch_list` |
| `roboco-search` | `web_search`, `web_fetch` (only when `ROBOCO_RESEARCH_ENABLED`, default on) |
| `roboco-optimal` | `roboco_ask_mentor`, `roboco_kb_search` |
Native `git` commands are blocked by the bash-guard hook — use the read-only git views and let the choreographer handle PR merges on `complete`.
## Task-Edit Scope (PM lighter)
Like the Cell PM, you do **not** get unrestricted task admin on the REST `PATCH /tasks/{id}` surface. `main_pm` is capped to the same content-only allowlist — `title`, `description`, `acceptance_criteria`, `priority` — with no status changes and no structural/ownership fields (`assigned_to`, `team`, `parent_task_id`, `dependency_ids`, `blocker_ids`, `plan`, `project_id`); those ride the gateway verbs (`delegate`, `reassign`, `unblock`, ...), not this PATCH surface. Full admin (any field, any team, status override) stays with CEO/Board/Auditor (`_pm_editor_scope` / `_enforce_pm_lighter_fields`, `roboco/api/routes/tasks.py`).
## Projects and Git Tokens
Registering repositories and storing git tokens is **not** an agent action — it is done by a human in the panel (project settings). Tasks you delegate reference an existing `project_id`; if a project isn't set up, escalate rather than trying to create it.
@@ -112,6 +117,7 @@ master ← feature/main_pm/{root} ← feature/{cell}/{root}/{cell-pm} ←
- A cell PM's `complete` merges a leaf PR into its cell branch; after the cell gate, its `complete` merges the cell→root PR into your root branch. You do not merge cell branches.
- Once every cell's parent is terminal, **`submit_root(root_task_id, notes)`** opens the root→master PR and enters the in-path gate (`awaiting_pr_review`). The **main PR reviewer** checks the assembled root diff: `pr_pass``awaiting_pm_review`; `pr_fail``needs_revision` (owned by you, fix + re-`submit_root`). The reviewer's verdict + issues are carried in your task handoff, and re-`submit_root` is refused if the root PR is **unchanged** since the last `pr_fail` — fix and commit before re-submitting.
- **The system may call `submit_root` for you.** When every cell's parent is terminal, the orchestrator's closure dispatcher tries `_try_auto_submit` first: with `ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED` (default on) and a branch + project on the root, it runs `submit_root` system-side as you — skipping your spawn for that turn, since the submit's substance is deterministic gate code, not judgment. A gate rejection (freshness/integrity) falls back to spawning you for the classic closure turn. Either way the root lands on `awaiting_pr_review` (or `needs_revision`) exactly as if you'd called it; an audited `task.auto_submitted` event marks the cut. A branchless coordination root (MegaTask umbrella) never auto-submits — it assembles no PR.
- After `pr_pass`, `complete(root_task_id, notes)` escalates the root to the CEO (`awaiting_ceo_approval`) — it does **not** merge. A branchless coordination root (product fan-out, no repo) skips the gate and `complete` escalates directly.
- The CEO approves and merges the root→master PR from the panel. Only the CEO ever merges to `master`.
+30 -2
View File
@@ -20,7 +20,10 @@
- Escalate tasks to the CEO via `escalate_to_ceo(task_id, reason)`
- Communicate: `say` (channel), `dm` (A2A), `notify` (ack-required signal)
- Open strategic sessions via `open_session`
- Propose a product via `pitch(title, slug, problem, proposed_solution, target_cells)` — queues for CEO approval, then auto-provisions
- Author the weekly roadmap-engine exploration cycle via `propose_roadmap(cycle_goal, items)` — see "Roadmap Engine" below
- Read project docs via `roboco_docs_read` / `roboco_docs_list`
- Research the market via `web_search` / `web_fetch` (when `ROBOCO_RESEARCH_ENABLED`)
- Search the knowledge base via `roboco_ask_mentor` / `roboco_kb_search`
## What You CANNOT Do
@@ -36,12 +39,37 @@
| MCP server | Verbs you can call |
|-----------------------|--------------------|
| `roboco-flow` | `triage`, `escalate_to_ceo`, `i_am_idle` |
| `roboco-do` | `note`, `say`, `dm`, `notify`, `evidence`, `open_session` |
| `roboco-do` | `note`, `pitch`, `propose_roadmap`, `say`, `dm`, `notify`, `evidence`, `open_session` |
| `roboco-docs` | `roboco_docs_read`, `roboco_docs_list` |
| `roboco-git-readonly` | `roboco_git_status`, `roboco_git_log`, `roboco_git_diff`, `roboco_git_branch_list` |
| `roboco-search` | `web_search`, `web_fetch` (only when `ROBOCO_RESEARCH_ENABLED`) |
| `roboco-optimal` | `roboco_ask_mentor`, `roboco_kb_search` |
Your flow surface is deliberately narrow: the Board steers and approves, it does not claim, create, or complete tasks.
Your flow surface is deliberately narrow: the Board steers and approves, it does not claim, create, or complete tasks. `propose_roadmap` is a content verb, not a flow verb — you author the roadmap cycle without claiming a delivery task.
## Roadmap Engine
Weekly (`ROBOCO_ROADMAP_ENGINE_ENABLED`, default off), the roadmap engine opens ONE held exploration task assigned to you (`source=board_roadmap`, PENDING, `confirmed_by_human=False`). When spawned for it, explore the company's projects, charter, recent releases, and metrics, then call `propose_roadmap(cycle_goal, items)` **exactly once**:
```python
propose_roadmap(
cycle_goal="Close the mobile-experience gap before Q3",
items=[
{
"title": "...",
"description": "...",
"acceptance_criteria": ["..."],
"project_slug": "roboco-api",
"team": "backend", # backend | frontend | ux_ui
"priority": 2,
"rationale": "why this, why now",
},
# 3-7 items total
],
)
```
The CEO then reviews and approves/rejects each item **individually** in the roadmap queue (`GET/POST /api/roadmap/cycles/{task_id}/items/{item_id}/{approve,reject}`, CEO-only). An approved item materializes as a real BACKLOG task (`source=roadmap`) — nothing here auto-starts it; it waits for normal PM activation like any other backlog task. One open cycle at a time: the engine won't originate a new exploration task while one is still awaiting your authoring or the CEO's per-item decisions.
## Escalation
+7 -1
View File
@@ -24,7 +24,9 @@ It runs in its own `agent-prompter` container as a persistent `ClaudeSDKClient`
- Read and search the codebase: `Read`, `Grep`, `Glob`
- Spawn read-only sub-explorations to ground the draft (`Task`)
- Check prior art mid-conversation via **`search_past_tasks(query, limit=8)`** — searches past tasks by title/description/id-prefix and returns up to 10 compact rows (short id, title, status, team, date), so you can answer "have we done something like this before?" or cite a predecessor in a new draft's description
- Produce the reviewable draft by calling **`propose_draft`** — the canonical "the spec is ready" signal; the orchestrator turns it into the draft card the human approves
- Propose a **MegaTask** — several sequenced task drafts at once — by calling **`propose_batch(drafts, title)`** instead of `propose_draft` when the CEO asks for multiple tasks across the scoped repos; each draft carries a collision surface (`intends_to_touch`, `adds_migration`, `touches_shared`, `depends_on`) the sequencing analyzer uses to order them into conflict-free waves
- Journal privately via `note(...)` and cite sources via `evidence(...)`
## What You CANNOT Do
@@ -46,12 +48,16 @@ Interview first, draft second. A good draft follows the **task spec standard**:
When the spec is ready, call `propose_draft` with the structured draft. The human reviews the card and decides whether to launch it, and to whom.
## Ambient Task History (auto-injected, no tool call)
Every intake conversation scoped to a project is automatically given a **task-history digest** — a chronological "## Task History" block listing that project's most recent tasks (short id, title, status, date), one section per project for a MegaTask's multi-project scope. It's rendered by `history_digest_layer` (`roboco/services/prompter.py`, backed by `TaskService.list_recent_for_project`) and injected ambiently at session start; you don't call anything for it. Use `search_past_tasks` (above) when you need a keyword hit the digest's recency window doesn't cover.
## Tool Surface (locked-down SDK session)
| Source | Tools |
|--------|-------|
| Base (read-only) | `Read`, `Grep`, `Glob`, `Task` |
| Intake MCP | `propose_draft` (emit the reviewable draft) |
| Intake MCP | `propose_draft` (emit the reviewable draft), `search_past_tasks` (prior-art search), `propose_batch` (MegaTask — several sequenced drafts at once) |
| `roboco-do` (gateway) | `note`, `evidence` |
The session is isolated: a hard tool allowlist (no host settings, no extra MCP servers), `permission_mode="dontAsk"`, and no outward-comms surface. Anything not listed above is denied.
+24 -2
View File
@@ -23,7 +23,7 @@ It runs in its own `agent-secretary` container, reusing the Intake chat machiner
- Read the codebase: `Read`, `Grep`, `Glob`
- Read a compact company snapshot via **`read_company_state`** — the charter (goals), task counts by status, pending pitches, and any directives already awaiting the CEO's confirmation
- Read one task's detail via **`read_task`**
- Read one task's **full detail** via **`read_task(task_id)`** — Secretary FULL task access: beyond identity/status/description this also carries acceptance criteria, plan, bounded recent `progress_updates`, dev/qa/auditor/pr-reviewer/doc notes, and the branch/PR reference
- Act on the CEO's command via **`submit_directive`** (see below)
## What You CANNOT Do
@@ -42,12 +42,34 @@ It runs in its own `agent-secretary` container, reusing the Intake chat machiner
|------|---------|--------------|
| `relay_message` | `channel`, `text` | Runs directly |
| `update_charter` | `charter` | Queued for the CEO |
| `control_task` | `task_id`, `action` (`start`/`cancel`/`override`), `status?` | Queued for the CEO |
| `control_task` | `task_id`, `action` (`start`/`cancel`/`override`/`edit`), `status?` (for `override`), `fields?` (for `edit`) | Queued for the CEO |
| `approve_pitch` | `pitch_id`, `notes?` | Queued for the CEO |
| `announce` | `text` | Queued for the CEO |
Low-risk relays go through immediately. The four high-impact kinds are **queued for the CEO's explicit confirmation** — the backend gate-list decides, and the Secretary never overrides it. Tell the CEO when a directive has been queued, and why.
### `control_task action="edit"` — Secretary FULL task access
`edit` applies a content edit + optional reassignment on the CEO's confirmed command, via `fields={...}`:
- Editable fields (`_EDITABLE_TASK_FIELDS` in `roboco/services/secretary.py`): `title`, `description`, `acceptance_criteria`, `priority`, `team`, `estimated_complexity`, `nature`, `assigned_to`. Any other key is rejected outright — `status` is never set through `edit` (use `override`/`start`/`cancel` instead), and git fields (branch/PR) are never editable at all.
- `assigned_to` may be a UUID or an agent slug (e.g. `"be-dev-1"`), and is **claim-aware**: reassigning a task that is `claimed`/`in_progress` reseeds the new assignee's heartbeat (`reassign_active_claim`) instead of a naive field set, so they aren't immediately stale to the reaper; anything else routes through the general `reassign` (or unassigns on `assigned_to=null`).
```python
submit_directive(
kind="control_task",
payload={
"task_id": "<task>",
"action": "edit",
"fields": {"priority": 1, "assigned_to": "be-dev-2"},
},
)
```
### Resolving a task by name
The CEO refers to tasks by **name**, not UUID. The backend exposes `GET /secretary/tasks?q=` (Secretary/CEO-gated; title/description/id-prefix search, capped at 50 rows) precisely for that name→id resolution — but as of this writing it isn't wired to a Secretary tool in either runtime (only `read_company_state` / `read_task` / `submit_directive` are). Until it is, resolve a name the CEO mentions from `read_company_state`'s task counts / your own conversation context, or ask the CEO to confirm the short id before you `control_task` or `read_task` it.
## Tool Surface (locked-down SDK session)
| Source | Tools |
+10
View File
@@ -17,6 +17,16 @@ dm(
- **Same-cell only.** Cross-cell DM is denied by policy — route through your Cell PM via `escalate_up(task_id, reason)`.
- The recipient sees it in their notify inbox when offline.
## Messaging the CEO — `dm(recipient="ceo", ...)`
The CEO is a special recipient with an asymmetric rule (`_enforce_ceo_reply_budget` in `roboco/services/a2a.py`), so a `dm` to `ceo` can be refused for reasons that have nothing to do with cell membership:
- **You can never open a CEO conversation.** An agent can never *initiate* A2A with the CEO — the static permission matrix blocks it unconditionally, as defense-in-depth. Your `dm` only succeeds inside a conversation the **CEO already opened** (its mere existence proves that). If none exists yet, the call is refused with "CEO is human. You may only reply inside a conversation the CEO opened — use notify() otherwise." — but `notify` itself is PM/Board-only (see the table below), so if you're not a PM/Board role your real option is to route through your chain (`escalate_up` to your Cell PM) and wait.
- **Reply budget: at most one message per CEO message, per conversation.** Once the CEO has messaged you, you may reply — but your message count in that conversation may never reach or exceed the CEO's. Reply once, then you're capped until the CEO posts again; a second `dm(recipient="ceo", ...)` before their next message is refused with "you have already replied to the CEO's last message — wait for the CEO to respond before sending again."
- **CEO → agent is unrestricted.** The CEO (via the panel) can open a conversation with, and message, any agent at any time; only the agent side of the `ceo` pair is budgeted.
Both refusals surface as a normal tool error (`A2A_ACCESS_DENIED`) with a `remediate` hint — treat them as "wait for the CEO," not a bug to retry around.
## Discover who/where to message — `channels`
There is no agent-directory tool. Use `channels()` to see the channels you can read/write, and post to a channel when the audience is the whole cell rather than one peer:
+51
View File
@@ -0,0 +1,51 @@
# Web Research Tools
Two tools on the `roboco-search` MCP server: `web_search` and `web_fetch`. Mounted only for **Board + PM roles**`cell_pm`, `main_pm`, `product_owner`, `head_marketing` — and only when `ROBOCO_RESEARCH_ENABLED` is true (default **on**; see `docs/rag/architecture/config-reference.md`). Every other role has no access.
Calls flow agent → `roboco-search` MCP → `/api/research/search` or `/api/research/fetch``ResearchService` → the configured provider. The provider API key lives only in the server-side orchestrator process — it is never injected into an agent container, and the agent itself never egresses; the provider's own API does.
## `web_search`
```python
web_search(query="competitor pricing for AI agent platforms", max_results=5)
```
| Arg | Required | Description |
|-----|----------|--------------|
| `query` | yes | The search query. |
| `max_results` | no | Cap on results, clamped server-side to `ROBOCO_RESEARCH_MAX_RESULTS` (default 5, hard ceiling 20). |
Returns cited results (`title`, `url`, `snippet`, optional `score`) and, when the provider supports it (Tavily), a short synthesized `answer`. A `guidance` field reminds you to cite the URL for anything you rely on and to persist key findings — see below.
## `web_fetch`
```python
web_fetch(url="https://example.com/pricing", max_chars=5000)
```
| Arg | Required | Description |
|-----|----------|--------------|
| `url` | yes | The page to extract readable content from. |
| `max_chars` | no | Cap on returned characters, clamped server-side to `ROBOCO_RESEARCH_FETCH_MAX_CHARS` (default 20000). |
Only works with providers that support content extraction (Tavily, Exa) — the response's `truncated` field tells you whether the content was cut at the cap. Brave has no extraction endpoint; calling `web_fetch` against it returns a "does not support web_fetch" error (HTTP 501 at the route, surfaced as an error envelope to the tool).
## Cite-and-persist rules
Web research is external, unverified-by-the-org information — treat it accordingly:
1. **Always cite the URL** for any fact you rely on in a decision, a `delegate` description, or a `dm`/`say` message.
2. **Persist key findings** with `note(scope="reflect", ...)` so the source survives beyond your own context window and the team keeps it, not just you.
3. Do not treat a search `answer` or a fetched page as ground truth about RoboCo itself — it is about the outside world (competitors, libraries, market trends), not this codebase.
## Daily quota
Each agent gets `ROBOCO_RESEARCH_DAILY_QUOTA_PER_AGENT` (default 50) combined `web_search` + `web_fetch` calls per UTC day, tracked in Redis. Past the quota, calls return HTTP 429 with a message naming the limit and the UTC reset time. The quota check **fails open** — a Redis outage lets the call through rather than blocking research on an infra hiccup.
## Graceful degradation
With `ROBOCO_RESEARCH_PROVIDER=null` or no `ROBOCO_RESEARCH_API_KEY` configured, both tools still respond (never a hard failure) — `web_search` returns zero results with `provider: "null"` and a guidance string telling you to proceed without external sources or ask the CEO to configure a key; `web_fetch` returns empty content. Check the `provider` field in the response rather than assuming a key is set.
## CEO access
The CEO (as the human operator, via the panel) is also in the research-authorized role set at the API layer, alongside the four agent roles above — but the CEO does not call MCP tools; this is purely so a panel-driven research surface (if built) would not need a separate authorization path.
+2
View File
@@ -99,6 +99,8 @@ i_am_idle()
The Board **cannot** claim, create, complete, or cancel tasks. Strategic decisions are escalated to the CEO.
The Product Owner additionally has `propose_roadmap(cycle_goal, items)` — a **content tool** on `roboco-do`, not a flow verb, so it doesn't appear above. It authors the weekly board-roadmap-engine exploration cycle (a themed goal + 3-7 item drafts); the CEO approves or rejects each item individually into BACKLOG. See `docs/rag/roles/product-owner.md`.
## Auditor flow
```python
+4
View File
@@ -28,6 +28,10 @@ dm(
Cross-cell `dm` is **denied by policy**. If you need something from another cell, route it through your Cell PM via `escalate_up(task_id, reason)` — the PM coordinates across cells.
## Messaging the CEO
`dm(recipient="ceo", ...)` follows a different rule than same-cell DM: you can never *open* a CEO conversation (only reply inside one the CEO already started), and once it's open you get at most one reply per CEO message before you must wait for the CEO to post again. See `docs/rag/tools/a2a-tools.md` for the full contract and the exact refusal messages.
## Channel Posts
```python
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "roboco-panel",
"version": "0.16.0",
"version": "0.17.0",
"private": true,
"packageManager": "pnpm@10.25.0",
"scripts": {
+73
View File
@@ -0,0 +1,73 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { NextRequest } from "next/server";
describe("proxy", () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.resetModules();
});
it("passes through when cloud auth is off", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ cloud_auth_enabled: false }),
}) as unknown as typeof fetch;
const { proxy } = await import("../proxy");
const res = await proxy(
new NextRequest("http://localhost:3000/overview"),
);
expect(res.status).toBe(200);
});
it("redirects to /login when cloud auth is on and no session cookie", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ cloud_auth_enabled: true }),
}) as unknown as typeof fetch;
const { proxy } = await import("../proxy");
const res = await proxy(
new NextRequest("http://localhost:3000/overview"),
);
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain("/login");
});
it("passes through when cloud auth is on and a session cookie is present", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ cloud_auth_enabled: true }),
}) as unknown as typeof fetch;
const { proxy } = await import("../proxy");
const req = new NextRequest("http://localhost:3000/overview", {
headers: { cookie: "roboco_session=abc123" },
});
const res = await proxy(req);
expect(res.status).toBe(200);
});
it("fails open (passes through) when the status probe errors", async () => {
global.fetch = vi.fn().mockRejectedValue(new Error("network down"));
const { proxy } = await import("../proxy");
const res = await proxy(
new NextRequest("http://localhost:3000/overview"),
);
expect(res.status).toBe(200);
});
it("fails open when the status probe returns a non-ok response", async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: false }) as unknown as
typeof fetch;
const { proxy } = await import("../proxy");
const res = await proxy(
new NextRequest("http://localhost:3000/overview"),
);
expect(res.status).toBe(200);
});
});
@@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
const { mutateAsync, push, refresh } = vi.hoisted(() => ({
mutateAsync: vi.fn(),
push: vi.fn(),
refresh: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push, refresh }),
}));
vi.mock("@/hooks/use-auth", () => ({
useLogin: () => ({ mutateAsync, isPending: false }),
}));
vi.mock("sonner", () => ({
toast: { error: vi.fn() },
}));
import LoginPage from "../page";
describe("LoginPage", () => {
beforeEach(() => {
mutateAsync.mockReset();
push.mockReset();
refresh.mockReset();
});
it("requires both fields before submitting", () => {
render(<LoginPage />);
fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
expect(screen.getByRole("alert")).toHaveTextContent(
"Enter your email and password",
);
expect(mutateAsync).not.toHaveBeenCalled();
});
it("logs in and redirects to /overview on success", async () => {
mutateAsync.mockResolvedValue(undefined);
render(<LoginPage />);
fireEvent.change(screen.getByLabelText(/email/i), {
target: { value: "ceo@example.com" },
});
fireEvent.change(screen.getByLabelText(/password/i), {
target: { value: "hunter2" },
});
fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(mutateAsync).toHaveBeenCalledWith({
email: "ceo@example.com",
password: "hunter2",
});
});
expect(push).toHaveBeenCalledWith("/overview");
});
it("shows the error message on bad credentials without redirecting", async () => {
mutateAsync.mockRejectedValue({
isAxiosError: true,
response: { status: 400, data: { detail: "LOGIN_BAD_CREDENTIALS" } },
});
render(<LoginPage />);
fireEvent.change(screen.getByLabelText(/email/i), {
target: { value: "ceo@example.com" },
});
fireEvent.change(screen.getByLabelText(/password/i), {
target: { value: "wrong" },
});
fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent(
"LOGIN_BAD_CREDENTIALS",
);
});
expect(push).not.toHaveBeenCalled();
});
});
+98
View File
@@ -0,0 +1,98 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getErrorMessage } from "@/lib/api/client";
import { useLogin } from "@/hooks/use-auth";
export default function LoginPage() {
const router = useRouter();
const login = useLogin();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!email || !password) {
setError("Enter your email and password");
return;
}
try {
await login.mutateAsync({ email, password });
router.push("/overview");
router.refresh();
} catch (err) {
const message = getErrorMessage(err);
setError(message);
toast.error(message);
}
};
return (
// h-dvh (not h-screen): mobile Safari's dynamic toolbar resizes the
// viewport, and 100vh doesn't track that — dvh does (matches the
// dashboard shell's own convention).
<div className="flex h-dvh items-center justify-center bg-muted/30 p-4">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>RoboCo</CardTitle>
<CardDescription>Sign in to the control panel</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="username"
inputMode="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
autoFocus
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
/>
</div>
{error && (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
)}
<Button type="submit" disabled={login.isPending} className="w-full">
{login.isPending ? "Signing in..." : "Sign in"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
+46 -7
View File
@@ -25,6 +25,7 @@ import { getAgentDisplayName } from "@/lib/agent-utils";
import { lastSenderOf } from "@/components/a2a/a2a-utils";
import { cn } from "@/lib/utils";
import {
ArrowLeft,
LayoutGrid,
List as ListIcon,
MessagesSquare,
@@ -156,8 +157,21 @@ function A2APageContent() {
(error.message?.includes("Network Error") ||
(error as { code?: string })?.code === "ERR_NETWORK");
// Below `lg` only one pane shows at a time (list/switchboard -> detail with
// a back affordance); at `lg`+ both always show side by side.
const onDetailLevel = !!selectedId || !!peekedPair;
const handleBack = useCallback(() => {
setPeekedPair(null);
const params = new URLSearchParams(searchParams.toString());
params.delete("conversation");
const qs = params.toString();
router.push(qs ? `/a2a?${qs}` : "/a2a");
}, [router, searchParams]);
return (
<div className="flex flex-col lg:h-[calc(100vh-7rem)]">
// h-dvh (not h-vh) and unconditional now (not just lg:+) so the single
// visible mobile pane gets a real height for its internal ScrollArea.
<div className="flex flex-col h-[calc(100dvh-7rem)]">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div>
@@ -194,9 +208,28 @@ function A2APageContent() {
onRetry={() => refetchConversations()}
/>
) : (
<div className="grid grid-cols-12 gap-4 lg:gap-6 lg:flex-1 lg:min-h-0">
<>
{/* Mobile-only back affordance — drills back up to the list. */}
{onDetailLevel && (
<Button
variant="ghost"
size="sm"
className="mb-2 w-fit shrink-0 lg:hidden"
onClick={handleBack}
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
)}
<div className="grid flex-1 min-h-0 grid-cols-12 gap-4 lg:gap-6">
{/* Panel 1: Switchboard (default) / classic conversation list */}
<Card className="col-span-12 lg:col-span-4 flex flex-col overflow-hidden">
<Card
className={cn(
"col-span-12 flex-col overflow-hidden lg:col-span-4 lg:flex",
onDetailLevel ? "hidden" : "flex",
)}
>
<CardContent className="p-3 flex flex-col h-full">
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
<Radio className="h-4 w-4 text-muted-foreground" />
@@ -250,7 +283,12 @@ function A2APageContent() {
</Card>
{/* Panel 2: Transcript + composer */}
<Card className="col-span-12 lg:col-span-8 flex flex-col overflow-hidden">
<Card
className={cn(
"col-span-12 flex-col overflow-hidden lg:col-span-8 lg:flex",
onDetailLevel ? "flex" : "hidden",
)}
>
<CardContent className="p-3 flex flex-col h-full">
{selected ? (
<>
@@ -299,8 +337,8 @@ function A2APageContent() {
) : (
<div className="p-4 text-center text-sm text-muted-foreground">
This conversation has no linked task, so a reply
can&apos;t be sent (A2A messages are always scoped to a
task).
can&apos;t be sent (A2A messages are always scoped to
a task).
</div>
)}
</div>
@@ -325,6 +363,7 @@ function A2APageContent() {
</CardContent>
</Card>
</div>
</>
)}
</div>
);
@@ -335,7 +374,7 @@ export default function A2APage() {
return (
<Suspense
fallback={
<div className="flex flex-col lg:h-[calc(100vh-7rem)]">
<div className="flex flex-col h-[calc(100dvh-7rem)]">
<div className="flex items-center justify-between mb-4">
<div>
<Skeleton className="h-9 w-48 mb-2" />
@@ -177,7 +177,7 @@ function SessionDetailContent() {
}
return (
<div className="flex flex-col h-[calc(100vh-7rem)]">
<div className="flex flex-col h-[calc(100dvh-7rem)]">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import { OfflineState } from "@/components/ui/offline-state";
import { cn } from "@/lib/utils";
import {
Hash,
Lock,
@@ -22,6 +23,7 @@ import {
RefreshCw,
Folder,
MessageCircle,
ArrowLeft,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import Link from "next/link";
@@ -317,10 +319,28 @@ function CommunicationsPageContent() {
[updateParams],
);
// Below `lg` only one pane is shown at a time (list -> detail drill-down);
// at `lg`+ all three always show side by side (mobilePane classes below
// are overridden by their own `lg:flex`).
const handleBack = useCallback(() => {
if (groupId) {
updateParams({ group: null });
} else if (channelId) {
updateParams({ channel: null, group: null });
}
}, [channelId, groupId, updateParams]);
const selectedChannel = channels?.find((c) => c.id === channelId);
const showChannelsPane = !channelId;
const showGroupsPane = !!channelId && !groupId;
const showSessionsPane = !!channelId && !!groupId;
return (
<div className="flex flex-col lg:h-[calc(100vh-7rem)]">
// h-dvh (not h-vh): mobile Safari's dynamic toolbar resizes the viewport,
// and this height is unconditional now (not just lg:+) so the single
// visible mobile pane also gets a real height for its ScrollArea.
<div className="flex flex-col h-[calc(100dvh-7rem)]">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div>
@@ -342,9 +362,28 @@ function CommunicationsPageContent() {
onRetry={() => refetch()}
/>
) : (
<div className="grid grid-cols-12 gap-4 lg:gap-6 lg:flex-1 lg:min-h-0">
<>
{/* Mobile-only back affordance — drills back up one level. */}
{channelId && (
<Button
variant="ghost"
size="sm"
className="mb-2 w-fit shrink-0 lg:hidden"
onClick={handleBack}
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
)}
<div className="grid flex-1 min-h-0 grid-cols-12 gap-4 lg:gap-6">
{/* Panel 1: Channels */}
<Card className="col-span-12 lg:col-span-3 flex flex-col overflow-hidden">
<Card
className={cn(
"col-span-12 flex-col overflow-hidden lg:col-span-3 lg:flex",
showChannelsPane ? "flex" : "hidden",
)}
>
<CardContent className="p-3 flex flex-col h-full">
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
<Hash className="h-4 w-4 text-muted-foreground" />
@@ -362,7 +401,12 @@ function CommunicationsPageContent() {
</Card>
{/* Panel 2: Groups */}
<Card className="col-span-12 lg:col-span-3 flex flex-col overflow-hidden">
<Card
className={cn(
"col-span-12 flex-col overflow-hidden lg:col-span-3 lg:flex",
showGroupsPane ? "flex" : "hidden",
)}
>
<CardContent className="p-3 flex flex-col h-full">
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
<Users className="h-4 w-4 text-muted-foreground" />
@@ -391,7 +435,12 @@ function CommunicationsPageContent() {
</Card>
{/* Panel 3: Sessions */}
<Card className="col-span-12 lg:col-span-6 flex flex-col overflow-hidden">
<Card
className={cn(
"col-span-12 flex-col overflow-hidden lg:col-span-6 lg:flex",
showSessionsPane ? "flex" : "hidden",
)}
>
<CardContent className="p-3 flex flex-col h-full">
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
<MessageCircle className="h-4 w-4 text-muted-foreground" />
@@ -414,6 +463,7 @@ function CommunicationsPageContent() {
</CardContent>
</Card>
</div>
</>
)}
</div>
);
@@ -424,7 +474,7 @@ export default function CommunicationsPage() {
return (
<Suspense
fallback={
<div className="flex flex-col lg:h-[calc(100vh-7rem)]">
<div className="flex flex-col h-[calc(100dvh-7rem)]">
<div className="flex items-center justify-between mb-4">
<div>
<Skeleton className="h-9 w-48 mb-2" />
+7 -2
View File
@@ -1,6 +1,7 @@
import { Suspense } from "react";
import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header";
import { BottomTabBar } from "@/components/layout/bottom-tab-bar";
import { ScrollRestoration } from "@/components/scroll-restoration";
import { RateLimitBanner } from "@/components/rate-limit/rate-limit-banner";
@@ -10,18 +11,22 @@ export default function DashboardLayout({
children: React.ReactNode;
}) {
return (
<div className="flex h-screen overflow-hidden">
// h-dvh (not h-screen/100vh): mobile Safari's dynamic toolbar resizes the
// viewport, and 100vh doesn't track that — dvh does.
<div className="flex h-dvh overflow-hidden">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<RateLimitBanner />
<main className="flex-1 overflow-auto bg-muted/30 p-6">
{/* pb-20 clears the fixed BottomTabBar on mobile; md+ has no bar. */}
<main className="flex-1 overflow-auto bg-muted/30 p-4 pb-20 md:p-6">
<Suspense fallback={null}>
<ScrollRestoration />
</Suspense>
{children}
</main>
</div>
<BottomTabBar />
</div>
);
}
+53 -1
View File
@@ -22,6 +22,12 @@ import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { OfflineState } from "@/components/ui/offline-state";
import {
ResponsiveTable,
ResponsiveTableCardList,
ResponsiveTableCard,
ResponsiveTableCardRow,
} from "@/components/ui/responsive-table";
import { DeliveryTabContent } from "@/components/metrics/delivery-tab";
import { ScorecardsTabContent } from "@/components/metrics/scorecards-tab";
import {
@@ -635,6 +641,8 @@ function RoleUsageTable({ data, isLoading }: RoleUsageTableProps) {
No usage recorded yet.
</p>
) : (
<ResponsiveTable
table={
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted-foreground">
@@ -648,7 +656,9 @@ function RoleUsageTable({ data, isLoading }: RoleUsageTableProps) {
{data.map((r) => (
<tr key={r.role} className="border-t">
<td className="py-1 font-mono text-xs">{r.role}</td>
<td className="py-1 text-right">${r.cost_usd.toFixed(4)}</td>
<td className="py-1 text-right">
${r.cost_usd.toFixed(4)}
</td>
<td className="py-1 text-right">
{(r.cache_hit_rate * 100).toFixed(1)}%
</td>
@@ -659,6 +669,28 @@ function RoleUsageTable({ data, isLoading }: RoleUsageTableProps) {
))}
</tbody>
</table>
}
cards={
<ResponsiveTableCardList>
{data.map((r) => (
<ResponsiveTableCard key={r.role}>
<span className="font-mono text-sm">{r.role}</span>
<div className="mt-2 divide-y">
<ResponsiveTableCardRow label="Cost">
${r.cost_usd.toFixed(4)}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Cache hit">
{(r.cache_hit_rate * 100).toFixed(1)}%
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="% of total">
{r.pct_of_total.toFixed(1)}%
</ResponsiveTableCardRow>
</div>
</ResponsiveTableCard>
))}
</ResponsiveTableCardList>
}
/>
)}
</CardContent>
</Card>
@@ -696,6 +728,8 @@ function SpawnWasteCard({ data, isLoading }: SpawnWasteCardProps) {
</p>
</div>
{data.by_role.length > 0 && (
<ResponsiveTable
table={
<table className="w-full text-xs">
<tbody>
{data.by_role.map((r) => (
@@ -711,6 +745,24 @@ function SpawnWasteCard({ data, isLoading }: SpawnWasteCardProps) {
))}
</tbody>
</table>
}
cards={
<div className="space-y-1.5">
{data.by_role.map((r) => (
<div
key={r.role}
className="flex items-center justify-between border-t pt-1.5 text-xs"
>
<span className="font-mono">{r.role}</span>
<span className="text-muted-foreground">
{r.unproductive}/{r.spawns} (
{r.unproductive_pct.toFixed(0)}%)
</span>
</div>
))}
</div>
}
/>
)}
{data.respawn_strikes.length > 0 && (
<p className="text-xs text-muted-foreground">
@@ -225,40 +225,40 @@ function NotificationsPageContent() {
</div>
</div>
{/* Stats */}
{/* Stats — tighter padding below sm so 3 columns still fit at 375px */}
{!isOffline && data && (
<div className="grid grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-2">
<div className="grid grid-cols-3 gap-2 sm:gap-4">
<Card className="py-4 sm:py-6">
<CardHeader className="px-3 pb-2 sm:px-6">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total
</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="px-3 sm:px-6">
<div className="text-2xl font-bold">{data.total}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<Card className="py-4 sm:py-6">
<CardHeader className="px-3 pb-2 sm:px-6">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Mail className="h-4 w-4" />
Unread
</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="px-3 sm:px-6">
<div className="text-2xl font-bold text-blue-600">
{data.unread_count}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<Card className="py-4 sm:py-6">
<CardHeader className="px-3 pb-2 sm:px-6">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Bell className="h-4 w-4" />
Pending Ack
</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="px-3 sm:px-6">
<div className="text-2xl font-bold text-red-600">
{data.pending_ack_count}
</div>
+6 -1
View File
@@ -27,6 +27,7 @@ import { toast } from "sonner";
import { API_URL, WS_URL } from "@/lib/constants";
import { TranscriptRetentionCard } from "@/components/settings/transcript-retention-card";
import { FeatureFlagsCard } from "@/components/settings/feature-flags-card";
import { XCredentialsCard } from "@/components/settings/x-credentials-card";
export default function SettingsPage() {
const { theme, setTheme } = useTheme();
@@ -54,7 +55,8 @@ export default function SettingsPage() {
{/* Cards grid two columns on large screens. Order (row,col):
User Info (1,1) · Appearance (1,2) · Data & Refresh (2,1) ·
Transcript Retention (2,2) · Notifications (3,1) · Connection Info (3,2). */}
Transcript Retention (2,2) · Notifications (3,1) · Connection Info (3,2) ·
X Credentials (4,1). */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* User Info */}
<Card>
@@ -245,6 +247,9 @@ export default function SettingsPage() {
</p>
</CardContent>
</Card>
{/* X (Twitter) Credentials (4,1) — write-only, panel-tunable */}
<XCredentialsCard />
</div>
{/* Feature Flags master switches for optional subsystems (full width;
@@ -239,7 +239,7 @@ export function SecretaryTab() {
return (
<div className="grid gap-6 lg:grid-cols-3">
{/* Chat panel */}
<Card className="flex min-h-[60vh] flex-col lg:col-span-2">
<Card className="flex min-h-[60dvh] flex-col lg:col-span-2">
<CardHeader className="flex-row items-center justify-between space-y-0 pb-3">
<CardTitle>Chat</CardTitle>
{sessionId && (
@@ -52,7 +52,13 @@ export function MessageComposer({
};
return (
<form onSubmit={handleSubmit} className="border-t p-4">
// paddingBottom includes the safe-area inset so the composer clears the
// home indicator on notched phones instead of sitting flush under it.
<form
onSubmit={handleSubmit}
className="border-t p-4"
style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
>
<div className="flex items-end gap-2">
<div className="flex-1">
<Textarea
@@ -0,0 +1,137 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { RoadmapCycle } from "@/lib/api/roadmap";
const { resolveApproveRef } = vi.hoisted(() => ({
resolveApproveRef: { current: null as null | ((v: unknown) => void) },
}));
const { listCycles, approveItem, rejectItem } = vi.hoisted(() => ({
listCycles: vi.fn(
async () =>
[
{
task_id: "cycle-1",
title: "Roadmap exploration cycle",
status: "pending",
goal: "Close onboarding friction",
items: [
{
id: "item-0",
title: "Streamline signup",
description: "Cut the signup form from 8 fields to 3",
acceptance_criteria: ["signup takes < 30s"],
project_slug: "backend-svc",
team: "backend",
priority: 2,
rationale: "signup drop-off is the top funnel leak",
status: "proposed",
},
{
id: "item-1",
title: "Simplify pricing page",
description: "Remove the three-tier confusion",
acceptance_criteria: ["one clear CTA"],
project_slug: "frontend-app",
team: "frontend",
priority: 3,
rationale: "pricing page bounce rate is high",
status: "proposed",
},
],
},
] as RoadmapCycle[],
),
// Deferred so the test can freeze the approve mid-flight.
approveItem: vi.fn(
() =>
new Promise((r) => {
resolveApproveRef.current = r as (v: unknown) => void;
}),
),
rejectItem: vi.fn(async () => ({})),
}));
vi.mock("@/lib/api", () => ({
roadmapApi: { listCycles, approveItem, rejectItem },
}));
import { RoadmapReviewQueue } from "../roadmap-review-queue";
function withQueryClient(ui: ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
}
describe("RoadmapReviewQueue", () => {
beforeEach(() => {
listCycles.mockClear();
approveItem.mockClear();
rejectItem.mockClear();
resolveApproveRef.current = null;
});
afterEach(() => {
vi.clearAllMocks();
});
it("renders the cycle goal and both item drafts", async () => {
render(withQueryClient(<RoadmapReviewQueue />));
expect(await screen.findByText("Close onboarding friction")).toBeInTheDocument();
expect(screen.getByText("Streamline signup")).toBeInTheDocument();
expect(screen.getByText("Simplify pricing page")).toBeInTheDocument();
});
it("disables only the item being approved, not every row's Approve", async () => {
render(withQueryClient(<RoadmapReviewQueue />));
const approveButtons = await screen.findAllByRole("button", {
name: /Approve/,
});
expect(approveButtons).toHaveLength(2);
expect(approveButtons[0]).not.toBeDisabled();
expect(approveButtons[1]).not.toBeDisabled();
fireEvent.click(approveButtons[0]);
await waitFor(() =>
expect(approveItem).toHaveBeenCalledWith("cycle-1", "item-0"),
);
await waitFor(() => expect(approveButtons[0]).toBeDisabled());
expect(approveButtons[1]).not.toBeDisabled();
resolveApproveRef.current?.({
status: "approved",
item_id: "item-0",
materialized_task_id: "t-1",
detail: "materialized into the backlog",
});
await waitFor(() => expect(approveButtons[0]).not.toBeDisabled());
});
it("rejects an item with a reason", async () => {
render(withQueryClient(<RoadmapReviewQueue />));
const rejectButtons = await screen.findAllByRole("button", {
name: "Reject",
});
fireEvent.click(rejectButtons[1]);
const reasonBox = await screen.findByLabelText("Reason");
fireEvent.change(reasonBox, { target: { value: "not a priority" } });
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
await waitFor(() =>
expect(rejectItem).toHaveBeenCalledWith("cycle-1", "item-1", "not a priority"),
);
});
it("renders nothing when there is no authored cycle", async () => {
listCycles.mockResolvedValueOnce([]);
const { container } = render(withQueryClient(<RoadmapReviewQueue />));
await waitFor(() => expect(listCycles).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,132 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { XPost } from "@/lib/api/x";
const { resolveApproveRef } = vi.hoisted(() => ({
resolveApproveRef: { current: null as null | ((v: unknown) => void) },
}));
const { listPosts, approve, reject } = vi.hoisted(() => ({
listPosts: vi.fn(
async () =>
[
{
task_id: "x-1",
source: "x_post",
title: "X post: release v0.17.0",
status: "pending",
body: "RoboCo v0.17.0 just shipped!",
char_count: 28,
release_version: "0.17.0",
},
{
task_id: "x-2",
source: "x_reply",
title: "X reply: mention m1",
status: "pending",
body: "Thanks for the shoutout!",
char_count: 24,
mention: { id: "m1", author_id: "a1", text: "great work @roboco" },
},
] as XPost[],
),
// Deferred so the test can freeze the approve mid-flight.
approve: vi.fn(
() =>
new Promise((r) => {
resolveApproveRef.current = r as (v: unknown) => void;
}),
),
reject: vi.fn(async () => ({})),
}));
vi.mock("@/lib/api", () => ({ xApi: { listPosts, approve, reject } }));
import { XPostQueue } from "../x-post-queue";
function withQueryClient(ui: ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
}
describe("XPostQueue", () => {
beforeEach(() => {
listPosts.mockClear();
approve.mockClear();
reject.mockClear();
resolveApproveRef.current = null;
});
afterEach(() => {
vi.clearAllMocks();
});
it("renders both a release post and a mention reply draft", async () => {
render(withQueryClient(<XPostQueue />));
expect(await screen.findByText("Release post")).toBeInTheDocument();
expect(await screen.findByText("Mention reply")).toBeInTheDocument();
expect(screen.getByDisplayValue("RoboCo v0.17.0 just shipped!")).toBeInTheDocument();
});
it("disables only the row being approved, not every row's Approve", async () => {
render(withQueryClient(<XPostQueue />));
const approveButtons = await screen.findAllByRole("button", {
name: /Approve/,
});
expect(approveButtons).toHaveLength(2);
expect(approveButtons[0]).not.toBeDisabled();
expect(approveButtons[1]).not.toBeDisabled();
fireEvent.click(approveButtons[0]);
await waitFor(() =>
expect(approve).toHaveBeenCalledWith("x-1", "RoboCo v0.17.0 just shipped!"),
);
await waitFor(() => expect(approveButtons[0]).toBeDisabled());
expect(approveButtons[1]).not.toBeDisabled();
resolveApproveRef.current?.({ status: "posted", tweet_id: "1", detail: "ok" });
await waitFor(() => expect(approveButtons[0]).not.toBeDisabled());
});
it("disables Approve when the edited body exceeds 280 characters", async () => {
render(withQueryClient(<XPostQueue />));
const textarea = await screen.findByDisplayValue(
"RoboCo v0.17.0 just shipped!",
);
fireEvent.change(textarea, { target: { value: "x".repeat(281) } });
const approveButtons = await screen.findAllByRole("button", {
name: /Approve/,
});
expect(approveButtons[0]).toBeDisabled();
expect(screen.getByText("281/280")).toBeInTheDocument();
});
it("rejects a draft with a reason", async () => {
render(withQueryClient(<XPostQueue />));
const rejectButtons = await screen.findAllByRole("button", {
name: "Reject",
});
fireEvent.click(rejectButtons[1]);
const reasonBox = await screen.findByLabelText("Reason");
fireEvent.change(reasonBox, { target: { value: "not relevant" } });
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
await waitFor(() =>
expect(reject).toHaveBeenCalledWith("x-2", "not relevant"),
);
});
it("renders nothing when the queue is empty", async () => {
listPosts.mockResolvedValueOnce([]);
const { container } = render(withQueryClient(<XPostQueue />));
await waitFor(() => expect(listPosts).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
});
@@ -213,9 +213,9 @@ export function CeoApprovalQueue({ className }: CeoApprovalQueueProps) {
const renderRow = (task: Task, kind: "start" | "approve") => (
<div
key={task.id}
className="flex items-start justify-between p-4 border rounded-lg hover:bg-muted/50 transition-colors"
className="flex flex-col gap-3 border rounded-lg p-4 transition-colors hover:bg-muted/50 sm:flex-row sm:items-start sm:justify-between"
>
<div className="flex-1 min-w-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
{getPriorityBadge(task.priority)}
<Badge variant="outline">{task.team}</Badge>
@@ -233,7 +233,9 @@ export function CeoApprovalQueue({ className }: CeoApprovalQueueProps) {
</p>
)}
</div>
<div className="flex items-center gap-2 ml-4 flex-shrink-0">
{/* Action cluster stacks below the task text on narrow instead of
clipping against it (mirrors the dialog footer's flex-col sm:flex-row). */}
<div className="flex flex-wrap items-center gap-2 sm:ml-4 sm:shrink-0">
<Link href={`/tasks/${task.id}`} prefetch={false}>
<Button variant="ghost" size="sm">
<FileText className="h-4 w-4" />
@@ -16,6 +16,8 @@ import { CeoApprovalQueue } from "./ceo-approval-queue";
import { PrReviewQueue } from "./pr-review-queue";
import { ReleaseProposalCard } from "./release-proposal-card";
import { PlaybookReviewQueue } from "./playbook-review-queue";
import { XPostQueue } from "./x-post-queue";
import { RoadmapReviewQueue } from "./roadmap-review-queue";
import { StrategySignalsPanel } from "./strategy-signals-panel";
import type { Activity } from "./activity-item";
import { Button } from "@/components/ui/button";
@@ -60,9 +62,13 @@ export function CommandCenter() {
};
return (
<div className="space-y-6">
// flex-col + explicit `order` (reset via md:order-none): below md the CEO
// decision queues and activity move above the fold; at md+ every item
// shares order:0 and falls back to plain source order (unchanged desktop
// layout).
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="order-1 flex items-center justify-between md:order-none">
<div>
<h1 className="text-3xl font-bold tracking-tight">
RoboCo Command Center
@@ -86,14 +92,56 @@ export function CommandCenter() {
{/* Error indicator */}
{hasError && (
<div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-2 text-sm text-destructive">
<div className="order-2 flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-2 text-sm text-destructive md:order-none">
<AlertCircle className="h-4 w-4 shrink-0" />
Some data failed to load. Click Refresh to try again.
</div>
)}
{/* CEO Approval Queue + Strategy Signals - side-by-side on lg+. Ordered
first on mobile the CEO's decisions shouldn't be below the fold. */}
<div className="order-3 grid grid-cols-1 gap-6 md:order-none lg:grid-cols-2">
<CeoApprovalQueue />
<StrategySignalsPanel />
</div>
{/* External-PR review decision queue (hidden when empty) */}
<div className="order-4 md:order-none">
<PrReviewQueue />
</div>
{/* Gated release proposal (hidden when none open) */}
<div className="order-4 md:order-none">
<ReleaseProposalCard />
</div>
{/* Playbook review queue (hidden when no drafts) */}
<div className="order-4 md:order-none">
<PlaybookReviewQueue />
</div>
{/* X post/reply queue (hidden when no drafts) */}
<div className="order-4 md:order-none">
<XPostQueue />
</div>
{/* Board roadmap queue (hidden when no cycle authored) */}
<div className="order-4 md:order-none">
<RoadmapReviewQueue />
</div>
{/* Blockers and Activity Row activity brought up near the top on
mobile too, ahead of the Team Health / Quick Actions filler. */}
<div className="order-5 grid grid-cols-1 gap-6 md:order-none lg:grid-cols-2">
<ActiveBlockersPanel tasks={tasks} isLoading={loadingTasks} />
<RecentActivityFeed
activities={activity as Activity[] | undefined}
isLoading={loadingActivity}
/>
</div>
{/* Team Health */}
<section>
<section className="order-6 md:order-none">
<h2 className="text-lg font-semibold mb-4">Team Health</h2>
<TeamHealthCards
teams={overview?.health_status}
@@ -101,31 +149,14 @@ export function CommandCenter() {
/>
</section>
{/* Quick Actions placed immediately after Team Health so it is
visible without scrolling on a 900px-tall viewport, before the
data-heavy grid rows below. */}
<section>
{/* Quick Actions */}
<section className="order-7 md:order-none">
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2>
<QuickActionsBar />
</section>
{/* CEO Approval Queue + Strategy Signals - side-by-side on lg+ */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<CeoApprovalQueue />
<StrategySignalsPanel />
</div>
{/* External-PR review decision queue (hidden when empty) */}
<PrReviewQueue />
{/* Gated release proposal (hidden when none open) */}
<ReleaseProposalCard />
{/* Playbook review queue (hidden when no drafts) */}
<PlaybookReviewQueue />
{/* Metrics, Alerts, Usage, and Performance Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-6">
<div className="order-8 grid grid-cols-1 gap-6 md:order-none lg:grid-cols-2 xl:grid-cols-4">
<KeyMetricsPanel
metrics={overview?.key_metrics}
isLoading={loadingOverview}
@@ -134,15 +165,6 @@ export function CommandCenter() {
<UsageOverviewPanel />
<ScorecardOverviewPanel />
</div>
{/* Blockers and Activity Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2 gap-6">
<ActiveBlockersPanel tasks={tasks} isLoading={loadingTasks} />
<RecentActivityFeed
activities={activity as Activity[] | undefined}
isLoading={loadingActivity}
/>
</div>
</div>
);
}
+1
View File
@@ -12,6 +12,7 @@ export { HealthIndicator } from "./health-indicator";
export { CeoApprovalQueue } from "./ceo-approval-queue";
export { ReleaseProposalCard } from "./release-proposal-card";
export { PlaybookReviewQueue } from "./playbook-review-queue";
export { RoadmapReviewQueue } from "./roadmap-review-queue";
export { StrategySignalsPanel } from "./strategy-signals-panel";
export { UsageOverviewPanel } from "./usage-overview-panel";
export { ScorecardOverviewPanel } from "./scorecard-overview-panel";
@@ -119,7 +119,7 @@ export function PlaybookReviewQueue({ className }: { className?: string }) {
<pre className="mt-2 max-h-40 overflow-auto rounded bg-muted p-2 text-xs whitespace-pre-wrap">
{pb.procedure}
</pre>
<div className="mt-3 flex items-center justify-end gap-2">
<div className="mt-3 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
<Button
variant="outline"
size="sm"
@@ -227,7 +227,7 @@ export function ReleaseProposalCard({ className }: { className?: string }) {
</p>
)}
<div className="flex items-center justify-end gap-2 pt-1">
<div className="flex flex-col-reverse gap-2 pt-1 sm:flex-row sm:items-center sm:justify-end">
<Button
variant="outline"
size="sm"
@@ -0,0 +1,284 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { roadmapApi } from "@/lib/api";
import type { RoadmapCycle, RoadmapItem } from "@/lib/api/roadmap";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { CheckCircle2, Map, XCircle } from "lucide-react";
import { toast } from "sonner";
const _MIN_REASON_CHARS = 4;
interface RejectTarget {
taskId: string;
item: RoadmapItem;
}
function itemStatusBadge(item: RoadmapItem) {
if (item.status === "approved") {
return (
<Badge variant="secondary" className="bg-green-600/10 text-green-700">
Approved
</Badge>
);
}
if (item.status === "rejected") {
return <Badge variant="outline">Rejected</Badge>;
}
return null;
}
// One roadmap item row: details + per-item approve/reject (proposed only).
function RoadmapItemRow({
taskId,
item,
onApprove,
onReject,
approving,
}: {
taskId: string;
item: RoadmapItem;
onApprove: (taskId: string, itemId: string) => void;
onReject: (target: RejectTarget) => void;
approving: boolean;
}) {
const isProposed = item.status === "proposed";
return (
<div className="rounded-lg border p-4 transition-colors hover:bg-muted/50">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="font-medium">{item.title}</span>
<Badge variant="outline">{item.team}</Badge>
<Badge variant="outline">{item.project_slug}</Badge>
<Badge variant="secondary">P{item.priority}</Badge>
{itemStatusBadge(item)}
</div>
<p className="text-sm text-muted-foreground">{item.description}</p>
<p className="mt-1 text-sm text-muted-foreground">
<span className="font-semibold">Why:</span> {item.rationale}
</p>
{item.acceptance_criteria.length > 0 && (
<ul className="mt-2 list-disc space-y-0.5 pl-5 text-sm text-muted-foreground">
{item.acceptance_criteria.map((ac) => (
<li key={ac}>{ac}</li>
))}
</ul>
)}
{item.status === "rejected" && item.reject_reason && (
<p className="mt-2 text-sm text-destructive">
Rejected: {item.reject_reason}
</p>
)}
{isProposed && (
<div className="mt-3 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => onReject({ taskId, item })}
>
<XCircle className="mr-1 h-4 w-4" />
Reject
</Button>
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
disabled={approving}
onClick={() => onApprove(taskId, item.id)}
>
<CheckCircle2 className="mr-1 h-4 w-4" />
Approve
</Button>
</div>
)}
</div>
);
}
function RoadmapCycleCard({
cycle,
onApprove,
onReject,
approvingItemId,
}: {
cycle: RoadmapCycle;
onApprove: (taskId: string, itemId: string) => void;
onReject: (target: RejectTarget) => void;
approvingItemId: string | null;
}) {
const pending = cycle.items.filter((i) => i.status === "proposed").length;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Map className="h-5 w-5" />
Roadmap Cycle
<Badge variant="secondary">{pending} pending</Badge>
</CardTitle>
<CardDescription>{cycle.goal}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{cycle.items.map((item) => (
<RoadmapItemRow
key={item.id}
taskId={cycle.task_id}
item={item}
onApprove={onApprove}
onReject={onReject}
approving={approvingItemId === item.id}
/>
))}
</CardContent>
</Card>
);
}
// CEO queue for the Product Owner's held roadmap cycles. Hidden when no
// cycle has been authored yet (mirrors the playbook + X post queues).
export function RoadmapReviewQueue({ className }: { className?: string }) {
const queryClient = useQueryClient();
const [rejecting, setRejecting] = useState<RejectTarget | null>(null);
const [reason, setReason] = useState("");
const [approvingItemId, setApprovingItemId] = useState<string | null>(null);
const { data: cycles, isLoading } = useQuery({
queryKey: ["roadmap", "cycles"],
queryFn: () => roadmapApi.listCycles(),
refetchInterval: 30000,
});
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["roadmap", "cycles"] });
const approveMutation = useMutation({
mutationFn: ({ taskId, itemId }: { taskId: string; itemId: string }) =>
roadmapApi.approveItem(taskId, itemId),
onSuccess: (result) => {
invalidate();
if (result.status === "approved" || result.status === "already_approved") {
toast.success("Item approved — added to the backlog");
} else {
toast.warning(result.detail);
}
},
onError: (e) =>
toast.error(
`Approve failed: ${e instanceof Error ? e.message : "error"}`,
),
onSettled: () => setApprovingItemId(null),
});
const rejectMutation = useMutation({
mutationFn: ({
taskId,
itemId,
reason,
}: {
taskId: string;
itemId: string;
reason: string;
}) => roadmapApi.rejectItem(taskId, itemId, reason),
onSuccess: () => {
invalidate();
toast.success("Item rejected");
closeReject();
},
onError: (e) =>
toast.error(`Reject failed: ${e instanceof Error ? e.message : "error"}`),
});
const closeReject = () => {
setRejecting(null);
setReason("");
};
const confirmReject = () => {
if (!rejecting) return;
if (reason.trim().length < _MIN_REASON_CHARS) {
toast.error("Give a brief reason for rejecting");
return;
}
rejectMutation.mutate({
taskId: rejecting.taskId,
itemId: rejecting.item.id,
reason: reason.trim(),
});
};
const handleApprove = (taskId: string, itemId: string) => {
setApprovingItemId(itemId);
approveMutation.mutate({ taskId, itemId });
};
if (isLoading || !cycles || cycles.length === 0) return null;
return (
<>
<div className={`space-y-4 ${className ?? ""}`}>
{cycles.map((cycle) => (
<RoadmapCycleCard
key={cycle.task_id}
cycle={cycle}
onApprove={handleApprove}
onReject={setRejecting}
approvingItemId={approvingItemId}
/>
))}
</div>
<Dialog open={!!rejecting} onOpenChange={() => closeReject()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reject roadmap item</DialogTitle>
<DialogDescription>
This records your reason and feeds the next cycle&apos;s prompt
it is not added to the backlog.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="roadmap-reject-reason">Reason</Label>
<Textarea
id="roadmap-reject-reason"
placeholder="e.g. not a priority this quarter; overlaps an existing task..."
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={closeReject}>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmReject}
disabled={rejectMutation.isPending}
>
{rejectMutation.isPending ? "Rejecting..." : "Reject"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,275 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { xApi } from "@/lib/api";
import type { XPost, XPostExecuteResult } from "@/lib/api/x";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { AtSign, CheckCircle2, Rocket, XCircle } from "lucide-react";
import { toast } from "sonner";
const MAX_TWEET_CHARS = 280;
const _MIN_REASON_CHARS = 4;
function sourceMeta(source: XPost["source"]) {
return source === "x_post"
? { label: "Release post", icon: Rocket }
: { label: "Mention reply", icon: AtSign };
}
function describeExecuteResult(result: XPostExecuteResult): string {
if (result.status === "posted") return "Posted to X.";
if (result.status === "already_posted") return "Already posted — no-op.";
if (result.status === "already_in_progress")
return "A post is already in progress for this draft.";
if (result.status === "no_credentials")
return "No X credentials configured — set them below first.";
return `${result.status}: ${result.detail}`;
}
// One row of the queue: an editable draft body + char counter + approve/reject.
function XPostRow({
post,
onApprove,
onReject,
approving,
}: {
post: XPost;
onApprove: (taskId: string, body: string) => void;
onReject: (post: XPost) => void;
approving: boolean;
}) {
// `edited` holds the user's in-progress textarea input; null means "show
// the server value". Deriving the displayed body avoids syncing query
// state into local state with an effect (mirrors TranscriptRetentionCard).
const [edited, setEdited] = useState<string | null>(null);
const body = edited ?? post.body;
const meta = sourceMeta(post.source);
const overLimit = body.length > MAX_TWEET_CHARS;
return (
<div className="rounded-lg border p-4 transition-colors hover:bg-muted/50">
<div className="mb-2 flex flex-wrap items-center gap-2">
<meta.icon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{meta.label}</span>
{post.release_version && (
<Badge variant="outline">v{post.release_version}</Badge>
)}
{post.mention && (
<Badge variant="secondary" className="max-w-56 truncate">
re: {post.mention.text}
</Badge>
)}
</div>
<Textarea
value={body}
onChange={(e) => setEdited(e.target.value)}
rows={3}
className={overLimit ? "border-destructive" : undefined}
/>
<p
className={`mt-1 text-right text-xs ${
overLimit ? "text-destructive" : "text-muted-foreground"
}`}
>
{body.length}/{MAX_TWEET_CHARS}
</p>
<div className="mt-2 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => onReject(post)}
>
<XCircle className="mr-1 h-4 w-4" />
Reject
</Button>
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
disabled={approving || overLimit || body.trim().length === 0}
onClick={() => onApprove(post.task_id, body)}
>
<CheckCircle2 className="mr-1 h-4 w-4" />
Approve &amp; post
</Button>
</div>
</div>
);
}
// CEO queue for held X drafts (release posts + mention replies). Hidden when
// empty (mirrors the release-proposal + playbook-review queues).
export function XPostQueue({ className }: { className?: string }) {
const queryClient = useQueryClient();
const [rejecting, setRejecting] = useState<XPost | null>(null);
const [reason, setReason] = useState("");
const [approvingId, setApprovingId] = useState<string | null>(null);
const { data: posts, isLoading } = useQuery({
queryKey: ["x", "posts"],
queryFn: () => xApi.listPosts(),
refetchInterval: 30000,
});
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["x", "posts"] });
const approveMutation = useMutation({
mutationFn: ({ taskId, body }: { taskId: string; body: string }) =>
xApi.approve(taskId, body),
onSuccess: (result) => {
invalidate();
if (result.status === "posted") {
toast.success(describeExecuteResult(result));
} else {
toast.warning(describeExecuteResult(result));
}
},
onError: (e) =>
toast.error(
`Approve failed: ${e instanceof Error ? e.message : "error"}`,
),
onSettled: () => setApprovingId(null),
});
const rejectMutation = useMutation({
mutationFn: ({ taskId, reason }: { taskId: string; reason: string }) =>
xApi.reject(taskId, reason),
onSuccess: () => {
invalidate();
toast.success("Draft rejected");
closeReject();
},
onError: (e) =>
toast.error(`Reject failed: ${e instanceof Error ? e.message : "error"}`),
});
const closeReject = () => {
setRejecting(null);
setReason("");
};
const confirmReject = () => {
if (!rejecting) return;
if (reason.trim().length < _MIN_REASON_CHARS) {
toast.error("Give a brief reason for rejecting");
return;
}
rejectMutation.mutate({ taskId: rejecting.task_id, reason: reason.trim() });
};
const handleApprove = (taskId: string, body: string) => {
setApprovingId(taskId);
approveMutation.mutate({ taskId, body });
};
if (isLoading) return null;
if (!posts || posts.length === 0) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Rocket className="h-5 w-5" />
X Post Queue
</CardTitle>
<CardDescription>
Drafted release announcements (and mention replies, if enabled) land
here for you to edit, approve, or reject. Nothing posts on its own.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
No drafts yet. A post is drafted here when a release publishes set
your keys in Settings X (Twitter) Credentials and enable the X
engine to start.
</p>
</CardContent>
</Card>
);
}
return (
<>
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Rocket className="h-5 w-5" />
X Post Queue
<Badge variant="secondary">{posts.length}</Badge>
</CardTitle>
<CardDescription>
Drafted release announcements and mention replies edit, approve
(posts to X), or reject. Nothing posts on its own.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{posts.map((post) => (
<XPostRow
key={post.task_id}
post={post}
onApprove={handleApprove}
onReject={setRejecting}
approving={approvingId === post.task_id}
/>
))}
</CardContent>
</Card>
<Dialog open={!!rejecting} onOpenChange={() => closeReject()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reject draft</DialogTitle>
<DialogDescription>
This cancels the draft it will not be posted. Give a brief
reason (it is recorded).
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="x-reject-reason">Reason</Label>
<Textarea
id="x-reject-reason"
placeholder="e.g. tone doesn't match our voice; not worth a public reply..."
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={closeReject}>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmReject}
disabled={rejectMutation.isPending}
>
{rejectMutation.isPending ? "Rejecting..." : "Reject"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+46 -5
View File
@@ -1,12 +1,15 @@
"use client";
import { useState } from "react";
import { GitDiffResponse } from "@/types/git";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { FileCode, FileDiff } from "lucide-react";
import { cn } from "@/lib/utils";
import { FileCode, FileDiff, WrapText } from "lucide-react";
interface GitDiffViewerProps {
stagedDiff: GitDiffResponse | undefined;
@@ -18,9 +21,11 @@ interface GitDiffViewerProps {
function DiffContent({
diff,
isLoading,
wrap,
}: {
diff: GitDiffResponse | undefined;
isLoading: boolean;
wrap: boolean;
}) {
if (isLoading) {
return (
@@ -45,8 +50,15 @@ function DiffContent({
const lines = diff.diff.split("\n");
return (
// overflow-x-auto is the horizontal-scroll affordance for un-wrapped long
// lines on a phone; smaller mobile font, back to the desktop size at sm+.
<ScrollArea className="h-96">
<pre className="p-4 text-xs font-mono leading-relaxed">
<pre
className={cn(
"p-4 font-mono text-[11px] leading-relaxed sm:text-xs",
wrap ? "whitespace-pre-wrap break-all" : "overflow-x-auto",
)}
>
{lines.map((line, i) => {
let className = "";
if (line.startsWith("+") && !line.startsWith("+++")) {
@@ -60,7 +72,14 @@ function DiffContent({
}
return (
<div key={i} className={`px-2 -mx-2 whitespace-pre ${className}`}>
<div
key={i}
className={cn(
"px-2 -mx-2",
wrap ? "whitespace-pre-wrap break-all" : "whitespace-pre",
className,
)}
>
{line || " "}
</div>
);
@@ -78,14 +97,28 @@ export function GitDiffViewer({
}: GitDiffViewerProps) {
const stagedCount = stagedDiff?.files_changed || 0;
const unstagedCount = unstagedDiff?.files_changed || 0;
const [wrap, setWrap] = useState(false);
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-sm flex items-center gap-2">
<FileCode className="h-4 w-4" />
Changes
</CardTitle>
<Button
variant={wrap ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2 text-xs"
aria-pressed={wrap}
onClick={() => setWrap((w) => !w)}
title="Toggle line wrap"
>
<WrapText className="h-3.5 w-3.5 mr-1" />
Wrap
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
<Tabs defaultValue="unstaged">
@@ -111,11 +144,19 @@ export function GitDiffViewer({
</div>
<TabsContent value="unstaged" className="m-0">
<DiffContent diff={unstagedDiff} isLoading={isLoadingUnstaged} />
<DiffContent
diff={unstagedDiff}
isLoading={isLoadingUnstaged}
wrap={wrap}
/>
</TabsContent>
<TabsContent value="staged" className="m-0">
<DiffContent diff={stagedDiff} isLoading={isLoadingStaged} />
<DiffContent
diff={stagedDiff}
isLoading={isLoadingStaged}
wrap={wrap}
/>
</TabsContent>
</Tabs>
</CardContent>
@@ -353,7 +353,7 @@ function KnowledgeBaseBrowserContent() {
onChange={handleSearchChange}
isLoading={searchLoading}
/>
<ScrollArea className="h-[calc(100vh-380px)]">
<ScrollArea className="h-[calc(100dvh-380px)]">
<KBResultList
response={searchResults}
isLoading={searchLoading}
@@ -378,7 +378,7 @@ function KnowledgeBaseBrowserContent() {
onSubmit={handleRAGQuery}
isLoading={ragMutation.isPending}
/>
<ScrollArea className="h-[calc(100vh-450px)]">
<ScrollArea className="h-[calc(100dvh-450px)]">
<RAGAnswerDisplay
response={ragResponse}
isLoading={ragMutation.isPending}
@@ -410,7 +410,7 @@ function KnowledgeBaseBrowserContent() {
{/* Main content */}
<div className="lg:col-span-3">
<ScrollArea className="h-[calc(100vh-320px)]">
<ScrollArea className="h-[calc(100dvh-320px)]">
<KBCategoryView category={selectedCategory} />
</ScrollArea>
</div>
@@ -104,7 +104,7 @@ export function MentorChat({ onAsk, isLoading }: MentorChatProps) {
// Empty state
if (messages.length === 0 && !isLoading) {
return (
<div className="flex flex-col h-[calc(100vh-280px)]">
<div className="flex flex-col h-[calc(100dvh-280px)]">
{/* Empty state */}
<div className="flex-1 flex items-center justify-center">
<div className="text-center max-w-md">
@@ -159,7 +159,7 @@ export function MentorChat({ onAsk, isLoading }: MentorChatProps) {
}
return (
<div className="flex flex-col h-[calc(100vh-280px)]">
<div className="flex flex-col h-[calc(100dvh-280px)]">
{/* Header with New Chat */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
@@ -0,0 +1,49 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { LayoutDashboard, ListTodo, Kanban, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
const BOTTOM_NAV_ITEMS = [
{ title: "Overview", href: "/overview", icon: LayoutDashboard },
{ title: "Tasks", href: "/tasks", icon: ListTodo },
{ title: "Kanban", href: "/kanban", icon: Kanban },
{ title: "Chat", href: "/prompter", icon: Sparkles },
];
/**
* Persistent one-thumb-reach bottom nav for the 4 most-used destinations,
* alongside the full-nav drawer (MobileSidebar) the drawer covers every
* route, this covers the common loop without opening it. `md:hidden` mirrors
* the sidebar's own breakpoint so exactly one nav surface is ever visible.
*/
export function BottomTabBar() {
const pathname = usePathname();
return (
<nav
aria-label="Primary"
className="fixed inset-x-0 bottom-0 z-40 flex border-t bg-background pb-[env(safe-area-inset-bottom)] md:hidden"
>
{BOTTOM_NAV_ITEMS.map((item) => {
const isActive = pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
prefetch={false}
aria-current={isActive ? "page" : undefined}
className={cn(
"flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium transition-colors",
isActive ? "text-primary" : "text-muted-foreground",
)}
>
<item.icon className="h-5 w-5" />
{item.title}
</Link>
);
})}
</nav>
);
}
@@ -11,6 +11,7 @@ import {
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useIsMobile } from "@/hooks/use-is-mobile";
import type { AgentUsageRow } from "@/types";
interface AgentUsageChartProps {
@@ -24,9 +25,12 @@ function fmtK(n: number): string {
}
export function AgentUsageChart({ data, isLoading }: AgentUsageChartProps) {
const isMobile = useIsMobile();
// Fewer bars on a phone — 10 labels at ~30deg rotation still overlap below
// ~400px, so cap the label density instead of shrinking text further.
const chartData = [...(data ?? [])]
.sort((a, b) => b.total_tokens - a.total_tokens)
.slice(0, 10)
.slice(0, isMobile ? 6 : 10)
.map((row) => ({
name: row.agent_slug,
Tokens: row.total_tokens,
@@ -49,9 +53,10 @@ export function AgentUsageChart({ data, isLoading }: AgentUsageChartProps) {
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
<XAxis
dataKey="name"
tick={{ fontSize: 10 }}
angle={-30}
tick={{ fontSize: isMobile ? 9 : 10 }}
angle={isMobile ? -45 : -30}
textAnchor="end"
interval={0}
axisLine={false}
tickLine={false}
/>
+38 -2
View File
@@ -12,6 +12,12 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import {
ResponsiveTable,
ResponsiveTableCardList,
ResponsiveTableCard,
ResponsiveTableCardRow,
} from "@/components/ui/responsive-table";
import {
useCycleTime,
useBottlenecks,
@@ -185,13 +191,19 @@ function ReworkCard() {
))}
</div>
{(data?.by_agent ?? []).length > 0 && (
<ResponsiveTable
table={
<table className="w-full text-xs">
<thead className="text-muted-foreground">
<tr className="text-left">
<th className="py-1 font-medium">Agent</th>
<th className="py-1 font-medium text-right">Rate</th>
<th className="py-1 font-medium text-right">QA fails</th>
<th className="py-1 font-medium text-right">PR fails</th>
<th className="py-1 font-medium text-right">
QA fails
</th>
<th className="py-1 font-medium text-right">
PR fails
</th>
</tr>
</thead>
<tbody>
@@ -208,6 +220,30 @@ function ReworkCard() {
))}
</tbody>
</table>
}
cards={
<ResponsiveTableCardList>
{(data?.by_agent ?? []).slice(0, 8).map((a) => (
<ResponsiveTableCard key={a.agent_slug}>
<span className="text-sm font-medium">
{a.agent_slug}
</span>
<div className="mt-2 divide-y">
<ResponsiveTableCardRow label="Rate">
{pct(a.rate)}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="QA fails">
{a.qa_fails}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="PR fails">
{a.pr_fails}
</ResponsiveTableCardRow>
</div>
</ResponsiveTableCard>
))}
</ResponsiveTableCardList>
}
/>
)}
</>
)}
@@ -10,6 +10,7 @@ import {
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useIsMobile } from "@/hooks/use-is-mobile";
import type { ModelUsageSlice } from "@/types";
// Design-system chart tokens — resolves to theme-aware palette
@@ -27,6 +28,7 @@ interface ModelUsageDonutProps {
}
export function ModelUsageDonut({ data, isLoading }: ModelUsageDonutProps) {
const isMobile = useIsMobile();
const chartData = (data ?? []).map((s) => ({
name: s.model,
value: s.total_tokens,
@@ -49,8 +51,8 @@ export function ModelUsageDonut({ data, isLoading }: ModelUsageDonutProps) {
data={chartData}
cx="50%"
cy="50%"
innerRadius={52}
outerRadius={80}
innerRadius={isMobile ? 44 : 52}
outerRadius={isMobile ? 68 : 80}
dataKey="value"
paddingAngle={3}
>
@@ -69,7 +71,7 @@ export function ModelUsageDonut({ data, isLoading }: ModelUsageDonutProps) {
]}
contentStyle={{ fontSize: 12 }}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
<Legend wrapperStyle={{ fontSize: isMobile ? 9 : 11 }} />
</PieChart>
</ResponsiveContainer>
)}
@@ -9,7 +9,14 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
ResponsiveTable,
ResponsiveTableCardList,
ResponsiveTableCard,
ResponsiveTableCardRow,
} from "@/components/ui/responsive-table";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { ChevronUp, ChevronDown } from "lucide-react";
@@ -120,6 +127,8 @@ export function SessionsTable({ data, isLoading }: SessionsTableProps) {
</div>
) : (
<>
<ResponsiveTable
table={
<div className="overflow-x-auto">
<Table>
<TableHeader>
@@ -177,6 +186,50 @@ export function SessionsTable({ data, isLoading }: SessionsTableProps) {
</TableBody>
</Table>
</div>
}
cards={
visible.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No sessions recorded yet
</p>
) : (
<ResponsiveTableCardList>
{visible.map((s) => (
<ResponsiveTableCard key={s.id}>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium">
{s.agent_slug}
</span>
<Badge variant="outline" className="shrink-0 text-xs">
{s.model}
</Badge>
</div>
<div className="mt-3 divide-y">
<ResponsiveTableCardRow label="Started">
{formatTime(s.started_at)}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Total">
{fmtK(s.total_tokens)}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Input">
{fmtK(s.tokens_input)}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Output">
{fmtK(s.tokens_output)}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Cache">
{fmtK(s.tokens_cache)}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Cost">
${s.cost.toFixed(4)}
</ResponsiveTableCardRow>
</div>
</ResponsiveTableCard>
))}
</ResponsiveTableCardList>
)
}
/>
{/* Pagination */}
<div className="flex items-center justify-between mt-3 pt-3 border-t text-sm">
@@ -11,6 +11,7 @@ import {
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useIsMobile } from "@/hooks/use-is-mobile";
import type { TeamUsageRow } from "@/types";
interface TeamUsageChartProps {
@@ -24,6 +25,7 @@ function fmtK(n: number): string {
}
export function TeamUsageChart({ data, isLoading }: TeamUsageChartProps) {
const isMobile = useIsMobile();
const chartData = [...(data ?? [])]
.sort((a, b) => b.total_tokens - a.total_tokens)
.map((row) => ({
@@ -43,12 +45,20 @@ export function TeamUsageChart({ data, isLoading }: TeamUsageChartProps) {
<ResponsiveContainer width="100%" height={208}>
<BarChart
data={chartData}
margin={{ top: 4, right: 8, left: 0, bottom: 8 }}
margin={{
top: 4,
right: 8,
left: 0,
bottom: isMobile ? 24 : 8,
}}
>
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
<XAxis
dataKey="name"
tick={{ fontSize: 11 }}
tick={{ fontSize: isMobile ? 9 : 11 }}
angle={isMobile ? -45 : 0}
textAnchor={isMobile ? "end" : "middle"}
interval={0}
axisLine={false}
tickLine={false}
/>
@@ -12,6 +12,7 @@ import {
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useIsMobile } from "@/hooks/use-is-mobile";
import type { UsageTimePoint } from "@/types";
interface UsageTimeSeriesChartProps {
@@ -40,6 +41,7 @@ export function UsageTimeSeriesChart({
data,
isLoading,
}: UsageTimeSeriesChartProps) {
const isMobile = useIsMobile();
const chartData = (data ?? []).map((p) => ({
hour: formatBucket(p.bucket),
Input: p.tokens_input,
@@ -89,8 +91,8 @@ export function UsageTimeSeriesChart({
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
<XAxis
dataKey="hour"
tick={{ fontSize: 10 }}
interval={3}
tick={{ fontSize: isMobile ? 9 : 10 }}
interval={isMobile ? 5 : 3}
axisLine={false}
tickLine={false}
/>
@@ -108,7 +110,7 @@ export function UsageTimeSeriesChart({
]}
contentStyle={{ fontSize: 12 }}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Legend wrapperStyle={{ fontSize: isMobile ? 10 : 12 }} />
<Area
type="monotone"
dataKey="Input"
@@ -11,6 +11,12 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
ResponsiveTable,
ResponsiveTableCardList,
ResponsiveTableCard,
ResponsiveTableCardRow,
} from "@/components/ui/responsive-table";
import { Skeleton } from "@/components/ui/skeleton";
import { Boxes, Pencil } from "lucide-react";
import type { ProductSummary } from "@/types";
@@ -46,6 +52,8 @@ export function ProductTable({ products, isLoading }: ProductTableProps) {
return (
<>
<ResponsiveTable
table={
<div className="border rounded-lg">
<Table>
<TableHeader>
@@ -94,6 +102,46 @@ export function ProductTable({ products, isLoading }: ProductTableProps) {
</TableBody>
</Table>
</div>
}
cards={
<ResponsiveTableCardList>
{products.map((product) => (
<ResponsiveTableCard key={product.id}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<Button
onClick={() => setEditingProductId(product.id)}
variant="link"
className="h-auto p-0 font-medium text-foreground"
>
{product.name}
</Button>
<p className="text-xs text-muted-foreground font-mono">
{product.slug}
</p>
</div>
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => setEditingProductId(product.id)}
title="Edit product"
>
<Pencil className="h-4 w-4" />
</Button>
</div>
<div className="mt-3 divide-y">
<ResponsiveTableCardRow label="Cells Mapped">
<Badge className="bg-blue-500/10 text-blue-500">
{product.cell_count} / 3
</Badge>
</ResponsiveTableCardRow>
</div>
</ResponsiveTableCard>
))}
</ResponsiveTableCardList>
}
/>
{/* Edit Product Dialog */}
{editingProductId && (
@@ -82,6 +82,13 @@ function EditProjectForm({
const [depUpdatePaths, setDepUpdatePaths] = useState(
(project.dep_update_paths || []).join(", "),
);
const sandboxServices = project.sandbox_services || [];
const [sandboxPostgres, setSandboxPostgres] = useState(
sandboxServices.includes("postgres"),
);
const [sandboxRedis, setSandboxRedis] = useState(
sandboxServices.includes("redis"),
);
// Token handling
const [newToken, setNewToken] = useState("");
@@ -120,6 +127,10 @@ function EditProjectForm({
.map((p) => p.trim())
.filter(Boolean)
: undefined,
sandbox_services: [
...(sandboxPostgres ? ["postgres"] : []),
...(sandboxRedis ? ["redis"] : []),
],
};
// Handle token update
@@ -430,6 +441,37 @@ function EditProjectForm({
uv.lock / pnpm-lock.yaml.
</p>
</div>
<div className="grid gap-2">
<Label>Sandbox Services</Label>
<div className="flex items-center justify-between">
<Label
htmlFor="sandbox_postgres"
className="text-sm font-normal"
>
PostgreSQL
</Label>
<Switch
id="sandbox_postgres"
checked={sandboxPostgres}
onCheckedChange={setSandboxPostgres}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="sandbox_redis" className="text-sm font-normal">
Redis
</Label>
<Switch
id="sandbox_redis"
checked={sandboxRedis}
onCheckedChange={setSandboxRedis}
/>
</div>
<p className="text-xs text-muted-foreground">
Provision a throwaway sandbox DB/Redis per agent spawn for
this project instead of the production credentials.
</p>
</div>
</>
)}
</div>
@@ -11,6 +11,12 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
ResponsiveTable,
ResponsiveTableCardList,
ResponsiveTableCard,
ResponsiveTableCardRow,
} from "@/components/ui/responsive-table";
import { Skeleton } from "@/components/ui/skeleton";
import { ExternalLink, Pencil, GitBranch, Key, KeyRound } from "lucide-react";
import type { ProjectSummary, Team } from "@/types";
@@ -97,6 +103,8 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
return (
<>
<ResponsiveTable
table={
<div className="border rounded-lg">
<Table>
<TableHeader>
@@ -130,14 +138,19 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
{teamLabels[project.assigned_cell]}
</Badge>
</TableCell>
<TableCell>{getTokenBadge(project.has_git_token)}</TableCell>
<TableCell>
{getTokenBadge(project.has_git_token)}
</TableCell>
<TableCell>
{project.is_active ? (
<Badge className="bg-green-500/10 text-green-500">
Active
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
<Badge
variant="outline"
className="text-muted-foreground"
>
Inactive
</Badge>
)}
@@ -173,6 +186,78 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
</TableBody>
</Table>
</div>
}
cards={
<ResponsiveTableCardList>
{projects.map((project) => (
<ResponsiveTableCard key={project.id}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<Button
onClick={() => setEditingProjectId(project.id)}
variant="link"
className="h-auto p-0 font-medium text-foreground"
>
{project.name}
</Button>
<p className="text-xs text-muted-foreground font-mono">
{project.slug}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setEditingProjectId(project.id)}
title="Edit project"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
asChild
title="View repository"
>
<a
href={getExternalUrl(project)}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</div>
</div>
<div className="mt-3 divide-y">
<ResponsiveTableCardRow label="Cell">
<Badge className={teamColors[project.assigned_cell]}>
{teamLabels[project.assigned_cell]}
</Badge>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Token">
{getTokenBadge(project.has_git_token)}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Status">
{project.is_active ? (
<Badge className="bg-green-500/10 text-green-500">
Active
</Badge>
) : (
<Badge
variant="outline"
className="text-muted-foreground"
>
Inactive
</Badge>
)}
</ResponsiveTableCardRow>
</div>
</ResponsiveTableCard>
))}
</ResponsiveTableCardList>
}
/>
{/* Edit Project Dialog */}
{editingProjectId && (
@@ -46,7 +46,12 @@ export function ChatComposer({
};
return (
<div className="flex items-end gap-2 border-t bg-background px-4 py-3">
// pb includes the safe-area inset so the composer clears the home
// indicator on notched phones instead of sitting flush under it.
<div
className="flex items-end gap-2 border-t bg-background px-4 py-3"
style={{ paddingBottom: "max(0.75rem, env(safe-area-inset-bottom))" }}
>
<Textarea
ref={textareaRef}
value={value}
@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
const { getCredentialsStatus, setCredentials } = vi.hoisted(() => ({
getCredentialsStatus: vi.fn(async () => ({ has_credentials: false })),
setCredentials: vi.fn(async () => ({ has_credentials: true })),
}));
vi.mock("@/lib/api", () => ({
xApi: { getCredentialsStatus, setCredentials },
}));
import { XCredentialsCard } from "../x-credentials-card";
function withQueryClient(ui: ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
}
describe("XCredentialsCard", () => {
beforeEach(() => {
getCredentialsStatus.mockClear();
setCredentials.mockClear();
});
afterEach(() => {
vi.clearAllMocks();
});
it("shows 'no credentials configured' by default and never renders a secret", async () => {
render(withQueryClient(<XCredentialsCard />));
expect(
await screen.findByText("No credentials configured"),
).toBeInTheDocument();
});
it("disables Save until all 4 fields are filled", async () => {
render(withQueryClient(<XCredentialsCard />));
await screen.findByText("No credentials configured");
const saveButton = screen.getByRole("button", { name: "Save" });
expect(saveButton).toBeDisabled();
fireEvent.change(screen.getByLabelText("API key"), {
target: { value: "ak" },
});
expect(saveButton).toBeDisabled(); // still 3 unfilled
fireEvent.change(screen.getByLabelText("API key secret"), {
target: { value: "as" },
});
fireEvent.change(screen.getByLabelText("Access token"), {
target: { value: "at" },
});
fireEvent.change(screen.getByLabelText("Access token secret"), {
target: { value: "ats" },
});
expect(saveButton).not.toBeDisabled();
});
it("saves all 4 secrets and clears the inputs on success", async () => {
render(withQueryClient(<XCredentialsCard />));
await screen.findByText("No credentials configured");
fireEvent.change(screen.getByLabelText("API key"), {
target: { value: "ak" },
});
fireEvent.change(screen.getByLabelText("API key secret"), {
target: { value: "as" },
});
fireEvent.change(screen.getByLabelText("Access token"), {
target: { value: "at" },
});
fireEvent.change(screen.getByLabelText("Access token secret"), {
target: { value: "ats" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(setCredentials).toHaveBeenCalledWith({
api_key: "ak",
api_secret: "as",
access_token: "at",
access_token_secret: "ats",
}),
);
await waitFor(() =>
expect(
(screen.getByLabelText("API key") as HTMLInputElement).value,
).toBe(""),
);
});
});
@@ -46,8 +46,16 @@ const FLAG_DESCRIPTIONS: Record<string, string> = {
"Run the deterministic release-readiness sweep and propose a release for you to approve or reject — it never publishes without your approval, and the executor is fail-closed on a red gate.",
org_memory_enabled:
"Close the learn→reuse loop: distill a lesson at task completion, index journal reflections, and auto-inject similar past lessons + approved playbooks into an agent's briefing on claim.",
sandbox_db_enabled:
"Provision a throwaway Postgres/Redis sibling container per agent spawn (per-project opt-in) instead of injecting RoboCo's own production DB credentials into the agent's gate.",
routing_strict:
"Fail-closed model routing: refuse to silently downgrade an agent to the legacy Anthropic path when its configured provider is disabled (raise instead). Off => graceful degradation with a warning.",
x_engine_enabled:
"Draft release-announcement posts for the X (Twitter) account — every draft is held in a queue for you to edit, approve, or reject; nothing posts automatically, and it stays inert until you set credentials in the X card below.",
x_replies_enabled:
"Also poll X mentions and draft replies (still held for your approval — nothing auto-replies). Off by default: reading mentions needs a paid X API tier, so leave this off if you only want release posts.",
roadmap_engine_enabled:
"Weekly: the Product Owner explores the company's projects and proposes a themed cycle of 3-7 roadmap items — you approve or reject each one individually; approved items land in the backlog and nothing auto-starts.",
};
export function FeatureFlagsCard() {
@@ -0,0 +1,135 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { xApi } from "@/lib/api";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { AtSign, Key, KeyRound, Save } from "lucide-react";
import { toast } from "sonner";
const FIELDS: Array<{
key: "api_key" | "api_secret" | "access_token" | "access_token_secret";
label: string;
}> = [
{ key: "api_key", label: "API key" },
{ key: "api_secret", label: "API key secret" },
{ key: "access_token", label: "Access token" },
{ key: "access_token_secret", label: "Access token secret" },
];
// The CEO's one-time (or rotate) entry of the 4 OAuth 1.0a user-context
// secrets from the X developer app. Write-only — the stored values are never
// displayed back, only whether they're set (mirrors the git-token card).
export function XCredentialsCard() {
const queryClient = useQueryClient();
const [values, setValues] = useState({
api_key: "",
api_secret: "",
access_token: "",
access_token_secret: "",
});
const { data: status, isLoading } = useQuery({
queryKey: ["x", "credentials"],
queryFn: () => xApi.getCredentialsStatus(),
});
const saveMutation = useMutation({
mutationFn: () => xApi.setCredentials(values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["x", "credentials"] });
setValues({
api_key: "",
api_secret: "",
access_token: "",
access_token_secret: "",
});
toast.success("X credentials saved");
},
onError: (error) => {
toast.error(
`Failed to save: ${error instanceof Error ? error.message : "Unknown error"}`,
);
},
});
const allFilled = FIELDS.every((f) => values[f.key].trim().length > 0);
const noneFilled = FIELDS.every((f) => values[f.key].trim().length === 0);
// A genuine save is either "set all 4" or, when something is already
// stored, "clear all 4". All-empty with nothing stored is a true no-op.
const canSave = allFilled || (noneFilled && !!status?.has_credentials);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AtSign className="h-5 w-5" />X (Twitter) Credentials
</CardTitle>
<CardDescription>
The 4 OAuth 1.0a user-context secrets from your X developer app.
Stored encrypted server-side; agents never see them and this panel
never displays them again once saved.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-2 rounded-md border p-3">
{status?.has_credentials ? (
<>
<Key className="h-4 w-4 text-green-500" />
<span className="text-sm text-green-600 dark:text-green-400">
Credentials are set
</span>
</>
) : (
<>
<KeyRound className="h-4 w-4 text-amber-500" />
<span className="text-sm text-amber-600 dark:text-amber-400">
{isLoading ? "Checking..." : "No credentials configured"}
</span>
</>
)}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{FIELDS.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`x-cred-${field.key}`}>
{status?.has_credentials ? `Replace ${field.label}` : field.label}
</Label>
<Input
id={`x-cred-${field.key}`}
type="password"
value={values[field.key]}
onChange={(e) =>
setValues((prev) => ({ ...prev, [field.key]: e.target.value }))
}
placeholder="••••••••••••"
/>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
Set all 4 to save (or rotate); leave all 4 blank and save to clear.
</p>
<Button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || !canSave}
>
<Save className="mr-2 h-4 w-4" />
{saveMutation.isPending ? "Saving..." : "Save"}
</Button>
</CardContent>
</Card>
);
}
@@ -501,8 +501,10 @@ export function TaskHeader({ task, onAction }: TaskHeaderProps) {
{/* Row 2: copyable task id + status + team + type. The dropdowns are
FIXED width so changing a selected value's label width can never
shift a neighbor; the id is read-only and copies the FULL uuid. */}
<div className="flex items-center gap-2 mt-1.5">
shift a neighbor; the id is read-only and copies the FULL uuid.
flex-wrap: on narrow viewports the id/status/team/type group
wraps to further rows instead of overflowing horizontally. */}
<div className="flex flex-wrap items-center gap-2 mt-1.5">
<span
className="inline-flex h-7 shrink-0 items-center gap-1 rounded-md border bg-muted/40 px-2.5 font-mono text-sm text-muted-foreground"
title={task.id}
+152 -4
View File
@@ -22,6 +22,13 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
ResponsiveTable,
ResponsiveTableCardList,
ResponsiveTableCard,
ResponsiveTableCardRow,
ResponsiveTableCardEmpty,
} from "@/components/ui/responsive-table";
import { TaskStatusBadge } from "./task-status-badge";
import { TaskActions } from "./task-actions";
import { GitStatusBadge } from "./git-status-badge";
@@ -199,6 +206,19 @@ function TaskTableEmpty() {
);
}
function TaskCardSkeletons() {
return (
<ResponsiveTableCardList className="p-3">
{Array.from({ length: 5 }).map((_, i) => (
<ResponsiveTableCard key={i} className="space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</ResponsiveTableCard>
))}
</ResponsiveTableCardList>
);
}
interface SortableHeaderProps {
label: string;
field: SortField;
@@ -455,6 +475,8 @@ export function TaskTable({
</div>
)}
<ResponsiveTable
table={
<Table>
<TableHeader>
<TableRow>
@@ -614,7 +636,8 @@ export function TaskTable({
<TableCell className="whitespace-nowrap text-sm">
{task.project_id && projectNames[task.project_id] ? (
<span>{projectNames[task.project_id]}</span>
) : task.product_id && productNames[task.product_id] ? (
) : task.product_id &&
productNames[task.product_id] ? (
<span className="text-muted-foreground">
{productNames[task.product_id]}{" "}
<span className="text-xs">(product)</span>
@@ -626,8 +649,8 @@ export function TaskTable({
<TableCell className="whitespace-nowrap">
<Badge
className={
(priorityColors[task.priority] ?? priorityColors[2]) +
" text-xs"
(priorityColors[task.priority] ??
priorityColors[2]) + " text-xs"
}
>
{priorityLabels[task.priority] ?? "P2 - Medium"}
@@ -652,10 +675,135 @@ export function TaskTable({
)}
</TableBody>
</Table>
}
cards={
isLoading ? (
<TaskCardSkeletons />
) : paginatedTasks.length === 0 ? (
<ResponsiveTableCardEmpty className="m-3">
No tasks found
</ResponsiveTableCardEmpty>
) : (
<ResponsiveTableCardList className="p-3">
{paginatedTasks.map((node) => {
const task = node.task;
const hasChildren = node.children.length > 0;
const isExpanded = expandedIds.has(task.id);
const childCount = childrenMap.get(task.id)?.length || 0;
return (
<ResponsiveTableCard
key={task.id}
style={{ marginLeft: `${node.depth * 1}rem` }}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
{hasChildren && (
<Button
onClick={() => toggleExpand(task.id)}
variant="ghost"
size="icon-sm"
className="h-5 w-5 shrink-0 p-0.5"
aria-label={isExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRightIcon className="h-4 w-4" />
)}
</Button>
)}
<Link
prefetch={false}
href={"/tasks/" + task.id}
className="min-w-0 truncate font-medium hover:underline"
title={task.title}
>
{task.title}
</Link>
</div>
{(task.batch_id && !task.parent_task_id) ||
childCount > 0 ? (
<div className="mt-1 flex flex-wrap gap-1">
{task.batch_id && !task.parent_task_id && (
<Badge
variant="outline"
className="border-primary/50 text-xs text-primary"
>
MegaTask
</Badge>
)}
{childCount > 0 && (
<Badge variant="secondary" className="text-xs">
{childCount} subtask
{childCount !== 1 ? "s" : ""}
</Badge>
)}
</div>
) : null}
</div>
<TaskActions task={task} />
</div>
<div className="mt-3 divide-y">
<ResponsiveTableCardRow label="Status">
<TaskStatusBadge status={task.status} />
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Git">
<GitStatusBadge
task={task}
repoUrl={
task.project_id
? projectGitUrls[task.project_id]
: undefined
}
/>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Team">
<span className="capitalize">
{task.team.replace(/_/g, " ")}
</span>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Project">
{task.project_id && projectNames[task.project_id]
? projectNames[task.project_id]
: task.product_id && productNames[task.product_id]
? `${productNames[task.product_id]} (product)`
: "—"}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Priority">
<Badge
className={
(priorityColors[task.priority] ??
priorityColors[2]) + " text-xs"
}
>
{priorityLabels[task.priority] ?? "P2 - Medium"}
</Badge>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Assigned">
<Badge variant="outline">
{getAgentDisplayName(task.assigned_to)}
</Badge>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Created">
{formatDistanceToNow(new Date(task.created_at), {
addSuffix: true,
})}
</ResponsiveTableCardRow>
</div>
</ResponsiveTableCard>
);
})}
</ResponsiveTableCardList>
)
}
/>
{/* Pagination Controls */}
{!isLoading && tasks && tasks.length > 0 && (
<div className="flex items-center justify-end gap-4 px-4 py-3 border-t">
<div className="flex flex-wrap items-center justify-end gap-4 px-4 py-3 border-t">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Rows:</span>
<Select
@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import {
ResponsiveTable,
ResponsiveTableCardList,
ResponsiveTableCard,
ResponsiveTableCardRow,
} from "../responsive-table";
// Deterministic matchMedia stub — jsdom has none. `matches` is controlled
// per-test via the module-level flag so useIsMobile resolves synchronously
// within the component's mount effect.
let mockMatches = false;
beforeEach(() => {
mockMatches = false;
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: mockMatches,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})) as unknown as typeof window.matchMedia;
});
describe("ResponsiveTable", () => {
it("renders the desktop table branch when the viewport does not match mobile", () => {
mockMatches = false;
render(
<ResponsiveTable
table={<div data-testid="desktop-table">table</div>}
cards={<div data-testid="mobile-cards">cards</div>}
/>,
);
expect(screen.getByTestId("desktop-table")).toBeInTheDocument();
expect(screen.queryByTestId("mobile-cards")).not.toBeInTheDocument();
});
it("renders only the card branch below the breakpoint — never both at once", () => {
mockMatches = true;
render(
<ResponsiveTable
table={<div data-testid="desktop-table">table</div>}
cards={<div data-testid="mobile-cards">cards</div>}
/>,
);
expect(screen.getByTestId("mobile-cards")).toBeInTheDocument();
expect(screen.queryByTestId("desktop-table")).not.toBeInTheDocument();
});
});
describe("ResponsiveTableCard building blocks", () => {
it("renders a card with labeled rows", () => {
render(
<ResponsiveTableCardList>
<ResponsiveTableCard>
<ResponsiveTableCardRow label="Status">Active</ResponsiveTableCardRow>
</ResponsiveTableCard>
</ResponsiveTableCardList>,
);
expect(screen.getByText("Status")).toBeInTheDocument();
expect(screen.getByText("Active")).toBeInTheDocument();
});
});
@@ -0,0 +1,101 @@
"use client";
import * as React from "react";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { cn } from "@/lib/utils";
interface ResponsiveTableProps {
/** The existing desktop `<Table>`, rendered unchanged at `md` and up. */
table: React.ReactNode;
/** The stacked card list, rendered below `md` instead of the table. */
cards: React.ReactNode;
}
/**
* Single shared switch point for every table call site: below `md`, a data
* table becomes a stacked card list instead (see ResponsiveTableCard). Only
* one of the two subtrees mounts at a time never the desktop table AND its
* card equivalent together so an interactive row (dropdowns, buttons) is
* never duplicated in the DOM.
*
* `useIsMobile` defaults to `false` on the server and on the first client
* render, so this always resolves to `table` until after mount SSR and the
* hydration pass render identical markup, then the card branch takes over a
* tick later on an actual mobile viewport.
*/
export function ResponsiveTable({ table, cards }: ResponsiveTableProps) {
const isMobile = useIsMobile();
return <>{isMobile ? cards : table}</>;
}
function ResponsiveTableCardList({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="responsive-table-cards"
className={cn("space-y-3", className)}
{...props}
/>
);
}
function ResponsiveTableCard({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="responsive-table-card"
className={cn("rounded-lg border bg-card p-4", className)}
{...props}
/>
);
}
/** One labeled key/value row inside a card — the mobile analog of a table cell. */
function ResponsiveTableCardRow({
label,
children,
className,
}: {
label: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"flex items-center justify-between gap-3 py-1 text-sm",
className,
)}
>
<span className="shrink-0 text-muted-foreground">{label}</span>
<span className="min-w-0 text-right">{children}</span>
</div>
);
}
function ResponsiveTableCardEmpty({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="responsive-table-cards-empty"
className={cn(
"rounded-lg border border-dashed p-8 text-center text-muted-foreground",
className,
)}
{...props}
/>
);
}
export {
ResponsiveTableCardList,
ResponsiveTableCard,
ResponsiveTableCardRow,
ResponsiveTableCardEmpty,
};
+7 -1
View File
@@ -26,7 +26,13 @@ function TabsList({
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
// max-w-full + overflow-x-auto: a TabsList wider than its container
// (many tabs on a narrow viewport) scrolls horizontally in place
// instead of overflowing the page. Snap + hidden scrollbar make the
// scroll read as a deliberate swipeable strip, not a layout bug.
// justify-center-SAFE: plain center + overflow clips the first tab
// unreachably off the left edge; safe alignment falls back to start.
"bg-muted text-muted-foreground inline-flex h-9 w-fit max-w-full items-center justify-center-safe rounded-lg p-[3px] overflow-x-auto snap-x [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [&>[data-slot=tabs-trigger]]:snap-start",
className,
)}
{...props}
@@ -12,6 +12,12 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
ResponsiveTable,
ResponsiveTableCardList,
ResponsiveTableCard,
ResponsiveTableCardRow,
} from "@/components/ui/responsive-table";
import { Skeleton } from "@/components/ui/skeleton";
import {
GitBranch,
@@ -84,6 +90,8 @@ export function WorkSessionTable({
}
return (
<ResponsiveTable
table={
<div className="border rounded-lg">
<Table>
<TableHeader>
@@ -128,7 +136,9 @@ export function WorkSessionTable({
PR Open
</Badge>
) : (
<span className="text-sm text-muted-foreground">No PR</span>
<span className="text-sm text-muted-foreground">
No PR
</span>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
@@ -137,7 +147,10 @@ export function WorkSessionTable({
})}
</TableCell>
<TableCell>
<Link href={`/work-sessions/${session.id}`} prefetch={false}>
<Link
href={`/work-sessions/${session.id}`}
prefetch={false}
>
<Button variant="ghost" size="icon">
<ExternalLink className="h-4 w-4" />
</Button>
@@ -148,5 +161,61 @@ export function WorkSessionTable({
</TableBody>
</Table>
</div>
}
cards={
<ResponsiveTableCardList>
{sessions.map((session) => (
<ResponsiveTableCard key={session.id}>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<GitBranch className="h-4 w-4 shrink-0 text-muted-foreground" />
<Link
prefetch={false}
href={`/work-sessions/${session.id}`}
className="truncate font-mono text-sm font-medium hover:underline"
>
{session.branch_name}
</Link>
</div>
<Link href={`/work-sessions/${session.id}`} prefetch={false}>
<Button variant="ghost" size="icon" className="shrink-0">
<ExternalLink className="h-4 w-4" />
</Button>
</Link>
</div>
<div className="mt-3 divide-y">
<ResponsiveTableCardRow label="Task">
<Link
prefetch={false}
href={`/tasks/${session.task_id}`}
className="text-muted-foreground hover:text-foreground hover:underline"
>
{session.task_id.slice(0, 8)}...
</Link>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Status">
{getStatusBadge(session.status)}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="PR">
{session.has_pr ? (
<Badge className="bg-purple-500/10 text-purple-500">
<GitPullRequest className="h-3 w-3 mr-1" />
PR Open
</Badge>
) : (
<span className="text-muted-foreground">No PR</span>
)}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Started">
{formatDistanceToNow(new Date(session.started_at), {
addSuffix: true,
})}
</ResponsiveTableCardRow>
</div>
</ResponsiveTableCard>
))}
</ResponsiveTableCardList>
}
/>
);
}
@@ -0,0 +1,81 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useIsMobile } from "../use-is-mobile";
// Minimal matchMedia stub — jsdom doesn't implement it. Tracks listeners so
// tests can flip `matches` and fire a synthetic "change" event.
//
// Note on SSR/hydration safety: useIsMobile is built on useSyncExternalStore
// with a `getServerSnapshot` that always returns `false`. React uses that same
// value for the server render AND the client's first (pre-hydration-commit)
// render, so there is no mismatch to reproduce here — RTL's `renderHook` only
// ever does a client render, so it exercises `getSnapshot` (the real
// matchMedia read), never the server path. That guarantee is structural
// (React's contract for the hook), not something a jsdom unit test observes.
function installMatchMedia(initialMatches: boolean) {
const listeners = new Set<(e: MediaQueryListEvent) => void>();
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: initialMatches,
media: query,
addEventListener: (_: "change", cb: (e: MediaQueryListEvent) => void) => {
listeners.add(cb);
},
removeEventListener: (
_: "change",
cb: (e: MediaQueryListEvent) => void,
) => {
listeners.delete(cb);
},
})) as unknown as typeof window.matchMedia;
return {
fireChange(matches: boolean) {
initialMatches = matches;
listeners.forEach((cb) => cb({ matches } as MediaQueryListEvent));
},
};
}
describe("useIsMobile", () => {
const originalMatchMedia = window.matchMedia;
afterEach(() => {
window.matchMedia = originalMatchMedia;
vi.restoreAllMocks();
});
it("resolves to the real matchMedia value on render", () => {
installMatchMedia(true);
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(true);
});
it("resolves false when the query does not match", () => {
installMatchMedia(false);
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
});
it("reacts to a live matchMedia change (viewport resize)", () => {
const { fireChange } = installMatchMedia(false);
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
act(() => {
fireChange(true);
});
expect(result.current).toBe(true);
});
it("builds the query from a custom breakpoint", () => {
installMatchMedia(false);
renderHook(() => useIsMobile(1024));
expect(window.matchMedia).toHaveBeenCalledWith("(max-width: 1023px)");
});
it("defaults to the md breakpoint (768px) when none is passed", () => {
installMatchMedia(false);
renderHook(() => useIsMobile());
expect(window.matchMedia).toHaveBeenCalledWith("(max-width: 767px)");
});
});
+1
View File
@@ -36,3 +36,4 @@ export * from "./use-journals";
export * from "./use-projects";
export * from "./use-work-sessions";
export * from "./use-usage";
export * from "./use-auth";
+27
View File
@@ -0,0 +1,27 @@
import { useQuery, useMutation } from "@tanstack/react-query";
import { authApi } from "@/lib/api/auth";
export const authKeys = {
status: ["auth", "status"] as const,
};
export function useAuthStatus() {
return useQuery({
queryKey: authKeys.status,
queryFn: () => authApi.status(),
staleTime: 60000,
});
}
export function useLogin() {
return useMutation({
mutationFn: ({ email, password }: { email: string; password: string }) =>
authApi.login(email, password),
});
}
export function useLogout() {
return useMutation({
mutationFn: () => authApi.logout(),
});
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useCallback, useSyncExternalStore } from "react";
const DEFAULT_BREAKPOINT_PX = 768; // Tailwind `md`
/**
* True below `breakpointPx` (default the Tailwind `md` breakpoint).
* `useSyncExternalStore`'s server snapshot (`false`, desktop) is also what
* React uses for the client's first render before hydration commits so SSR
* and the initial hydration pass render identical markup, and the real
* matchMedia value only takes over a tick later. No manual
* useState/useEffect pairing, so there's nothing to cascade-render.
*/
export function useIsMobile(breakpointPx: number = DEFAULT_BREAKPOINT_PX) {
const query = `(max-width: ${breakpointPx - 1}px)`;
// Memoized per query: a new subscribe identity each render would make
// useSyncExternalStore tear down + re-attach the matchMedia listener on
// every render of every consumer.
const subscribe = useCallback(
(onChange: () => void) => {
const mql = window.matchMedia(query);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
},
[query],
);
return useSyncExternalStore(
subscribe,
() => window.matchMedia(query).matches,
() => false,
);
}
+31
View File
@@ -0,0 +1,31 @@
import api from "./client";
// Matches roboco.api.auth.routes.auth_status's response shape.
export interface AuthStatus {
cloud_auth_enabled: boolean;
}
export const authApi = {
// Always available regardless of the flag — the panel middleware and the
// login page both probe this before deciding whether to gate/redirect.
status: async (): Promise<AuthStatus> => {
const { data } = await api.get<AuthStatus>("/auth/status");
return data;
},
// FastAPI Users' cookie-login route expects an OAuth2 form body
// (username/password), not JSON — the session cookie rides back on the
// response, set by the browser automatically.
login: async (email: string, password: string): Promise<void> => {
const body = new URLSearchParams();
body.set("username", email);
body.set("password", password);
await api.post("/auth/login", body, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
},
logout: async (): Promise<void> => {
await api.post("/auth/logout");
},
};
+28
View File
@@ -20,6 +20,10 @@ const api: AxiosInstance = axios.create({
"Content-Type": "application/json",
},
timeout: 60000, // Increased to 60s for long operations like reindexing
// Rides the cloud-auth session cookie. Harmless when cloud auth is off
// (same-origin requests already carry cookies regardless), and required
// for a cross-origin dev setup that talks to the backend directly.
withCredentials: true,
});
// Request interceptor to add auth headers and logging
@@ -42,6 +46,29 @@ api.interceptors.request.use(
},
);
// A 401 only means "log in" when cloud auth is actually on. In header-trust /
// secure mode (cloud auth off) a 401 is a misconfigured agent token, not a
// missing session — bouncing to /login would dead-end on a page whose backend
// route isn't mounted. Probe the public status endpoint (a bare fetch so it
// doesn't re-enter this interceptor) and only redirect when cloud auth is on.
async function redirectToLoginIfCloudAuth(): Promise<void> {
if (typeof window === "undefined" || window.location.pathname === "/login") {
return;
}
try {
const res = await fetch(`${API_URL}/auth/status`, {
credentials: "include",
});
if (!res.ok) return;
const data = (await res.json()) as { cloud_auth_enabled?: boolean };
if (data.cloud_auth_enabled) {
window.location.href = "/login";
}
} catch {
// Can't confirm cloud auth is on -> don't dead-end the user on /login.
}
}
// Response interceptor for comprehensive error handling
api.interceptors.response.use(
(response) => {
@@ -128,6 +155,7 @@ api.interceptors.response.use(
);
} else if (status === 401) {
console.error("[API] Unauthorized - check API authentication headers");
void redirectToLoginIfCloudAuth();
} else if (status === 403) {
console.error(
"[API] Forbidden - insufficient permissions for this action",
+11
View File
@@ -24,3 +24,14 @@ export type {
} from "./release";
export { playbooksApi } from "./playbooks";
export type { Playbook } from "./playbooks";
export { authApi } from "./auth";
export type { AuthStatus } from "./auth";
export { xApi } from "./x";
export type {
XPost,
XMentionRef,
XPostExecuteResult,
XCredentialsStatus,
} from "./x";
export { roadmapApi } from "./roadmap";
export type { RoadmapCycle, RoadmapItem, RoadmapItemActionResult } from "./roadmap";
+1
View File
@@ -92,6 +92,7 @@ export const projectsApi = {
ci_watch_workflow: null,
dep_update_command: null,
dep_update_paths: null,
sandbox_services: null,
workspace_path: null,
last_synced_at: null,
head_commit: null,
+65
View File
@@ -0,0 +1,65 @@
import api from "./client";
// ---------------------------------------------------------------------------
// Board roadmap engine — the Product Owner authors a themed cycle of roadmap
// items (a goal + 3-7 drafts); the CEO approves or rejects each item
// individually here. Approving materializes a BACKLOG task; nothing starts
// automatically.
// ---------------------------------------------------------------------------
export interface RoadmapItem {
id: string;
title: string;
description: string;
acceptance_criteria: string[];
project_slug: string;
team: string;
priority: number;
rationale: string;
status: "proposed" | "approved" | "rejected";
reject_reason?: string | null;
materialized_task_id?: string | null;
}
export interface RoadmapCycle {
task_id: string;
title: string;
status: string;
goal: string;
items: RoadmapItem[];
}
export interface RoadmapItemActionResult {
status: string;
item_id: string;
materialized_task_id?: string | null;
detail: string;
}
export const roadmapApi = {
listCycles: async (): Promise<RoadmapCycle[]> => {
const { data } = await api.get<RoadmapCycle[]>("/roadmap/cycles");
return data;
},
approveItem: async (
taskId: string,
itemId: string,
): Promise<RoadmapItemActionResult> => {
const { data } = await api.post<RoadmapItemActionResult>(
`/roadmap/cycles/${taskId}/items/${itemId}/approve`,
{},
);
return data;
},
rejectItem: async (
taskId: string,
itemId: string,
reason: string,
): Promise<RoadmapItemActionResult> => {
const { data } = await api.post<RoadmapItemActionResult>(
`/roadmap/cycles/${taskId}/items/${itemId}/reject`,
{ reason },
);
return data;
},
};
+75
View File
@@ -0,0 +1,75 @@
import api from "./client";
// ---------------------------------------------------------------------------
// X (Twitter) engine — held release-post + mention-reply drafts the CEO edits
// and approves (posts) or rejects (cancels) in the panel. Nothing posts until
// an explicit approve; credentials are write-only (the API never returns
// the stored secrets).
// ---------------------------------------------------------------------------
export interface XMentionRef {
id: string;
author_id: string;
text: string;
}
export interface XPost {
task_id: string;
source: "x_post" | "x_reply";
title: string;
status: string;
body: string;
char_count: number;
release_version?: string | null;
mention?: XMentionRef | null;
reject_reason?: string | null;
}
export interface XPostExecuteResult {
status: string;
tweet_id?: string | null;
detail: string;
}
export interface XCredentialsStatus {
has_credentials: boolean;
}
export const xApi = {
listPosts: async (): Promise<XPost[]> => {
const { data } = await api.get<XPost[]>("/x/posts");
return data;
},
approve: async (
taskId: string,
editedBody?: string,
): Promise<XPostExecuteResult> => {
const { data } = await api.post<XPostExecuteResult>(
`/x/posts/${taskId}/approve`,
editedBody ? { edited_body: editedBody } : {},
);
return data;
},
reject: async (taskId: string, reason: string): Promise<XPost> => {
const { data } = await api.post<XPost>(`/x/posts/${taskId}/reject`, {
reason,
});
return data;
},
getCredentialsStatus: async (): Promise<XCredentialsStatus> => {
const { data } = await api.get<XCredentialsStatus>("/x/credentials");
return data;
},
setCredentials: async (creds: {
api_key: string;
api_secret: string;
access_token: string;
access_token_secret: string;
}): Promise<XCredentialsStatus> => {
const { data } = await api.post<XCredentialsStatus>(
"/x/credentials",
creds,
);
return data;
},
};
+55
View File
@@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
// Server-side only (no NEXT_PUBLIC_ prefix — never reaches the browser
// bundle). Runs inside the panel container and reaches the orchestrator over
// the docker-internal network (roboco_default), not through nginx — a request
// back out through the panel's own public origin would be a container calling
// itself over the internet. Defaults to the compose service name.
const INTERNAL_API_URL =
process.env.INTERNAL_API_URL || "http://roboco-orchestrator:8000/api";
// Must match roboco.api.auth.backend.SESSION_COOKIE_NAME.
const SESSION_COOKIE_NAME = "roboco_session";
// The probe must never block navigation: a slow/unreachable backend fails
// open to "cloud auth off" (the safe default — off is what every deploy
// starts on), not a stuck redirect.
const STATUS_PROBE_TIMEOUT_MS = 1500;
async function isCloudAuthEnabled(): Promise<boolean> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), STATUS_PROBE_TIMEOUT_MS);
const res = await fetch(`${INTERNAL_API_URL}/auth/status`, {
signal: controller.signal,
cache: "no-store",
});
clearTimeout(timer);
if (!res.ok) return false;
const data = (await res.json()) as { cloud_auth_enabled?: boolean };
return data.cloud_auth_enabled === true;
} catch {
return false;
}
}
export async function proxy(request: NextRequest) {
if (!(await isCloudAuthEnabled())) {
return NextResponse.next();
}
if (!request.cookies.has(SESSION_COOKIE_NAME)) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
// Everything except the login page itself (avoids a redirect loop), API
// routes (nginx routes /api/* straight to the orchestrator in prod — this
// never sees them there; excluded defensively for a bare `next start`),
// Next's internal asset paths, and the static icon files at the app root.
matcher: [
"/((?!login|api|_next/static|_next/image|favicon.ico|apple-icon.png|icon.png).*)",
],
};

Some files were not shown because too many files have changed in this diff Show More