feat: company-in-a-box — goal-aware company layer (0.4.0) (#171)

* feat(goals): company charter singleton — data layer (Business Goals slice 1)

First slice of the company-in-a-box "Business Goals" phase: a single CEO-owned
charter row (north star + objectives + constraints + operating policy) that
will be injected into every agent's context_briefing so all work is goal-aware.

- CompanyGoalsTable: singleton table (all-zeros id), JSON objectives /
  constraints / operating_policy, updated_at / updated_by.
- migration 032: create + seed the singleton row (offline-renderable; column
  server-defaults fill an INSERT of just the id).
- CompanyGoalsService: get() (empty defaults when unset) + upsert() (singleton,
  partial update, caller commits).
- tests: empty defaults, roundtrip, singleton + partial-update preservation.

Next slices (mapped, not yet built): briefing injection (BriefingInputs +
build_context_briefing + EvidenceRepo), API route (GET any / PUT CEO-only),
panel /goals page, and base/Board/PM prompt mentions.

* feat(goals): inject the company charter into every agent briefing (slice 2)

The charter is now goal-aware context for every agent:
- BriefingInputs gains company_goals; build_context_briefing surfaces it.
- EvidenceRepo.company_goals(): single-row lookup returning a COMPACT charter
  (north star + objectives + constraints + operating policy; audit columns
  dropped, lists capped) or None when unset, so an empty charter never bloats
  the per-verb briefing.
- _briefing_for wires it into every context_briefing.

Tests: briefing surfaces company_goals (defaults None); repo returns None for an
absent/empty charter and the compact dict when set.

* feat(goals): company charter API — GET any agent, PUT CEO-only (slice 3)

- routes/company_goals.py: GET returns the charter (any authenticated agent —
  it drives every briefing); PUT is CEO-only (403 otherwise), partial update via
  model_dump(exclude_unset=True), explicit commit.
- schemas/company_goals.py: response + partial-update models.
- registered at /api/company-goals.
- tests: GET open to any role, CEO update persists + is readable, non-CEO 403.

* feat(goals): make the company charter actionable in agent prompts (slice 5)

Agents already receive company_goals in the briefing (slice 2); now tell them to
act on it:
- base.md: universal "Align with the company charter" section — favour work and
  trade-offs that advance the objectives, honour the constraints, flag conflicts;
  never a license to leave your role.
- board / main_pm / cell_pm: role-specific lines tying triage / cell-routing /
  subtask decomposition to the charter.

Prompts are composed at spawn from base.md + roles/*.md directly (compose_prompt),
so no _generated regeneration is needed.

* feat(goals): company charter panel page (slice 4)

CEO-facing editor for the charter at /company-goals:
- lib/api/company-goals.ts: get / update (PUT) client.
- company-goals-card.tsx: edit north star + constraints (one per line) +
  objectives / operating_policy (JSON, parsed + validated with toast errors);
  display derives from server state (no set-state-in-effect).
- (dashboard)/company-goals/page.tsx + a "Company Goals" sidebar nav link.

tsc --noEmit + eslint clean. Completes Phase 1 (Business Goals): data, briefing
injection, API, prompts, panel.

* fix(test): make test_app route assertions robust to FastAPI 0.137 _IncludedRouter

FastAPI 0.137 stopped flattening include_router into app.routes — each include is
now an _IncludedRouter (a BaseRoute with no .path), so `{r.path for r in
app.routes}` raised AttributeError and the two router-registration tests failed
(the bump arrived via the claude-agent-sdk update in uv.lock). Add
_registered_paths(): OpenAPI schema paths (the stable public contract) plus each
included router's prefix, which also covers the websocket /ws mount (never in the
schema). Drops the now-incorrect type: ignore[attr-defined].

* feat(research): pluggable web search/fetch for Board + PM agents

Add a provider-agnostic web-research capability so the Board and PMs can
ground decisions in current external evidence the knowledge base can't
answer.

- ResearchService selects a provider adapter from config: Tavily, Brave,
  and Exa adapters plus a NullProvider that degrades gracefully when no
  key is set. Result count and fetched-content size are clamped to caps.
- /api/research/search and /api/research/fetch: role-gated to Board + PMs
  (and the CEO), with a per-agent/day Redis quota that fails open.
- roboco-search MCP server (web_search / web_fetch) calls those routes;
  the provider key stays server-side and agent containers never egress.
  Mounted per role by the orchestrator, behind a master switch.
- Charter-aware prompt guidance for Board, Main PM, and Cell PM.

Additive: with no key configured it is a no-op and the existing delivery
lifecycle is unchanged.

* feat(pitch): Board pitch -> CEO approve -> auto-provision repos

Add an additive origination path so a product can be proposed, approved,
and stood up without manual repo/Project setup.

- Pitch entity + migration (pitches table); PitchService create/list/
  reject/approve.
- GitHubProvisioningService: the one place that creates repos (POST
  /orgs/{org}/repos). Server-side token/org; when unconfigured the whole
  approve path is inert and nothing is created.
- On approval: provision one repo per target cell, register a Project per
  repo, create a Product when multi-cell, and seed one Main-PM delivery
  task — all reusing the existing Product / coordination-task machinery.
- /api/pitches: Board authors (PO/HoM), CEO approves/rejects, Board+PM+CEO
  view. Errors mapped via a single translator.

Additive: the delivery lifecycle is untouched; with no provisioning token
the capability is a no-op. Agent-facing pitch tool + panel are follow-ups.

* feat(strategy): dormant autonomous strategy engine (engine 2)

Add a second, optional engine that watches the company against its
standing goals and surfaces what needs the CEO — without touching the
delivery lifecycle (engine 1).

- StrategyEngine.assess() reports observations: the company is idle while
  goals stand, and tasks stranded in 'blocked' past a threshold.
- run_cycle() notifies the CEO (notify-only; it never spends, builds, or
  auto-approves — originating work stays a CEO decision).
- Orchestrator runs it on its own interval, started/stopped with the other
  background loops; the loop returns immediately unless enabled.

DORMANT by default (strategy_engine_enabled=False): the loop never runs and
a standard deployment is unchanged. Auto-origination is a further opt-in.

* docs(changelog): record Business Goals, Web Research, Pitch->Provision, and the dormant strategy engine under Unreleased

* feat(secretary): wire the Secretary role end-to-end (foundation)

Add SECRETARY as a distinct role — the CEO's conversational chief-of-staff,
governed separately from the Prompter (which stays read-only/human-only).
This is the role foundation only; authority, the live agent, and the panel
land in following commits.

- foundation/identity: Role.SECRETARY (board level), seeded secretary-1 agent,
  role-level mapping.
- journaling read tier (ALL — it advises the CEO), role_config entry,
  per-role model (opus), prompt-layer mapping + roles/secretary.md.
- i_am_idle gains SECRETARY so the role has a verb surface.
- migration 034: add 'secretary' to the agentrole enum (mirrors 025).
- Role-registry tests updated for the new role.

Inert by itself (nothing spawns it yet); additive — existing roles unchanged.

* feat(secretary): directives + gate-list authority (backend)

The Secretary acts only under CEO command. Low-risk directives (relay a
dictated message) execute immediately; high-impact ones — charter edits,
task start/cancel/override, pitch approval, announcements — are recorded
pending and run only after the CEO confirms (the gate list).

- secretary_directives table (migration 035) as the command audit + queue.
- SecretaryService: read company state; submit (direct->run, gated->queue +
  notify CEO); confirm/reject; execution runs with the CEO as actor through
  the existing services (the Secretary never holds CEO authority itself).
- /api/secretary: submit + state/task reads (Secretary or CEO); list/confirm/
  reject (CEO only). Writes commit explicitly.

* feat(secretary): live conversational agent (container + bridge)

Stand up the Secretary as a persistent Claude-SDK container the CEO chats
with, mirroring the Intake agent and reusing its driver/session machinery.

- secretary_driver: build_secretary_options exposes read_company_state /
  read_task / submit_directive as SDK tools that call /api/secretary/* with
  the agent's HMAC token; backend-call logic is module-level + tested.
- secretary_main: container entrypoint (receiver + relay) reusing IntakeDriver.
- orchestrator: start/spawn/reap secretary session + run-cmd builder; no
  workspace clone (reads state via API), mints a role=secretary token.
- secretary_live routes: panel <-> container bridge over the live registry.
- agent-secretary image (Dockerfile + compose build service).

Inert until a session is started; additive — intake and all agents unchanged.

* feat(secretary): panel chat + directive confirmation queue

The CEO's Secretary surface: a live chat (SSE) to talk to the Secretary, and
a 'Needs your confirmation' queue listing gated directives the Secretary
proposed — each with Confirm / Reject. Adds the sidebar nav entry.

- lib/api/secretary.ts: live (start/stream/status/send/stop) + directive
  (list/confirm/reject) + state clients (all as the CEO).
- hooks/use-secretary.ts: drives one chat, accumulating SSE token deltas.
- secretary page: chat pane + pending-directive cards.

Completes the Secretary end-to-end (role + authority + live agent + panel).

* feat(pitch): agent-facing pitch tool + pitches panel

Complete the pitch path: the Board can now author pitches through the gateway,
and the CEO reviews/approves them in the panel.

- content_actions.pitch (Board-only) -> PitchService.create, returning an
  Envelope; wired as a do-tool (do_server + /api/v1/do/pitch + schema) and
  added to the Board's do-tools.
- Panel /pitches page: lists pitches with CEO Approve & provision / Reject;
  sidebar nav entry.

Pitch (Phase 4) is now end-to-end: author -> CEO approve -> auto-provision.

* feat(cockpit): read-only 'is the business winning?' summary

A pure aggregation for the CEO over existing data — no new state, no writes.

- CockpitService.summary(): charter north-star/objectives, delivery counts
  (in-flight/blocked/awaiting-CEO), 30-day spend vs the charter's budget cap,
  pending pitches, and the strategy engine's signals (what needs you). Stamped
  basis='proxy' — performance is a proxy until real launches.
- GET /api/cockpit/summary (CEO / Board / Main PM / Secretary).
- Panel /cockpit page + sidebar nav.

Reuses goals + usage + StrategyEngine.assess(); reads only.

* docs(changelog): add the Secretary and Cockpit to Unreleased

* fix(test): isolate the company-goals empty-defaults test from committed state

The shared test DB persists committed writes across tests; a route test
commits a charter, so the unit test's 'unset' assertion must establish its
own clean precondition rather than assume global emptiness.

* fix(gateway): lower evidence_repo complexity to rank A (xenon gate)

company_goals()'s 4-way `or` emptiness check tipped the module average to
rank B; `any(...)` is equivalent and keeps the module under the gate's A bar.

* chore(compose): mirror agent-secretary-image build into docker-compose.yaml

Both compose files are byte-identical and tracked; .yaml carries the same
agent-secretary-image build service already present in docker-compose.yml.

* chore(lifecycle): regenerate artifacts for secretary i_am_idle

The secretary role gained i_am_idle in the lifecycle spec; regenerate the
generated prompt/doc/json artifacts so foundation-check stays green.

* docs(changelog): cut the company-in-a-box phases to 0.4.0

Label the six additive phases (business goals, web research, pitch-provision,
strategy engine, secretary, cockpit) as 0.4.0; tag v0.4.0 is held until the
branch merges to master so it points at the release commit.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-06-15 20:47:41 +02:00
committed by GitHub
co-authored by Renn F
parent 9f668b2a50
commit 46d89b58fe
92 changed files with 7249 additions and 5 deletions
+42 -1
View File
@@ -5,6 +5,46 @@ All notable changes to RoboCo are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.4.0] - 2026-06-15
### Added
- **Business Goals — the company charter.** A single CEO-owned charter (north
star, prioritized objectives, constraints, operating policy) injected
compactly into every agent's briefing so all work is goal-aware.
`GET /api/company-goals` (any agent) / `PUT` (CEO-only), with a panel editor.
- **Web research for the Board and PMs.** Pluggable `web_search` / `web_fetch`
exposed through a `roboco-search` MCP server backed by `/api/research/*`, with
Tavily / Brave / Exa adapters and a graceful no-op when no provider is
configured. The provider key stays server-side — agent containers never make
the external request themselves — and a per-agent daily quota (Redis,
fail-open) bounds cost.
- **Pitch → approve → provision.** The Board proposes a product (a "pitch");
on CEO approval the system provisions a GitHub repo per target cell, registers
a Project for each (and a Product when multi-cell), and seeds one Main-PM
delivery task — reusing the existing Product / coordination-task machinery.
Default-off: with no provisioning token configured, approval is refused and
nothing is created.
- **Autonomous strategy engine (dormant).** An optional second engine that
watches the company against its standing goals and surfaces drift, idle, and
long-stranded blocked work to the CEO (notify-only — it never spends, builds,
or auto-approves). Off by default; the delivery lifecycle is unchanged.
- **The Secretary — the CEO's chief-of-staff.** A live conversational agent (its
own role, distinct from the Prompter) the CEO chats with in the panel. It acts
only under the CEO's command: it reads company state and relays dictated
messages directly, but high-impact actions — editing the charter, starting /
cancelling / overriding tasks, approving a pitch, announcements — are queued
and run only after the CEO's explicit confirmation (the gate list). Its
authority is HMAC-scoped to the secretary role and routed through the existing
enforcement, never a parallel permission model.
- **The Cockpit.** A read-only `/cockpit` view answering "is the business
winning, what's happening, what needs me" — the charter, delivery counts,
30-day spend vs the budget cap, pending pitches, and the strategy engine's
signals. Honestly stamped `basis: proxy` (a proxy until real launches).
All of these are additive and opt-in or default-off — an unconfigured deployment
behaves exactly as before.
## [0.3.0] - 2026-06-15
### Added
@@ -114,6 +154,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Next.js control panel (`panel/`) behind a single nginx entry point.
- Multi-agent workspace management with per-project encrypted git tokens.
[Released]: https://github.com/rennf93/roboco/compare/v0.2.0...HEAD
[0.4.0]: https://github.com/rennf93/roboco/compare/v0.3.0...HEAD
[0.3.0]: https://github.com/rennf93/roboco/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/rennf93/roboco/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/rennf93/roboco/releases/tag/v0.1.0
@@ -0,0 +1,6 @@
# Verbs available to your role (secretary)
These are the only verbs the gateway will accept from you. Calling any
other verb will be rejected with a Decision telling you the right one.
- **i_am_idle**: Signal you have no active work. PMs auto-pause owned in_progress tasks.
+4
View File
@@ -58,6 +58,10 @@ Every success envelope carries a `context_briefing`. **Read it before you touch
If `task_handoff` is present, treat the work as in-progress: read these fields first, then do only what is left. Re-scanning the whole repository or re-deriving the plan when the briefing already told you the state is wasted effort. Also scan `unread_a2a`, `unread_mentions`, and `pending_notifications` — those are messages addressed to you.
## Align with the company charter
The briefing also carries `company_goals` — the company's charter (north star, prioritized objectives, constraints, operating policy) set by the CEO. When it is present, let it steer your judgment: favour work and trade-offs that advance the stated objectives and honour the constraints, and flag work that conflicts with them. The charter shapes *how* you do your role's work well — it is never a license to step outside your role.
## Channels
Channel arguments take the slug **without** the `#` prefix: `"backend-cell"`, not `"#backend-cell"`. Channel names with `#` may be tolerated but are not correct.
+12
View File
@@ -8,6 +8,8 @@ The Auditor is silent: read-only across every channel, no `say` or `dm`, observa
If you find yourself reaching for `Bash git`, `Edit`, or any execution tool, stop — you are about to step out of role. The right move at the Board level is `escalate_to_ceo` for strategic decisions, or `note` for observations.
When the briefing carries `company_goals`, that charter is your reference for triage and escalation: prioritize, accept, and reject work by how well it advances the CEO's stated objectives and respects the charter's constraints.
## Inputs you start with
- Your `task_id` (if you were spawned to triage a specific task) and `agent_id` are pre-baked.
@@ -91,6 +93,16 @@ The Auditor has no escalation verb — every observation flows through the journ
- ❌ Skipping the `journal:decision` entry before `escalate_to_ceo`. The gateway rejects with a tracing-gap envelope.
- ❌ Trying to merge or complete tasks. PMs and CEO own merge/complete; the Board does not have those verbs.
## Web research (Product Owner & Head of Marketing only)
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.)
## 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.
+10
View File
@@ -8,6 +8,8 @@ You are a coordinator. You receive a task from Main PM, you break it into focuse
You merge what your developers submit (leaf PRs into your cell branch via `complete`), and you submit your cell branch up to Main PM via `submit_up`. You never merge to master — that is the CEO's seat.
When the briefing carries `company_goals`, let the charter guide how you scope and prioritize the subtasks you cut: favour decomposition that advances the stated objectives and respects the constraints.
## Inputs you start with
- Your `task_id` (your cell-PM task) and `agent_id` are pre-baked into the gateway session.
@@ -188,6 +190,14 @@ The PM journal is what makes the cell legible to Main PM and CEO. Skipping entri
and let the chain progress. A second `code` subtask to your *other* dev,
however, is allowed — that's the parallel path, not a rejection.
## Web research
You have `web_search` and `web_fetch` for the rare moment decomposition needs a
current external fact — an unfamiliar library's status or an API's constraints —
that the knowledge base can't answer. Cite the URL and capture the finding with
`note` so your developers inherit the context. Calls are quota-limited per day;
use them for genuine unknowns, not routine planning.
## 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, sufficient notes, or a precondition transition). Fix that one piece and retry the same verb.
+11
View File
@@ -8,6 +8,8 @@ You are a coordinator at the org level. You receive a root task from the Board o
You merge what your Cell PMs submit (cell PRs into your root branch via `complete`). When all cell-PM subtasks are terminal, you open the master PR via `complete` on the root task, which transitions it to `awaiting_ceo_approval`. The CEO approves and merges to master.
When the briefing carries `company_goals`, weight your cell-routing and delegation by it: scope and sequence subtasks to advance the CEO's stated objectives within the charter's constraints.
## Read the upstream handoff BEFORE you research or plan
Your root task did not appear from nowhere. It was shaped upstream by the **Product Owner** (PO) and, for launch-facing work, the **Head of Marketing** (HoM). Their analysis, scoping decisions, and guidance live in the task's journal as `decision`/`reflect` entries and in the task description — that is your **handoff**. It exists precisely so you do NOT redo the work they already did.
@@ -188,6 +190,15 @@ You are the integration layer between Cells and CEO. Your journal is what tells
and let the chain progress; the orchestrator will respawn you when
the child needs review.
## Web research
You have `web_search` and `web_fetch` for the moments planning needs current
external facts the knowledge base can't supply — a library's maintenance
status, an API's limits, how a competitor approaches a problem. Cite the URL
and persist what you learn with `note` so the decision is traceable. Calls are
quota-limited per day; reserve them for genuine planning unknowns, not routine
coordination.
## 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 or a precondition transition). Fix that one piece and retry the same verb.
+68
View File
@@ -0,0 +1,68 @@
# Secretary
## Identity
You are the **Secretary** — the CEO's conversational chief-of-staff. You exist
to serve the CEO directly: you read the state of the company, answer the CEO's
questions, and carry out the CEO's directives. You talk **only** to the CEO,
the way the Intake interviewer talks only to the human — never to other agents
on your own initiative.
You are **not** autonomous. You never originate strategy, never decide what the
company should do, and never act except on the CEO's instruction. Think of
yourself as an extension of the CEO's hands and memory, not a decision-maker.
(The company's autonomous watching is a separate, dormant engine; that is not
you.)
## Under the CEO's command, always
Everything you do traces to something the CEO just told you. There is no
"acting on your own."
- **Reading is always free.** You may read the company charter (goals), the
task queue, task details, agent/cell status, and recent activity at any time
to inform your answers. Reading never needs confirmation.
- **Preparing is direct.** When the CEO asks you to draft something — a task
spec for their review, a summary, a single message to relay verbatim — you do
it directly and show them the result.
- **High-impact actions bounce back for an explicit confirm.** Even when the
CEO has told you to do one of these, you restate exactly what you are about to
do and wait for a clear "yes" before executing. These are the **gated**
actions:
- Changing the **company charter** (north star, objectives, constraints,
operating policy).
- **Starting, cancelling, or overriding** any task's status.
- **Approving a pitch** (this provisions real repositories and commits spend).
- Posting **announcements** or notifying the whole company.
For everything in that list: summarize the action and its blast radius in one
or two lines, then ask the CEO to confirm. Do not execute until they confirm.
## Your authority is the CEO's, exercised on command
When you carry out a directive, you act with the CEO's authority — but that
authority is scoped and routed through the same enforcement every other action
goes through. You cannot do anything the CEO could not do, and you cannot
escalate your own privileges. If an action is refused by the system, report the
refusal plainly; do not try to work around it.
## How you work
- Keep replies tight and decision-oriented. The CEO is busy; lead with the
answer, then the supporting detail.
- When you need information, read it — don't guess. Ground every claim about
company state in what you actually read.
- When the CEO is vague, ask a short clarifying question rather than assuming.
- Never invent agents, channels, tasks, or numbers. If you don't know, say so
and offer to look it up.
- You do not write code, open PRs, or merge. You coordinate and inform; the
cells and PMs execute, and the CEO decides.
## Anti-patterns
- ❌ Doing anything the CEO did not ask for.
- ❌ Executing a gated action without an explicit confirmation.
- ❌ Talking to other agents on your own initiative, or trying to run the
delivery lifecycle yourself.
- ❌ Presenting guesses as facts about company state.
- ❌ Attempting to widen your own authority or bypass a refusal.
+62
View File
@@ -0,0 +1,62 @@
"""Add the company_goals singleton charter table.
A single CEO-owned row — north star, prioritized objectives, constraints, and
operating policy — injected compactly into every agent's context_briefing so all
work is goal-aware. Seeded with one empty row at the all-zeros singleton id; the
CEO populates it via the API (CEO-only writes).
Revision ID: 032_company_goals
Revises: 031_rag_chunks_fulltext
Create Date: 2026-06-15
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "032_company_goals"
down_revision = "031_rag_chunks_fulltext"
branch_labels = None
depends_on = None
_SINGLETON_ID = "00000000-0000-0000-0000-000000000000"
def upgrade() -> None:
op.create_table(
"company_goals",
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True, nullable=False),
sa.Column("north_star", sa.Text(), nullable=False, server_default=""),
sa.Column(
"objectives",
sa.JSON(),
nullable=False,
server_default=sa.text("'[]'::json"),
),
sa.Column(
"constraints",
sa.JSON(),
nullable=False,
server_default=sa.text("'[]'::json"),
),
sa.Column(
"operating_policy",
sa.JSON(),
nullable=False,
server_default=sa.text("'{}'::json"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column("updated_by", sa.UUID(as_uuid=True), nullable=True),
)
# Seed the singleton row; the column server defaults fill the rest.
op.execute(f"INSERT INTO company_goals (id) VALUES ('{_SINGLETON_ID}')")
def downgrade() -> None:
op.drop_table("company_goals")
+64
View File
@@ -0,0 +1,64 @@
"""Add the pitches table — Board proposals the CEO approves to auto-provision.
A pitch is a Board-authored product proposal (problem + proposed solution +
target cells). On CEO approval the system provisions a GitHub repo per target
cell, registers each as a Project (and a Product when multi-cell), and seeds an
initial delivery task to Main PM. Purely additive origination path — the
existing delivery lifecycle is unchanged.
Revision ID: 033_pitches
Revises: 032_company_goals
Create Date: 2026-06-15
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "033_pitches"
down_revision = "032_company_goals"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"pitches",
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True, nullable=False),
sa.Column("title", sa.String(length=200), nullable=False),
sa.Column("slug", sa.String(length=50), nullable=False, unique=True),
sa.Column("problem", sa.Text(), nullable=False),
sa.Column("proposed_solution", sa.Text(), nullable=False),
sa.Column(
"target_cells",
sa.JSON(),
nullable=False,
server_default=sa.text("'[]'::json"),
),
sa.Column(
"status",
sa.String(length=20),
nullable=False,
server_default="proposed",
),
sa.Column("created_by", sa.UUID(as_uuid=True), nullable=False),
sa.Column("decided_by", sa.UUID(as_uuid=True), nullable=True),
sa.Column("decision_notes", sa.Text(), nullable=True),
sa.Column("provisioned_product_id", sa.UUID(as_uuid=True), nullable=True),
sa.Column("provisioned_project_ids", sa.JSON(), nullable=True),
sa.Column("seed_task_id", sa.UUID(as_uuid=True), 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_index("ix_pitches_status", "pitches", ["status"])
def downgrade() -> None:
op.drop_index("ix_pitches_status", table_name="pitches")
op.drop_table("pitches")
@@ -0,0 +1,31 @@
"""Add 'secretary' to the postgres agentrole enum.
The Secretary is the CEO's conversational chief-of-staff (``Role.SECRETARY`` in
foundation/identity). Seeding/spawning its agent row requires the postgres
``agentrole`` enum to carry the value. Mirrors migration 025's pattern.
Revision ID: 034_agentrole_secretary
Revises: 033_pitches
Create Date: 2026-06-15
"""
from __future__ import annotations
from alembic import op
revision = "034_agentrole_secretary"
down_revision = "033_pitches"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Unguarded (renders in offline --sql so the enum-migration-parity test
# sees it) and idempotent. PG 16 permits ADD VALUE inside a transaction.
op.execute("ALTER TYPE agentrole ADD VALUE IF NOT EXISTS 'secretary'")
def downgrade() -> None:
# Postgres does not support removing enum values without a destructive
# type recreation. Forward-only by design (see migration 025).
pass
@@ -0,0 +1,53 @@
"""Add the secretary_directives table — the Secretary's command audit + gate queue.
Every action the Secretary takes on the CEO's behalf is recorded here. Direct
(low-risk) directives are written already-executed; gated (high-impact)
directives are written ``pending`` and wait for the CEO's explicit confirmation
before they run. Forward-only enum-ish status stored as a string.
Revision ID: 035_secretary_directives
Revises: 034_agentrole_secretary
Create Date: 2026-06-15
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "035_secretary_directives"
down_revision = "034_agentrole_secretary"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"secretary_directives",
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True, nullable=False),
sa.Column("kind", sa.String(length=32), nullable=False),
sa.Column(
"payload", sa.JSON(), nullable=False, server_default=sa.text("'{}'::json")
),
sa.Column(
"status", sa.String(length=16), nullable=False, server_default="pending"
),
sa.Column("requested_by", sa.UUID(as_uuid=True), nullable=False),
sa.Column(
"requested_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column("decided_by", sa.UUID(as_uuid=True), nullable=True),
sa.Column("decided_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("result", sa.Text(), nullable=True),
)
op.create_index(
"ix_secretary_directives_status", "secretary_directives", ["status"]
)
def downgrade() -> None:
op.drop_index("ix_secretary_directives_status", table_name="secretary_directives")
op.drop_table("secretary_directives")
+10
View File
@@ -206,6 +206,16 @@ services:
depends_on:
- agent-base-image
agent-secretary-image:
build:
context: .
dockerfile: docker/agent-secretary.Dockerfile
image: roboco-agent-secretary
entrypoint: ["/bin/sh", "-c", 'echo "Agent Secretary image built"']
restart: "no"
depends_on:
- agent-base-image
# ==========================================================================
# Orchestrator - API Server + Agent Spawner
# ==========================================================================
+10
View File
@@ -206,6 +206,16 @@ services:
depends_on:
- agent-base-image
agent-secretary-image:
build:
context: .
dockerfile: docker/agent-secretary.Dockerfile
image: roboco-agent-secretary
entrypoint: ["/bin/sh", "-c", 'echo "Agent Secretary image built"']
restart: "no"
depends_on:
- agent-base-image
# ==========================================================================
# Orchestrator - API Server + Agent Spawner
# ==========================================================================
+18
View File
@@ -0,0 +1,18 @@
# Secretary Agent — the persistent Claude Code session the CEO chats with as
# their chief-of-staff.
#
# Like the intake (prompter) agent it runs a long-lived driver holding one
# `claude-agent-sdk` `ClaudeSDKClient` open, receiving the CEO's messages over
# HTTP (POST /turn) and streaming each reply to the panel. Unlike intake, its
# tools call the backend `/api/secretary/*` routes to read company state and
# submit directives (the gate-list bounces high-impact ones back to the CEO).
# `claude-agent-sdk` and `roboco` are already in the base image; the SDK drives
# the same `claude` binary using the same mounted ~/.claude auth — no API key.
FROM roboco-agent-base
LABEL role="secretary"
LABEL description="CEO's chief-of-staff — a long-lived Claude Agent SDK session with gated CEO authority"
# Override the base `["claude"]` entrypoint with the secretary driver. WORKDIR
# /app and the venv on PATH are inherited from the base; roboco lives at /app/roboco.
ENTRYPOINT ["python", "-m", "roboco.agent_sdk.secretary_main"]
+1 -1
View File
@@ -98,7 +98,7 @@ Submit work for QA. Auto-runs in_progress->verifying then verifying->awaiting_qa
Signal you have no active work. PMs auto-pause owned in_progress tasks.
**Allowed roles:** auditor, cell_pm, developer, documenter, head_marketing, main_pm, product_owner, prompter, qa
**Allowed roles:** auditor, cell_pm, developer, documenter, head_marketing, main_pm, product_owner, prompter, qa, secretary
**Composes:** (no atomic actions)
+2 -1
View File
@@ -159,7 +159,8 @@
"main_pm",
"product_owner",
"prompter",
"qa"
"qa",
"secretary"
],
"composes": [],
"description": "Signal you have no active work. PMs auto-pause owned in_progress tasks.",
+127
View File
@@ -0,0 +1,127 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, Loader2 } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { cockpitApi } from "@/lib/api/cockpit";
function Stat({ label, value }: { label: string; value: string | number }) {
return (
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-2xl font-semibold">{value}</p>
</div>
);
}
export default function CockpitPage() {
const { data, isLoading } = useQuery({
queryKey: ["cockpit", "summary"],
queryFn: () => cockpitApi.summary(),
refetchInterval: 30000,
});
if (isLoading || !data) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading the cockpit
</div>
);
}
const cap = data.spend.monthly_budget_cap_usd;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Cockpit</h1>
<p className="text-muted-foreground">
Is the business winning, what&apos;s happening, what needs you.
</p>
</div>
<Badge variant="secondary" title="Performance is a proxy until real launches">
basis: {data.basis}
</Badge>
</div>
<Card>
<CardHeader>
<CardTitle>North star</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm">
{data.north_star || "No north star set yet — define it in Company Goals."}
</p>
{data.objectives.length > 0 && (
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
{data.objectives.map((o, i) => (
<li key={i}>{JSON.stringify(o)}</li>
))}
</ul>
)}
</CardContent>
</Card>
<div className="grid gap-4 sm:grid-cols-3">
<Stat label="In flight" value={data.delivery.in_flight} />
<Stat label="Blocked" value={data.delivery.blocked} />
<Stat label="Awaiting your approval" value={data.delivery.awaiting_ceo} />
</div>
<Card>
<CardHeader>
<CardTitle>Spend (30 days)</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center gap-3">
<span className="text-2xl font-semibold">
${data.spend.spend_30d_usd.toFixed(2)}
</span>
{cap != null && (
<span className="text-sm text-muted-foreground">
/ ${cap.toFixed(2)} cap
</span>
)}
{data.spend.over_budget && (
<Badge variant="destructive">
<AlertTriangle className="mr-1 h-3 w-3" /> over budget
</Badge>
)}
</div>
{data.spend.projected_monthly_usd != null && (
<p className="text-xs text-muted-foreground">
Projected this month: ${data.spend.projected_monthly_usd.toFixed(2)}
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Needs your attention</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{data.pending_pitches > 0 && (
<p className="text-sm">
{data.pending_pitches} pitch(es) awaiting your approval.
</p>
)}
{data.signals.length === 0 && data.pending_pitches === 0 ? (
<p className="text-sm text-muted-foreground">
Nothing needs you right now.
</p>
) : (
data.signals.map((s, i) => (
<div key={i} className="rounded-lg border p-3">
<p className="text-sm font-medium">{s.summary}</p>
<p className="text-xs text-muted-foreground">{s.detail}</p>
</div>
))
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,18 @@
"use client";
import { CompanyGoalsCard } from "@/components/company-goals/company-goals-card";
export default function CompanyGoalsPage() {
return (
<div className="space-y-6 max-w-3xl">
<div>
<h1 className="text-3xl font-bold tracking-tight">Company Goals</h1>
<p className="text-muted-foreground">
The organization&apos;s charter north star, objectives, constraints,
and operating policy that steer every agent&apos;s work.
</p>
</div>
<CompanyGoalsCard />
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Check, Loader2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { getErrorMessage } from "@/lib/api/client";
import { pitchesApi, type Pitch } from "@/lib/api/pitches";
function PitchCard({
pitch,
onApprove,
onReject,
busy,
}: {
pitch: Pitch;
onApprove: (id: string) => void;
onReject: (id: string) => void;
busy: boolean;
}) {
const proposed = pitch.status === "proposed";
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{pitch.title}</CardTitle>
<Badge variant={proposed ? "default" : "secondary"}>{pitch.status}</Badge>
</div>
<div className="flex flex-wrap gap-1">
{pitch.target_cells.map((c) => (
<Badge key={c} variant="outline">
{c}
</Badge>
))}
</div>
</CardHeader>
<CardContent className="space-y-3">
<div>
<p className="text-xs font-medium text-muted-foreground">Problem</p>
<p className="text-sm whitespace-pre-wrap">{pitch.problem}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">
Proposed solution
</p>
<p className="text-sm whitespace-pre-wrap">{pitch.proposed_solution}</p>
</div>
{proposed ? (
<div className="flex gap-2">
<Button size="sm" disabled={busy} onClick={() => onApprove(pitch.id)}>
<Check className="mr-1 h-4 w-4" /> Approve &amp; provision
</Button>
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => onReject(pitch.id)}
>
<X className="mr-1 h-4 w-4" /> Reject
</Button>
</div>
) : (
pitch.decision_notes && (
<p className="text-xs text-muted-foreground">
Decision: {pitch.decision_notes}
</p>
)
)}
</CardContent>
</Card>
);
}
export default function PitchesPage() {
const qc = useQueryClient();
const { data: pitches = [], isLoading } = useQuery({
queryKey: ["pitches"],
queryFn: () => pitchesApi.list(),
refetchInterval: 30000,
});
const approveMutation = useMutation({
mutationFn: (id: string) => pitchesApi.approve(id),
onSuccess: () => {
toast.success("Pitch approved — provisioning started");
void qc.invalidateQueries({ queryKey: ["pitches"] });
},
onError: (e) => toast.error(getErrorMessage(e)),
});
const rejectMutation = useMutation({
mutationFn: (id: string) => pitchesApi.reject(id, "Rejected by CEO"),
onSuccess: () => {
toast.success("Pitch rejected");
void qc.invalidateQueries({ queryKey: ["pitches"] });
},
onError: (e) => toast.error(getErrorMessage(e)),
});
const busy = approveMutation.isPending || rejectMutation.isPending;
return (
<div className="max-w-3xl space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Pitches</h1>
<p className="text-muted-foreground">
Board proposals. Approving a pitch provisions a repository per target
cell, registers the projects, and seeds the first task to Main PM.
</p>
</div>
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading
</div>
) : pitches.length === 0 ? (
<p className="text-sm text-muted-foreground">
No pitches yet. The Board authors them; they appear here for your
approval.
</p>
) : (
<div className="space-y-4">
{pitches.map((p) => (
<PitchCard
key={p.id}
pitch={p}
busy={busy}
onApprove={(id) => approveMutation.mutate(id)}
onReject={(id) => rejectMutation.mutate(id)}
/>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,216 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Check, Loader2, Send, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { getErrorMessage } from "@/lib/api/client";
import { secretaryApi, type SecretaryDirective } from "@/lib/api/secretary";
import { useSecretary } from "@/hooks/use-secretary";
function DirectiveCard({
directive,
onConfirm,
onReject,
busy,
}: {
directive: SecretaryDirective;
onConfirm: (id: string) => void;
onReject: (id: string) => void;
busy: boolean;
}) {
return (
<div className="space-y-2 rounded-lg border p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{directive.kind}</span>
<span className="text-xs text-muted-foreground">{directive.status}</span>
</div>
<pre className="overflow-x-auto rounded bg-muted p-2 text-xs">
{JSON.stringify(directive.payload, null, 2)}
</pre>
<div className="flex gap-2">
<Button size="sm" disabled={busy} onClick={() => onConfirm(directive.id)}>
<Check className="mr-1 h-4 w-4" /> Confirm
</Button>
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => onReject(directive.id)}
>
<X className="mr-1 h-4 w-4" /> Reject
</Button>
</div>
</div>
);
}
export default function SecretaryPage() {
const qc = useQueryClient();
const { sessionId, messages, streaming, start, send, stop } = useSecretary();
const [input, setInput] = useState("");
const [starting, setStarting] = useState(false);
const { data: pending = [] } = useQuery({
queryKey: ["secretary", "directives", "pending"],
queryFn: () => secretaryApi.listDirectives("pending"),
refetchInterval: 15000,
});
const confirmMutation = useMutation({
mutationFn: (id: string) => secretaryApi.confirmDirective(id),
onSuccess: (d) => {
toast.success(`Directive ${d.kind}: ${d.status}`);
void qc.invalidateQueries({ queryKey: ["secretary", "directives"] });
},
onError: (e) => toast.error(getErrorMessage(e)),
});
const rejectMutation = useMutation({
mutationFn: (id: string) => secretaryApi.rejectDirective(id),
onSuccess: () => {
toast.success("Directive rejected");
void qc.invalidateQueries({ queryKey: ["secretary", "directives"] });
},
onError: (e) => toast.error(getErrorMessage(e)),
});
const busy = confirmMutation.isPending || rejectMutation.isPending;
const handleStart = async () => {
setStarting(true);
try {
await start(input.trim() || undefined);
setInput("");
} catch (e) {
toast.error(getErrorMessage(e));
} finally {
setStarting(false);
}
};
const handleSend = async () => {
const text = input.trim();
if (!text) return;
setInput("");
try {
await send(text);
} catch (e) {
toast.error(getErrorMessage(e));
}
};
return (
<div className="space-y-6">
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Secretary</h1>
<p className="text-muted-foreground">
Your chief-of-staff. It acts only on your command; high-impact
actions wait for your confirmation on the right.
</p>
</div>
{sessionId && (
<Button variant="outline" onClick={() => void stop()}>
End session
</Button>
)}
</div>
<div className="grid gap-6 lg:grid-cols-3">
<Card className="flex min-h-[60vh] flex-col lg:col-span-2">
<CardHeader>
<CardTitle>Chat</CardTitle>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-4">
<div className="flex-1 space-y-3 overflow-y-auto">
{messages.length === 0 && (
<p className="text-sm text-muted-foreground">
{sessionId
? "Say something to your Secretary…"
: "Start a session to talk to your Secretary."}
</p>
)}
{messages.map((m, i) => (
<div
key={i}
className={
m.role === "user"
? "ml-auto max-w-[80%] rounded-lg bg-primary px-3 py-2 text-sm text-primary-foreground"
: "mr-auto max-w-[80%] whitespace-pre-wrap rounded-lg bg-muted px-3 py-2 text-sm"
}
>
{m.text}
</div>
))}
{streaming && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> thinking
</div>
)}
</div>
<div className="flex gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={
sessionId
? "Message your Secretary…"
: "Opening message (optional)…"
}
rows={2}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (sessionId) void handleSend();
else void handleStart();
}
}}
/>
{sessionId ? (
<Button onClick={() => void handleSend()} disabled={!input.trim()}>
<Send className="h-4 w-4" />
</Button>
) : (
<Button onClick={() => void handleStart()} disabled={starting}>
{starting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Start"
)}
</Button>
)}
</div>
</CardContent>
</Card>
<Card className="flex flex-col">
<CardHeader>
<CardTitle>Needs your confirmation</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{pending.length === 0 ? (
<p className="text-sm text-muted-foreground">
No directives waiting. High-impact actions the Secretary proposes
will appear here for you to confirm or reject.
</p>
) : (
pending.map((d) => (
<DirectiveCard
key={d.id}
directive={d}
busy={busy}
onConfirm={(id) => confirmMutation.mutate(id)}
onReject={(id) => rejectMutation.mutate(id)}
/>
))
)}
</CardContent>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,160 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { companyGoalsApi, type CompanyGoalsUpdate } from "@/lib/api/company-goals";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Target, Save } from "lucide-react";
import { toast } from "sonner";
function parseError(e: unknown): string {
return e instanceof Error ? e.message : "parse error";
}
export function CompanyGoalsCard() {
const queryClient = useQueryClient();
// null = "show the server value"; deriving the displayed value avoids syncing
// query state into local state with an effect (react-hooks/set-state-in-effect).
const [northStar, setNorthStar] = useState<string | null>(null);
const [constraints, setConstraints] = useState<string | null>(null);
const [objectives, setObjectives] = useState<string | null>(null);
const [policy, setPolicy] = useState<string | null>(null);
const { data: goals, isLoading } = useQuery({
queryKey: ["company-goals"],
queryFn: companyGoalsApi.get,
});
const northStarVal = northStar ?? (goals?.north_star ?? "");
const constraintsVal = constraints ?? (goals?.constraints ?? []).join("\n");
const objectivesVal =
objectives ?? JSON.stringify(goals?.objectives ?? [], null, 2);
const policyVal = policy ?? JSON.stringify(goals?.operating_policy ?? {}, null, 2);
const saveMutation = useMutation({
mutationFn: (update: CompanyGoalsUpdate) => companyGoalsApi.update(update),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["company-goals"] });
setNorthStar(null);
setConstraints(null);
setObjectives(null);
setPolicy(null);
toast.success("Company charter updated");
},
onError: (error) => {
toast.error(`Failed to save: ${parseError(error)}`);
},
});
const handleSave = () => {
let parsedObjectives: Record<string, unknown>[];
let parsedPolicy: Record<string, unknown>;
try {
parsedObjectives = JSON.parse(objectivesVal);
if (!Array.isArray(parsedObjectives)) {
throw new Error("must be a JSON array");
}
} catch (e) {
toast.error(`Objectives: invalid JSON — ${parseError(e)}`);
return;
}
try {
parsedPolicy = JSON.parse(policyVal);
if (
typeof parsedPolicy !== "object" ||
parsedPolicy === null ||
Array.isArray(parsedPolicy)
) {
throw new Error("must be a JSON object");
}
} catch (e) {
toast.error(`Operating policy: invalid JSON — ${parseError(e)}`);
return;
}
saveMutation.mutate({
north_star: northStarVal,
objectives: parsedObjectives,
constraints: constraintsVal
.split("\n")
.map((c) => c.trim())
.filter(Boolean),
operating_policy: parsedPolicy,
});
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Target className="h-5 w-5" />
Company Charter
</CardTitle>
<CardDescription>
The CEO-owned north star, objectives, constraints, and operating policy.
Injected into every agent&apos;s briefing so all work stays goal-aware.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="north-star">North star</Label>
<Textarea
id="north-star"
rows={3}
value={northStarVal}
disabled={isLoading}
onChange={(e) => setNorthStar(e.target.value)}
placeholder="The long-term vision in one or two sentences..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="constraints">Constraints (one per line)</Label>
<Textarea
id="constraints"
rows={3}
value={constraintsVal}
disabled={isLoading}
onChange={(e) => setConstraints(e.target.value)}
placeholder={"AGPL only\nNo external data egress"}
/>
</div>
<div className="space-y-2">
<Label htmlFor="objectives">Objectives (JSON array)</Label>
<Textarea
id="objectives"
rows={6}
value={objectivesVal}
disabled={isLoading}
onChange={(e) => setObjectives(e.target.value)}
className="font-mono text-xs"
placeholder='[{"metric": "NPS", "target": 50, "status": "active"}]'
/>
</div>
<div className="space-y-2">
<Label htmlFor="policy">Operating policy (JSON object)</Label>
<Textarea
id="policy"
rows={6}
value={policyVal}
disabled={isLoading}
onChange={(e) => setPolicy(e.target.value)}
className="font-mono text-xs"
placeholder='{"autonomy_level": "assisted", "monthly_budget_cap": 500}'
/>
</div>
<Button onClick={handleSave} disabled={saveMutation.isPending || isLoading}>
<Save className="h-4 w-4 mr-2" />
{saveMutation.isPending ? "Saving..." : "Save charter"}
</Button>
</CardContent>
</Card>
);
}
+8
View File
@@ -22,6 +22,10 @@ import {
Database,
Cpu,
Sparkles,
Target,
Briefcase,
Lightbulb,
Gauge,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -30,15 +34,19 @@ import { useUIStore } from "@/store";
export const navItems = [
// Dashboard
{ title: "Overview", href: "/overview", icon: LayoutDashboard },
{ title: "Cockpit", href: "/cockpit", icon: Gauge },
{ title: "Company Goals", href: "/company-goals", icon: Target },
// Work Management
{ title: "Tasks", href: "/tasks", icon: ListTodo },
{ title: "Kanban", href: "/kanban", icon: Kanban },
{ title: "Task Assistant", href: "/prompter", icon: Sparkles },
{ title: "Secretary", href: "/secretary", icon: Briefcase },
// Development
{ title: "Projects", href: "/projects", icon: FolderGit2 },
{ title: "Products", href: "/products", icon: Boxes },
{ title: "Pitches", href: "/pitches", icon: Lightbulb },
{ title: "Git", href: "/git", icon: GitBranch },
// Team & Reference
+117
View File
@@ -0,0 +1,117 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
LIVE_EVENT_KINDS,
secretaryApi,
type LiveEvent,
} from "@/lib/api/secretary";
export interface ChatMessage {
role: "user" | "assistant";
text: string;
}
/**
* Drives one live Secretary chat: starts the session, opens the SSE stream,
* accumulates the assistant's token deltas into the last message, and sends the
* CEO's messages. Intentionally simple — no localStorage persistence; a session
* lives for the visit.
*/
export function useSecretary() {
const [sessionId, setSessionId] = useState<string | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [streaming, setStreaming] = useState(false);
const esRef = useRef<EventSource | null>(null);
const bufRef = useRef<string>("");
const closeStream = useCallback(() => {
esRef.current?.close();
esRef.current = null;
}, []);
const handleEvent = useCallback((raw: string) => {
let event: LiveEvent;
try {
event = JSON.parse(raw) as LiveEvent;
} catch {
return;
}
if (event.kind === "text" && event.text) {
bufRef.current += event.text;
const text = bufRef.current;
setStreaming(true);
setMessages((prev) => {
const next = [...prev];
const last = next[next.length - 1];
if (last && last.role === "assistant") {
next[next.length - 1] = { role: "assistant", text };
} else {
next.push({ role: "assistant", text });
}
return next;
});
} else if (event.kind === "turn_end") {
bufRef.current = "";
setStreaming(false);
} else if (event.kind === "error" && event.text) {
setStreaming(false);
setMessages((prev) => [
...prev,
{ role: "assistant", text: `⚠️ ${event.text}` },
]);
}
}, []);
const openStream = useCallback(
(sid: string) => {
closeStream();
const source = new EventSource(secretaryApi.streamUrl(sid));
esRef.current = source;
const listener = (e: MessageEvent) => handleEvent(e.data);
LIVE_EVENT_KINDS.forEach((kind) =>
source.addEventListener(kind, listener as EventListener)
);
},
[closeStream, handleEvent]
);
const start = useCallback(
async (initialMessage?: string): Promise<string> => {
const { session_id } = await secretaryApi.startLive(initialMessage);
setSessionId(session_id);
bufRef.current = "";
setMessages(initialMessage ? [{ role: "user", text: initialMessage }] : []);
openStream(session_id);
return session_id;
},
[openStream]
);
const send = useCallback(
async (text: string): Promise<void> => {
if (!sessionId) return;
bufRef.current = "";
setMessages((prev) => [...prev, { role: "user", text }]);
await secretaryApi.sendMessage(sessionId, text);
},
[sessionId]
);
const stop = useCallback(async (): Promise<void> => {
if (sessionId) {
try {
await secretaryApi.stop(sessionId);
} catch {
// Best-effort reap; closing the stream is what matters for the UI.
}
}
closeStream();
setSessionId(null);
setMessages([]);
}, [sessionId, closeStream]);
useEffect(() => () => closeStream(), [closeStream]);
return { sessionId, messages, streaming, start, send, stop };
}
+29
View File
@@ -0,0 +1,29 @@
import api from "./client";
export interface CockpitSummary {
basis: string;
north_star: string;
objectives: Record<string, unknown>[];
delivery: {
task_counts: Record<string, number>;
in_flight: number;
blocked: number;
awaiting_ceo: number;
};
spend: {
spend_30d_usd: number;
projected_monthly_usd: number | null;
monthly_budget_cap_usd: number | null;
over_budget: boolean;
};
pending_pitches: number;
signals: { kind: string; summary: string; detail: string }[];
}
export const cockpitApi = {
// GET /api/cockpit/summary — read-only company snapshot (CEO / Board / PM).
summary: async (): Promise<CockpitSummary> => {
const { data } = await api.get<CockpitSummary>("/cockpit/summary");
return data;
},
};
+30
View File
@@ -0,0 +1,30 @@
import api from "./client";
export interface CompanyGoals {
north_star: string;
objectives: Record<string, unknown>[];
constraints: string[];
operating_policy: Record<string, unknown>;
updated_at?: string | null;
updated_by?: string | null;
}
export type CompanyGoalsUpdate = Partial<
Pick<
CompanyGoals,
"north_star" | "objectives" | "constraints" | "operating_policy"
>
>;
export const companyGoalsApi = {
// GET /api/company-goals — the charter (any authenticated agent).
get: async (): Promise<CompanyGoals> => {
const { data } = await api.get<CompanyGoals>("/company-goals");
return data;
},
// PUT /api/company-goals — CEO-only; partial update, returns the full charter.
update: async (update: CompanyGoalsUpdate): Promise<CompanyGoals> => {
const { data } = await api.put<CompanyGoals>("/company-goals", update);
return data;
},
};
+1
View File
@@ -14,3 +14,4 @@ export { a2aApi } from "./a2a";
export { streamApi } from "./stream";
export { groupsApi } from "./groups";
export { settingsApi } from "./settings";
export { companyGoalsApi } from "./company-goals";
+41
View File
@@ -0,0 +1,41 @@
import api from "./client";
// ---------------------------------------------------------------------------
// Pitches — Board proposals the CEO approves to auto-provision a product.
// The panel (acting as CEO) lists pitches and approves/rejects them; the
// Board authors them through the agent gateway.
// ---------------------------------------------------------------------------
export interface Pitch {
id: string;
title: string;
slug: string;
problem: string;
proposed_solution: string;
target_cells: string[];
status: string;
created_by: string;
decided_by?: string | null;
decision_notes?: string | null;
provisioned_product_id?: string | null;
provisioned_project_ids: string[];
seed_task_id?: string | null;
created_at?: string | null;
}
export const pitchesApi = {
list: async (statusFilter?: string): Promise<Pitch[]> => {
const { data } = await api.get<Pitch[]>("/pitches", {
params: statusFilter ? { status_filter: statusFilter } : undefined,
});
return data;
},
approve: async (id: string, notes?: string): Promise<Pitch> => {
const { data } = await api.post<Pitch>(`/pitches/${id}/approve`, { notes });
return data;
},
reject: async (id: string, notes: string): Promise<Pitch> => {
const { data } = await api.post<Pitch>(`/pitches/${id}/reject`, { notes });
return data;
},
};
+123
View File
@@ -0,0 +1,123 @@
import api, { API_URL } from "./client";
// ---------------------------------------------------------------------------
// Secretary — the CEO's chief-of-staff. Two surfaces:
// 1. a live chat (spawned Secretary container, SSE stream) the CEO talks to;
// 2. the directive queue — high-impact actions the Secretary proposed that
// wait for the CEO's explicit confirmation (the gate list).
// The panel calls everything as the CEO (see client.ts interceptor).
// ---------------------------------------------------------------------------
export interface SecretaryDirective {
id: string;
kind: string;
status: string;
payload: Record<string, unknown>;
requested_by: string;
requested_at?: string | null;
decided_by?: string | null;
decided_at?: string | null;
result?: string | null;
}
export interface CompanyState {
goals: Record<string, unknown>;
task_counts: Record<string, number>;
pending_pitches: Record<string, unknown>[];
pending_directives: SecretaryDirective[];
}
export type LiveEventKind =
| "text"
| "thinking"
| "tool_use"
| "tool_result"
| "turn_end"
| "system"
| "error";
export interface LiveEvent {
kind: LiveEventKind;
text?: string;
tool?: string;
data?: Record<string, unknown>;
}
export const LIVE_EVENT_KINDS: LiveEventKind[] = [
"text",
"thinking",
"tool_use",
"tool_result",
"turn_end",
"system",
"error",
];
export const secretaryApi = {
/** Spawn the Secretary agent for a new chat. */
startLive: async (initialMessage?: string): Promise<{ session_id: string }> => {
const { data } = await api.post<{ session_id: string }>(
"/secretary/live/start",
{ initial_message: initialMessage }
);
return data;
},
/** SSE URL the panel opens to watch the Secretary reply. */
streamUrl: (sessionId: string): string =>
`${API_URL}/secretary/live/${sessionId}/stream`,
/** Is this session still running? */
status: async (sessionId: string): Promise<{ alive: boolean }> => {
const { data } = await api.get<{ alive: boolean }>(
`/secretary/live/${sessionId}/status`
);
return data;
},
/** Deliver the CEO's message to the running Secretary; the reply streams back. */
sendMessage: async (sessionId: string, text: string): Promise<void> => {
await api.post(`/secretary/live/${sessionId}/messages`, { text });
},
/** Reap the session. */
stop: async (sessionId: string): Promise<void> => {
await api.post(`/secretary/live/${sessionId}/stop`);
},
/** List directives (CEO only); defaults to the pending queue. */
listDirectives: async (
statusFilter = "pending"
): Promise<SecretaryDirective[]> => {
const { data } = await api.get<SecretaryDirective[]>("/secretary/directives", {
params: { status_filter: statusFilter },
});
return data;
},
/** Confirm a pending directive — it executes with CEO authority. */
confirmDirective: async (id: string): Promise<SecretaryDirective> => {
const { data } = await api.post<SecretaryDirective>(
`/secretary/directives/${id}/confirm`
);
return data;
},
/** Reject a pending directive. */
rejectDirective: async (
id: string,
reason?: string
): Promise<SecretaryDirective> => {
const { data } = await api.post<SecretaryDirective>(
`/secretary/directives/${id}/reject`,
{ reason }
);
return data;
},
/** A compact snapshot of company state. */
state: async (): Promise<CompanyState> => {
const { data } = await api.get<CompanyState>("/secretary/state");
return data;
},
};
+4 -2
View File
@@ -162,8 +162,10 @@ select = [
# bottom of policy/lifecycle.py at module-load time, so the validators must
# defer their inverse imports until call time to avoid a cycle.
"roboco/foundation/_validate_lifecycle.py" = ["PLC0415"]
# Test fixtures that reload modules to test env-var-at-import-time behavior
"tests/unit/mcp_servers/*.py" = ["PLC0415"]
# Test fixtures that reload modules to test env-var-at-import-time behavior.
# ARG002: ApiClient-subclassing fakes must keep the superclass parameter names
# for mypy's override check, so unused override-stub args can't be renamed.
"tests/unit/mcp_servers/*.py" = ["PLC0415", "ARG002"]
# Abstract-method stubs in test helpers: parameters must match the superclass
# signature for keyword-argument compatibility (mypy override check), but the
# stub bodies are empty — ARG002 would require renaming them, which breaks mypy.
+199
View File
@@ -0,0 +1,199 @@
"""Secretary agent driver — SDK options + the CEO-authority tools.
The Secretary is a long-lived conversational agent like Intake; it reuses the
generic chat machinery (``IntakeDriver``, ``SdkIntakeSession``, ``normalize``)
and differs only in its tools. Where Intake has a single intercepted
``propose_draft``, the Secretary has three tools that actually call the backend
``/api/secretary/*`` routes on the CEO's behalf:
* ``read_company_state`` / ``read_task`` — reads (always allowed)
* ``submit_directive`` — acts; the backend gate-list queues high-impact kinds
for the CEO's confirmation and runs low-risk ones directly.
The backend-calling logic lives in module-level helpers (``_do_*``) so it is
unit-testable with ``httpx.MockTransport``; ``build_secretary_options`` only
wraps them as SDK tools (the SDK construction itself is not gate-covered).
"""
from __future__ import annotations
import json
import os
from typing import Any
import httpx
_TIMEOUT = 30.0
_SECRETARY_BASE_TOOLS: tuple[str, ...] = ("Read", "Grep", "Glob")
def _api_base() -> str:
return os.environ.get("ROBOCO_API_URL", "http://roboco-orchestrator:8000").rstrip(
"/"
)
def _headers() -> dict[str, str]:
headers = {
"X-Agent-ID": os.environ.get("ROBOCO_AGENT_ID", ""),
"X-Agent-Role": os.environ.get("ROBOCO_AGENT_ROLE", "secretary"),
}
token = os.environ.get("ROBOCO_AGENT_TOKEN")
if token:
headers["X-Agent-Token"] = token
return headers
async def _call_backend(
method: str,
path: str,
*,
json_body: dict[str, Any] | None = None,
client: httpx.AsyncClient | None = None,
) -> dict[str, Any]:
"""Call ``/api/secretary{path}`` with the agent's auth; never raises."""
owns = client is None
http = client or httpx.AsyncClient(timeout=_TIMEOUT)
try:
resp = await http.request(
method,
f"{_api_base()}/api/secretary{path}",
headers=_headers(),
json=json_body,
timeout=_TIMEOUT,
)
except httpx.HTTPError as exc:
return {"error": "request_failed", "detail": str(exc)}
finally:
if owns:
await http.aclose()
if not resp.is_success:
return {"error": f"http_{resp.status_code}", "detail": resp.text[:300]}
parsed: dict[str, Any] = resp.json()
return parsed
async def _do_read_state(*, client: httpx.AsyncClient | None = None) -> dict[str, Any]:
return await _call_backend("GET", "/state", client=client)
async def _do_read_task(
task_id: str, *, client: httpx.AsyncClient | None = None
) -> dict[str, Any]:
return await _call_backend("GET", f"/tasks/{task_id}", client=client)
async def _do_submit_directive(
kind: str,
payload: dict[str, Any],
*,
client: httpx.AsyncClient | None = None,
) -> dict[str, Any]:
return await _call_backend(
"POST",
"/directives",
json_body={"kind": kind, "payload": payload},
client=client,
)
def _text_result(data: dict[str, Any]) -> dict[str, Any]:
"""Shape a backend result as an SDK tool text result."""
return {"content": [{"type": "text", "text": json.dumps(data)}]}
def build_secretary_options(
*,
system_prompt: str,
cwd: str,
model: str | None = None,
) -> Any: # pragma: no cover - thin SDK construction
"""Build locked-down ``ClaudeAgentOptions`` for the Secretary session.
Same isolation as Intake (``strict_mcp_config`` + ``setting_sources=[]`` +
a ``can_use_tool`` allowlist), but the MCP server exposes the Secretary's
read + directive tools, which call the backend.
"""
from claude_agent_sdk import (
ClaudeAgentOptions,
PermissionResultAllow,
PermissionResultDeny,
create_sdk_mcp_server,
tool,
)
@tool(
"read_company_state",
"Read a compact snapshot of company state: the charter (goals), task "
"counts by status, pending pitches, and any directives awaiting the "
"CEO's confirmation.",
{},
)
async def _t_read_state(_args: dict[str, Any]) -> dict[str, Any]:
return _text_result(await _do_read_state())
@tool("read_task", "Read one task's detail by its id.", {"task_id": str})
async def _t_read_task(args: dict[str, Any]) -> dict[str, Any]:
return _text_result(await _do_read_task(str(args["task_id"])))
@tool(
"submit_directive",
"Act on the CEO's command. 'kind' is one of: relay_message "
"(payload: channel, text), update_charter (payload: charter), "
"control_task (payload: task_id, action[start|cancel|override], "
"status?), approve_pitch (payload: pitch_id, notes?), announce "
"(payload: text). High-impact kinds (charter, control_task, "
"approve_pitch, announce) are queued for the CEO's explicit "
"confirmation; relay_message runs directly.",
{"kind": str, "payload": dict},
)
async def _t_submit(args: dict[str, Any]) -> dict[str, Any]:
return _text_result(
await _do_submit_directive(str(args["kind"]), dict(args.get("payload", {})))
)
server = create_sdk_mcp_server(
name="secretary",
version="1.0.0",
tools=[_t_read_state, _t_read_task, _t_submit],
)
async def _gate(tool_name: str, _input: dict[str, Any], _ctx: Any) -> Any:
if tool_name in _SECRETARY_BASE_TOOLS or "secretary__" in tool_name:
return PermissionResultAllow()
if tool_name == "AskUserQuestion" or tool_name.endswith("AskUserQuestion"):
return PermissionResultDeny(
message=(
"AskUserQuestion isn't available — just write your question "
"as a normal chat message; the CEO reads every reply live."
)
)
if tool_name == "ExitPlanMode" or tool_name.endswith("ExitPlanMode"):
return PermissionResultDeny(
message="You don't use plan mode. Act via submit_directive."
)
return PermissionResultDeny(
message=(
f"{tool_name} is not available to the Secretary. Your tools are "
"Read, Grep, Glob, read_company_state, read_task, and "
"submit_directive."
)
)
return ClaudeAgentOptions(
system_prompt=system_prompt,
cwd=cwd,
mcp_servers={"secretary": server},
allowed_tools=[
*_SECRETARY_BASE_TOOLS,
"mcp__secretary__read_company_state",
"mcp__secretary__read_task",
"mcp__secretary__submit_directive",
],
model=model,
include_partial_messages=True,
permission_mode="dontAsk",
strict_mcp_config=True,
setting_sources=[],
can_use_tool=_gate,
)
+109
View File
@@ -0,0 +1,109 @@
"""Container entrypoint for the Secretary agent — the live CEO chief-of-staff.
Mirrors ``intake_main``: an in-process HTTP receiver (`POST /turn`) the
orchestrator delivers the CEO's messages to, and a relay sink that POSTs each
``StreamChunk`` to `/api/secretary/live/{session}/events`. It reuses the generic
``IntakeDriver`` + ``SdkIntakeSession`` and supplies the Secretary's SDK options
(the CEO-authority tools). ``main()`` needs the live container; the relay-sink
wiring is unit-tested.
"""
from __future__ import annotations
import asyncio
import os
from contextlib import asynccontextmanager
from pathlib import Path
from typing import TYPE_CHECKING
import httpx
import structlog
from roboco.agent_sdk.intake_driver import IntakeDriver, SdkIntakeSession, StreamChunk
from roboco.agent_sdk.intake_main import build_receiver, make_message_source
from roboco.agent_sdk.secretary_driver import build_secretary_options
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Awaitable, Callable
logger = structlog.get_logger()
_RECEIVER_PORT = 9000 # ROBOCO_SDK_PORT — the orchestrator delivers messages here
def make_relay_sink(
base_url: str, session_id: str, client: httpx.AsyncClient
) -> Callable[[StreamChunk], Awaitable[None]]:
"""An ``EventSink`` that POSTs each chunk to the Secretary live relay."""
url = f"{base_url}/api/secretary/live/{session_id}/events"
async def _emit(chunk: StreamChunk) -> None:
try:
await client.post(
url,
json={
"kind": chunk.kind,
"text": chunk.text,
"tool": chunk.tool,
"data": chunk.data,
},
)
except Exception as exc:
logger.error(
"Secretary relay POST failed", session_id=session_id, error=str(exc)
)
return _emit
async def main() -> None: # pragma: no cover - needs the live container + SDK
"""Wire receiver + driver and run them concurrently for the chat's lifetime."""
import uvicorn
claude_json = Path.home() / ".claude.json"
if not claude_json.exists():
try:
claude_json.write_text("{}", encoding="utf-8")
except OSError as exc:
logger.warning("Could not pre-create ~/.claude.json", error=str(exc))
session_id = os.environ["ROBOCO_SECRETARY_SESSION_ID"]
base_url = os.environ.get("ROBOCO_API_URL", "http://roboco-orchestrator:8000")
cwd = os.environ.get("ROBOCO_WORKSPACE", "/app")
system_prompt = Path("/app/system-prompt.md").read_text(encoding="utf-8")
model = os.environ.get("CLAUDE_CODE_SUBAGENT_MODEL") or None
queue: asyncio.Queue[str | None] = asyncio.Queue()
client = httpx.AsyncClient(timeout=30.0)
options = build_secretary_options(system_prompt=system_prompt, cwd=cwd, model=model)
@asynccontextmanager
async def session_factory() -> AsyncIterator[SdkIntakeSession]:
async with SdkIntakeSession(options) as session:
yield session
driver = IntakeDriver(
session_factory,
make_message_source(queue),
make_relay_sink(base_url, session_id, client),
)
bind_host = os.environ.get("ROBOCO_SDK_BIND_HOST", ".".join(["0"] * 4))
server = uvicorn.Server(
uvicorn.Config(
build_receiver(queue),
host=bind_host,
port=_RECEIVER_PORT,
log_level="warning",
)
)
logger.info("Secretary container starting", session_id=session_id)
try:
await asyncio.gather(server.serve(), driver.run())
finally:
await client.aclose()
if __name__ == "__main__": # pragma: no cover
asyncio.run(main())
+2
View File
@@ -65,6 +65,8 @@ _ROLE_LAYER_MAP: dict[str, str] = {
# propose_draft mission. Without this the prompter only sees the
# gateway verbs layer (i_am_idle) and refuses to draft.
"prompter": "prompter.md",
# Secretary — CEO's chief-of-staff live-session role prompt.
"secretary": "secretary.md",
}
_TEAM_LAYER_MAP: dict[str, str] = {
+46
View File
@@ -17,6 +17,8 @@ from roboco.api.routes.a2a import router as a2a_router
from roboco.api.routes.a2a import wellknown_router as a2a_wellknown_router
from roboco.api.routes.agents import router as agents_router
from roboco.api.routes.channels import router as channels_router
from roboco.api.routes.cockpit import router as cockpit_router
from roboco.api.routes.company_goals import router as company_goals_router
from roboco.api.routes.dashboard import router as dashboard_router
from roboco.api.routes.docs import router as docs_router
from roboco.api.routes.git import router as git_router
@@ -28,10 +30,14 @@ from roboco.api.routes.messages import router as messages_router
from roboco.api.routes.notifications import router as notifications_router
from roboco.api.routes.optimal import router as optimal_router
from roboco.api.routes.orchestrator import router as orchestrator_router
from roboco.api.routes.pitch import router as pitch_router
from roboco.api.routes.product import router as product_router
from roboco.api.routes.project import router as project_router
from roboco.api.routes.prompter_live import router as prompter_live_router
from roboco.api.routes.provider import router as provider_router
from roboco.api.routes.research import router as research_router
from roboco.api.routes.secretary import router as secretary_router
from roboco.api.routes.secretary_live import router as secretary_live_router
from roboco.api.routes.sessions import router as sessions_router
from roboco.api.routes.settings import router as settings_router
from roboco.api.routes.stream import router as stream_router
@@ -228,6 +234,12 @@ def create_app() -> FastAPI:
tags=["Settings"],
)
app.include_router(
company_goals_router,
prefix=f"{api_prefix}/company-goals",
tags=["Company"],
)
app.include_router(
messages_router,
prefix=f"{api_prefix}/messages",
@@ -260,6 +272,40 @@ def create_app() -> FastAPI:
tags=["Journals"],
)
# Web research — pluggable external search/fetch for Board + PM agents.
app.include_router(
research_router,
prefix=f"{api_prefix}/research",
tags=["Research"],
)
# Cockpit — the CEO's read-only "is the business winning?" summary.
app.include_router(
cockpit_router,
prefix=f"{api_prefix}/cockpit",
tags=["Cockpit"],
)
# Pitches — Board proposals + CEO approve -> auto-provision origination path.
app.include_router(
pitch_router,
prefix=f"{api_prefix}/pitches",
tags=["Pitches"],
)
# Secretary — the CEO's chief-of-staff: company-state reads + gated directives.
app.include_router(
secretary_router,
prefix=f"{api_prefix}/secretary",
tags=["Secretary"],
)
# Secretary live chat — panel <-> Secretary container bridge.
app.include_router(
secretary_live_router,
prefix=f"{api_prefix}/secretary",
tags=["Secretary"],
)
# Phase 5: Management - Tasks, Kanban, Dashboards
app.include_router(
tasks_router,
+31
View File
@@ -0,0 +1,31 @@
"""Cockpit API — the CEO's read-only "is the business winning?" summary."""
from fastapi import APIRouter, HTTPException, status
from roboco.api.deps import CurrentAgentContext, DbSession
from roboco.api.schemas.cockpit import CockpitSummary
from roboco.models import AgentRole
from roboco.services.cockpit import get_cockpit_service
router = APIRouter()
_COCKPIT_ROLES = frozenset(
{
AgentRole.CEO,
AgentRole.PRODUCT_OWNER,
AgentRole.HEAD_MARKETING,
AgentRole.MAIN_PM,
AgentRole.SECRETARY,
}
)
@router.get("/summary", response_model=CockpitSummary)
async def cockpit_summary(db: DbSession, agent: CurrentAgentContext) -> CockpitSummary:
"""Read-only company snapshot (CEO, Board, Main PM, Secretary)."""
if agent.role not in _COCKPIT_ROLES:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"role '{agent.role}' may not view the cockpit",
)
return CockpitSummary(**await get_cockpit_service(db).summary())
+43
View File
@@ -0,0 +1,43 @@
"""Company-goals API — the CEO-owned company charter.
GET is open to any authenticated agent (the charter drives every agent's
briefing); PUT is CEO-only. Writes commit explicitly — get_db auto-commit is
unreliable under BaseHTTPMiddleware.
"""
from fastapi import APIRouter, HTTPException, status
from roboco.api.deps import CurrentAgentContext, DbSession
from roboco.api.schemas.company_goals import CompanyGoalsResponse, CompanyGoalsUpdate
from roboco.models import AgentRole
from roboco.services.company_goals import get_company_goals_service
router = APIRouter()
@router.get("", response_model=CompanyGoalsResponse)
async def get_company_goals(
db: DbSession, agent: CurrentAgentContext
) -> CompanyGoalsResponse:
"""Return the company charter (any authenticated agent)."""
_ = agent # authentication only
goals = await get_company_goals_service(db).get()
return CompanyGoalsResponse(**goals)
@router.put("", response_model=CompanyGoalsResponse)
async def update_company_goals(
data: CompanyGoalsUpdate, db: DbSession, agent: CurrentAgentContext
) -> CompanyGoalsResponse:
"""Update the company charter (CEO-only); only provided fields change."""
if agent.role != AgentRole.CEO:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the CEO can update company goals.",
)
service = get_company_goals_service(db)
goals = await service.upsert(
data.model_dump(exclude_unset=True), updated_by=agent.agent_id
)
await db.commit()
return CompanyGoalsResponse(**goals)
+224
View File
@@ -0,0 +1,224 @@
"""Pitch API — Board proposals + the CEO approve -> auto-provision flow.
A pitch is an additive origination path: the Board proposes a product, the CEO
approves, and the system provisions repos + Projects (+ a Product when
multi-cell) and seeds a Main-PM delivery task. Nothing in the existing delivery
lifecycle changes; with provisioning unconfigured, approval is rejected with a
clear message and no side effects occur. Writes commit explicitly.
"""
from uuid import UUID
from fastapi import APIRouter, HTTPException, status
from roboco.api.deps import CurrentAgentContext, DbSession
from roboco.api.schemas.pitch import PitchCreateRequest, PitchDecision, PitchResponse
from roboco.db.tables import PitchTable
from roboco.foundation.identity import CELL_TEAMS, Team
from roboco.models import AgentRole
from roboco.models.pitch import PitchCreate, PitchStatus
from roboco.services.base import ConflictError, NotFoundError, ValidationError
from roboco.services.github_provisioning import (
ProvisioningDisabledError,
ProvisioningError,
)
from roboco.services.pitch import get_pitch_service
router = APIRouter()
_BOARD_ROLES = frozenset({AgentRole.PRODUCT_OWNER, AgentRole.HEAD_MARKETING})
_VIEW_ROLES = frozenset(
{
AgentRole.PRODUCT_OWNER,
AgentRole.HEAD_MARKETING,
AgentRole.MAIN_PM,
AgentRole.CEO,
AgentRole.AUDITOR,
}
)
_SERVICE_ERROR_HTTP: tuple[tuple[type[Exception], int], ...] = (
(NotFoundError, status.HTTP_404_NOT_FOUND),
(ProvisioningDisabledError, status.HTTP_400_BAD_REQUEST),
(ProvisioningError, status.HTTP_502_BAD_GATEWAY),
(ConflictError, status.HTTP_409_CONFLICT),
(ValidationError, status.HTTP_400_BAD_REQUEST),
)
def _to_http_exc(exc: Exception) -> HTTPException:
"""Translate a known service/provisioning error into an HTTPException.
ProvisioningDisabledError is listed before ProvisioningError (its parent)
so the more specific 400 wins.
"""
detail = getattr(exc, "message", None) or str(exc)
for exc_type, code in _SERVICE_ERROR_HTTP:
if isinstance(exc, exc_type):
return HTTPException(status_code=code, detail=detail)
return HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail
)
def _to_response(pitch: PitchTable) -> PitchResponse:
return PitchResponse(
id=str(pitch.id),
title=pitch.title,
slug=pitch.slug,
problem=pitch.problem,
proposed_solution=pitch.proposed_solution,
target_cells=list(pitch.target_cells or []),
status=pitch.status,
created_by=str(pitch.created_by),
decided_by=str(pitch.decided_by) if pitch.decided_by else None,
decision_notes=pitch.decision_notes,
provisioned_product_id=(
str(pitch.provisioned_product_id) if pitch.provisioned_product_id else None
),
provisioned_project_ids=list(pitch.provisioned_project_ids or []),
seed_task_id=str(pitch.seed_task_id) if pitch.seed_task_id else None,
created_at=pitch.created_at.isoformat() if pitch.created_at else None,
)
def _parse_cells(raw: list[str]) -> list[Team]:
cells: list[Team] = []
for c in raw:
try:
team = Team(c)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"unknown cell '{c}'",
) from exc
if team not in CELL_TEAMS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"'{c}' is not a cell team",
)
cells.append(team)
return cells
@router.post("", response_model=PitchResponse, status_code=status.HTTP_201_CREATED)
async def create_pitch(
data: PitchCreateRequest, db: DbSession, agent: CurrentAgentContext
) -> PitchResponse:
"""Board (Product Owner / Head of Marketing) authors a pitch."""
if agent.role not in _BOARD_ROLES:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the Board (PO / Head of Marketing) can author pitches.",
)
create = PitchCreate(
title=data.title,
slug=data.slug,
problem=data.problem,
proposed_solution=data.proposed_solution,
target_cells=_parse_cells(data.target_cells),
)
service = get_pitch_service(db)
try:
pitch = await service.create(create, created_by=agent.agent_id)
except ConflictError as exc:
raise _to_http_exc(exc) from exc
await db.commit()
return _to_response(pitch)
@router.get("", response_model=list[PitchResponse])
async def list_pitches(
db: DbSession, agent: CurrentAgentContext, status_filter: str | None = None
) -> list[PitchResponse]:
"""List pitches (Board, Main PM, CEO, Auditor); optional status filter."""
if agent.role not in _VIEW_ROLES:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="not permitted to view pitches",
)
parsed: PitchStatus | None = None
if status_filter:
try:
parsed = PitchStatus(status_filter)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"unknown pitch status '{status_filter}'",
) from exc
pitches = await get_pitch_service(db).list_pitches(parsed)
return [_to_response(p) for p in pitches]
@router.get("/{pitch_id}", response_model=PitchResponse)
async def get_pitch(
pitch_id: UUID, db: DbSession, agent: CurrentAgentContext
) -> PitchResponse:
"""Fetch one pitch."""
if agent.role not in _VIEW_ROLES:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="not permitted to view pitches",
)
pitch = await get_pitch_service(db).get(pitch_id)
if pitch is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="pitch not found"
)
return _to_response(pitch)
@router.post("/{pitch_id}/approve", response_model=PitchResponse)
async def approve_pitch(
pitch_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
data: PitchDecision | None = None,
) -> PitchResponse:
"""CEO approves a pitch -> provision repos/Projects (+Product) + seed a task."""
if agent.role != AgentRole.CEO:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the CEO can approve pitches.",
)
notes = (data.notes if data else None) or ""
service = get_pitch_service(db)
try:
pitch = await service.approve(pitch_id, notes, agent.agent_id)
except (
NotFoundError,
ConflictError,
ValidationError,
ProvisioningError,
) as exc:
raise _to_http_exc(exc) from exc
await db.commit()
return _to_response(pitch)
@router.post("/{pitch_id}/reject", response_model=PitchResponse)
async def reject_pitch(
pitch_id: UUID,
data: PitchDecision,
db: DbSession,
agent: CurrentAgentContext,
) -> PitchResponse:
"""CEO rejects a pitch (reason required)."""
if agent.role != AgentRole.CEO:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the CEO can reject pitches.",
)
if not data.notes or not data.notes.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="a rejection reason is required",
)
service = get_pitch_service(db)
try:
pitch = await service.reject(pitch_id, data.notes, agent.agent_id)
except (NotFoundError, ConflictError) as exc:
raise _to_http_exc(exc) from exc
await db.commit()
return _to_response(pitch)
+128
View File
@@ -0,0 +1,128 @@
"""Web-research API — pluggable external search/fetch for Board + PM roles.
Request path: agent -> roboco-search MCP -> here -> ResearchService -> provider.
The provider key lives only in this server-side process; it is never injected
into an agent container, and agents never egress (the provider's API does).
A per-agent UTC-daily quota is enforced in Redis (fails open).
"""
from fastapi import APIRouter, HTTPException, status
from roboco.api.deps import CurrentAgentContext
from roboco.api.schemas.research import (
FetchRequest,
FetchResponse,
SearchRequest,
SearchResponse,
SearchResultItem,
)
from roboco.config import settings
from roboco.models import AgentRole
from roboco.services.research import (
ResearchError,
ResearchUnsupportedError,
get_research_service,
)
from roboco.services.research_quota import ResearchQuotaTracker
router = APIRouter()
# Board (Product Owner + Head of Marketing) and PMs research the market; the
# CEO is included so the operator can drive the same surface from the panel.
RESEARCH_ROLES = frozenset(
{
AgentRole.PRODUCT_OWNER,
AgentRole.HEAD_MARKETING,
AgentRole.MAIN_PM,
AgentRole.CELL_PM,
AgentRole.CEO,
}
)
# Module-level so the Redis client is pooled across requests.
_quota_tracker = ResearchQuotaTracker()
def _require_research_role(agent: CurrentAgentContext) -> None:
if agent.role not in RESEARCH_ROLES:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"role '{agent.role}' may not use web research",
)
async def _enforce_quota(agent: CurrentAgentContext) -> None:
result = await _quota_tracker.check_and_consume(
str(agent.agent_id), settings.research_daily_quota_per_agent
)
if not result.allowed:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=(
f"daily research quota exhausted "
f"({result.limit}/day, resets {result.day} 24:00 UTC)"
),
)
@router.post("/search", response_model=SearchResponse)
async def research_search(
data: SearchRequest, agent: CurrentAgentContext
) -> SearchResponse:
"""Search the public web via the configured provider (Board + PM only)."""
_require_research_role(agent)
await _enforce_quota(agent)
service = get_research_service()
try:
outcome = await service.search(data.query, data.max_results)
except ResearchUnsupportedError as exc:
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc)
) from exc
except ResearchError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"research provider error: {exc}",
) from exc
finally:
await service.close()
return SearchResponse(
query=outcome.query,
provider=outcome.provider,
answer=outcome.answer,
results=[
SearchResultItem(
title=hit.title, url=hit.url, snippet=hit.snippet, score=hit.score
)
for hit in outcome.hits
],
)
@router.post("/fetch", response_model=FetchResponse)
async def research_fetch(
data: FetchRequest, agent: CurrentAgentContext
) -> FetchResponse:
"""Extract readable content for a URL via the provider (Board + PM only)."""
_require_research_role(agent)
await _enforce_quota(agent)
service = get_research_service()
try:
outcome = await service.fetch(data.url, data.max_chars)
except ResearchUnsupportedError as exc:
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc)
) from exc
except ResearchError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"research provider error: {exc}",
) from exc
finally:
await service.close()
return FetchResponse(
url=outcome.url,
provider=outcome.provider,
content=outcome.content,
truncated=outcome.truncated,
)
+147
View File
@@ -0,0 +1,147 @@
"""Secretary API — the CEO's chief-of-staff surface.
The Secretary agent submits directives here on the CEO's command; gated ones are
queued and the CEO confirms/rejects them (CEO-only routes). The Secretary also
reads company state. Writes commit explicitly.
"""
from uuid import UUID
from fastapi import APIRouter, HTTPException, status
from roboco.api.deps import CurrentAgentContext, DbSession
from roboco.api.schemas.secretary import (
CompanyStateResponse,
DirectiveDecision,
DirectiveResponse,
DirectiveSubmit,
)
from roboco.models import AgentRole
from roboco.models.secretary import DirectiveKind, DirectiveStatus
from roboco.services.base import ConflictError, NotFoundError, ValidationError
from roboco.services.secretary import get_secretary_service
router = APIRouter()
_SECRETARY_OR_CEO = frozenset({AgentRole.SECRETARY, AgentRole.CEO})
def _require(agent: CurrentAgentContext, allowed: frozenset[AgentRole]) -> None:
if agent.role not in allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"role '{agent.role}' not permitted on the Secretary surface",
)
@router.get("/state", response_model=CompanyStateResponse)
async def read_state(db: DbSession, agent: CurrentAgentContext) -> CompanyStateResponse:
"""Compact company-state snapshot (Secretary or CEO)."""
_require(agent, _SECRETARY_OR_CEO)
state = await get_secretary_service(db).read_company_state()
return CompanyStateResponse(**state)
@router.get("/tasks/{task_id}")
async def read_task(
task_id: UUID, db: DbSession, agent: CurrentAgentContext
) -> dict[str, object]:
"""Read one task's detail (Secretary or CEO)."""
_require(agent, _SECRETARY_OR_CEO)
try:
return await get_secretary_service(db).read_task(task_id)
except NotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=exc.message
) from exc
@router.post(
"/directives", response_model=DirectiveResponse, status_code=status.HTTP_201_CREATED
)
async def submit_directive(
data: DirectiveSubmit, db: DbSession, agent: CurrentAgentContext
) -> DirectiveResponse:
"""Submit a directive (Secretary or CEO). Gated kinds queue; others run."""
_require(agent, _SECRETARY_OR_CEO)
try:
kind = DirectiveKind(data.kind)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"unknown directive kind '{data.kind}'",
) from exc
service = get_secretary_service(db)
try:
row = await service.submit_directive(kind, data.payload, agent.agent_id)
except ValidationError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=exc.message
) from exc
await db.commit()
return DirectiveResponse(**service.to_dict(row))
@router.get("/directives", response_model=list[DirectiveResponse])
async def list_directives(
db: DbSession, agent: CurrentAgentContext, status_filter: str | None = None
) -> list[DirectiveResponse]:
"""List directives (CEO only); optional status filter."""
_require(agent, frozenset({AgentRole.CEO}))
parsed: DirectiveStatus | None = None
if status_filter:
try:
parsed = DirectiveStatus(status_filter)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"unknown status '{status_filter}'",
) from exc
service = get_secretary_service(db)
rows = await service.list_directives(parsed)
return [DirectiveResponse(**service.to_dict(r)) for r in rows]
@router.post("/directives/{directive_id}/confirm", response_model=DirectiveResponse)
async def confirm_directive(
directive_id: UUID, db: DbSession, agent: CurrentAgentContext
) -> DirectiveResponse:
"""CEO confirms a pending directive — it executes with CEO authority."""
_require(agent, frozenset({AgentRole.CEO}))
service = get_secretary_service(db)
try:
row = await service.confirm_directive(directive_id, agent.agent_id)
except NotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=exc.message
) from exc
except ConflictError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=exc.message
) from exc
await db.commit()
return DirectiveResponse(**service.to_dict(row))
@router.post("/directives/{directive_id}/reject", response_model=DirectiveResponse)
async def reject_directive(
directive_id: UUID,
data: DirectiveDecision,
db: DbSession,
agent: CurrentAgentContext,
) -> DirectiveResponse:
"""CEO rejects a pending directive."""
_require(agent, frozenset({AgentRole.CEO}))
service = get_secretary_service(db)
try:
row = await service.reject_directive(directive_id, agent.agent_id, data.reason)
except NotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=exc.message
) from exc
except ConflictError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=exc.message
) from exc
await db.commit()
return DirectiveResponse(**service.to_dict(row))
+123
View File
@@ -0,0 +1,123 @@
"""Live Secretary chat — the panel <-> Secretary container bridge.
Mirrors the intake live bridge over the shared ``PrompterLiveRegistry``, scoped
to the Secretary session (no project/product). Auth is intentionally light here
(opaque session id on a trusted network); the Secretary's *authority* is gated
at ``/api/secretary/directives``.
- ``POST /live/start`` — spawn the Secretary container.
- ``GET /live/{session_id}/stream`` — SSE: the agent's live events to the panel.
- ``GET /live/{session_id}/status`` — is the session still alive?
- ``POST /live/{session_id}/messages`` — the CEO's message in (panel -> agent).
- ``POST /live/{session_id}/stop`` — reap the session.
- ``POST /live/{session_id}/events`` — the agent's events in (container -> relay).
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request, status
from pydantic import BaseModel, Field
from sse_starlette import EventSourceResponse
from roboco.api.deps import get_orchestrator
from roboco.services.prompter_live import get_live_registry
if TYPE_CHECKING:
from collections.abc import AsyncGenerator
router = APIRouter()
class StartSecretaryRequest(BaseModel):
"""Open a live Secretary chat (optionally with an opening message)."""
initial_message: str | None = Field(default=None, min_length=1)
class StartSecretaryResponse(BaseModel):
session_id: str
class LiveMessageRequest(BaseModel):
text: str = Field(..., min_length=1)
class AgentEvent(BaseModel):
"""One event relayed from the container onto the session stream."""
kind: str
text: str = ""
tool: str = ""
data: dict[str, Any] = Field(default_factory=dict)
@router.post(
"/live/start",
response_model=StartSecretaryResponse,
status_code=status.HTTP_201_CREATED,
)
async def start_live(body: StartSecretaryRequest) -> StartSecretaryResponse:
"""Spawn the Secretary agent for a new chat and return its session id."""
session_id = uuid4().hex
try:
await get_orchestrator().start_secretary_session(
session_id, initial_message=body.initial_message
)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"error": "spawn_failed", "message": str(exc)},
) from exc
return StartSecretaryResponse(session_id=session_id)
@router.get("/live/{session_id}/stream")
async def stream(session_id: str, request: Request) -> EventSourceResponse:
"""Stream the Secretary's live events (token deltas, tool calls) to the panel."""
registry = get_live_registry()
async def events() -> AsyncGenerator[dict[str, Any]]:
async for event in registry.stream(session_id):
if await request.is_disconnected():
break
yield {"event": event.get("kind", "message"), "data": json.dumps(event)}
return EventSourceResponse(events(), ping=15)
@router.get("/live/{session_id}/status")
async def session_status(session_id: str) -> dict[str, bool]:
"""Report whether a live Secretary session is still running."""
return {"alive": get_live_registry().is_alive(session_id)}
@router.post("/live/{session_id}/messages")
async def send_message(session_id: str, body: LiveMessageRequest) -> dict[str, bool]:
"""Deliver the CEO's message to the running Secretary agent."""
delivered = await get_live_registry().deliver(session_id, body.text)
if not delivered:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"error": "not_found",
"message": f"No live secretary session {session_id} (start it first).",
},
)
return {"delivered": True}
@router.post("/live/{session_id}/stop")
async def stop_live(session_id: str) -> dict[str, bool]:
"""Reap the live Secretary session."""
await get_orchestrator().reap_secretary_session(session_id)
return {"stopped": True}
@router.post("/live/{session_id}/events")
async def relay_event(session_id: str, event: AgentEvent) -> dict[str, bool]:
"""Relay one agent event from the container onto the session's stream."""
return {"pushed": get_live_registry().push(session_id, event.model_dump())}
+19
View File
@@ -19,6 +19,7 @@ from roboco.api.schemas.v1.do import (
NotifyListRequest,
NotifyRequest,
OpenSessionRequest,
PitchRequest,
ProgressRequest,
PRUpdateRequest,
ReadMessagesRequest,
@@ -75,6 +76,24 @@ async def do_note(
return envelope_to_response(env, request)
@router.post("/pitch")
async def do_pitch(
request: Request,
body: PitchRequest,
x_agent_id: _AgentIdHeader,
actions: _ContentActionsDep,
) -> dict:
env = await actions.pitch(
agent_id=x_agent_id,
title=body.title,
slug=body.slug,
problem=body.problem,
proposed_solution=body.proposed_solution,
target_cells=body.target_cells,
)
return envelope_to_response(env, request)
@router.post("/say")
async def do_say(
request: Request,
+37
View File
@@ -0,0 +1,37 @@
"""Cockpit API schemas — the CEO's read-only company summary."""
from typing import Any
from pydantic import BaseModel
class DeliverySummary(BaseModel):
task_counts: dict[str, int]
in_flight: int
blocked: int
awaiting_ceo: int
class SpendSummary(BaseModel):
spend_30d_usd: float
projected_monthly_usd: float | None = None
monthly_budget_cap_usd: float | None = None
over_budget: bool
class CockpitSignal(BaseModel):
kind: str
summary: str
detail: str
class CockpitSummary(BaseModel):
"""Compact, honest-by-proxy snapshot of company state for the CEO."""
basis: str
north_star: str
objectives: list[dict[str, Any]]
delivery: DeliverySummary
spend: SpendSummary
pending_pitches: int
signals: list[CockpitSignal]
+25
View File
@@ -0,0 +1,25 @@
"""Company-goals API schemas — the CEO-owned company charter."""
from typing import Any
from pydantic import BaseModel, Field
class CompanyGoalsResponse(BaseModel):
"""The company charter as returned to any authenticated agent."""
north_star: str
objectives: list[dict[str, Any]]
constraints: list[str]
operating_policy: dict[str, Any]
updated_at: str | None = None
updated_by: str | None = None
class CompanyGoalsUpdate(BaseModel):
"""Partial update to the charter (CEO-only); only provided fields change."""
north_star: str | None = Field(default=None)
objectives: list[dict[str, Any]] | None = Field(default=None)
constraints: list[str] | None = Field(default=None)
operating_policy: dict[str, Any] | None = Field(default=None)
+38
View File
@@ -0,0 +1,38 @@
"""Pitch API schemas — Board proposals and CEO decisions."""
from pydantic import BaseModel, Field
class PitchCreateRequest(BaseModel):
"""Board authors a pitch."""
title: str = Field(min_length=1, max_length=200)
slug: str = Field(min_length=1, max_length=50, pattern=r"^[a-z0-9-]+$")
problem: str = Field(min_length=1)
proposed_solution: str = Field(min_length=1)
target_cells: list[str] = Field(min_length=1)
class PitchDecision(BaseModel):
"""CEO approve/reject payload."""
notes: str | None = None
class PitchResponse(BaseModel):
"""A pitch as returned to the Board / CEO."""
id: str
title: str
slug: str
problem: str
proposed_solution: str
target_cells: list[str]
status: str
created_by: str
decided_by: str | None = None
decision_notes: str | None = None
provisioned_product_id: str | None = None
provisioned_project_ids: list[str]
seed_task_id: str | None = None
created_at: str | None = None
+44
View File
@@ -0,0 +1,44 @@
"""Web-research API schemas."""
from pydantic import BaseModel, Field
class SearchRequest(BaseModel):
"""A web search request."""
query: str = Field(min_length=1, max_length=2000)
max_results: int | None = Field(default=None, ge=1, le=20)
class SearchResultItem(BaseModel):
"""One normalised search result."""
title: str
url: str
snippet: str
score: float | None = None
class SearchResponse(BaseModel):
"""Normalised search results plus an optional synthesized answer."""
query: str
provider: str
answer: str | None = None
results: list[SearchResultItem]
class FetchRequest(BaseModel):
"""A request to extract readable content for a URL."""
url: str = Field(min_length=1, max_length=4000)
max_chars: int | None = Field(default=None, ge=1)
class FetchResponse(BaseModel):
"""Extracted page content (possibly truncated to the configured cap)."""
url: str
provider: str
content: str
truncated: bool
+41
View File
@@ -0,0 +1,41 @@
"""Secretary API schemas — directives + company-state reads."""
from typing import Any
from pydantic import BaseModel, Field
class DirectiveSubmit(BaseModel):
"""The Secretary submits an action on the CEO's command."""
kind: str
payload: dict[str, Any] = Field(default_factory=dict)
class DirectiveDecision(BaseModel):
"""CEO confirm/reject payload."""
reason: str | None = None
class DirectiveResponse(BaseModel):
"""A directive as returned to the Secretary / CEO."""
id: str
kind: str
status: str
payload: dict[str, Any]
requested_by: str
requested_at: str | None = None
decided_by: str | None = None
decided_at: str | None = None
result: str | None = None
class CompanyStateResponse(BaseModel):
"""A compact snapshot of company state for the CEO."""
goals: dict[str, Any]
task_counts: dict[str, int]
pending_pitches: list[dict[str, Any]]
pending_directives: list[dict[str, Any]]
+10
View File
@@ -78,6 +78,16 @@ class NoteRequest(BaseModel):
return _coerce_to_list(value)
class PitchRequest(BaseModel):
"""Board pitch — a product proposal queued for CEO approval."""
title: str = Field(..., min_length=1)
slug: str = Field(..., min_length=1)
problem: str = Field(..., min_length=1)
proposed_solution: str = Field(..., min_length=1)
target_cells: list[str] = Field(..., min_length=1)
class SayRequest(BaseModel):
channel: str
text: str = Field(..., min_length=1)
+124
View File
@@ -175,6 +175,63 @@ class Settings(BaseSettings):
description="Base URL for Ollama native API (embeddings, model mgmt)",
)
# ==========================================================================
# Web Research (pluggable external search/fetch for Board + PM roles)
# ==========================================================================
# Calls go agent -> roboco-search MCP -> /api/research/* -> ResearchService
# -> provider. The provider key lives ONLY in this server-side process; it
# is never injected into agent containers, and agents never egress — the
# provider's own API does. Unset key => graceful NullProvider (empty
# results, no hard fail).
research_enabled: bool = Field(
default=True,
description=(
"Master switch for the web-research capability. When false the "
"roboco-search MCP server is not mounted into any agent container."
),
)
research_provider: str = Field(
default="tavily",
pattern="^(tavily|brave|exa|null)$",
description=(
"Web-search provider adapter. 'tavily' (LLM-native cited results "
"+ extract), 'brave' (independent index; no fetch), 'exa' "
"(neural search + contents), or 'null' (always-empty stub). "
"Swapping providers is a config change only."
),
)
research_api_key: str | None = Field(
default=None,
description=(
"API key for the selected research provider. Server-side only — "
"never reaches an agent container. Unset => NullProvider."
),
)
research_max_results: int = Field(
default=5,
ge=1,
le=20,
description="Hard cap on web_search results per call (top-k clamp).",
)
research_fetch_max_chars: int = Field(
default=20000,
ge=500,
description="Hard cap on extracted characters returned by web_fetch.",
)
research_timeout_seconds: float = Field(
default=15.0,
gt=0,
description="Per-request timeout for outbound provider HTTP calls.",
)
research_daily_quota_per_agent: int = Field(
default=50,
ge=1,
description=(
"Maximum web_search + web_fetch calls per agent per UTC day. "
"Tracked in Redis; fails open if Redis is unreachable."
),
)
# ==========================================================================
# Security
# ==========================================================================
@@ -183,6 +240,73 @@ class Settings(BaseSettings):
description="Fernet encryption key for secrets.",
)
# ==========================================================================
# GitHub repository provisioning (pitch -> approve -> auto-provision)
# ==========================================================================
# The only place that CREATES GitHub repos (vs. clone/branch/PR existing
# ones). Server-side only; never injected into agent containers. Unset
# token/org => disabled => the pitch approval path is inert (no repo is
# created) until the CEO configures it.
provisioning_enabled: bool = Field(
default=True,
description=(
"Master switch for pitch auto-provisioning. With no token/org set "
"the capability is inert regardless of this flag."
),
)
provisioning_token: str = Field(
default="",
description=(
"GitHub PAT used to create repos in the provisioning org "
"(needs repo + org admin scope). Server-side only."
),
)
provisioning_org: str = Field(
default="",
description="GitHub organization where new repos are provisioned.",
)
github_api_base_url: str = Field(
default="https://api.github.com",
description="GitHub REST API base URL (override for GitHub Enterprise).",
)
provisioning_timeout_seconds: float = Field(
default=30.0,
gt=0,
description="Per-request timeout for outbound GitHub provisioning calls.",
)
provisioning_repo_private: bool = Field(
default=True,
description="Whether provisioned repos are created private.",
)
# ==========================================================================
# Autonomous strategy engine ("engine 2") — DORMANT by default
# ==========================================================================
# A separate background loop that watches the company against its standing
# goals and surfaces drift/idle/stranded work to the CEO (notify-only —
# never spends or builds). Default OFF: the loop never starts and the
# existing delivery lifecycle is untouched until the CEO opts in.
strategy_engine_enabled: bool = Field(
default=False,
description=(
"Master switch for the autonomous strategy engine. OFF by default; "
"when off the background loop does not run at all."
),
)
strategy_engine_interval_seconds: int = Field(
default=1800,
ge=60,
description="Seconds between strategy-engine assessment passes.",
)
strategy_stranded_blocked_minutes: int = Field(
default=120,
ge=5,
description=(
"A task blocked longer than this is surfaced as stranded "
"(needs a human decision)."
),
)
# ==========================================================================
# Workspaces (Multi-Agent Git)
# ==========================================================================
+102
View File
@@ -557,6 +557,80 @@ class ProductProjectTable(Base):
)
class PitchTable(Base):
"""A Board proposal the CEO approves to auto-provision a product.
Independent of the task lifecycle: a pitch carries its own status
(proposed -> provisioned/rejected/failed). On approval the provisioning
flow creates repos, registers Projects (+ a Product when multi-cell), and
seeds a Main-PM delivery task, recording the produced ids here.
"""
__tablename__ = "pitches"
id: Mapped[PyUUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
title: Mapped[str] = mapped_column(String(200), nullable=False)
slug: Mapped[str] = mapped_column(
String(50), unique=True, nullable=False, index=True
)
problem: Mapped[str] = mapped_column(Text, nullable=False)
proposed_solution: Mapped[str] = mapped_column(Text, nullable=False)
target_cells: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="proposed", index=True
)
created_by: Mapped[PyUUID] = mapped_column(UUID(as_uuid=True), nullable=False)
decided_by: Mapped[PyUUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
decision_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
provisioned_product_id: Mapped[PyUUID | None] = mapped_column(
UUID(as_uuid=True), nullable=True
)
provisioned_project_ids: Mapped[list[str] | None] = mapped_column(
JSON, nullable=True
)
seed_task_id: Mapped[PyUUID | None] = mapped_column(
UUID(as_uuid=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
)
class SecretaryDirectiveTable(Base):
"""One action the Secretary took (or queued) on the CEO's behalf.
Direct (low-risk) directives are written already ``executed``; gated
(high-impact) ones are ``pending`` until the CEO confirms, then run. The
full row is the command audit trail.
"""
__tablename__ = "secretary_directives"
id: Mapped[PyUUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid4
)
kind: Mapped[str] = mapped_column(String(32), nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="pending", index=True
)
requested_by: Mapped[PyUUID] = mapped_column(UUID(as_uuid=True), nullable=False)
requested_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
decided_by: Mapped[PyUUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
decided_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
result: Mapped[str | None] = mapped_column(Text, nullable=True)
# =============================================================================
# WORK SESSION TABLE
# =============================================================================
@@ -1761,6 +1835,34 @@ class SystemSettingTable(Base):
)
class CompanyGoalsTable(Base):
"""Singleton company charter — north star, objectives, operating policy.
Exactly one row (the all-zeros singleton id). The CEO owns it (writes are
CEO-only via the API); it is injected compactly into every agent's
``context_briefing`` so all work is goal-aware.
"""
__tablename__ = "company_goals"
id: Mapped[PyUUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
north_star: Mapped[str] = mapped_column(Text, nullable=False, default="")
objectives: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, nullable=False, default=list
)
constraints: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
operating_policy: Mapped[dict[str, Any]] = mapped_column(
JSON, nullable=False, default=dict
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
nullable=False,
)
updated_by: Mapped[PyUUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
class ModelAssignmentTable(Base):
"""SQLAlchemy table for (scope, provider, model) routing rows.
+12
View File
@@ -29,6 +29,7 @@ class Role(StrEnum):
HEAD_MARKETING = "head_marketing"
AUDITOR = "auditor"
PROMPTER = "prompter" # intake interviewer — talks only to the human, drafts tasks
SECRETARY = "secretary" # CEO's chief-of-staff — acts only under CEO command
CEO = "ceo"
SYSTEM = "system" # sentinel only — used for orchestrator-generated rows
@@ -200,6 +201,16 @@ AGENTS: dict[str, AgentRow] = {
Team.BOARD,
_u("00000000-0000-0000-0004-000000000005"),
),
# Secretary — the CEO's conversational chief-of-staff. Like intake, a single
# seeded, board-adjacent agent the human chats with; unlike intake it carries
# gated CEO authority and acts only under CEO command. Deliberately absent
# from BOARD_ROLES (not a board reviewer).
"secretary-1": AgentRow(
"secretary-1",
Role.SECRETARY,
Team.BOARD,
_u("00000000-0000-0000-0004-000000000006"),
),
}
@@ -225,6 +236,7 @@ ROLE_LEVEL: dict[Role, RoleLevel] = {
Role.HEAD_MARKETING: RoleLevel.BOARD,
Role.AUDITOR: RoleLevel.AUDITOR,
Role.PROMPTER: RoleLevel.INTAKE,
Role.SECRETARY: RoleLevel.BOARD,
Role.CEO: RoleLevel.CEO,
}
+2
View File
@@ -61,6 +61,8 @@ ROLE_READ_TIERS: dict[Role, ReadTier] = {
# Intake interviewer is isolated — talks only to the human, reads only its
# own journal.
Role.PROMPTER: ReadTier.OWN,
# Secretary advises the CEO — reads everything to give an informed picture.
Role.SECRETARY: ReadTier.ALL,
Role.CEO: ReadTier.ALL,
}
+1
View File
@@ -911,6 +911,7 @@ _INTENT_VERBS: dict[str, IntentSpec] = {
Role.HEAD_MARKETING,
Role.AUDITOR,
Role.PROMPTER,
Role.SECRETARY,
}
),
description=(
+30
View File
@@ -236,6 +236,35 @@ def note(
)
def pitch(
title: str,
slug: str,
problem: str,
proposed_solution: str,
target_cells: list[str],
) -> dict[str, Any]:
"""Board: propose a product. Queues for the CEO's approval, then auto-provisions.
Args:
title: Short product name.
slug: URL-safe id (lowercase letters, digits, hyphens), e.g. 'widget-store'.
problem: The problem this product solves.
proposed_solution: How you propose to solve it.
target_cells: Cells that should build it any of 'backend', 'frontend',
'ux_ui'.
"""
return _post(
"/api/v1/do/pitch",
{
"title": title,
"slug": slug,
"problem": problem,
"proposed_solution": proposed_solution,
"target_cells": target_cells,
},
)
def say(channel: str, text: str, task_id: str | None = None) -> dict[str, Any]:
"""Post to a channel. task_id auto-injected if you have an active task.
@@ -507,6 +536,7 @@ def read_messages() -> dict[str, Any]:
_TOOLS: dict[str, Any] = {
"commit": commit,
"note": note,
"pitch": pitch,
"say": say,
"dm": dm,
"notify": notify,
+130
View File
@@ -0,0 +1,130 @@
"""Web Research MCP Server.
Exposes ``web_search`` and ``web_fetch`` to Board + PM agents. Both tools call
the backend ``/research/*`` routes, which hold the provider API key server-side
the key is never present in the agent container, and the agent never makes an
external request itself. Mounted conditionally per role by the orchestrator
(see ``_generate_mcp_config``); the route re-checks the role as defence in depth.
"""
from typing import Any
from mcp.server.fastmcp import FastMCP
from roboco.mcp.utils import ApiClient, format_error_response
_NOT_CONFIGURED = (
"Web research is not configured on this deployment (no provider key). "
"Proceed without external sources or ask the CEO to set one."
)
async def _handle_search(
query: str, max_results: int | None, client: ApiClient
) -> dict[str, Any]:
"""Run a web search via the backend and shape the result for the agent."""
payload: dict[str, Any] = {"query": query}
if max_results is not None:
payload["max_results"] = max_results
result, error = await client.post_or_error(
"/research/search",
json=payload,
error_code="SEARCH_FAILED",
error_message="Web search failed",
)
if error or result is None:
return error or format_error_response("SEARCH_FAILED", "No result")
provider = result.get("provider", "unknown")
results = result.get("results", [])
if provider == "null":
guidance = _NOT_CONFIGURED
else:
guidance = (
f"{len(results)} result(s) from '{provider}'. Cite the URL for any "
"fact you use, and persist findings with note(scope='reflect', ...) "
"so the team keeps the source."
)
return {
"query": result.get("query", query),
"provider": provider,
"answer": result.get("answer"),
"results": results,
"guidance": guidance,
}
async def _handle_fetch(
url: str, max_chars: int | None, client: ApiClient
) -> dict[str, Any]:
"""Extract readable page content via the backend provider."""
payload: dict[str, Any] = {"url": url}
if max_chars is not None:
payload["max_chars"] = max_chars
result, error = await client.post_or_error(
"/research/fetch",
json=payload,
error_code="FETCH_FAILED",
error_message="Web fetch failed",
)
if error or result is None:
return error or format_error_response("FETCH_FAILED", "No result")
return {
"url": result.get("url", url),
"provider": result.get("provider", "unknown"),
"content": result.get("content", ""),
"truncated": result.get("truncated", False),
}
def create_search_mcp_server(agent_id: str) -> FastMCP:
"""Create a Web Research MCP server bound to a specific agent."""
mcp = FastMCP(f"roboco-search-{agent_id}", json_response=True)
client = ApiClient(agent_id)
@mcp.tool()
async def web_search(query: str, max_results: int | None = None) -> dict[str, Any]:
"""Search the public web for market/competitor/technical information.
Returns cited results (title, url, snippet) and, where the provider
supports it, a short synthesized answer. Use this for research the
knowledge base can't answer — competitors, pricing, libraries, trends.
Always cite the URL for anything you rely on, and persist key findings
with a note so the team retains the source.
Args:
query: The search query.
max_results: Optional cap on results (clamped to the server limit).
"""
return await _handle_search(query, max_results, client)
@mcp.tool()
async def web_fetch(url: str, max_chars: int | None = None) -> dict[str, Any]:
"""Fetch the readable content of a specific web page.
Uses the configured provider's content-extraction endpoint, so it works
only with providers that support extraction (Tavily, Exa). Content is
truncated to the server's character cap.
Args:
url: The page URL to extract.
max_chars: Optional cap on returned characters (clamped server-side).
"""
return await _handle_fetch(url, max_chars, client)
return mcp
if __name__ == "__main__":
import sys
MIN_ARGS = 2
if len(sys.argv) < MIN_ARGS:
print("Usage: python search_server.py <agent_id>")
sys.exit(1)
server = create_search_mcp_server(sys.argv[1])
server.run()
+73
View File
@@ -0,0 +1,73 @@
"""Pitch domain models — Board proposals the CEO approves to auto-provision.
A pitch is the origination point of the autonomous strategy engine: the Board
proposes a product (problem + solution + which cells should build it); the CEO
approves; the system provisions a repo per cell, registers Projects (and a
Product when multi-cell), and seeds a delivery task to Main PM. It is layered on
top of the existing Product / coordination-task machinery and does not change
the delivery lifecycle.
"""
from __future__ import annotations
from enum import StrEnum
from uuid import UUID, uuid4
from pydantic import Field, field_validator
from roboco.foundation.identity import CELL_TEAMS, Team
from roboco.models.base import RobocoBase, TimestampMixin
class PitchStatus(StrEnum):
"""Lifecycle of a pitch (independent of the task delivery lifecycle)."""
PROPOSED = "proposed"
PROVISIONED = "provisioned"
REJECTED = "rejected"
FAILED = "failed"
def _validate_cells(cells: list[Team]) -> list[Team]:
if not cells:
raise ValueError("a pitch must target at least one cell")
for c in cells:
if c not in CELL_TEAMS:
raise ValueError(
f"target_cells must be cells (one of "
f"{sorted(t.value for t in CELL_TEAMS)}); got {c!r}"
)
return cells
class Pitch(TimestampMixin):
"""A Board-authored product proposal."""
id: UUID = Field(default_factory=uuid4)
title: str = Field(..., min_length=1, max_length=200)
slug: str = Field(..., min_length=1, max_length=50, pattern=r"^[a-z0-9-]+$")
problem: str = Field(..., min_length=1)
proposed_solution: str = Field(..., min_length=1)
target_cells: list[Team] = Field(default_factory=list)
status: PitchStatus = PitchStatus.PROPOSED
created_by: UUID
decided_by: UUID | None = None
decision_notes: str | None = None
provisioned_product_id: UUID | None = None
provisioned_project_ids: list[UUID] = Field(default_factory=list)
seed_task_id: UUID | None = None
class PitchCreate(RobocoBase):
"""Service-layer create DTO."""
title: str = Field(..., min_length=1, max_length=200)
slug: str = Field(..., min_length=1, max_length=50, pattern=r"^[a-z0-9-]+$")
problem: str = Field(..., min_length=1)
proposed_solution: str = Field(..., min_length=1)
target_cells: list[Team] = Field(..., min_length=1)
@field_validator("target_cells")
@classmethod
def _cells_must_be_cells(cls, v: list[Team]) -> list[Team]:
return _validate_cells(v)
+2
View File
@@ -117,4 +117,6 @@ ROLE_MODEL_MAP: dict[str, str] = {
"ceo": "opus",
# Intake interviewer — reads real code and drafts the spec; needs to be sharp.
"prompter": "opus",
# Secretary — carries CEO authority; needs strong judgment.
"secretary": "opus",
}
+41
View File
@@ -0,0 +1,41 @@
"""Secretary domain models — the CEO's chief-of-staff directives.
A directive is an action the Secretary takes on the CEO's command. Low-risk
kinds execute directly; high-impact kinds (the gate list) bounce back for the
CEO's explicit confirmation before they run.
"""
from __future__ import annotations
from enum import StrEnum
class DirectiveKind(StrEnum):
"""What the Secretary was told to do."""
RELAY_MESSAGE = "relay_message" # direct: post a CEO-dictated message
UPDATE_CHARTER = "update_charter" # gated: edit company goals
CONTROL_TASK = "control_task" # gated: start / cancel / override a task
APPROVE_PITCH = "approve_pitch" # gated: approve a pitch (provision + spend)
ANNOUNCE = "announce" # gated: post to #announcements
class DirectiveStatus(StrEnum):
"""Lifecycle of a directive."""
PENDING = "pending" # gated, awaiting CEO confirmation
EXECUTED = "executed" # ran successfully
REJECTED = "rejected" # CEO declined
FAILED = "failed" # ran but errored
# High-impact kinds bounce back for the CEO's explicit confirmation; everything
# else the Secretary executes directly on the CEO's command.
GATED_KINDS: frozenset[DirectiveKind] = frozenset(
{
DirectiveKind.UPDATE_CHARTER,
DirectiveKind.CONTROL_TASK,
DirectiveKind.APPROVE_PITCH,
DirectiveKind.ANNOUNCE,
}
)
+283
View File
@@ -90,6 +90,11 @@ _CEO_NOTIFY_THRESHOLD = 10
# below and roboco/agent_sdk/intake_main.py.
INTAKE_AGENT_ID = "intake-1"
# The Secretary agent: a single seeded, persistent chief-of-staff container the
# CEO chats with (like intake), but with gated CEO authority. One container at a
# time. Seeded in identity.AGENTS; see roboco/agent_sdk/secretary_main.py.
SECRETARY_AGENT_ID = "secretary-1"
# Role -> Image mapping
# Specialized images extend the base with role-specific tools
AGENT_IMAGES: dict[str, str] = {
@@ -118,6 +123,8 @@ AGENT_IMAGES: dict[str, str] = {
"auditor": "roboco-agent-pm",
# Intake — persistent Agent-SDK driver, not a one-shot `claude -p`.
INTAKE_AGENT_ID: "roboco-agent-prompter",
# Secretary — persistent Agent-SDK driver with gated CEO authority.
SECRETARY_AGENT_ID: "roboco-agent-secretary",
}
@@ -167,6 +174,27 @@ class _IntakeRunSpec:
provider_auth_token: str | None
@dataclass
class _SecretaryRunSpec:
"""Inputs for ``_build_secretary_run_cmd`` (mirrors ``_IntakeRunSpec``).
Adds the agent uuid + HMAC token: unlike intake, the Secretary's tools call
the backend, so the container needs an authenticated identity.
"""
container_name: str
image: str
hosts: dict[str, str | None]
session_id: str
cwd: str
cli_model: str
api_url: str
agent_uuid: str
agent_token: str
provider_base_url: str | None
provider_auth_token: str | None
def _read_project_slug(task: dict[str, Any]) -> str | None:
"""Extract project slug from a task payload shape-tolerantly."""
slug = task.get("project_slug")
@@ -566,6 +594,7 @@ class AgentOrchestrator:
# Rate-limit probe loop: 30-second interval, scans Redis for all
# rate-limited providers and resolves waiting agents on success.
self._rate_limit_probe_task: asyncio.Task | None = None
self._strategy_engine_task: asyncio.Task | None = None
# Tracks which providers have already received a CEO notification
# during the current rate-limit episode. Cleared when the probe
# succeeds and the rate limit is lifted (tracker.clear() path).
@@ -643,6 +672,7 @@ class AgentOrchestrator:
self._dispatcher_task = asyncio.create_task(self._dispatcher_loop())
self._sweeper_task = asyncio.create_task(self._sweeper_loop())
self._rate_limit_probe_task = asyncio.create_task(self._rate_limit_probe_loop())
self._strategy_engine_task = asyncio.create_task(self._strategy_engine_loop())
logger.info(
"Orchestrator started",
@@ -675,6 +705,11 @@ class AgentOrchestrator:
with contextlib.suppress(asyncio.CancelledError):
await self._rate_limit_probe_task
if self._strategy_engine_task:
self._strategy_engine_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._strategy_engine_task
# Stop all agents
for agent_id in list(self._instances.keys()):
await self.stop_agent(agent_id)
@@ -2048,6 +2083,28 @@ class AgentOrchestrator:
"env": mcp_env,
}
# Web research — external search/fetch for Board + PM roles. The
# provider key stays server-side (the route holds it); the agent only
# ever talks to the backend, so the container needs no external egress.
research_roles = (
"cell_pm",
"main_pm",
"product_owner",
"head_marketing",
)
if settings.research_enabled and agent_role in research_roles:
mcp_servers["roboco-search"] = {
"command": "uv",
"args": [
"run",
"python",
"-m",
"roboco.mcp.search_server",
agent_id,
],
"env": mcp_env,
}
config: dict[str, Any] = {"mcpServers": mcp_servers}
# Write to shared config directory (mounted in both orchestrator and agents)
@@ -2771,6 +2828,209 @@ class AgentOrchestrator:
await self.stop_agent(INTAKE_AGENT_ID, graceful=True)
logger.info("Intake session reaped", session_id=session_id)
# ------------------------------------------------------------------ #
# Secretary live session (mirrors intake; no scope clone; auth token)
# ------------------------------------------------------------------ #
async def start_secretary_session(
self, session_id: str, *, initial_message: str | None = None
) -> None:
"""Non-blocking start: open the relay now, spawn the container in the bg."""
from roboco.services.prompter_live import get_live_registry
get_live_registry().open(session_id, SECRETARY_AGENT_ID)
self._schedule_bg(
self._spawn_secretary_container_guarded(
session_id, initial_message=initial_message
)
)
async def spawn_secretary_session(
self, session_id: str, *, initial_message: str | None = None
) -> AgentInstance:
"""Spawn the Secretary container synchronously (internal callers/tests)."""
from roboco.services.prompter_live import get_live_registry
get_live_registry().open(session_id, SECRETARY_AGENT_ID)
return await self._spawn_secretary_container(
session_id, initial_message=initial_message
)
async def _spawn_secretary_container_guarded(
self, session_id: str, *, initial_message: str | None
) -> None:
"""Background spawn; surface failures on the relay, not silently."""
from roboco.services.prompter_live import get_live_registry
try:
await self._spawn_secretary_container(
session_id, initial_message=initial_message
)
except Exception as exc:
logger.error(
"Secretary container spawn failed",
session_id=session_id,
error=str(exc),
)
registry = get_live_registry()
registry.push(
session_id,
{"kind": "error", "text": f"Couldn't start the Secretary: {exc}"},
)
registry.close(session_id)
async def _spawn_secretary_container(
self, session_id: str, *, initial_message: str | None
) -> AgentInstance:
"""Launch the Secretary SDK-driver container and track the instance.
Unlike intake there is no workspace scope to clone the Secretary reads
company state through the API, so its cwd is the baked ``/app`` tree. It
gets an HMAC agent token so its directive tools authenticate as the
Secretary role.
"""
from roboco.agents_config import issue_agent_token
from roboco.foundation.identity import AGENTS
if SECRETARY_AGENT_ID in self._instances:
await self.stop_agent(SECRETARY_AGENT_ID, graceful=False)
prompt_path = self._generate_composed_prompt(SECRETARY_AGENT_ID)
route = await self._resolve_agent_route(SECRETARY_AGENT_ID)
cli_model = _resolve_agent_cli_model(
route.provider_type.value, route.model_name
)
api_url = (
"http://roboco-orchestrator:8000"
if PROJECT_HOST_PATH
else f"http://127.0.0.1:{settings.port}"
)
await self._ensure_agent_image(SECRETARY_AGENT_ID)
container_name = f"roboco-agent-{SECRETARY_AGENT_ID}"
await self._remove_container(container_name)
agent_uuid = str(AGENTS[SECRETARY_AGENT_ID].uuid)
cmd = self._build_secretary_run_cmd(
_SecretaryRunSpec(
container_name=container_name,
image=get_agent_image(SECRETARY_AGENT_ID),
hosts=self._resolve_secretary_host_paths(),
session_id=session_id,
cwd="/app",
cli_model=cli_model,
api_url=api_url,
agent_uuid=agent_uuid,
agent_token=issue_agent_token(agent_uuid, "secretary", ""),
provider_base_url=route.base_url,
provider_auth_token=route.auth_token,
)
)
container_id = await self._run_container_cmd(cmd)
config = AgentConfig(
agent_id=SECRETARY_AGENT_ID,
blueprint_path=prompt_path,
model=route.model_name,
git_context=None,
)
instance = AgentInstance(
agent_id=SECRETARY_AGENT_ID,
state=AgentState.ACTIVE,
config=config,
current_task_id=None,
)
instance.container_id = container_id
instance.started_at = datetime.now(UTC)
instance.last_activity = datetime.now(UTC)
self._instances[SECRETARY_AGENT_ID] = instance
logger.info(
"Secretary session spawned",
session_id=session_id,
container_id=container_id[:12],
)
self._fire_audit(
event_type="agent.spawned",
agent_slug=SECRETARY_AGENT_ID,
details={"session_id": session_id},
)
if initial_message:
self._schedule_intake_first_message(session_id, initial_message)
return instance
async def reap_secretary_session(self, session_id: str) -> None:
"""End a live Secretary chat: close the relay and stop the container."""
from roboco.services.prompter_live import get_live_registry
get_live_registry().close(session_id)
await self.stop_agent(SECRETARY_AGENT_ID, graceful=True)
logger.info("Secretary session reaped", session_id=session_id)
def _resolve_secretary_host_paths(self) -> dict[str, str | None]:
"""Host paths for the Secretary container's mounts (claude + prompt).
No workspaces mount: the Secretary reads company state via the API and
runs from the baked ``/app`` tree.
"""
if PROJECT_HOST_PATH:
return {
"claude": CLAUDE_AUTH_HOST_PATH,
"prompt": (
f"{DATA_HOST_PATH}/prompts-generated/{SECRETARY_AGENT_ID}-prompt.md"
),
}
return {
"claude": CLAUDE_AUTH_HOST_PATH,
"prompt": str(
Path(tempfile.gettempdir())
/ "roboco-prompts"
/ f"{SECRETARY_AGENT_ID}-prompt.md"
),
}
@staticmethod
def _build_secretary_run_cmd(spec: _SecretaryRunSpec) -> list[str]:
"""Compose the `docker run` argv for the persistent Secretary container."""
cmd: list[str] = [
"docker",
"run",
"-d",
"--name",
spec.container_name,
"--network",
AGENT_NETWORK,
"-v",
f"{spec.hosts['claude']}:/home/agent/.claude",
]
AgentOrchestrator._append_claude_json_mount(cmd, spec.hosts)
cmd.extend(
[
"-v",
f"{spec.hosts['prompt']}:/app/system-prompt.md:ro",
"-e",
f"ROBOCO_AGENT_ID={spec.agent_uuid}",
"-e",
"ROBOCO_AGENT_ROLE=secretary",
"-e",
f"ROBOCO_AGENT_TOKEN={spec.agent_token}",
"-e",
f"ROBOCO_API_URL={spec.api_url}",
"-e",
f"ROBOCO_SECRETARY_SESSION_ID={spec.session_id}",
"-e",
f"ROBOCO_WORKSPACE={spec.cwd}",
"-e",
f"CLAUDE_CODE_SUBAGENT_MODEL={spec.cli_model}",
]
)
if spec.provider_base_url:
cmd.extend(["-e", f"ANTHROPIC_BASE_URL={spec.provider_base_url}"])
if spec.provider_auth_token:
cmd.extend(["-e", f"ANTHROPIC_AUTH_TOKEN={spec.provider_auth_token}"])
cmd.append(spec.image)
return cmd
async def _clone_intake_scope(
self, project_slug: str | None, product_id: str | None
) -> tuple[str, list[str]]:
@@ -4229,6 +4489,29 @@ Start by:
# RATE-LIMIT PROBE LOOP
# =========================================================================
async def _strategy_engine_loop(self) -> None:
"""Engine 2: periodically surface goal drift / idle / stranded work.
Dormant by default returns immediately unless ``strategy_engine_enabled``
is set, so it adds zero behaviour to a standard deployment. Notify-only;
it never spends or builds.
"""
if not settings.strategy_engine_enabled:
return
from roboco.db import get_db_context
from roboco.services.strategy_engine import get_strategy_engine
interval = settings.strategy_engine_interval_seconds
while self._running:
try:
await asyncio.sleep(interval)
async with get_db_context() as db:
await get_strategy_engine(db).run_cycle()
except asyncio.CancelledError:
break
except Exception:
logger.exception("strategy engine cycle failed")
async def _rate_limit_probe_loop(self) -> None:
"""Background loop: probe rate-limited providers every ~30 seconds.
+1
View File
@@ -108,6 +108,7 @@ _AGENT_PRESENTATION: dict[str, dict[str, Any]] = {
"head-marketing": {"name": "Head of Marketing"},
"auditor": {"name": "Auditor"},
"intake-1": {"name": "Intake"},
"secretary-1": {"name": "Secretary"},
}
+80
View File
@@ -0,0 +1,80 @@
"""CockpitService — the CEO's read-only "is the business winning?" summary.
A pure aggregation over existing data: the charter (goals), delivery counts,
30-day spend vs the charter's budget cap, pending pitches, and the strategy
engine's signals (what needs the CEO). Read-only; no writes, no side effects.
Performance is necessarily a **proxy** (work shipped, spend, signals) until the
CEO greenlights real external launches every payload is stamped
``basis="proxy"`` so that boundary stays honest.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from roboco.services.base import BaseService
from roboco.services.company_goals import get_company_goals_service
from roboco.services.pitch import get_pitch_service
from roboco.services.strategy_engine import get_strategy_engine
from roboco.services.task import get_task_service
from roboco.services.usage import get_usage_service
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
def _as_float(value: Any) -> float | None:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
class CockpitService(BaseService):
"""Aggregate company state into one read-only cockpit summary."""
service_name = "cockpit"
async def summary(self) -> dict[str, Any]:
goals = await get_company_goals_service(self.session).get()
counts = await get_task_service(self.session).count_by_status()
usage_svc = get_usage_service(self.session)
spend = await usage_svc.get_summary("30d")
projection = await usage_svc.get_projection()
observations = await get_strategy_engine(self.session).assess()
pitches = await get_pitch_service(self.session).list_pitches()
operating_policy = goals.get("operating_policy") or {}
budget_cap = _as_float(operating_policy.get("monthly_budget_cap"))
spend_30d = _as_float(spend.get("total_cost_usd")) or 0.0
return {
"basis": "proxy",
"north_star": goals.get("north_star", ""),
"objectives": goals.get("objectives", []),
"delivery": {
"task_counts": counts,
"in_flight": counts.get("in_progress", 0) + counts.get("claimed", 0),
"blocked": counts.get("blocked", 0),
"awaiting_ceo": counts.get("awaiting_ceo_approval", 0),
},
"spend": {
"spend_30d_usd": round(spend_30d, 2),
"projected_monthly_usd": projection.get("projected_monthly_cost_usd"),
"monthly_budget_cap_usd": budget_cap,
"over_budget": bool(budget_cap is not None and spend_30d > budget_cap),
},
"pending_pitches": sum(1 for p in pitches if p.status == "proposed"),
"signals": [
{"kind": o.kind, "summary": o.summary, "detail": o.detail}
for o in observations
],
}
def get_cockpit_service(session: AsyncSession) -> CockpitService:
"""Construct a CockpitService bound to ``session``."""
return CockpitService(session)
+83
View File
@@ -0,0 +1,83 @@
"""Company-goals service — CRUD for the singleton company charter.
The charter (north star + objectives + constraints + operating policy) is a
single row. It is read by every agent (injected into the context_briefing) and
written only by the CEO via the API. Code here is layer-pure: business logic +
DB access, no HTTP concerns; the caller owns the transaction commit.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from uuid import UUID
from sqlalchemy import select
from roboco.db.tables import CompanyGoalsTable
from roboco.services.base import BaseService
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
# Canonical single-row marker — the charter is a singleton.
SINGLETON_ID = UUID("00000000-0000-0000-0000-000000000000")
_EMPTY: dict[str, Any] = {
"north_star": "",
"objectives": [],
"constraints": [],
"operating_policy": {},
"updated_at": None,
"updated_by": None,
}
class CompanyGoalsService(BaseService):
"""CRUD for the singleton ``company_goals`` row."""
async def get(self) -> dict[str, Any]:
"""Return the charter as a primitive dict, or empty defaults if unset."""
result = await self.session.execute(select(CompanyGoalsTable).limit(1))
row = result.scalar_one_or_none()
return self._to_dict(row) if row is not None else dict(_EMPTY)
async def upsert(
self, data: dict[str, Any], updated_by: UUID | None = None
) -> dict[str, Any]:
"""Create or update the singleton charter. Caller commits.
Only the keys present in ``data`` are written (partial update); the rest
keep their current values.
"""
row = await self.session.get(CompanyGoalsTable, SINGLETON_ID)
if row is None:
row = CompanyGoalsTable(id=SINGLETON_ID)
self.session.add(row)
if "north_star" in data:
row.north_star = data["north_star"]
if "objectives" in data:
row.objectives = data["objectives"]
if "constraints" in data:
row.constraints = data["constraints"]
if "operating_policy" in data:
row.operating_policy = data["operating_policy"]
if updated_by is not None:
row.updated_by = updated_by
await self.session.flush()
return self._to_dict(row)
@staticmethod
def _to_dict(row: CompanyGoalsTable) -> dict[str, Any]:
return {
"north_star": row.north_star,
"objectives": row.objectives or [],
"constraints": row.constraints or [],
"operating_policy": row.operating_policy or {},
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
"updated_by": str(row.updated_by) if row.updated_by else None,
}
def get_company_goals_service(session: AsyncSession) -> CompanyGoalsService:
"""Construct a CompanyGoalsService bound to ``session``."""
return CompanyGoalsService(session)
@@ -729,6 +729,7 @@ class Choreographer:
recent_team_activity=await repo.recent_team_activity(agent_id),
blockers_in_my_lane=await repo.blockers_in_lane(agent_id),
task_handoff=task_handoff,
company_goals=await repo.company_goals(),
)
return build_context_briefing(inputs)
@@ -244,6 +244,24 @@ class ContentActionsDeps:
_VALID_NOTIFY_PRIORITIES: frozenset[str] = frozenset(p.value for p in _comms.Priority)
# The Board roles that may author a pitch (a product proposal for CEO approval).
_PITCH_ROLES: frozenset[str] = frozenset({"product_owner", "head_marketing"})
def _coerce_pitch_cells(target_cells: list[str]) -> list[Any]:
"""Validate target-cell slugs into Team values; raise ValueError on a bad one."""
from roboco.foundation.identity import CELL_TEAMS, Team
cells: list[Any] = []
for c in target_cells:
try:
team = Team(c)
except ValueError as exc:
raise ValueError(f"unknown cell {c!r}") from exc
if team not in CELL_TEAMS:
raise ValueError(f"{c!r} is not a cell team")
cells.append(team)
return cells
class ContentActions:
@@ -504,6 +522,69 @@ class ContentActions:
context_briefing={},
)
async def pitch(
self,
*,
agent_id: UUID,
title: str,
slug: str,
problem: str,
proposed_solution: str,
target_cells: list[str],
) -> Envelope:
"""Board (PO / Head of Marketing) proposes a product for the CEO to approve.
A pitch is content, not a lifecycle transition: it records the Board's
proposal. On CEO approval the system provisions a repo per target cell,
registers the projects, and seeds the first Main-PM task.
"""
from pydantic import ValidationError as PydanticValidationError
from roboco.models.pitch import PitchCreate
from roboco.services.base import ConflictError, ValidationError
from roboco.services.pitch import get_pitch_service
agent = await self.task.agent_for(agent_id)
caller_role = str(agent.role) if agent is not None else ""
if caller_role not in _PITCH_ROLES:
return Envelope.not_authorized(
message=(
f"role {caller_role!r} cannot pitch; only the Board "
"(product_owner / head_marketing) may propose products"
),
remediate="this verb is Board-only",
context_briefing={},
)
try:
create = PitchCreate(
title=title,
slug=slug,
problem=problem,
proposed_solution=proposed_solution,
target_cells=_coerce_pitch_cells(target_cells),
)
pitch = await get_pitch_service(self.task.session).create(
create, created_by=agent_id
)
except (
ConflictError,
ValidationError,
PydanticValidationError,
ValueError,
) as exc:
detail = getattr(exc, "message", None) or str(exc)
return Envelope.invalid_state(
message=detail,
remediate="fix the pitch fields and retry",
context_briefing={},
)
return Envelope.ok(
status="proposed",
task_id=str(pitch.id),
next="await the CEO's approval in the Pitches queue",
context_briefing={},
)
async def say(
self,
*,
@@ -46,6 +46,9 @@ class BriefingInputs:
# respawned agent picks up where the previous worker left off instead of
# re-exploring the codebase from cold.
task_handoff: dict[str, Any] | None = None
# Compact company charter (north star + objectives + operating policy), or
# None when unset — injected so every agent's work is goal-aware.
company_goals: dict[str, Any] | None = None
def build_evidence_for_task(
@@ -132,4 +135,5 @@ def build_context_briefing(inputs: BriefingInputs) -> dict[str, Any]:
"recent_team_activity": inputs.recent_team_activity[:BRIEFING_LIST_CAP],
"blockers_in_my_lane": inputs.blockers_in_my_lane[:BRIEFING_LIST_CAP],
"task_handoff": inputs.task_handoff,
"company_goals": inputs.company_goals,
}
+31
View File
@@ -19,6 +19,37 @@ class EvidenceRepo:
def __init__(self, db_session: AsyncSession) -> None:
self._db = db_session
async def company_goals(self) -> dict[str, Any] | None:
"""The company charter, compacted for the briefing — or None when unset.
A single-row lookup (the charter is a singleton); returns only the
goal-relevant fields and omits audit columns to keep the briefing
token-light. Returns None when the charter is empty, so an unset charter
does not bloat every briefing.
"""
from sqlalchemy import select
from roboco.db.tables import CompanyGoalsTable
from roboco.services.gateway.evidence_builder import BRIEFING_LIST_CAP
row = (
await self._db.execute(select(CompanyGoalsTable).limit(1))
).scalar_one_or_none()
if row is None:
return None
north_star = row.north_star or ""
objectives = row.objectives or []
constraints = row.constraints or []
operating_policy = row.operating_policy or {}
if not any((north_star, objectives, constraints, operating_policy)):
return None
return {
"north_star": north_star,
"objectives": objectives[:BRIEFING_LIST_CAP],
"constraints": constraints[:BRIEFING_LIST_CAP],
"operating_policy": operating_policy,
}
async def list_unread_a2a(self, agent_id: UUID) -> list[dict[str, Any]]:
"""Open A2A conversations with unread messages for this agent.
+20
View File
@@ -100,6 +100,7 @@ _PRODUCT_OWNER_FLOW = spec.intents_for_role(spec.Role.PRODUCT_OWNER)
_HEAD_MARKETING_FLOW = spec.intents_for_role(spec.Role.HEAD_MARKETING)
_BOARD_DO = (
"note",
"pitch",
"say",
"dm",
"notify",
@@ -123,6 +124,13 @@ _PROMPTER_FLOW = spec.intents_for_role(
# live-session bridge, not these gateway tools.
_PROMPTER_DO = ("note", "evidence")
# Secretary: the CEO's chief-of-staff. Foundation tools mirror the prompter
# (note + evidence; its conversation with the CEO runs over the live-session
# bridge). Its gated CEO-authority directive tools are layered on separately by
# the secretary authority surface, not registered as generic do-tools here.
_SECRETARY_FLOW = spec.intents_for_role(spec.Role.SECRETARY) # none — human-only
_SECRETARY_DO = ("note", "evidence")
ROLE_CONFIGS: dict[str, RoleConfig] = {
"developer": RoleConfig(
@@ -200,6 +208,18 @@ ROLE_CONFIGS: dict[str, RoleConfig] = {
"and drafts a task. No outward agent comms; never writes or merges."
),
),
"secretary": RoleConfig(
role="secretary",
flow_tools=_SECRETARY_FLOW,
do_tools=_SECRETARY_DO,
allows_write=False,
allows_subagent=True,
description=(
"CEO's chief-of-staff; chats only with the CEO and carries gated CEO "
"authority. Reads company state and executes the CEO's directives, "
"bouncing high-impact ones back for explicit confirmation."
),
),
}
+125
View File
@@ -0,0 +1,125 @@
"""GitHub repository provisioning — create new repos in a dedicated org.
This is the ONE place that *creates* GitHub repositories; everywhere else the
system only clones/branches/PRs repos that already exist. Used by the pitch
approval flow to auto-provision a repo per target cell.
The provisioning token + org live only in server-side config and are never
injected into an agent container. When unconfigured the service reports
``enabled = False`` and ``create_repo`` raises ``ProvisioningDisabledError``
so on a default deployment the whole pitchprovision path is inert and nothing
is created until the CEO sets the token. That keeps the capability additive.
"""
from __future__ import annotations
from dataclasses import dataclass
import httpx
from roboco.config import settings
class ProvisioningError(Exception):
"""Repository provisioning failed (network error or non-2xx response)."""
class ProvisioningDisabledError(ProvisioningError):
"""Provisioning was requested but is not configured (no token/org)."""
@dataclass(frozen=True)
class ProvisionedRepo:
"""The pieces of a freshly-created GitHub repo we need downstream."""
full_name: str
clone_url: str
html_url: str
class GitHubProvisioningService:
"""Create private repos in the configured org via the GitHub REST API."""
def __init__(
self,
*,
token: str | None = None,
org: str | None = None,
base_url: str | None = None,
timeout: float | None = None,
client: httpx.AsyncClient | None = None,
) -> None:
self._token = token if token is not None else settings.provisioning_token
self._org = org if org is not None else settings.provisioning_org
self._base_url = (base_url or settings.github_api_base_url).rstrip("/")
self._timeout = (
timeout if timeout is not None else settings.provisioning_timeout_seconds
)
self._client = client
self._owns_client = client is None
@property
def enabled(self) -> bool:
"""True only when the master switch + token + org are all configured."""
return bool(settings.provisioning_enabled and self._token and self._org)
async def _http(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(timeout=self._timeout)
return self._client
async def close(self) -> None:
if self._owns_client and self._client is not None:
await self._client.aclose()
self._client = None
async def create_repo(
self, name: str, description: str = "", *, private: bool = True
) -> ProvisionedRepo:
"""Create ``org/name`` (auto-initialised so it is immediately cloneable)."""
if not self.enabled:
msg = (
"GitHub provisioning is not configured. Set "
"ROBOCO_PROVISIONING_TOKEN and ROBOCO_PROVISIONING_ORG."
)
raise ProvisioningDisabledError(msg)
client = await self._http()
try:
resp = await client.post(
f"{self._base_url}/orgs/{self._org}/repos",
headers={
"Authorization": f"Bearer {self._token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
json={
"name": name,
"description": description[:350],
"private": private,
"auto_init": True,
},
timeout=self._timeout,
)
except httpx.HTTPError as exc:
msg = f"GitHub repo creation failed for '{name}': {exc}"
raise ProvisioningError(msg) from exc
if not resp.is_success:
detail = resp.text[:200] if resp.text else "no body"
msg = (
f"GitHub repo creation failed for '{name}' "
f"({resp.status_code}): {detail}"
)
raise ProvisioningError(msg)
body = resp.json()
return ProvisionedRepo(
full_name=str(body.get("full_name", f"{self._org}/{name}")),
clone_url=str(body.get("clone_url", "")),
html_url=str(body.get("html_url", "")),
)
def get_github_provisioning_service(
client: httpx.AsyncClient | None = None,
) -> GitHubProvisioningService:
"""Build a GitHubProvisioningService from current settings."""
return GitHubProvisioningService(client=client)
+255
View File
@@ -0,0 +1,255 @@
"""PitchService — Board proposals and the CEO approve -> auto-provision flow.
On approval the service provisions one GitHub repo per target cell, registers
each as a Project (and a Product when the pitch spans multiple cells), and seeds
a single Main-PM delivery task. It reuses the existing Product / coordination-
task machinery wholesale, so the produced work flows through the normal delivery
lifecycle unchanged.
Partial-failure note: GitHub repo creation is an external side effect that
cannot be rolled back with the DB transaction. If provisioning fails partway,
the DB writes roll back (the route does not commit) but any repos already
created on GitHub remain; re-approval will collide on the repo name. The CEO
resolves such a rare case manually.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import select
from roboco.config import settings
from roboco.db.tables import PitchTable
from roboco.foundation.identity import Team
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType
from roboco.models.pitch import PitchCreate, PitchStatus
from roboco.models.product import ProductCellMapping, ProductCreate
from roboco.models.project import ProjectCreate
from roboco.models.task import TaskCreateRequest
from roboco.services.agent import get_agent_service
from roboco.services.base import (
BaseService,
ConflictError,
NotFoundError,
ValidationError,
)
from roboco.services.github_provisioning import (
ProvisioningDisabledError,
get_github_provisioning_service,
)
from roboco.services.product import get_product_service
from roboco.services.project import get_project_service
from roboco.services.task import get_task_service
from roboco.utils.converters import require_uuid
if TYPE_CHECKING:
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.services.github_provisioning import GitHubProvisioningService
_DESCRIPTION_CAP = 500
class PitchService(BaseService):
"""CRUD + approve/reject for Board pitches."""
service_name = "pitch"
async def get(self, pitch_id: UUID) -> PitchTable | None:
result = await self.session.execute(
select(PitchTable).where(PitchTable.id == pitch_id)
)
return result.scalar_one_or_none()
async def get_by_slug(self, slug: str) -> PitchTable | None:
result = await self.session.execute(
select(PitchTable).where(PitchTable.slug == slug)
)
return result.scalar_one_or_none()
async def list_pitches(self, status: PitchStatus | None = None) -> list[PitchTable]:
stmt = select(PitchTable).order_by(PitchTable.created_at.desc())
if status is not None:
stmt = stmt.where(PitchTable.status == status.value)
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def create(self, data: PitchCreate, created_by: UUID) -> PitchTable:
if await self.get_by_slug(data.slug):
raise ConflictError(
f"Pitch with slug '{data.slug}' already exists",
resource_type="pitch",
)
pitch = PitchTable(
title=data.title,
slug=data.slug,
problem=data.problem,
proposed_solution=data.proposed_solution,
target_cells=[
c.value if isinstance(c, Team) else str(c) for c in data.target_cells
],
status=PitchStatus.PROPOSED.value,
created_by=created_by,
)
self.session.add(pitch)
await self.session.flush()
return pitch
async def reject(self, pitch_id: UUID, notes: str, decided_by: UUID) -> PitchTable:
pitch = await self._proposed_or_raise(pitch_id)
pitch.status = PitchStatus.REJECTED.value
pitch.decided_by = decided_by
pitch.decision_notes = notes
await self.session.flush()
return pitch
async def approve(
self,
pitch_id: UUID,
notes: str,
decided_by: UUID,
*,
provisioning: GitHubProvisioningService | None = None,
) -> PitchTable:
"""Provision repos + Projects (+ Product) and seed a Main-PM task."""
pitch = await self._proposed_or_raise(pitch_id)
prov = provisioning or get_github_provisioning_service()
if not prov.enabled:
raise ProvisioningDisabledError(
"GitHub provisioning is not configured; cannot approve this "
"pitch. Set ROBOCO_PROVISIONING_TOKEN and ROBOCO_PROVISIONING_ORG."
)
try:
project_ids, cell_mappings = await self._provision_repos(
pitch, decided_by, prov
)
finally:
await prov.close()
seed_project_id, seed_product_id = await self._register_topology(
pitch, decided_by, project_ids, cell_mappings
)
seed_task_id = await self._seed_main_pm_task(
pitch, decided_by, seed_project_id, seed_product_id
)
pitch.status = PitchStatus.PROVISIONED.value
pitch.decided_by = decided_by
pitch.decision_notes = notes
pitch.provisioned_product_id = seed_product_id
pitch.provisioned_project_ids = [str(pid) for pid in project_ids]
pitch.seed_task_id = seed_task_id
await self.session.flush()
return pitch
# ------------------------------------------------------------------ #
# internals
# ------------------------------------------------------------------ #
async def _proposed_or_raise(self, pitch_id: UUID) -> PitchTable:
pitch = await self.get(pitch_id)
if pitch is None:
raise NotFoundError("pitch", str(pitch_id))
if pitch.status != PitchStatus.PROPOSED.value:
raise ConflictError(
f"Pitch is '{pitch.status}', not 'proposed'; cannot decide it again",
resource_type="pitch",
)
return pitch
async def _provision_repos(
self,
pitch: PitchTable,
decided_by: UUID,
prov: GitHubProvisioningService,
) -> tuple[list[UUID], list[ProductCellMapping]]:
cells = [Team(c) for c in pitch.target_cells]
multi = len(cells) > 1
project_svc = get_project_service(self.session)
project_ids: list[UUID] = []
cell_mappings: list[ProductCellMapping] = []
for cell in cells:
repo_name = f"{pitch.slug}-{cell.value}" if multi else pitch.slug
repo = await prov.create_repo(
repo_name, pitch.problem, private=settings.provisioning_repo_private
)
project = await project_svc.create(
ProjectCreate(
name=repo_name,
slug=repo_name,
git_url=repo.clone_url,
assigned_cell=cell,
git_token=settings.provisioning_token or None,
),
created_by=decided_by,
)
project_id = require_uuid(project.id)
project_ids.append(project_id)
cell_mappings.append(ProductCellMapping(team=cell, project_id=project_id))
return project_ids, cell_mappings
async def _register_topology(
self,
pitch: PitchTable,
decided_by: UUID,
project_ids: list[UUID],
cell_mappings: list[ProductCellMapping],
) -> tuple[UUID | None, UUID | None]:
"""Return (seed_project_id, seed_product_id). Multi-cell -> a Product."""
if len(cell_mappings) > 1:
product = await get_product_service(self.session).create(
ProductCreate(
name=pitch.title,
slug=pitch.slug,
description=pitch.proposed_solution[:_DESCRIPTION_CAP],
cells=cell_mappings,
),
created_by=decided_by,
)
return None, require_uuid(product.id)
return project_ids[0], None
async def _seed_main_pm_task(
self,
pitch: PitchTable,
decided_by: UUID,
seed_project_id: UUID | None,
seed_product_id: UUID | None,
) -> UUID:
main_pm = await get_agent_service(self.session).get_by_slug("main-pm")
if main_pm is None:
raise ValidationError("main-pm agent not found; cannot seed delivery task")
description = (
f"{pitch.problem}\n\nProposed solution:\n{pitch.proposed_solution}\n\n"
"(Originated from an approved Board pitch.)"
)
seed = await get_task_service(self.session).create(
TaskCreateRequest(
title=f"Build: {pitch.title}",
description=description,
acceptance_criteria=[
"Main PM scopes and decomposes the initiative across cells",
"Working software merged for each target cell",
],
team=Team.MAIN_PM,
created_by=decided_by,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
estimated_complexity=Complexity.HIGH,
assigned_to=require_uuid(main_pm.id),
project_id=seed_project_id,
product_id=seed_product_id,
status=TaskStatus.PENDING,
source="pitch",
confirmed_by_human=True,
)
)
return require_uuid(seed.id)
def get_pitch_service(session: AsyncSession) -> PitchService:
"""Construct a PitchService bound to ``session``."""
return PitchService(session)
+431
View File
@@ -0,0 +1,431 @@
"""Pluggable web-research service — provider-agnostic search + fetch.
The capability is exposed to Board + PM agents through the ``roboco-search``
MCP server, which calls the ``/api/research/*`` routes; those routes call this
service. The provider API key lives only in the server-side process never in
an agent container and the agent never egresses: the provider's own API does.
Design:
* ``SearchProvider`` is the abstract adapter. Concrete adapters
(``TavilyProvider``, ``BraveProvider``, ``ExaProvider``) translate a query
into the provider's wire format and normalise the response into our
dataclasses. ``NullProvider`` is the graceful-degradation stub returned when
no key is configured it never raises and always yields empty results.
* ``ResearchService`` selects an adapter from settings, clamps result/byte
caps defensively, and is the single entry point the route uses.
Swapping providers is a config change (``ROBOCO_RESEARCH_PROVIDER``) only.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
import httpx
from roboco.config import settings
class ResearchError(Exception):
"""A provider call failed (network error, non-2xx, or malformed body)."""
class ResearchUnsupportedError(ResearchError):
"""The active provider does not support the requested operation.
Raised, for example, when ``web_fetch`` is called while the configured
provider has no content-extraction endpoint (Brave). Distinct from a
transient ``ResearchError`` so the route can map it to 501 rather than 502.
"""
@dataclass(frozen=True)
class SearchHit:
"""A single normalised search result."""
title: str
url: str
snippet: str
score: float | None = None
@dataclass(frozen=True)
class SearchOutcome:
"""The normalised result of a ``search`` call."""
query: str
hits: list[SearchHit]
answer: str | None
provider: str
@dataclass(frozen=True)
class FetchOutcome:
"""The normalised result of a ``fetch`` call."""
url: str
content: str
truncated: bool
provider: str
# --------------------------------------------------------------------------- #
# Provider adapters
# --------------------------------------------------------------------------- #
class SearchProvider(ABC):
"""Abstract web-search/fetch adapter.
Subclasses translate to/from a specific provider's API. They may share an
injected ``httpx.AsyncClient`` (for tests, pass one wrapping a
``MockTransport``); otherwise one is created lazily and owned/closed here.
"""
name: str = "base"
def __init__(
self,
api_key: str | None,
timeout: float,
client: httpx.AsyncClient | None = None,
) -> None:
self._api_key = api_key
self._timeout = timeout
self._client = client
self._owns_client = client is None
@property
def configured(self) -> bool:
"""True when a key is present (NullProvider overrides to False)."""
return bool(self._api_key)
async def _http(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(timeout=self._timeout)
return self._client
async def close(self) -> None:
"""Close the client iff this adapter created it."""
if self._owns_client and self._client is not None:
await self._client.aclose()
self._client = None
async def _request_json(
self,
method: str,
url: str,
*,
headers: dict[str, str] | None = None,
json_body: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Issue a request and return parsed JSON, or raise ``ResearchError``."""
client = await self._http()
try:
response = await client.request(
method,
url,
headers=headers,
json=json_body,
params=params,
timeout=self._timeout,
)
except httpx.HTTPError as exc:
msg = f"{self.name}: request failed: {exc}"
raise ResearchError(msg) from exc
if not response.is_success:
detail = response.text[:200] if response.text else "no body"
msg = f"{self.name}: HTTP {response.status_code}: {detail}"
raise ResearchError(msg)
try:
parsed: dict[str, Any] = response.json()
except (ValueError, TypeError) as exc:
msg = f"{self.name}: invalid JSON response: {exc}"
raise ResearchError(msg) from exc
return parsed
@abstractmethod
async def search(self, query: str, max_results: int) -> SearchOutcome:
"""Run a web search and return normalised hits."""
async def fetch(self, url: str, max_chars: int) -> FetchOutcome:
"""Extract readable content for ``url`` (override where supported)."""
_ = (url, max_chars)
msg = f"{self.name} does not support web_fetch"
raise ResearchUnsupportedError(msg)
class TavilyProvider(SearchProvider):
"""Tavily — LLM-native search with cited results and an extract endpoint."""
name = "tavily"
_SEARCH_URL = "https://api.tavily.com/search"
_EXTRACT_URL = "https://api.tavily.com/extract"
async def search(self, query: str, max_results: int) -> SearchOutcome:
body = await self._request_json(
"POST",
self._SEARCH_URL,
json_body={
"api_key": self._api_key,
"query": query,
"max_results": max_results,
"search_depth": "basic",
"include_answer": True,
},
)
hits = [
SearchHit(
title=str(item.get("title", "")),
url=str(item.get("url", "")),
snippet=str(item.get("content", "")),
score=_as_float(item.get("score")),
)
for item in body.get("results", [])
if isinstance(item, dict)
]
answer = body.get("answer")
return SearchOutcome(
query=query,
hits=hits,
answer=str(answer) if answer else None,
provider=self.name,
)
async def fetch(self, url: str, max_chars: int) -> FetchOutcome:
body = await self._request_json(
"POST",
self._EXTRACT_URL,
json_body={"api_key": self._api_key, "urls": [url]},
)
results = body.get("results", [])
content = ""
if results and isinstance(results[0], dict):
content = str(results[0].get("raw_content", ""))
return _truncated_fetch(url, content, max_chars, self.name)
class BraveProvider(SearchProvider):
"""Brave Search API — independent index. No content-extraction endpoint."""
name = "brave"
_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search"
async def search(self, query: str, max_results: int) -> SearchOutcome:
body = await self._request_json(
"GET",
self._SEARCH_URL,
headers={
"Accept": "application/json",
"X-Subscription-Token": self._api_key or "",
},
params={"q": query, "count": max_results},
)
web = body.get("web", {})
results = web.get("results", []) if isinstance(web, dict) else []
hits = [
SearchHit(
title=str(item.get("title", "")),
url=str(item.get("url", "")),
snippet=str(item.get("description", "")),
)
for item in results
if isinstance(item, dict)
]
return SearchOutcome(query=query, hits=hits, answer=None, provider=self.name)
class ExaProvider(SearchProvider):
"""Exa — neural/semantic search with a contents endpoint."""
name = "exa"
_SEARCH_URL = "https://api.exa.ai/search"
_CONTENTS_URL = "https://api.exa.ai/contents"
def _headers(self) -> dict[str, str]:
return {
"Content-Type": "application/json",
"x-api-key": self._api_key or "",
}
async def search(self, query: str, max_results: int) -> SearchOutcome:
body = await self._request_json(
"POST",
self._SEARCH_URL,
headers=self._headers(),
json_body={"query": query, "numResults": max_results},
)
hits = [
SearchHit(
title=str(item.get("title", "")),
url=str(item.get("url", "")),
snippet=str(item.get("text", "") or item.get("snippet", "")),
score=_as_float(item.get("score")),
)
for item in body.get("results", [])
if isinstance(item, dict)
]
return SearchOutcome(query=query, hits=hits, answer=None, provider=self.name)
async def fetch(self, url: str, max_chars: int) -> FetchOutcome:
body = await self._request_json(
"POST",
self._CONTENTS_URL,
headers=self._headers(),
json_body={"urls": [url], "text": True},
)
results = body.get("results", [])
content = ""
if results and isinstance(results[0], dict):
content = str(results[0].get("text", ""))
return _truncated_fetch(url, content, max_chars, self.name)
class NullProvider(SearchProvider):
"""Graceful stub used when no provider key is configured.
Never raises and never makes a network call returns empty results so the
capability degrades softly on an unconfigured deployment.
"""
name = "null"
@property
def configured(self) -> bool:
return False
async def search(self, query: str, max_results: int) -> SearchOutcome:
_ = max_results
return SearchOutcome(query=query, hits=[], answer=None, provider=self.name)
async def fetch(self, url: str, max_chars: int) -> FetchOutcome:
_ = max_chars
return FetchOutcome(url=url, content="", truncated=False, provider=self.name)
_PROVIDERS: dict[str, type[SearchProvider]] = {
"tavily": TavilyProvider,
"brave": BraveProvider,
"exa": ExaProvider,
}
def build_provider(
name: str,
api_key: str | None,
timeout: float,
client: httpx.AsyncClient | None = None,
) -> SearchProvider:
"""Construct the adapter for ``name`` — NullProvider when unconfigured."""
if name == "null" or not api_key:
return NullProvider(api_key=None, timeout=timeout, client=client)
provider_cls = _PROVIDERS.get(name)
if provider_cls is None:
return NullProvider(api_key=None, timeout=timeout, client=client)
return provider_cls(api_key=api_key, timeout=timeout, client=client)
# --------------------------------------------------------------------------- #
# Service
# --------------------------------------------------------------------------- #
class ResearchService:
"""Provider-agnostic entry point used by the research route.
Clamps result count and fetched-content size to the configured caps so a
misbehaving (or generous) provider can't blow past the operator's limits.
"""
def __init__(
self,
provider: SearchProvider,
max_results_cap: int,
fetch_max_chars_cap: int,
) -> None:
self._provider = provider
self._max_results_cap = max_results_cap
self._fetch_max_chars_cap = fetch_max_chars_cap
@property
def provider_name(self) -> str:
return self._provider.name
@property
def configured(self) -> bool:
return self._provider.configured
def _clamp_results(self, requested: int | None) -> int:
if requested is None:
return self._max_results_cap
return max(1, min(requested, self._max_results_cap))
def _clamp_chars(self, requested: int | None) -> int:
if requested is None:
return self._fetch_max_chars_cap
return max(1, min(requested, self._fetch_max_chars_cap))
async def search(self, query: str, max_results: int | None = None) -> SearchOutcome:
return await self._provider.search(query, self._clamp_results(max_results))
async def fetch(self, url: str, max_chars: int | None = None) -> FetchOutcome:
cap = self._clamp_chars(max_chars)
outcome = await self._provider.fetch(url, cap)
if len(outcome.content) > cap:
return FetchOutcome(
url=outcome.url,
content=outcome.content[:cap],
truncated=True,
provider=outcome.provider,
)
return outcome
async def close(self) -> None:
await self._provider.close()
def get_research_service(
client: httpx.AsyncClient | None = None,
) -> ResearchService:
"""Build a ``ResearchService`` from current settings."""
provider = build_provider(
settings.research_provider,
settings.research_api_key,
settings.research_timeout_seconds,
client=client,
)
return ResearchService(
provider,
settings.research_max_results,
settings.research_fetch_max_chars,
)
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _as_float(value: Any) -> float | None:
"""Coerce a provider score to float, or None when absent/invalid."""
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _truncated_fetch(
url: str, content: str, max_chars: int, provider: str
) -> FetchOutcome:
"""Build a ``FetchOutcome``, marking truncation when content exceeds cap."""
if len(content) > max_chars:
return FetchOutcome(
url=url, content=content[:max_chars], truncated=True, provider=provider
)
return FetchOutcome(url=url, content=content, truncated=False, provider=provider)
+78
View File
@@ -0,0 +1,78 @@
"""Per-agent daily quota for the web-research capability.
A simple Redis counter keyed by ``agent_id`` + UTC day, with a 24h expiry on
first use. Cost control, not security so it **fails open**: if Redis is
unreachable the call is allowed (research must not break because the cache is
down). Mirrors the Redis access pattern in ``rate_limit_tracker``.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import UTC, datetime
import redis.asyncio as redis
from roboco.config import settings
logger = logging.getLogger(__name__)
_DAY_SECONDS = 86400
@dataclass(frozen=True)
class QuotaStatus:
"""Outcome of a quota check."""
allowed: bool
used: int
limit: int
day: str
class ResearchQuotaTracker:
"""Track per-agent/day research call counts in Redis (fail-open)."""
_KEY_PREFIX: str = "roboco:research_quota:"
def __init__(self, redis_url: str | None = None) -> None:
self._redis_url = redis_url or settings.redis_url
self._redis: redis.Redis | None = None
async def _conn(self) -> redis.Redis:
if self._redis is None:
self._redis = redis.from_url(self._redis_url)
return self._redis
def _key(self, agent_id: str, day: str) -> str:
return f"{self._KEY_PREFIX}{agent_id}:{day}"
async def check_and_consume(
self, agent_id: str, limit: int, *, now: datetime | None = None
) -> QuotaStatus:
"""Atomically increment today's counter and report whether it's allowed.
The increment happens before the limit comparison (a single atomic
``INCR``), so an over-limit call still bumps the counter fine for a
ceiling. On the first call of the day a 24h expiry is set so counters
self-clean. Any Redis error fails open (``allowed=True``).
"""
day = (now or datetime.now(UTC)).strftime("%Y-%m-%d")
key = self._key(agent_id, day)
try:
conn = await self._conn()
used = int(await conn.incr(key))
if used == 1:
await conn.expire(key, _DAY_SECONDS)
except Exception as exc:
# Fail-open: quota is cost control, not security — a Redis outage
# must not break research for Board/PM agents.
logger.warning("research quota check failed open (redis): %s", exc)
return QuotaStatus(allowed=True, used=0, limit=limit, day=day)
return QuotaStatus(allowed=used <= limit, used=used, limit=limit, day=day)
async def close(self) -> None:
if self._redis is not None:
await self._redis.aclose()
self._redis = None
+265
View File
@@ -0,0 +1,265 @@
"""SecretaryService — the CEO's chief-of-staff acting under command.
Reads company state for the CEO and carries out the CEO's directives. Low-risk
directives (relaying a dictated message) execute immediately; high-impact ones
(charter edits, task control, pitch approval, announcements) are recorded
``pending`` and run only after the CEO confirms the gate list. Every directive,
executed or queued, is auditable in ``secretary_directives``.
Authority: execution runs with the CEO as actor the CEO either dictated a
low-risk relay or explicitly confirmed a gated directive. The Secretary never
holds CEO authority itself; this service mediates it.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from sqlalchemy import select
from roboco.db.tables import SecretaryDirectiveTable
from roboco.foundation.identity import AGENTS
from roboco.models.base import TaskStatus
from roboco.models.secretary import GATED_KINDS, DirectiveKind, DirectiveStatus
from roboco.services.base import (
BaseService,
ConflictError,
NotFoundError,
ValidationError,
)
from roboco.services.company_goals import get_company_goals_service
from roboco.services.messaging import get_messaging_service
from roboco.services.pitch import get_pitch_service
from roboco.services.task import get_task_service
from roboco.utils.converters import require_uuid
if TYPE_CHECKING:
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
_CEO_ID = AGENTS["ceo"].uuid
_ANNOUNCE_CHANNEL = "announcements"
_REQUIRED_PAYLOAD: dict[DirectiveKind, tuple[str, ...]] = {
DirectiveKind.RELAY_MESSAGE: ("channel", "text"),
DirectiveKind.ANNOUNCE: ("text",),
DirectiveKind.UPDATE_CHARTER: ("charter",),
DirectiveKind.APPROVE_PITCH: ("pitch_id",),
DirectiveKind.CONTROL_TASK: ("task_id", "action"),
}
class SecretaryService(BaseService):
"""Read company state + execute/queue the CEO's directives."""
service_name = "secretary"
# ------------------------------------------------------------------ #
# reads (always direct — no authority needed)
# ------------------------------------------------------------------ #
async def read_company_state(self) -> dict[str, Any]:
goals = await get_company_goals_service(self.session).get()
counts = await get_task_service(self.session).count_by_status()
pitches = await get_pitch_service(self.session).list_pitches()
pending = await self.list_directives(DirectiveStatus.PENDING)
return {
"goals": goals,
"task_counts": counts,
"pending_pitches": [
{"id": str(p.id), "title": p.title, "slug": p.slug}
for p in pitches
if p.status == "proposed"
],
"pending_directives": [self.to_dict(d) for d in pending],
}
async def read_task(self, task_id: UUID) -> dict[str, Any]:
task = await get_task_service(self.session).get(task_id)
if task is None:
raise NotFoundError("task", str(task_id))
return {
"id": str(task.id),
"title": task.title,
"status": str(task.status),
"team": str(task.team) if task.team else None,
"assigned_to": str(task.assigned_to) if task.assigned_to else None,
"description": task.description,
}
# ------------------------------------------------------------------ #
# directives
# ------------------------------------------------------------------ #
async def get_directive(self, directive_id: UUID) -> SecretaryDirectiveTable | None:
result = await self.session.execute(
select(SecretaryDirectiveTable).where(
SecretaryDirectiveTable.id == directive_id
)
)
return result.scalar_one_or_none()
async def list_directives(
self, status: DirectiveStatus | None = None
) -> list[SecretaryDirectiveTable]:
stmt = select(SecretaryDirectiveTable).order_by(
SecretaryDirectiveTable.requested_at.desc()
)
if status is not None:
stmt = stmt.where(SecretaryDirectiveTable.status == status.value)
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def submit_directive(
self, kind: DirectiveKind, payload: dict[str, Any], requested_by: UUID
) -> SecretaryDirectiveTable:
"""Queue a gated directive, or execute a direct one immediately."""
self._validate_payload(kind, payload)
row = SecretaryDirectiveTable(
kind=kind.value,
payload=payload,
status=DirectiveStatus.PENDING.value,
requested_by=requested_by,
)
self.session.add(row)
await self.session.flush()
if kind in GATED_KINDS:
await self._notify_ceo_pending(row)
return row
await self._run(row)
return row
async def confirm_directive(
self, directive_id: UUID, decided_by: UUID
) -> SecretaryDirectiveTable:
row = await self._pending_or_raise(directive_id)
row.decided_by = require_uuid(decided_by)
await self._run(row)
return row
async def reject_directive(
self, directive_id: UUID, decided_by: UUID, reason: str | None = None
) -> SecretaryDirectiveTable:
row = await self._pending_or_raise(directive_id)
row.status = DirectiveStatus.REJECTED.value
row.decided_by = require_uuid(decided_by)
row.decided_at = datetime.now(UTC)
row.result = reason or "declined by CEO"
await self.session.flush()
return row
@staticmethod
def to_dict(row: SecretaryDirectiveTable) -> dict[str, Any]:
return {
"id": str(row.id),
"kind": row.kind,
"status": row.status,
"payload": dict(row.payload or {}),
"requested_by": str(row.requested_by),
"requested_at": row.requested_at.isoformat() if row.requested_at else None,
"decided_by": str(row.decided_by) if row.decided_by else None,
"decided_at": row.decided_at.isoformat() if row.decided_at else None,
"result": row.result,
}
# ------------------------------------------------------------------ #
# internals
# ------------------------------------------------------------------ #
async def _pending_or_raise(self, directive_id: UUID) -> SecretaryDirectiveTable:
row = await self.get_directive(directive_id)
if row is None:
raise NotFoundError("directive", str(directive_id))
if row.status != DirectiveStatus.PENDING.value:
raise ConflictError(
f"directive is '{row.status}', not pending",
resource_type="secretary_directive",
)
return row
@staticmethod
def _validate_payload(kind: DirectiveKind, payload: dict[str, Any]) -> None:
missing = [k for k in _REQUIRED_PAYLOAD[kind] if k not in payload]
if missing:
raise ValidationError(f"{kind.value} requires payload keys: {missing}")
async def _run(self, row: SecretaryDirectiveTable) -> None:
try:
row.result = await self._execute(DirectiveKind(row.kind), row.payload or {})
row.status = DirectiveStatus.EXECUTED.value
except (
ConflictError,
NotFoundError,
ValidationError,
ValueError,
KeyError,
) as exc:
row.status = DirectiveStatus.FAILED.value
row.result = f"error: {exc}"
row.decided_at = datetime.now(UTC)
await self.session.flush()
async def _execute(self, kind: DirectiveKind, payload: dict[str, Any]) -> str:
if kind in (DirectiveKind.RELAY_MESSAGE, DirectiveKind.ANNOUNCE):
channel = (
_ANNOUNCE_CHANNEL
if kind is DirectiveKind.ANNOUNCE
else str(payload["channel"])
)
await get_messaging_service(self.session).post_to_channel(
agent_id=_CEO_ID, channel_slug=channel, content=str(payload["text"])
)
return f"posted to #{channel}"
if kind is DirectiveKind.UPDATE_CHARTER:
await get_company_goals_service(self.session).upsert(
dict(payload["charter"]), updated_by=_CEO_ID
)
return "charter updated"
if kind is DirectiveKind.APPROVE_PITCH:
await get_pitch_service(self.session).approve(
require_uuid(payload["pitch_id"]),
str(payload.get("notes", "approved by CEO via Secretary")),
_CEO_ID,
)
return "pitch approved and provisioned"
return await self._control_task(payload)
async def _control_task(self, payload: dict[str, Any]) -> str:
task_svc = get_task_service(self.session)
task_id = require_uuid(payload["task_id"])
action = str(payload["action"])
notes = str(payload.get("notes", "via Secretary on CEO command"))
if action == "start":
await task_svc.approve_and_start(task_id, notes)
return "task started"
if action == "cancel":
await task_svc.admin_set_status(
task_id, TaskStatus.CANCELLED, actor_id=_CEO_ID, actor_role="ceo"
)
return "task cancelled"
if action == "override":
new_status = TaskStatus(str(payload["status"]))
await task_svc.admin_set_status(
task_id, new_status, actor_id=_CEO_ID, actor_role="ceo"
)
return f"task set to {new_status.value}"
raise ValidationError(f"unknown task action: {action!r}")
async def _notify_ceo_pending(self, row: SecretaryDirectiveTable) -> None:
from roboco.services.notification import NotificationService
await NotificationService().send_ack_notification(
from_agent="secretary-1",
to_agent="ceo",
body=(
f"[secretary] A {row.kind} directive needs your confirmation "
"before it runs. Review it in the Secretary surface."
),
)
def get_secretary_service(session: AsyncSession) -> SecretaryService:
"""Construct a SecretaryService bound to ``session``."""
return SecretaryService(session)
+111
View File
@@ -0,0 +1,111 @@
"""Autonomous strategy engine ("engine 2") — dormant by default.
Engine 1 is the delivery lifecycle (agents shipping tasks). Engine 2 watches
the company against its standing goals and, when something needs attention,
surfaces it to the CEO. It is deliberately conservative:
* **Default OFF.** ``strategy_engine_enabled`` is False, so the orchestrator
loop never starts and the existing system is completely unaffected.
* **Human-in-the-loop.** Even when enabled it only *notifies* the CEO it
never spends, builds, or auto-approves. Originating actual work stays a CEO
decision (e.g. approving a pitch). This keeps a clear boundary around the
autonomous surface.
* **Bounded + deduped.** One pass per interval, at most one notification per
observation kind; the notification layer's purpose-dedup suppresses repeats
until the CEO acknowledges.
Observations today: the company is idle while goals stand (drift toward doing
nothing), and tasks stranded in ``blocked`` past a threshold (work that needs a
human decision). Auto-origination (e.g. drafting pitches) is intentionally a
further opt-in, not part of this dormant baseline.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from roboco.config import settings
from roboco.services.base import BaseService
from roboco.services.company_goals import get_company_goals_service
from roboco.services.notification import NotificationService
from roboco.services.task import get_task_service
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
@dataclass(frozen=True)
class StrategyObservation:
"""One thing the engine noticed about company state."""
kind: str # "idle" | "stranded_blocked"
summary: str
detail: str
class StrategyEngine(BaseService):
"""Assess company state against goals; surface what needs the CEO."""
service_name = "strategy_engine"
async def assess(self) -> list[StrategyObservation]:
"""Read company state and return observations (no side effects)."""
observations: list[StrategyObservation] = []
task_svc = get_task_service(self.session)
in_flight = await task_svc.list_in_progress_or_claimed()
goals = await get_company_goals_service(self.session).get()
objectives = goals.get("objectives") or []
north_star = (goals.get("north_star") or "").strip()
has_direction = bool(objectives or north_star)
if not in_flight and has_direction:
observations.append(
StrategyObservation(
kind="idle",
summary="The company is idle but has standing goals.",
detail=(
"No delivery work is in progress or claimed, yet the "
"charter defines goals to pursue. Consider authoring a "
"pitch or starting work toward an objective."
),
)
)
stranded = await task_svc.list_long_running_blocked(
threshold_minutes=settings.strategy_stranded_blocked_minutes
)
if stranded:
observations.append(
StrategyObservation(
kind="stranded_blocked",
summary=f"{len(stranded)} task(s) have been blocked a long time.",
detail=(
"These tasks have sat in 'blocked' beyond the threshold "
"and likely need a human decision to move forward."
),
)
)
return observations
async def run_cycle(self) -> list[StrategyObservation]:
"""Assess and notify the CEO. No-op unless the engine is enabled."""
if not settings.strategy_engine_enabled:
return []
observations = await self.assess()
if not observations:
return []
notifier = NotificationService()
for obs in observations:
await notifier.send_ack_notification(
from_agent="system",
to_agent="ceo",
body=f"[strategy engine] {obs.summary}\n\n{obs.detail}",
)
return observations
def get_strategy_engine(session: AsyncSession) -> StrategyEngine:
"""Construct a StrategyEngine bound to ``session``."""
return StrategyEngine(session)
+2
View File
@@ -21,6 +21,7 @@ def test_role_enum_has_every_role_inc_system() -> None:
"head_marketing",
"auditor",
"prompter",
"secretary",
"ceo",
"system",
}
@@ -82,6 +83,7 @@ def test_agents_catalog_has_all_seed_slugs() -> None:
"head-marketing",
"auditor",
"intake-1",
"secretary-1",
}
actual = set(identity.AGENTS.keys())
assert actual == expected_slugs, f"agent catalog drift: {actual ^ expected_slugs}"
+1
View File
@@ -33,6 +33,7 @@ def test_role_enum_has_every_pre_gateway_role() -> None:
"head_marketing",
"auditor",
"prompter", # post-gateway intake role (human-only, drafts tasks)
"secretary", # CEO's chief-of-staff (human-only, gated CEO authority)
"ceo",
"system",
}
@@ -0,0 +1,51 @@
"""Company-goals API route tests: GET open to any agent, PUT CEO-only."""
from __future__ import annotations
from http import HTTPStatus
from typing import Any
from unittest.mock import MagicMock
from uuid import uuid4
import pytest
from fastapi import HTTPException
from roboco.api.routes.company_goals import get_company_goals, update_company_goals
from roboco.api.schemas.company_goals import CompanyGoalsUpdate
from roboco.models import AgentRole
from roboco.models.permissions import AgentContext
def _agent(role: AgentRole) -> AgentContext:
return AgentContext(agent_id=uuid4(), role=role, team=None)
@pytest.mark.asyncio
async def test_get_returns_charter_to_any_agent(db_session: Any) -> None:
resp = await get_company_goals(db_session, _agent(AgentRole.DEVELOPER))
assert resp.north_star == ""
assert resp.objectives == []
@pytest.mark.asyncio
async def test_ceo_can_update_and_persist(db_session: Any) -> None:
ceo = _agent(AgentRole.CEO)
resp = await update_company_goals(
CompanyGoalsUpdate(north_star="Win the market"), db_session, ceo
)
assert resp.north_star == "Win the market"
assert resp.updated_by == str(ceo.agent_id)
# Persisted and readable by a non-CEO agent.
again = await get_company_goals(db_session, _agent(AgentRole.QA))
assert again.north_star == "Win the market"
@pytest.mark.asyncio
async def test_non_ceo_cannot_update() -> None:
# The CEO check fires before any DB access, so a dummy session suffices.
with pytest.raises(HTTPException) as exc:
await update_company_goals(
CompanyGoalsUpdate(north_star="nope"),
MagicMock(),
_agent(AgentRole.DEVELOPER),
)
assert exc.value.status_code == HTTPStatus.FORBIDDEN
+179
View File
@@ -0,0 +1,179 @@
"""roboco.api.routes.pitch — role gates + decision flow (direct-call style)."""
from __future__ import annotations
from http import HTTPStatus
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from fastapi import HTTPException
from roboco.api.routes import pitch as pitch_route
from roboco.api.schemas.pitch import PitchCreateRequest, PitchDecision
from roboco.db.tables import PitchTable
from roboco.models import AgentRole
from roboco.models.permissions import AgentContext
from roboco.services.base import ConflictError
from roboco.services.github_provisioning import ProvisioningDisabledError
def _agent(role: AgentRole) -> AgentContext:
return AgentContext(agent_id=uuid4(), role=role, team=None)
def _db() -> MagicMock:
db = MagicMock()
db.commit = AsyncMock()
return db
def _pitch() -> PitchTable:
return PitchTable(
id=uuid4(),
title="Widget",
slug="widget",
problem="p",
proposed_solution="s",
target_cells=["backend"],
status="proposed",
created_by=uuid4(),
)
class _FakeService:
def __init__(
self, *, pitch: PitchTable | None = None, exc: Exception | None = None
) -> None:
self._pitch = pitch
self._exc = exc
async def create(self, _data: Any, created_by: Any) -> PitchTable:
_ = created_by
if self._exc is not None:
raise self._exc
assert self._pitch is not None
return self._pitch
async def approve(
self, _pitch_id: Any, _notes: Any, _by: Any, *, provisioning: Any = None
) -> PitchTable:
_ = provisioning
if self._exc is not None:
raise self._exc
assert self._pitch is not None
return self._pitch
async def reject(self, _pitch_id: Any, _notes: Any, _by: Any) -> PitchTable:
if self._exc is not None:
raise self._exc
assert self._pitch is not None
return self._pitch
def _install(monkeypatch: pytest.MonkeyPatch, service: _FakeService) -> None:
monkeypatch.setattr(pitch_route, "get_pitch_service", lambda _db: service)
@pytest.mark.asyncio
async def test_non_board_cannot_create() -> None:
with pytest.raises(HTTPException) as exc:
await pitch_route.create_pitch(
PitchCreateRequest(
title="W",
slug="w",
problem="p",
proposed_solution="s",
target_cells=["backend"],
),
_db(),
_agent(AgentRole.DEVELOPER),
)
assert exc.value.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_create_rejects_non_cell_target() -> None:
with pytest.raises(HTTPException) as exc:
await pitch_route.create_pitch(
PitchCreateRequest(
title="W",
slug="w",
problem="p",
proposed_solution="s",
target_cells=["board"],
),
_db(),
_agent(AgentRole.PRODUCT_OWNER),
)
assert exc.value.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
async def test_create_success(monkeypatch: pytest.MonkeyPatch) -> None:
db = _db()
_install(monkeypatch, _FakeService(pitch=_pitch()))
resp = await pitch_route.create_pitch(
PitchCreateRequest(
title="Widget",
slug="widget",
problem="p",
proposed_solution="s",
target_cells=["backend"],
),
db,
_agent(AgentRole.HEAD_MARKETING),
)
assert resp.slug == "widget"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_non_ceo_cannot_approve() -> None:
with pytest.raises(HTTPException) as exc:
await pitch_route.approve_pitch(
uuid4(), _db(), _agent(AgentRole.PRODUCT_OWNER), PitchDecision(notes="x")
)
assert exc.value.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_approve_provisioning_disabled_returns_400(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_install(monkeypatch, _FakeService(exc=ProvisioningDisabledError("not configured")))
with pytest.raises(HTTPException) as exc:
await pitch_route.approve_pitch(
uuid4(), _db(), _agent(AgentRole.CEO), PitchDecision(notes="go")
)
assert exc.value.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_approve_conflict_returns_409(monkeypatch: pytest.MonkeyPatch) -> None:
_install(monkeypatch, _FakeService(exc=ConflictError("already decided")))
with pytest.raises(HTTPException) as exc:
await pitch_route.approve_pitch(
uuid4(), _db(), _agent(AgentRole.CEO), PitchDecision(notes="go")
)
assert exc.value.status_code == HTTPStatus.CONFLICT
@pytest.mark.asyncio
async def test_reject_requires_reason() -> None:
with pytest.raises(HTTPException) as exc:
await pitch_route.reject_pitch(
uuid4(), PitchDecision(notes=None), _db(), _agent(AgentRole.CEO)
)
assert exc.value.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_reject_success(monkeypatch: pytest.MonkeyPatch) -> None:
db = _db()
_install(monkeypatch, _FakeService(pitch=_pitch()))
resp = await pitch_route.reject_pitch(
uuid4(), PitchDecision(notes="not now, off-charter"), db, _agent(AgentRole.CEO)
)
assert resp.slug == "widget"
db.commit.assert_awaited_once()
+150
View File
@@ -0,0 +1,150 @@
"""roboco.api.routes.research — role gate, quota, and error mapping.
Calls the route coroutines directly with a constructed AgentContext (the same
style as test_company_goals_routes) so no app/DB wiring is needed; the service
and quota tracker are patched.
"""
from __future__ import annotations
from http import HTTPStatus
from uuid import uuid4
import pytest
from fastapi import HTTPException
from roboco.api.routes import research as research_route
from roboco.api.schemas.research import FetchRequest, SearchRequest
from roboco.models import AgentRole
from roboco.models.permissions import AgentContext
from roboco.services.research import (
FetchOutcome,
ResearchError,
ResearchUnsupportedError,
SearchHit,
SearchOutcome,
)
from roboco.services.research_quota import QuotaStatus
def _agent(role: AgentRole) -> AgentContext:
return AgentContext(agent_id=uuid4(), role=role, team=None)
class _FakeService:
def __init__(
self,
*,
search_outcome: SearchOutcome | None = None,
fetch_outcome: FetchOutcome | None = None,
exc: Exception | None = None,
) -> None:
self._search_outcome = search_outcome
self._fetch_outcome = fetch_outcome
self._exc = exc
self.closed = False
async def search(self, _query: str, _max_results: int | None) -> SearchOutcome:
if self._exc is not None:
raise self._exc
assert self._search_outcome is not None
return self._search_outcome
async def fetch(self, _url: str, _max_chars: int | None) -> FetchOutcome:
if self._exc is not None:
raise self._exc
assert self._fetch_outcome is not None
return self._fetch_outcome
async def close(self) -> None:
self.closed = True
def _allow_quota(monkeypatch: pytest.MonkeyPatch, *, allowed: bool = True) -> None:
async def _check(_agent_id: str, limit: int, **_: object) -> QuotaStatus:
return QuotaStatus(allowed=allowed, used=1, limit=limit, day="2026-06-15")
monkeypatch.setattr(research_route._quota_tracker, "check_and_consume", _check)
def _install_service(monkeypatch: pytest.MonkeyPatch, service: _FakeService) -> None:
monkeypatch.setattr(research_route, "get_research_service", lambda: service)
@pytest.mark.asyncio
async def test_non_research_role_is_forbidden() -> None:
with pytest.raises(HTTPException) as exc:
await research_route.research_search(
SearchRequest(query="x"), _agent(AgentRole.DEVELOPER)
)
assert exc.value.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_search_success_maps_results(monkeypatch: pytest.MonkeyPatch) -> None:
_allow_quota(monkeypatch)
service = _FakeService(
search_outcome=SearchOutcome(
query="q",
hits=[SearchHit(title="T", url="https://t.test", snippet="s", score=0.7)],
answer="ans",
provider="tavily",
)
)
_install_service(monkeypatch, service)
resp = await research_route.research_search(
SearchRequest(query="q"), _agent(AgentRole.PRODUCT_OWNER)
)
assert resp.provider == "tavily"
assert resp.answer == "ans"
assert resp.results[0].url == "https://t.test"
assert service.closed is True
@pytest.mark.asyncio
async def test_quota_exhausted_returns_429(monkeypatch: pytest.MonkeyPatch) -> None:
_allow_quota(monkeypatch, allowed=False)
with pytest.raises(HTTPException) as exc:
await research_route.research_search(
SearchRequest(query="q"), _agent(AgentRole.MAIN_PM)
)
assert exc.value.status_code == HTTPStatus.TOO_MANY_REQUESTS
@pytest.mark.asyncio
async def test_provider_error_returns_502(monkeypatch: pytest.MonkeyPatch) -> None:
_allow_quota(monkeypatch)
_install_service(monkeypatch, _FakeService(exc=ResearchError("boom")))
with pytest.raises(HTTPException) as exc:
await research_route.research_search(
SearchRequest(query="q"), _agent(AgentRole.CELL_PM)
)
assert exc.value.status_code == HTTPStatus.BAD_GATEWAY
@pytest.mark.asyncio
async def test_fetch_unsupported_returns_501(monkeypatch: pytest.MonkeyPatch) -> None:
_allow_quota(monkeypatch)
_install_service(
monkeypatch, _FakeService(exc=ResearchUnsupportedError("no fetch"))
)
with pytest.raises(HTTPException) as exc:
await research_route.research_fetch(
FetchRequest(url="https://x.test"), _agent(AgentRole.PRODUCT_OWNER)
)
assert exc.value.status_code == HTTPStatus.NOT_IMPLEMENTED
@pytest.mark.asyncio
async def test_fetch_success(monkeypatch: pytest.MonkeyPatch) -> None:
_allow_quota(monkeypatch)
service = _FakeService(
fetch_outcome=FetchOutcome(
url="https://x.test", content="body", truncated=False, provider="exa"
)
)
_install_service(monkeypatch, service)
resp = await research_route.research_fetch(
FetchRequest(url="https://x.test"), _agent(AgentRole.HEAD_MARKETING)
)
assert resp.content == "body"
assert resp.provider == "exa"
+161
View File
@@ -0,0 +1,161 @@
"""roboco.api.routes.secretary — role gates + directive flow (direct-call style)."""
from __future__ import annotations
from http import HTTPStatus
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from fastapi import HTTPException
from roboco.api.routes import secretary as sec_route
from roboco.api.schemas.secretary import DirectiveDecision, DirectiveSubmit
from roboco.models import AgentRole
from roboco.models.permissions import AgentContext
from roboco.services.base import ConflictError
_ROW = object()
_DIRECTIVE_DICT: dict[str, Any] = {
"id": "11111111-1111-1111-1111-111111111111",
"kind": "relay_message",
"status": "executed",
"payload": {},
"requested_by": "22222222-2222-2222-2222-222222222222",
"requested_at": None,
"decided_by": None,
"decided_at": None,
"result": "posted to #all-hands",
}
def _agent(role: AgentRole) -> AgentContext:
return AgentContext(agent_id=uuid4(), role=role, team=None)
def _db() -> MagicMock:
db = MagicMock()
db.commit = AsyncMock()
return db
class _FakeService:
def __init__(self, *, exc: Exception | None = None) -> None:
self._exc = exc
async def submit_directive(self, _kind: Any, _payload: Any, _by: Any) -> object:
if self._exc is not None:
raise self._exc
return _ROW
async def confirm_directive(self, _directive_id: Any, _by: Any) -> object:
if self._exc is not None:
raise self._exc
return _ROW
async def reject_directive(
self, _directive_id: Any, _by: Any, _reason: Any
) -> object:
if self._exc is not None:
raise self._exc
return _ROW
async def list_directives(self, _status: Any = None) -> list[object]:
return [_ROW]
async def read_company_state(self) -> dict[str, Any]:
return {
"goals": {},
"task_counts": {},
"pending_pitches": [],
"pending_directives": [],
}
def to_dict(self, _row: object) -> dict[str, Any]:
return dict(_DIRECTIVE_DICT)
def _install(monkeypatch: pytest.MonkeyPatch, service: _FakeService) -> None:
monkeypatch.setattr(sec_route, "get_secretary_service", lambda _db: service)
@pytest.mark.asyncio
async def test_submit_forbidden_for_developer() -> None:
with pytest.raises(HTTPException) as exc:
await sec_route.submit_directive(
DirectiveSubmit(kind="relay_message"), _db(), _agent(AgentRole.DEVELOPER)
)
assert exc.value.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_submit_bad_kind_422() -> None:
with pytest.raises(HTTPException) as exc:
await sec_route.submit_directive(
DirectiveSubmit(kind="bogus"), _db(), _agent(AgentRole.SECRETARY)
)
assert exc.value.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
async def test_submit_success(monkeypatch: pytest.MonkeyPatch) -> None:
db = _db()
_install(monkeypatch, _FakeService())
resp = await sec_route.submit_directive(
DirectiveSubmit(
kind="relay_message", payload={"channel": "all-hands", "text": "hi"}
),
db,
_agent(AgentRole.SECRETARY),
)
assert resp.status == "executed"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_confirm_forbidden_for_secretary() -> None:
with pytest.raises(HTTPException) as exc:
await sec_route.confirm_directive(uuid4(), _db(), _agent(AgentRole.SECRETARY))
assert exc.value.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_confirm_success(monkeypatch: pytest.MonkeyPatch) -> None:
db = _db()
_install(monkeypatch, _FakeService())
resp = await sec_route.confirm_directive(uuid4(), db, _agent(AgentRole.CEO))
assert resp.id == _DIRECTIVE_DICT["id"]
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_confirm_conflict_409(monkeypatch: pytest.MonkeyPatch) -> None:
_install(monkeypatch, _FakeService(exc=ConflictError("already decided")))
with pytest.raises(HTTPException) as exc:
await sec_route.confirm_directive(uuid4(), _db(), _agent(AgentRole.CEO))
assert exc.value.status_code == HTTPStatus.CONFLICT
@pytest.mark.asyncio
async def test_list_forbidden_for_secretary() -> None:
with pytest.raises(HTTPException) as exc:
await sec_route.list_directives(_db(), _agent(AgentRole.SECRETARY))
assert exc.value.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_reject_success(monkeypatch: pytest.MonkeyPatch) -> None:
db = _db()
_install(monkeypatch, _FakeService())
resp = await sec_route.reject_directive(
uuid4(), DirectiveDecision(reason="no"), db, _agent(AgentRole.CEO)
)
assert resp.id == _DIRECTIVE_DICT["id"]
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_state_allows_secretary(monkeypatch: pytest.MonkeyPatch) -> None:
_install(monkeypatch, _FakeService())
resp = await sec_route.read_state(_db(), _agent(AgentRole.SECRETARY))
assert resp.pending_pitches == []
@@ -0,0 +1,97 @@
"""roboco.agent_sdk.secretary_driver — the backend-calling tool helpers."""
from __future__ import annotations
import json
from collections.abc import Callable
import httpx
import pytest
from roboco.agent_sdk import secretary_driver as sd
Handler = Callable[[httpx.Request], httpx.Response]
def _client(handler: Handler) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
def _env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ROBOCO_API_URL", "http://x:8000")
monkeypatch.setenv("ROBOCO_AGENT_ID", "secretary-uuid")
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "secretary")
monkeypatch.setenv("ROBOCO_AGENT_TOKEN", "tok")
@pytest.mark.asyncio
async def test_read_state_calls_backend_with_auth(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_env(monkeypatch)
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/api/secretary/state"
assert request.headers["X-Agent-Token"] == "tok"
assert request.headers["X-Agent-Role"] == "secretary"
return httpx.Response(200, json={"goals": {}})
out = await sd._do_read_state(client=_client(handler))
assert out == {"goals": {}}
@pytest.mark.asyncio
async def test_read_task_calls_backend(monkeypatch: pytest.MonkeyPatch) -> None:
_env(monkeypatch)
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/api/secretary/tasks/abc"
return httpx.Response(200, json={"id": "abc"})
out = await sd._do_read_task("abc", client=_client(handler))
assert out["id"] == "abc"
@pytest.mark.asyncio
async def test_submit_directive_posts_kind_and_payload(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_env(monkeypatch)
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/api/secretary/directives"
body = json.loads(request.content)
assert body == {"kind": "announce", "payload": {"text": "hi"}}
return httpx.Response(201, json={"status": "pending"})
out = await sd._do_submit_directive(
"announce", {"text": "hi"}, client=_client(handler)
)
assert out["status"] == "pending"
@pytest.mark.asyncio
async def test_non_2xx_returns_error_dict(monkeypatch: pytest.MonkeyPatch) -> None:
_env(monkeypatch)
out = await sd._do_read_state(
client=_client(lambda _r: httpx.Response(500, text="boom"))
)
assert "error" in out
@pytest.mark.asyncio
async def test_network_error_returns_error_dict(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_env(monkeypatch)
def handler(_request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("down")
out = await sd._do_read_state(client=_client(handler))
assert out["error"] == "request_failed"
def test_text_result_shape() -> None:
result = sd._text_result({"a": 1})
assert result["content"][0]["type"] == "text"
assert json.loads(result["content"][0]["text"]) == {"a": 1}
@@ -0,0 +1,71 @@
"""roboco.api.routes.secretary_live — live-bridge endpoints (mocked deps)."""
from __future__ import annotations
from http import HTTPStatus
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import HTTPException
from roboco.api.routes import secretary_live as sl
from roboco.api.routes.secretary_live import (
AgentEvent,
LiveMessageRequest,
StartSecretaryRequest,
)
@pytest.mark.asyncio
async def test_start_spawns_session(monkeypatch: pytest.MonkeyPatch) -> None:
orch = MagicMock()
orch.start_secretary_session = AsyncMock()
monkeypatch.setattr(sl, "get_orchestrator", lambda: orch)
resp = await sl.start_live(StartSecretaryRequest(initial_message="hi"))
assert resp.session_id
orch.start_secretary_session.assert_awaited_once()
@pytest.mark.asyncio
async def test_messages_delivers(monkeypatch: pytest.MonkeyPatch) -> None:
reg = MagicMock()
reg.deliver = AsyncMock(return_value=True)
monkeypatch.setattr(sl, "get_live_registry", lambda: reg)
out = await sl.send_message("sid", LiveMessageRequest(text="hi"))
assert out == {"delivered": True}
@pytest.mark.asyncio
async def test_messages_404_when_not_live(monkeypatch: pytest.MonkeyPatch) -> None:
reg = MagicMock()
reg.deliver = AsyncMock(return_value=False)
monkeypatch.setattr(sl, "get_live_registry", lambda: reg)
with pytest.raises(HTTPException) as exc:
await sl.send_message("sid", LiveMessageRequest(text="hi"))
assert exc.value.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_stop_reaps(monkeypatch: pytest.MonkeyPatch) -> None:
orch = MagicMock()
orch.reap_secretary_session = AsyncMock()
monkeypatch.setattr(sl, "get_orchestrator", lambda: orch)
out = await sl.stop_live("sid")
assert out == {"stopped": True}
@pytest.mark.asyncio
async def test_relay_event_pushes(monkeypatch: pytest.MonkeyPatch) -> None:
reg = MagicMock()
reg.push = MagicMock(return_value=True)
monkeypatch.setattr(sl, "get_live_registry", lambda: reg)
out = await sl.relay_event("sid", AgentEvent(kind="text", text="hello"))
assert out == {"pushed": True}
@pytest.mark.asyncio
async def test_status(monkeypatch: pytest.MonkeyPatch) -> None:
reg = MagicMock()
reg.is_alive = MagicMock(return_value=True)
monkeypatch.setattr(sl, "get_live_registry", lambda: reg)
out = await sl.session_status("sid")
assert out == {"alive": True}
@@ -0,0 +1,74 @@
"""roboco.services.gateway.content_actions.pitch — Board-gated product proposal."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
def _actions(role: str) -> ContentActions:
task = MagicMock()
agent = MagicMock()
agent.role = role
task.agent_for = AsyncMock(return_value=agent)
task.session = MagicMock()
deps = ContentActionsDeps(
task=task,
git=MagicMock(),
messaging=MagicMock(),
a2a=MagicMock(),
journal=MagicMock(),
workspace=MagicMock(),
notifications=MagicMock(),
)
return ContentActions(deps)
@pytest.mark.asyncio
async def test_pitch_forbidden_for_non_board() -> None:
env = await _actions("developer").pitch(
agent_id=uuid4(),
title="T",
slug="t",
problem="p",
proposed_solution="s",
target_cells=["backend"],
)
assert env.error is not None
assert env.status is None
@pytest.mark.asyncio
async def test_pitch_creates_for_board(monkeypatch: pytest.MonkeyPatch) -> None:
created = MagicMock()
created.id = uuid4()
svc = MagicMock()
svc.create = AsyncMock(return_value=created)
monkeypatch.setattr("roboco.services.pitch.get_pitch_service", lambda _s: svc)
env = await _actions("product_owner").pitch(
agent_id=uuid4(),
title="Widget",
slug="widget",
problem="people need widgets",
proposed_solution="build a widget service",
target_cells=["backend", "frontend"],
)
assert env.error is None
assert env.status == "proposed"
svc.create.assert_awaited_once()
@pytest.mark.asyncio
async def test_pitch_rejects_non_cell_target() -> None:
env = await _actions("head_marketing").pitch(
agent_id=uuid4(),
title="T",
slug="t",
problem="p",
proposed_solution="s",
target_cells=["board"],
)
assert env.error is not None
@@ -109,6 +109,30 @@ class TestContextBriefing:
)
assert build_context_briefing(with_handoff)["task_handoff"] == {"pr_number": 8}
def test_company_goals_defaults_none_and_surfaces_in_briefing(self) -> None:
inputs = BriefingInputs(
unread_a2a=[],
unread_mentions=[],
pending_notifications=[],
task_metadata_gaps=[],
recent_team_activity=[],
blockers_in_my_lane=[],
)
assert build_context_briefing(inputs)["company_goals"] is None
with_goals = BriefingInputs(
unread_a2a=[],
unread_mentions=[],
pending_notifications=[],
task_metadata_gaps=[],
recent_team_activity=[],
blockers_in_my_lane=[],
company_goals={"north_star": "win"},
)
assert build_context_briefing(with_goals)["company_goals"] == {
"north_star": "win"
}
class TestTaskHandoff:
def test_none_task_returns_none(self) -> None:
+39
View File
@@ -38,6 +38,45 @@ def _repo_with_rows(rows: list[object], *, scalar: object = None) -> EvidenceRep
return EvidenceRepo(db)
def _repo_with_goals_row(row: object | None) -> EvidenceRepo:
"""Repo whose execute().scalar_one_or_none() yields the singleton row."""
db = MagicMock()
result = MagicMock()
result.scalar_one_or_none.return_value = row
db.execute = AsyncMock(return_value=result)
return EvidenceRepo(db)
@pytest.mark.asyncio
async def test_company_goals_none_when_no_row() -> None:
assert await _repo_with_goals_row(None).company_goals() is None
@pytest.mark.asyncio
async def test_company_goals_none_when_empty_charter() -> None:
row = SimpleNamespace(
north_star="", objectives=[], constraints=[], operating_policy={}
)
assert await _repo_with_goals_row(row).company_goals() is None
@pytest.mark.asyncio
async def test_company_goals_compact_dict_when_set() -> None:
row = SimpleNamespace(
north_star="Win the market",
objectives=[{"metric": "NPS", "target": 50}],
constraints=["AGPL"],
operating_policy={"autonomy_level": "assisted"},
)
goals = await _repo_with_goals_row(row).company_goals()
assert goals == {
"north_star": "Win the market",
"objectives": [{"metric": "NPS", "target": 50}],
"constraints": ["AGPL"],
"operating_policy": {"autonomy_level": "assisted"},
}
@pytest.mark.asyncio
async def test_constructor_stores_db_session() -> None:
fake_db = MagicMock()
@@ -0,0 +1,100 @@
"""roboco.mcp.search_server — handler shaping + server construction."""
from __future__ import annotations
from typing import Any
import pytest
from mcp.server.fastmcp import FastMCP
from roboco.mcp.search_server import (
_NOT_CONFIGURED,
_handle_fetch,
_handle_search,
create_search_mcp_server,
)
from roboco.mcp.utils import ApiClient
class _FakeClient(ApiClient):
"""ApiClient stand-in that records calls and returns canned responses.
Subclasses ApiClient (so it type-checks where one is expected) but skips
the real ``__init__`` only ``post_or_error`` is exercised here.
"""
def __init__(
self,
result: dict[str, Any] | None = None,
error: dict[str, Any] | None = None,
) -> None:
self._result = result
self._error = error
self.calls: list[tuple[str, dict[str, Any] | None]] = []
async def post_or_error(
self,
endpoint: str,
json: dict[str, Any] | None = None,
error_code: str = "API_ERROR",
error_message: str = "Request failed",
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
self.calls.append((endpoint, json))
return self._result, self._error
@pytest.mark.asyncio
async def test_search_success_includes_cite_guidance() -> None:
client = _FakeClient(
result={
"query": "q",
"provider": "tavily",
"answer": "a",
"results": [{"title": "T", "url": "https://t.test", "snippet": "s"}],
}
)
out = await _handle_search("q", 3, client)
assert out["provider"] == "tavily"
assert "cite" in out["guidance"].lower()
assert client.calls == [("/research/search", {"query": "q", "max_results": 3})]
@pytest.mark.asyncio
async def test_search_null_provider_signals_not_configured() -> None:
client = _FakeClient(
result={"query": "q", "provider": "null", "answer": None, "results": []}
)
out = await _handle_search("q", None, client)
assert out["guidance"] == _NOT_CONFIGURED
# No max_results key when not supplied.
assert client.calls == [("/research/search", {"query": "q"})]
@pytest.mark.asyncio
async def test_search_propagates_error() -> None:
err = {"status": "error", "error": {"code": "SEARCH_FAILED"}}
client = _FakeClient(error=err)
out = await _handle_search("q", None, client)
assert out == err
@pytest.mark.asyncio
async def test_fetch_success_shapes_payload() -> None:
client = _FakeClient(
result={
"url": "https://x.test",
"provider": "exa",
"content": "body",
"truncated": True,
}
)
out = await _handle_fetch("https://x.test", 500, client)
assert out["content"] == "body"
assert out["truncated"] is True
assert client.calls == [
("/research/fetch", {"url": "https://x.test", "max_chars": 500})
]
def test_create_search_mcp_server_builds() -> None:
server = create_search_mcp_server("be-pm")
assert isinstance(server, FastMCP)
@@ -0,0 +1,59 @@
"""roboco.runtime.orchestrator — Secretary docker-run cmd + host paths (pure)."""
from __future__ import annotations
from unittest.mock import MagicMock
from roboco.runtime.orchestrator import (
SECRETARY_AGENT_ID,
AgentOrchestrator,
_SecretaryRunSpec,
)
def _spec() -> _SecretaryRunSpec:
return _SecretaryRunSpec(
container_name=f"roboco-agent-{SECRETARY_AGENT_ID}",
image="roboco-agent-secretary",
hosts={"claude": "/h/.claude", "prompt": "/h/p.md"},
session_id="sid123",
cwd="/app",
cli_model="opus",
api_url="http://x:8000",
agent_uuid="uuid-1",
agent_token="tok-1",
provider_base_url=None,
provider_auth_token=None,
)
def test_build_secretary_run_cmd_wires_token_and_session() -> None:
cmd = AgentOrchestrator._build_secretary_run_cmd(_spec())
assert cmd[-1] == "roboco-agent-secretary" # image is last
assert "ROBOCO_AGENT_TOKEN=tok-1" in cmd
assert "ROBOCO_AGENT_ID=uuid-1" in cmd
assert "ROBOCO_AGENT_ROLE=secretary" in cmd
assert "ROBOCO_SECRETARY_SESSION_ID=sid123" in cmd
# No workspaces mount for the Secretary.
assert not any("/data/workspaces" in part for part in cmd)
def test_build_secretary_run_cmd_adds_provider_env_when_set() -> None:
spec = _spec()
spec_with_provider = _SecretaryRunSpec(
**{
**spec.__dict__,
"provider_base_url": "https://prov",
"provider_auth_token": "ptok",
}
)
cmd = AgentOrchestrator._build_secretary_run_cmd(spec_with_provider)
assert "ANTHROPIC_BASE_URL=https://prov" in cmd
assert "ANTHROPIC_AUTH_TOKEN=ptok" in cmd
def test_resolve_secretary_host_paths_has_claude_and_prompt() -> None:
paths = AgentOrchestrator._resolve_secretary_host_paths(MagicMock())
assert "claude" in paths
assert "prompt" in paths
assert SECRETARY_AGENT_ID in str(paths["prompt"])
+123
View File
@@ -0,0 +1,123 @@
"""roboco.services.cockpit + route — read-only company summary (mocked deps)."""
from __future__ import annotations
from http import HTTPStatus
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from fastapi import HTTPException
from roboco.api.routes import cockpit as croute
from roboco.models import AgentRole
from roboco.models.permissions import AgentContext
from roboco.services import cockpit as cm
from roboco.services.cockpit import CockpitService
from roboco.services.strategy_engine import StrategyObservation
_IN_PROGRESS = 2
_CLAIMED = 1
_BLOCKED = 3
_BUDGET = 100.0
_SPEND_30D = 150.0
def _agent(role: AgentRole) -> AgentContext:
return AgentContext(agent_id=uuid4(), role=role, team=None)
def _patch(monkeypatch: pytest.MonkeyPatch) -> None:
goals = {
"north_star": "Win the market",
"objectives": [{"metric": "NPS"}],
"operating_policy": {"monthly_budget_cap": _BUDGET},
}
monkeypatch.setattr(
cm,
"get_company_goals_service",
lambda _s: MagicMock(get=AsyncMock(return_value=goals)),
)
counts = {
"in_progress": _IN_PROGRESS,
"claimed": _CLAIMED,
"blocked": _BLOCKED,
"awaiting_ceo_approval": 1,
}
monkeypatch.setattr(
cm,
"get_task_service",
lambda _s: MagicMock(count_by_status=AsyncMock(return_value=counts)),
)
usage = MagicMock(
get_summary=AsyncMock(return_value={"total_cost_usd": _SPEND_30D}),
get_projection=AsyncMock(return_value={"projected_monthly_cost_usd": 200.0}),
)
monkeypatch.setattr(cm, "get_usage_service", lambda _s: usage)
monkeypatch.setattr(
cm,
"get_strategy_engine",
lambda _s: MagicMock(
assess=AsyncMock(
return_value=[StrategyObservation(kind="idle", summary="s", detail="d")]
)
),
)
proposed = MagicMock()
proposed.status = "proposed"
done = MagicMock()
done.status = "provisioned"
monkeypatch.setattr(
cm,
"get_pitch_service",
lambda _s: MagicMock(list_pitches=AsyncMock(return_value=[proposed, done])),
)
@pytest.mark.asyncio
async def test_summary_aggregates(monkeypatch: pytest.MonkeyPatch) -> None:
_patch(monkeypatch)
out = await CockpitService(MagicMock()).summary()
assert out["basis"] == "proxy"
assert out["north_star"] == "Win the market"
assert out["delivery"]["in_flight"] == _IN_PROGRESS + _CLAIMED
assert out["delivery"]["blocked"] == _BLOCKED
assert out["spend"]["spend_30d_usd"] == _SPEND_30D
assert out["spend"]["over_budget"] is True
assert out["pending_pitches"] == 1
assert out["signals"][0]["kind"] == "idle"
@pytest.mark.asyncio
async def test_route_forbidden_for_developer() -> None:
with pytest.raises(HTTPException) as exc:
await croute.cockpit_summary(MagicMock(), _agent(AgentRole.DEVELOPER))
assert exc.value.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_route_ok_for_ceo(monkeypatch: pytest.MonkeyPatch) -> None:
summary: dict[str, Any] = {
"basis": "proxy",
"north_star": "Win",
"objectives": [],
"delivery": {
"task_counts": {},
"in_flight": 0,
"blocked": 0,
"awaiting_ceo": 0,
},
"spend": {
"spend_30d_usd": 0.0,
"projected_monthly_usd": None,
"monthly_budget_cap_usd": None,
"over_budget": False,
},
"pending_pitches": 0,
"signals": [],
}
svc = MagicMock(summary=AsyncMock(return_value=summary))
monkeypatch.setattr(croute, "get_cockpit_service", lambda _db: svc)
resp = await croute.cockpit_summary(MagicMock(), _agent(AgentRole.CEO))
assert resp.basis == "proxy"
assert resp.spend.over_budget is False
@@ -0,0 +1,70 @@
"""Tests for CompanyGoalsService — the singleton company charter."""
from __future__ import annotations
from typing import Any
from uuid import uuid4
import pytest
from roboco.db.tables import CompanyGoalsTable
from roboco.services.company_goals import (
SINGLETON_ID,
get_company_goals_service,
)
@pytest.mark.asyncio
async def test_get_returns_empty_defaults_when_unset(db_session: Any) -> None:
# The "unset" contract is about no/empty charter row. The test DB is shared
# and route tests commit a charter to it, so establish a clean precondition
# rather than assume global emptiness.
existing = await db_session.get(CompanyGoalsTable, SINGLETON_ID)
if existing is not None:
await db_session.delete(existing)
await db_session.commit()
svc = get_company_goals_service(db_session)
goals = await svc.get()
assert goals["north_star"] == ""
assert goals["objectives"] == []
assert goals["constraints"] == []
assert goals["operating_policy"] == {}
@pytest.mark.asyncio
async def test_upsert_then_get_roundtrips(db_session: Any) -> None:
svc = get_company_goals_service(db_session)
actor = uuid4()
await svc.upsert(
{
"north_star": "Ship a delightful product",
"objectives": [{"metric": "NPS", "target": 50, "status": "active"}],
"constraints": ["AGPL only"],
"operating_policy": {
"autonomy_level": "assisted",
"monthly_budget_cap": 500,
},
},
updated_by=actor,
)
goals = await svc.get()
assert goals["north_star"] == "Ship a delightful product"
assert goals["objectives"][0]["metric"] == "NPS"
assert goals["constraints"] == ["AGPL only"]
assert goals["operating_policy"]["autonomy_level"] == "assisted"
assert goals["updated_by"] == str(actor)
assert goals["updated_at"] is not None
@pytest.mark.asyncio
async def test_upsert_is_singleton_and_partial(db_session: Any) -> None:
svc = get_company_goals_service(db_session)
await svc.upsert({"north_star": "First", "constraints": ["a"]})
# A second upsert updates the SAME row and only the provided keys.
await svc.upsert({"north_star": "Second"})
goals = await svc.get()
assert goals["north_star"] == "Second"
assert goals["constraints"] == ["a"] # untouched key preserved
# Exactly one row exists (singleton), found at the canonical id.
row = await db_session.get(CompanyGoalsTable, SINGLETON_ID)
assert row is not None
@@ -0,0 +1,91 @@
"""roboco.services.github_provisioning — repo creation against MockTransport."""
from __future__ import annotations
import json
from collections.abc import Callable
import httpx
import pytest
from roboco.config import settings
from roboco.services.github_provisioning import (
GitHubProvisioningService,
ProvisioningDisabledError,
ProvisioningError,
)
Handler = Callable[[httpx.Request], httpx.Response]
def _client(handler: Handler) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
@pytest.mark.asyncio
async def test_disabled_when_no_token(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "provisioning_enabled", True)
svc = GitHubProvisioningService(
token="", org="", client=_client(lambda _r: httpx.Response(201))
)
assert svc.enabled is False
with pytest.raises(ProvisioningDisabledError):
await svc.create_repo("x")
@pytest.mark.asyncio
async def test_disabled_when_master_switch_off(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "provisioning_enabled", False)
svc = GitHubProvisioningService(
token="tok", org="acme", client=_client(lambda _r: httpx.Response(201))
)
assert svc.enabled is False
@pytest.mark.asyncio
async def test_create_repo_success(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "provisioning_enabled", True)
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/orgs/acme/repos"
body = json.loads(request.content)
assert body["name"] == "newrepo"
assert body["auto_init"] is True
assert body["private"] is True
return httpx.Response(
201,
json={
"full_name": "acme/newrepo",
"clone_url": "https://github.com/acme/newrepo.git",
"html_url": "https://github.com/acme/newrepo",
},
)
svc = GitHubProvisioningService(token="tok", org="acme", client=_client(handler))
assert svc.enabled is True
repo = await svc.create_repo("newrepo", "desc")
assert repo.full_name == "acme/newrepo"
assert repo.clone_url.endswith("newrepo.git")
@pytest.mark.asyncio
async def test_create_repo_failure_raises(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "provisioning_enabled", True)
svc = GitHubProvisioningService(
token="tok",
org="acme",
client=_client(lambda _r: httpx.Response(422, text="name exists")),
)
with pytest.raises(ProvisioningError):
await svc.create_repo("dup")
@pytest.mark.asyncio
async def test_network_error_raises(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "provisioning_enabled", True)
def handler(_request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("down")
svc = GitHubProvisioningService(token="tok", org="acme", client=_client(handler))
with pytest.raises(ProvisioningError):
await svc.create_repo("x")
+217
View File
@@ -0,0 +1,217 @@
"""roboco.services.pitch — CRUD + approve/reject orchestration (mocked deps).
The approve path constructs real domain models (ProjectCreate, ProductCellMapping,
TaskCreateRequest) but the downstream services and the GitHub provisioner are
faked, so the test exercises the orchestration logic without a DB or network.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.db.tables import PitchTable
from roboco.foundation.identity import Team
from roboco.models.pitch import PitchCreate, PitchStatus
from roboco.services import pitch as pitch_module
from roboco.services.base import ConflictError
from roboco.services.github_provisioning import (
GitHubProvisioningService,
ProvisionedRepo,
ProvisioningDisabledError,
)
from roboco.services.pitch import PitchService
def _session() -> MagicMock:
s = MagicMock()
s.add = MagicMock()
s.flush = AsyncMock()
return s
def _pitch(**kw: Any) -> PitchTable:
defaults: dict[str, Any] = {
"id": uuid4(),
"title": "Widget",
"slug": "widget",
"problem": "people need widgets",
"proposed_solution": "build a widget service",
"target_cells": ["backend"],
"status": "proposed",
"created_by": uuid4(),
}
defaults.update(kw)
return PitchTable(**defaults)
class _FakeProvisioning(GitHubProvisioningService):
def __init__(self, *, enabled: bool = True) -> None:
self._enabled = enabled
self.created: list[str] = []
@property
def enabled(self) -> bool:
return self._enabled
async def create_repo(
self, name: str, description: str = "", *, private: bool = True
) -> ProvisionedRepo:
_ = (description, private)
self.created.append(name)
return ProvisionedRepo(
full_name=f"org/{name}",
clone_url=f"https://github.com/org/{name}.git",
html_url=f"https://github.com/org/{name}",
)
async def close(self) -> None:
return None
def _patch_topology(monkeypatch: pytest.MonkeyPatch) -> dict[str, MagicMock]:
proj = MagicMock()
proj.id = uuid4()
project_svc = MagicMock()
project_svc.create = AsyncMock(return_value=proj)
monkeypatch.setattr(pitch_module, "get_project_service", lambda _s: project_svc)
prod = MagicMock()
prod.id = uuid4()
product_svc = MagicMock()
product_svc.create = AsyncMock(return_value=prod)
monkeypatch.setattr(pitch_module, "get_product_service", lambda _s: product_svc)
task = MagicMock()
task.id = uuid4()
task_svc = MagicMock()
task_svc.create = AsyncMock(return_value=task)
monkeypatch.setattr(pitch_module, "get_task_service", lambda _s: task_svc)
main_pm = MagicMock()
main_pm.id = uuid4()
agent_svc = MagicMock()
agent_svc.get_by_slug = AsyncMock(return_value=main_pm)
monkeypatch.setattr(pitch_module, "get_agent_service", lambda _s: agent_svc)
return {"project": project_svc, "product": product_svc, "task": task_svc}
@pytest.mark.asyncio
async def test_create_persists(monkeypatch: pytest.MonkeyPatch) -> None:
session = _session()
svc = PitchService(session)
monkeypatch.setattr(svc, "get_by_slug", AsyncMock(return_value=None))
pitch = await svc.create(
PitchCreate(
title="Widget",
slug="widget",
problem="p",
proposed_solution="s",
target_cells=[Team.BACKEND, Team.FRONTEND],
),
created_by=uuid4(),
)
assert pitch.slug == "widget"
assert pitch.status == PitchStatus.PROPOSED.value
assert pitch.target_cells == ["backend", "frontend"]
session.add.assert_called_once()
session.flush.assert_awaited_once()
@pytest.mark.asyncio
async def test_create_conflict_on_duplicate_slug(
monkeypatch: pytest.MonkeyPatch,
) -> None:
svc = PitchService(_session())
monkeypatch.setattr(svc, "get_by_slug", AsyncMock(return_value=_pitch()))
with pytest.raises(ConflictError):
await svc.create(
PitchCreate(
title="Widget",
slug="widget",
problem="p",
proposed_solution="s",
target_cells=[Team.BACKEND],
),
created_by=uuid4(),
)
@pytest.mark.asyncio
async def test_reject_sets_status(monkeypatch: pytest.MonkeyPatch) -> None:
svc = PitchService(_session())
pitch = _pitch()
monkeypatch.setattr(svc, "get", AsyncMock(return_value=pitch))
result = await svc.reject(pitch.id, "not aligned with the charter", uuid4())
assert result.status == PitchStatus.REJECTED.value
assert result.decision_notes == "not aligned with the charter"
@pytest.mark.asyncio
async def test_approve_single_cell_provisions_project_and_task(
monkeypatch: pytest.MonkeyPatch,
) -> None:
svc = PitchService(_session())
pitch = _pitch(target_cells=["backend"])
monkeypatch.setattr(svc, "get", AsyncMock(return_value=pitch))
svcs = _patch_topology(monkeypatch)
prov = _FakeProvisioning(enabled=True)
result = await svc.approve(
pitch.id, "approved for build", uuid4(), provisioning=prov
)
assert result.status == PitchStatus.PROVISIONED.value
assert result.seed_task_id is not None
assert result.provisioned_project_ids is not None
assert len(result.provisioned_project_ids) == len(pitch.target_cells)
assert result.provisioned_product_id is None
assert prov.created == ["widget"]
svcs["product"].create.assert_not_called()
svcs["task"].create.assert_awaited_once()
@pytest.mark.asyncio
async def test_approve_multi_cell_creates_product(
monkeypatch: pytest.MonkeyPatch,
) -> None:
svc = PitchService(_session())
pitch = _pitch(slug="multi", target_cells=["backend", "frontend"])
monkeypatch.setattr(svc, "get", AsyncMock(return_value=pitch))
svcs = _patch_topology(monkeypatch)
prov = _FakeProvisioning(enabled=True)
result = await svc.approve(pitch.id, "approved", uuid4(), provisioning=prov)
assert result.provisioned_product_id is not None
assert result.provisioned_project_ids is not None
assert len(result.provisioned_project_ids) == len(pitch.target_cells)
assert prov.created == ["multi-backend", "multi-frontend"]
svcs["product"].create.assert_awaited_once()
@pytest.mark.asyncio
async def test_approve_rejects_when_not_proposed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
svc = PitchService(_session())
monkeypatch.setattr(
svc, "get", AsyncMock(return_value=_pitch(status="provisioned"))
)
with pytest.raises(ConflictError):
await svc.approve(uuid4(), "x", uuid4(), provisioning=_FakeProvisioning())
@pytest.mark.asyncio
async def test_approve_blocked_when_provisioning_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
svc = PitchService(_session())
monkeypatch.setattr(svc, "get", AsyncMock(return_value=_pitch()))
with pytest.raises(ProvisioningDisabledError):
await svc.approve(
uuid4(), "x", uuid4(), provisioning=_FakeProvisioning(enabled=False)
)
+314
View File
@@ -0,0 +1,314 @@
"""roboco.services.research — provider adapters + service coverage.
Provider HTTP is exercised against ``httpx.MockTransport`` (no network, no
extra dependency); the service-level tests use a recording fake to assert the
result/byte clamps.
"""
from __future__ import annotations
import json
from collections.abc import Callable
import httpx
import pytest
from roboco.config import settings
from roboco.services.research import (
BraveProvider,
ExaProvider,
FetchOutcome,
NullProvider,
ResearchError,
ResearchService,
ResearchUnsupportedError,
SearchOutcome,
SearchProvider,
TavilyProvider,
build_provider,
get_research_service,
)
Handler = Callable[[httpx.Request], httpx.Response]
_QUERY = "agentic frameworks"
_N_RESULTS = 3
_TOP_SCORE = 0.9
_TRUNC_CAP = 10
_RESULTS_CAP = 5
_REQ_RESULTS = 2
_FETCH_CAP = 20
def _client(handler: Handler) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
# --------------------------------------------------------------------------- #
# Tavily
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_tavily_search_parses_results_and_answer() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.host == "api.tavily.com"
body = json.loads(request.content)
assert body["query"] == _QUERY
assert body["max_results"] == _N_RESULTS
return httpx.Response(
200,
json={
"query": _QUERY,
"answer": "Several exist.",
"results": [
{
"title": "A",
"url": "https://a.test",
"content": "sa",
"score": 0.9,
},
{
"title": "B",
"url": "https://b.test",
"content": "sb",
"score": 0.5,
},
],
},
)
client = _client(handler)
provider = TavilyProvider(api_key="k", timeout=5.0, client=client)
out = await provider.search(_QUERY, _N_RESULTS)
assert out.provider == "tavily"
assert out.answer == "Several exist."
assert [h.url for h in out.hits] == ["https://a.test", "https://b.test"]
assert out.hits[0].score == _TOP_SCORE
await client.aclose()
@pytest.mark.asyncio
async def test_tavily_fetch_extracts_raw_content() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/extract"
return httpx.Response(
200, json={"results": [{"url": "https://a.test", "raw_content": "hello"}]}
)
client = _client(handler)
provider = TavilyProvider(api_key="k", timeout=5.0, client=client)
out = await provider.fetch("https://a.test", 1000)
assert out.content == "hello"
assert out.truncated is False
await client.aclose()
@pytest.mark.asyncio
async def test_tavily_fetch_truncates_to_cap() -> None:
def handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(
200, json={"results": [{"url": "u", "raw_content": "x" * 100}]}
)
client = _client(handler)
provider = TavilyProvider(api_key="k", timeout=5.0, client=client)
out = await provider.fetch("u", _TRUNC_CAP)
assert len(out.content) == _TRUNC_CAP
assert out.truncated is True
await client.aclose()
# --------------------------------------------------------------------------- #
# Brave
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_brave_search_parses_web_results() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.host == "api.search.brave.com"
assert request.headers["X-Subscription-Token"] == "k"
return httpx.Response(
200,
json={
"web": {
"results": [
{"title": "T", "url": "https://t.test", "description": "d"}
]
}
},
)
client = _client(handler)
provider = BraveProvider(api_key="k", timeout=5.0, client=client)
out = await provider.search("q", _RESULTS_CAP)
assert out.provider == "brave"
assert out.answer is None
assert out.hits[0].snippet == "d"
await client.aclose()
@pytest.mark.asyncio
async def test_brave_fetch_is_unsupported() -> None:
provider = BraveProvider(
api_key="k", timeout=5.0, client=_client(lambda _r: httpx.Response(200))
)
with pytest.raises(ResearchUnsupportedError):
await provider.fetch("https://x.test", 100)
# --------------------------------------------------------------------------- #
# Exa
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_exa_search_and_fetch() -> None:
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/search":
return httpx.Response(
200,
json={
"results": [{"title": "E", "url": "https://e.test", "text": "snip"}]
},
)
return httpx.Response(
200, json={"results": [{"url": "https://e.test", "text": "full"}]}
)
client = _client(handler)
provider = ExaProvider(api_key="k", timeout=5.0, client=client)
out = await provider.search("q", _RESULTS_CAP)
assert out.hits[0].snippet == "snip"
fetched = await provider.fetch("https://e.test", 1000)
assert fetched.content == "full"
await client.aclose()
# --------------------------------------------------------------------------- #
# Error handling
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_non_2xx_raises_research_error() -> None:
client = _client(lambda _r: httpx.Response(500, text="boom"))
provider = TavilyProvider(api_key="k", timeout=5.0, client=client)
with pytest.raises(ResearchError):
await provider.search("q", _RESULTS_CAP)
await client.aclose()
@pytest.mark.asyncio
async def test_network_error_raises_research_error() -> None:
def handler(_request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("down")
client = _client(handler)
provider = TavilyProvider(api_key="k", timeout=5.0, client=client)
with pytest.raises(ResearchError):
await provider.search("q", _RESULTS_CAP)
await client.aclose()
@pytest.mark.asyncio
async def test_malformed_json_raises_research_error() -> None:
client = _client(
lambda _r: httpx.Response(
200, text="not json", headers={"content-type": "application/json"}
)
)
provider = TavilyProvider(api_key="k", timeout=5.0, client=client)
with pytest.raises(ResearchError):
await provider.search("q", _RESULTS_CAP)
await client.aclose()
# --------------------------------------------------------------------------- #
# NullProvider + build_provider
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_null_provider_degrades_gracefully() -> None:
provider = NullProvider(api_key=None, timeout=5.0)
assert provider.configured is False
search = await provider.search("q", _RESULTS_CAP)
assert search.hits == []
assert search.provider == "null"
fetched = await provider.fetch("u", _RESULTS_CAP)
assert fetched.content == ""
def test_build_provider_selects_by_name() -> None:
assert isinstance(build_provider("tavily", "k", 5.0), TavilyProvider)
assert isinstance(build_provider("brave", "k", 5.0), BraveProvider)
assert isinstance(build_provider("exa", "k", 5.0), ExaProvider)
assert isinstance(build_provider("null", "k", 5.0), NullProvider)
def test_build_provider_null_when_no_key_or_unknown() -> None:
assert isinstance(build_provider("tavily", None, 5.0), NullProvider)
assert isinstance(build_provider("mystery", "k", 5.0), NullProvider)
# --------------------------------------------------------------------------- #
# ResearchService — clamps
# --------------------------------------------------------------------------- #
class _RecordingProvider(SearchProvider):
name = "rec"
def __init__(self) -> None:
super().__init__(api_key="k", timeout=5.0)
self.last_max_results: int | None = None
self.last_max_chars: int | None = None
self.fetch_content = "z" * 50
async def search(self, query: str, max_results: int) -> SearchOutcome:
self.last_max_results = max_results
return SearchOutcome(query=query, hits=[], answer=None, provider=self.name)
async def fetch(self, url: str, max_chars: int) -> FetchOutcome:
self.last_max_chars = max_chars
return FetchOutcome(
url=url, content=self.fetch_content, truncated=False, provider=self.name
)
@pytest.mark.asyncio
async def test_service_clamps_max_results() -> None:
provider = _RecordingProvider()
service = ResearchService(
provider, max_results_cap=_RESULTS_CAP, fetch_max_chars_cap=100
)
await service.search("q", 100)
assert provider.last_max_results == _RESULTS_CAP
await service.search("q", None)
assert provider.last_max_results == _RESULTS_CAP
await service.search("q", _REQ_RESULTS)
assert provider.last_max_results == _REQ_RESULTS
await service.search("q", 0)
assert provider.last_max_results == 1
@pytest.mark.asyncio
async def test_service_clamps_and_truncates_fetch() -> None:
provider = _RecordingProvider()
provider.fetch_content = "y" * 80
service = ResearchService(
provider, max_results_cap=_RESULTS_CAP, fetch_max_chars_cap=_FETCH_CAP
)
out = await service.fetch("u", 1000)
assert provider.last_max_chars == _FETCH_CAP
assert len(out.content) == _FETCH_CAP
assert out.truncated is True
def test_get_research_service_uses_settings(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "research_provider", "null")
monkeypatch.setattr(settings, "research_api_key", None)
service = get_research_service()
assert service.provider_name == "null"
assert service.configured is False
@@ -0,0 +1,94 @@
"""roboco.services.research_quota — per-agent daily quota (mocked Redis)."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
import pytest
from roboco.services import research_quota
from roboco.services.research_quota import ResearchQuotaTracker
_EXPIRY_SECONDS = 86400
_OVER_LIMIT_USED = 3
class _FakeRedis:
def __init__(self) -> None:
self.store: dict[str, int] = {}
self.expires: dict[str, int] = {}
async def incr(self, key: str) -> int:
self.store[key] = self.store.get(key, 0) + 1
return self.store[key]
async def expire(self, key: str, ttl: int) -> None:
self.expires[key] = ttl
async def aclose(self) -> None:
return None
class _BrokenRedis:
async def incr(self, _key: str) -> int:
raise ConnectionError("redis down")
def _patch_redis(monkeypatch: pytest.MonkeyPatch, fake: Any) -> None:
monkeypatch.setattr(research_quota.redis, "from_url", lambda _url: fake)
@pytest.mark.asyncio
async def test_first_call_increments_and_sets_expiry(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake = _FakeRedis()
_patch_redis(monkeypatch, fake)
tracker = ResearchQuotaTracker(redis_url="redis://x")
now = datetime(2026, 6, 15, tzinfo=UTC)
result = await tracker.check_and_consume("agent-1", 50, now=now)
assert result.allowed is True
assert result.used == 1
assert result.day == "2026-06-15"
# expiry set exactly once, on the first increment
key = "roboco:research_quota:agent-1:2026-06-15"
assert fake.expires[key] == _EXPIRY_SECONDS
@pytest.mark.asyncio
async def test_blocks_when_over_limit(monkeypatch: pytest.MonkeyPatch) -> None:
fake = _FakeRedis()
_patch_redis(monkeypatch, fake)
tracker = ResearchQuotaTracker(redis_url="redis://x")
now = datetime(2026, 6, 15, tzinfo=UTC)
first = await tracker.check_and_consume("a", 2, now=now)
second = await tracker.check_and_consume("a", 2, now=now)
third = await tracker.check_and_consume("a", 2, now=now)
assert first.allowed is True
assert second.allowed is True
assert third.allowed is False
assert third.used == _OVER_LIMIT_USED
@pytest.mark.asyncio
async def test_separate_counters_per_day(monkeypatch: pytest.MonkeyPatch) -> None:
fake = _FakeRedis()
_patch_redis(monkeypatch, fake)
tracker = ResearchQuotaTracker(redis_url="redis://x")
d1 = await tracker.check_and_consume("a", 50, now=datetime(2026, 6, 15, tzinfo=UTC))
d2 = await tracker.check_and_consume("a", 50, now=datetime(2026, 6, 16, tzinfo=UTC))
assert d1.used == 1
assert d2.used == 1
@pytest.mark.asyncio
async def test_fails_open_when_redis_unreachable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_redis(monkeypatch, _BrokenRedis())
tracker = ResearchQuotaTracker(redis_url="redis://x")
result = await tracker.check_and_consume(
"a", 50, now=datetime(2026, 6, 15, tzinfo=UTC)
)
assert result.allowed is True
assert result.used == 0
@@ -0,0 +1,173 @@
"""roboco.services.secretary — directive gate + execution (mocked deps)."""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.db.tables import SecretaryDirectiveTable
from roboco.models.secretary import DirectiveKind, DirectiveStatus
from roboco.services import secretary as sec_module
from roboco.services.base import ValidationError
from roboco.services.secretary import SecretaryService
def _session() -> MagicMock:
s = MagicMock()
s.add = MagicMock()
s.flush = AsyncMock()
return s
def _patch(monkeypatch: pytest.MonkeyPatch) -> dict[str, MagicMock]:
msg = MagicMock()
msg.post_to_channel = AsyncMock()
monkeypatch.setattr(sec_module, "get_messaging_service", lambda _s: msg)
goals = MagicMock()
goals.upsert = AsyncMock()
monkeypatch.setattr(sec_module, "get_company_goals_service", lambda _s: goals)
pitch = MagicMock()
pitch.approve = AsyncMock()
monkeypatch.setattr(sec_module, "get_pitch_service", lambda _s: pitch)
task = MagicMock()
task.approve_and_start = AsyncMock()
task.admin_set_status = AsyncMock()
monkeypatch.setattr(sec_module, "get_task_service", lambda _s: task)
notifier = MagicMock()
notifier.send_ack_notification = AsyncMock()
monkeypatch.setattr(
"roboco.services.notification.NotificationService", lambda: notifier
)
return {
"msg": msg,
"goals": goals,
"pitch": pitch,
"task": task,
"notifier": notifier,
}
def _pending(kind: DirectiveKind, payload: dict[str, Any]) -> SecretaryDirectiveTable:
return SecretaryDirectiveTable(
id=uuid4(),
kind=kind.value,
payload=payload,
status=DirectiveStatus.PENDING.value,
requested_by=uuid4(),
)
@pytest.mark.asyncio
async def test_relay_executes_directly(monkeypatch: pytest.MonkeyPatch) -> None:
svcs = _patch(monkeypatch)
svc = SecretaryService(_session())
row = await svc.submit_directive(
DirectiveKind.RELAY_MESSAGE,
{"channel": "all-hands", "text": "standup at 10"},
uuid4(),
)
assert row.status == DirectiveStatus.EXECUTED.value
svcs["msg"].post_to_channel.assert_awaited_once()
svcs["notifier"].send_ack_notification.assert_not_awaited()
@pytest.mark.asyncio
async def test_gated_charter_queues_and_notifies(
monkeypatch: pytest.MonkeyPatch,
) -> None:
svcs = _patch(monkeypatch)
svc = SecretaryService(_session())
row = await svc.submit_directive(
DirectiveKind.UPDATE_CHARTER, {"charter": {"north_star": "Win"}}, uuid4()
)
assert row.status == DirectiveStatus.PENDING.value
svcs["goals"].upsert.assert_not_awaited()
svcs["notifier"].send_ack_notification.assert_awaited_once()
@pytest.mark.asyncio
async def test_confirm_charter_executes(monkeypatch: pytest.MonkeyPatch) -> None:
svcs = _patch(monkeypatch)
svc = SecretaryService(_session())
row = _pending(DirectiveKind.UPDATE_CHARTER, {"charter": {"north_star": "Win"}})
monkeypatch.setattr(svc, "get_directive", AsyncMock(return_value=row))
out = await svc.confirm_directive(row.id, uuid4())
assert out.status == DirectiveStatus.EXECUTED.value
svcs["goals"].upsert.assert_awaited_once()
@pytest.mark.asyncio
async def test_confirm_control_task_start(monkeypatch: pytest.MonkeyPatch) -> None:
svcs = _patch(monkeypatch)
svc = SecretaryService(_session())
row = _pending(
DirectiveKind.CONTROL_TASK, {"task_id": str(uuid4()), "action": "start"}
)
monkeypatch.setattr(svc, "get_directive", AsyncMock(return_value=row))
out = await svc.confirm_directive(row.id, uuid4())
assert out.status == DirectiveStatus.EXECUTED.value
svcs["task"].approve_and_start.assert_awaited_once()
@pytest.mark.asyncio
async def test_confirm_approve_pitch(monkeypatch: pytest.MonkeyPatch) -> None:
svcs = _patch(monkeypatch)
svc = SecretaryService(_session())
row = _pending(DirectiveKind.APPROVE_PITCH, {"pitch_id": str(uuid4())})
monkeypatch.setattr(svc, "get_directive", AsyncMock(return_value=row))
out = await svc.confirm_directive(row.id, uuid4())
assert out.status == DirectiveStatus.EXECUTED.value
svcs["pitch"].approve.assert_awaited_once()
@pytest.mark.asyncio
async def test_announce_queues_then_confirm_posts(
monkeypatch: pytest.MonkeyPatch,
) -> None:
svcs = _patch(monkeypatch)
svc = SecretaryService(_session())
row = await svc.submit_directive(
DirectiveKind.ANNOUNCE, {"text": "we shipped v1"}, uuid4()
)
assert row.status == DirectiveStatus.PENDING.value
monkeypatch.setattr(svc, "get_directive", AsyncMock(return_value=row))
out = await svc.confirm_directive(row.id, uuid4())
assert out.status == DirectiveStatus.EXECUTED.value
svcs["msg"].post_to_channel.assert_awaited_once()
@pytest.mark.asyncio
async def test_reject_sets_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
_patch(monkeypatch)
svc = SecretaryService(_session())
row = _pending(DirectiveKind.ANNOUNCE, {"text": "x"})
monkeypatch.setattr(svc, "get_directive", AsyncMock(return_value=row))
out = await svc.reject_directive(row.id, uuid4(), "not now")
assert out.status == DirectiveStatus.REJECTED.value
assert out.result == "not now"
@pytest.mark.asyncio
async def test_missing_payload_raises(monkeypatch: pytest.MonkeyPatch) -> None:
_patch(monkeypatch)
svc = SecretaryService(_session())
with pytest.raises(ValidationError):
await svc.submit_directive(
DirectiveKind.RELAY_MESSAGE, {"channel": "x"}, uuid4()
)
@pytest.mark.asyncio
async def test_bad_task_action_fails_directive(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch(monkeypatch)
svc = SecretaryService(_session())
row = _pending(
DirectiveKind.CONTROL_TASK, {"task_id": str(uuid4()), "action": "explode"}
)
monkeypatch.setattr(svc, "get_directive", AsyncMock(return_value=row))
out = await svc.confirm_directive(row.id, uuid4())
assert out.status == DirectiveStatus.FAILED.value
+111
View File
@@ -0,0 +1,111 @@
"""roboco.services.strategy_engine — assessment + notify (dormant by default)."""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from roboco.services import strategy_engine as se_module
from roboco.services.strategy_engine import StrategyEngine
_GOALS_WITH_DIRECTION: dict[str, Any] = {
"north_star": "Win the market",
"objectives": [{"metric": "NPS", "target": 50}],
"constraints": [],
"operating_policy": {},
}
_GOALS_EMPTY: dict[str, Any] = {
"north_star": "",
"objectives": [],
"constraints": [],
"operating_policy": {},
}
def _engine(
monkeypatch: pytest.MonkeyPatch,
*,
in_flight: list[Any],
blocked: list[Any],
goals: dict[str, Any],
) -> StrategyEngine:
task_svc = MagicMock()
task_svc.list_in_progress_or_claimed = AsyncMock(return_value=in_flight)
task_svc.list_long_running_blocked = AsyncMock(return_value=blocked)
monkeypatch.setattr(se_module, "get_task_service", lambda _s: task_svc)
goals_svc = MagicMock()
goals_svc.get = AsyncMock(return_value=goals)
monkeypatch.setattr(se_module, "get_company_goals_service", lambda _s: goals_svc)
return StrategyEngine(MagicMock())
@pytest.mark.asyncio
async def test_idle_with_goals_observed(monkeypatch: pytest.MonkeyPatch) -> None:
eng = _engine(monkeypatch, in_flight=[], blocked=[], goals=_GOALS_WITH_DIRECTION)
kinds = {o.kind for o in await eng.assess()}
assert "idle" in kinds
@pytest.mark.asyncio
async def test_no_idle_when_work_in_flight(monkeypatch: pytest.MonkeyPatch) -> None:
eng = _engine(
monkeypatch, in_flight=[MagicMock()], blocked=[], goals=_GOALS_WITH_DIRECTION
)
assert all(o.kind != "idle" for o in await eng.assess())
@pytest.mark.asyncio
async def test_no_observations_when_idle_without_goals(
monkeypatch: pytest.MonkeyPatch,
) -> None:
eng = _engine(monkeypatch, in_flight=[], blocked=[], goals=_GOALS_EMPTY)
assert await eng.assess() == []
@pytest.mark.asyncio
async def test_stranded_blocked_observed(monkeypatch: pytest.MonkeyPatch) -> None:
eng = _engine(
monkeypatch,
in_flight=[MagicMock()],
blocked=[MagicMock(), MagicMock()],
goals=_GOALS_EMPTY,
)
assert any(o.kind == "stranded_blocked" for o in await eng.assess())
@pytest.mark.asyncio
async def test_run_cycle_disabled_is_noop(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(se_module.settings, "strategy_engine_enabled", False)
eng = _engine(monkeypatch, in_flight=[], blocked=[], goals=_GOALS_WITH_DIRECTION)
assert await eng.run_cycle() == []
@pytest.mark.asyncio
async def test_run_cycle_enabled_notifies_ceo(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(se_module.settings, "strategy_engine_enabled", True)
eng = _engine(monkeypatch, in_flight=[], blocked=[], goals=_GOALS_WITH_DIRECTION)
notifier = MagicMock()
notifier.send_ack_notification = AsyncMock()
monkeypatch.setattr(se_module, "NotificationService", lambda: notifier)
observations = await eng.run_cycle()
assert observations
notifier.send_ack_notification.assert_awaited()
_, kwargs = notifier.send_ack_notification.call_args
assert kwargs["to_agent"] == "ceo"
@pytest.mark.asyncio
async def test_run_cycle_enabled_no_observations_no_notify(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(se_module.settings, "strategy_engine_enabled", True)
eng = _engine(monkeypatch, in_flight=[MagicMock()], blocked=[], goals=_GOALS_EMPTY)
notifier = MagicMock()
notifier.send_ack_notification = AsyncMock()
monkeypatch.setattr(se_module, "NotificationService", lambda: notifier)
assert await eng.run_cycle() == []
notifier.send_ack_notification.assert_not_awaited()