mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
+42
-1
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
# ==========================================================================
|
||||
|
||||
@@ -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
|
||||
# ==========================================================================
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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'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's charter — north star, objectives, constraints,
|
||||
and operating policy that steer every agent's work.
|
||||
</p>
|
||||
</div>
|
||||
<CompanyGoalsCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]]
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
# ==========================================================================
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -911,6 +911,7 @@ _INTENT_VERBS: dict[str, IntentSpec] = {
|
||||
Role.HEAD_MARKETING,
|
||||
Role.AUDITOR,
|
||||
Role.PROMPTER,
|
||||
Role.SECRETARY,
|
||||
}
|
||||
),
|
||||
description=(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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."
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 pitch→provision 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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user