mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Retire channels/sessions/messages; A2A becomes primary agent comms (#306)
* feat(a2a): deliver latest incoming message preview into the claim briefing
list_unread_a2a now carries last_message_preview (the latest message from the
OTHER agent, never the agent's own reply), fetched via a correlated subquery in
the same query — no N+1 on the per-verb briefing path.
* feat(a2a): read_a2a verb delivers unread message bodies to the agent
A2AService.get_unread_messages returns the caller's unread INCOMING messages
(never its own sends), marking exactly those rows read atomically so a message
arriving mid-call is preserved. Wired as the read_a2a content verb (route +
do_server tool + granted to every delivery role) — the content-bearing read the
A2A inbox lacked (read_messages only zeroed the counter).
* docs(rag): document read_a2a as the A2A content-read path
* fix(task): backlog activation no longer requires a discussion session
Removes the SessionTaskTable gate in activate() (and its dangling log field),
deletes _inherit_parent_session + its create() call, and drops the now-unused
SessionTaskTable import. Coordination rides task state; the session subsystem is
being retired. Tests updated to the new (no-session) behavior.
* fix(orchestrator): drop session sweep from _run_sweep
Removes the messaging import + sweep_timed_out_sessions call. That import sat
outside the try/except, so once messaging.py is deleted it would have killed the
entire sweep cascade (budget kill-switch, token rollups, retention, image prune,
superseded-PR reconcile). Notification sweep + all maintenance sweeps unchanged.
* release-manager --no-tags read-clone fix
* test: update evidence_repo unit test for a2a last_message_preview
* refactor(gateway): drop session propagation on delegate
Removes propagate_sessions_to_subtask from delegate(), the ChoreographerDeps
messaging field + property, and the ChoreographerDeps messaging arg in deps.py
(ContentActions messaging + import stay until the verbs are removed). Deletes the
propagation test; strips the now-invalid messaging kwarg from ChoreographerDeps
test builders.
* refactor(gateway): remove say/open_session/link_session/channels verbs
Removes the four channel/session verbs across content_actions (impls +
ContentActionsDeps.messaging), do_server (tools + registry), role_config (grants
+ _CHANNEL_DISCOVERY), do.py (routes), schemas/v1/do.py (request models), and
deps.py (MessagingService import + construction). Regenerates the prompt verb
tables. dm/notify/read_messages/read_a2a stay. Tests deleted/updated accordingly.
* uv.lock Upgrade
* refactor: remove conversation RAG indexing; Secretary announces via notification
Drops the CONVERSATIONS index (index_conversation, ConversationsIndexPlugin,
IndexType.CONVERSATIONS enum, IndexConversationParams, mentor.py type-label, the
messaging index hook) and its chunk-table manifest entries. The Secretary's
ANNOUNCE/RELAY_MESSAGE now fan out a BROADCAST notification to every agent's
inbox (NotificationService.broadcast) instead of posting to a dead channel.
* fix(panel): label RAG health error lines by subsystem
A red llm_error (e.g. the glm-5.2:cloud weekly-limit 429) rendered under
the 'Embedding: ok' header with no label, reading as an embedding failure.
Prefix each error line with LLM / Embedding / Vector store.
* refactor: remove channel/message reads from metrics, dashboard, git, events
MetricsService drops get_communication_volume + the MessageTable
message-count in get_agent_metrics (and the now-dead messages_sent_week
field). DashboardService drops get_channel_feeds/_compute_channel_status
and the message read in get_recent_activity (task activity kept);
get_auditor_metrics no longer reports communication_volume.
GitService's two primary-session-id helpers always return None now
(callers already treat None as "no primary session"). events/handlers.py
drops the SESSION_CLOSED/SESSION_TIMEOUT subscriptions + the
handle_session_boundary handler.
Forced follow-on: api/routes/dashboard.py + api/schemas/dashboard.py
dropped the now-dangling live_feeds/ChannelFeed surface and the
/metrics/communication route, which wrapped the removed service calls
directly (mypy would otherwise fail on the missing attributes).
* refactor: delete MessagingService + channel seeding
Edited db/__init__.py and services/__init__.py first (drop the unconditional
Channel/Group/Message/Session table + MessagingService re-exports), then
deleted services/messaging.py, then trimmed db/seed.py to only create_agents
(create_channels/create_channel_memberships/create_initial_messages gone).
Forced expansion: api/routes/{channels,groups,sessions,messages}.py import
roboco.services.messaging directly (not through the package __init__), as
does api/routes/tasks.py (the session-links embed on GET /tasks/{id} and the
GET /{id}/sessions route). Deleting messaging.py without addressing these
breaks `import roboco.api.app` immediately, since app.py eagerly imports all
route modules at startup. Since the 4 CRUD route files are 100%
MessagingService-backed with zero independent logic (and are wholesale
deletes in the plan's later API-routes task anyway), deleted them now +
unmounted from app.py/routes/__init__.py; tasks.py got the same surgical
trim its later task already specified (drop session-links embed +
TaskSessionLinkResponse/TaskResponse.sessions). This pulls a slice of that
later work forward — the routes/schemas for channels/groups/sessions/messages
still need their own pass, but their messaging-coupled parts are gone.
Verified with a full-suite collection sweep (12010 tests collected, zero
import errors) beyond the directly touched test dirs, given the expanded
blast radius.
* refactor: remove channel/session/message models, tables, and channel policy
Models: deleted channel.py/group.py/session.py/messaging.py wholesale
(zero external consumers besides the models/__init__.py re-export).
message.py surgically trimmed: removed MessageCreate (dead) and MessageEdit
(never instantiated; ExtractedMessage.edit_history retyped to
list[dict[str, Any]] to match how it's actually persisted — confirmed
ExtractedMessage was never written to any DB table, so MessageTable's
removal carries no functional risk to the kept extraction pipeline).
base.py: removed SessionStatus + ChannelType, kept MessageType. Also
removed the confirmed-dead channels_read/channels_write fields from
models/agent.py:AgentPermissions and models/dashboard.py:ChannelFeedData.
db/tables.py: deleted ChannelTable/GroupTable/SessionTable/SessionTaskTable/
MessageTable, TaskTable.session_links, and JournalEntryTable.session_id —
cascaded through models/journal.py, services/journal.py, and
api/schemas+routes/journals.py (22 plumbing sites).
foundation/policy/communications.py: removed the ChannelSpec/CHANNELS
catalog + TEAM_SCOPED_ROLES/_CELL_*/_AUDITOR_ONLY helpers, kept the
notification policy (Priority/parse_priority/NOTIFY_SENDER_ROLES/
ACK_REQUIRED_BY_TYPE). enforcement/channel_access.py deleted (confirmed
fully dead in production). agents_config.py: removed CHANNEL_ACCESS
(kept A2A_ALLOWED_PAIRS). seeds/initial_data.py: removed
DEFAULT_CHANNELS/CHANNEL_MEMBERSHIPS/AUDITOR_SILENT_ACCESS + the
never-consumed INITIAL_MESSAGES. config.py: removed
session_idle_timeout_seconds (zero consumers). exceptions.py: removed
dead ChannelError/ChannelAccessDeniedError/SessionClosedError.
Forced expansion beyond the original file list — ChannelType cascaded
into a live, mounted surface the plan didn't trace: agents_config.
CHANNEL_ACCESS -> services/permissions.py's channel-RBAC methods (not
models/permissions.py, which turned out to have no channel code at all)
-> two real endpoints in api/routes/stream.py (GET /permissions,
GET /permissions/channel/{name}) and two dependency factories in
api/deps.py. Removed the channel methods + fields, deleted the
channel-specific stream.py endpoint, deleted require_channel_read/write.
Also deleted api/schemas/{channels,sessions}.py (hard dependency on the
removed enums; already fully dead after the Task 10 route deletions) and
api/schemas/messages.py (a TYPE_CHECKING-only import of the deleted
MessageTable; likewise already fully dead) + its dedicated test file.
Test updates: test_permissions.py -14 channel tests (matches the planned
count exactly), test_communications.py / test_communications_consumers.py
split to keep only notification-policy coverage, test_exceptions.py -9,
test_deps.py -4, plus the journal/stream/foundation-smoke fallout. Also
fixed a pre-existing (Task 7) broken assertion in
test_foundation_phase3_smoke.py that inspected a `say()` method already
removed from ContentActions.
Verified: full-suite collection (11961 tests, zero import errors) and a
complete test run (11567 passed, 394 skipped, 0 failed) in addition to
the targeted suites.
* migration: drop channels/groups/sessions/session_tasks/messages + enum types
alembic/versions/060_drop_messaging.py: drop_column journal_entries.
session_id (sidesteps hardcoding the FK constraint name — verified
empirically against a live migrated DB that it's actually
fk_journal_entries_session_id_sessions, but drop_column doesn't care
either way); drop_table in FK order (messages -> session_tasks ->
sessions -> groups -> channels); DROP TABLE IF EXISTS chunks_conversations
(runtime-provisioned, not alembic-managed, would otherwise orphan); DROP
TYPE IF EXISTS for messagetype/sessionstatus/sessionscope/channeltype
(messagetype's Python enum stays for ExtractedMessage, but the DB type
had zero live columns left once MessageTable was dropped in the prior
commit). downgrade() raises NotImplementedError — one-way removal.
Pruned scripts/reset_runtime_state.sql + .sh: removed the DELETE/COUNT
lines for messages/session_tasks/sessions/groups/channels and the
groups.active_session_id reset block.
Verified end-to-end against a scratch Postgres DB: full migration chain
001->060 applies cleanly, alembic heads shows a single head, all 6 dropped
tables + 4 enum types + the journal_entries.session_id column are
confirmed gone, journal_entries keeps only its journal_id/task_id FKs,
downgrade correctly raises NotImplementedError without corrupting DB
state, and the pruned reset_runtime_state.sql runs clean (no errors)
against a fully-migrated DB.
* refactor(api): remove channel/session/message routes + WS streams
Most of this task's file list was already forced through in earlier
commits (routes/{channels,groups,sessions,messages}.py + app.py/__init__.py
unmounting in the MessagingService-deletion commit; tasks.py's
session-links embed + GET /{id}/sessions + schemas/tasks.py's
TaskResponse.sessions in that same commit; deps.py's require_channel_read/
write + schemas/{channels,sessions}.py in the models/tables commit). This
closes out what was left:
- api/websocket.py: deleted the channel_stream + session_stream routes,
ConnectionManager's channel_connections/session_connections dicts,
connect_channel/connect_session, broadcast_to_channel/broadcast_to_session,
get_channel_subscriber_count, and their cleanup lines in disconnect().
Agent streams, notification streams, and the operator system stream are
untouched.
- api/websocket_bridge.py: deleted _handle_session_event +
_handle_message_event and their SESSION_CREATED/SESSION_CLOSED/
SESSION_TIMEOUT/MESSAGE_SENT subscriptions. The A2A live-view, rate-limit,
usage, agent-lifecycle, and notification bridges are untouched.
- api/schemas/websocket.py: removed NewMessageBroadcast, WSMessageNew,
WSMessageEdit, WSMessageDelete, WSSessionClosed — kept the WSMessage base
class (still subclassed by the kept WSAgentStream/WSNotification) plus
those two.
- api/schemas/groups.py: deleted (already fully orphaned since routes/
groups.py was removed; its GroupResponse/GroupDetailResponse had zero
consumers).
Updated the 5 websocket test files accordingly (removed the channel/
session-specific tests + fixed imports); test_websocket_bridge.py's
registration-coverage test dropped the SESSION_*/MESSAGE_SENT assertions.
Verified: full-suite collection (11943 tests, zero import errors) and a
complete test run (11549 passed, 394 skipped, 0 failed).
* docs: retire channels/sessions/messages from agent-facing docs + CLAUDE.md
Rewrites docs/rag (RAG-indexed) + docs/map + CLAUDE.md to reflect A2A (dm +
read_a2a) as primary agent comms; deletes the channel docs, splits messaging-tools
+ messaging-notification (renamed notification.md), swaps the WS worked example to
A2A_MESSAGE_SENT. _complete_map.md still needs regeneration (generated file).
* refactor(panel): remove Communications surface (channels/sessions)
Deletes the /communications routes, message components, task-detail Sessions tab,
use-channels + channel/session WS hooks, and the channels/sessions/messages/groups
api clients; prunes the Channel/Session/Message/Group types + mock data. (Auditor
live-feeds + dashboard.ts dead-route cleanup is a follow-up.)
* refactor(panel): drop auditor channel-feed + dead communication-metric route
* docs(map): regenerate _complete_map from updated slices
* fix(a2a): reduce get_unread_messages complexity below xenon C + stale comments
Extract the per-conversation unread-counter recompute into _reset_unread_counter
(the CI quality gate flagged get_unread_messages as rank C). Also drop the deleted
open_session from a content_actions comment and reword an evidence_repo docstring
that cited the removed messaging._notify_mentions.
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -281,9 +281,6 @@ Major tasks are escalated to CEO for final approval:
|
||||
| `Project` | Git repository configuration and CI/CD commands |
|
||||
| `WorkSession` | Links agent work to task, tracks branch/commits/PR |
|
||||
| `Agent` | AI agent with role, team, capabilities |
|
||||
| `Session` | Communication session with messages |
|
||||
| `Channel` | Team communication channel |
|
||||
| `Message` | Extracted message from agent streams |
|
||||
| `Notification` | Formal notification requiring acknowledgment |
|
||||
| `Journal` | Agent personal log for reflections/learnings |
|
||||
|
||||
@@ -308,15 +305,7 @@ commits: list[CommitRef] # All commits made for this task
|
||||
|
||||
## Communication Model
|
||||
|
||||
**Communication** = constant stream (always flowing, logged, observed) **Notifications** = formal signals (require acknowledgment, sent by PMs/Board only)
|
||||
|
||||
### Channel Structure
|
||||
- Cell channels: `#backend-cell`, `#frontend-cell`, `#uxui-cell`
|
||||
- Cross-cell: `#dev-all`, `#qa-all`, `#pm-all`, `#doc-all`
|
||||
- Management: `#main-pm-board`, `#board-private`
|
||||
- Special: `#announcements` (read-only except Board/Main PM), `#all-hands`
|
||||
|
||||
The Auditor has silent read access to ALL channels.
|
||||
Agents coordinate via **task state + task detail fields**, not a channel/session backbone. Two comms primitives sit alongside that: **A2A** (`dm` + `read_a2a`, direct peer-to-peer, same-cell only — see `docs/rag/tools/a2a-tools.md`) for informal contact, and **Notifications** (`notify`, ack-required, sent by PMs/Board only) for formal signals.
|
||||
|
||||
Agent learnings (`note` scope='learning') broadcast as knowledge-share notifications only to other **agents** — the human / human-driven roles (CEO, prompter, secretary) are excluded, since agent knowledge-sharing is noise in a human's inbox.
|
||||
|
||||
@@ -350,18 +339,18 @@ Each agent gets a **spawn manifest** at `/app/tool-manifest.json` listing the ve
|
||||
| pr_reviewer | `give_me_work`, `claim_pr_review`, `post_pr_review` (inbound external/fork PRs), `claim_gate_review`, `pr_pass`, `pr_fail` (in-path assembled-PR gate), `unclaim` |
|
||||
| product_owner | `triage`, `escalate_to_ceo` |
|
||||
| head_marketing| `triage`, `escalate_to_ceo` |
|
||||
| auditor | `triage` (read-only — no `say`/`dm`) |
|
||||
| auditor | `triage` (read-only — no `dm`) |
|
||||
| prompter | (none beyond `i_am_idle` — not a delivery-lifecycle role; intake interviewer, human-only) |
|
||||
| secretary | (none beyond `i_am_idle` — human-only chief-of-staff; reads company state + runs gated CEO directives) |
|
||||
|
||||
Content tools (do_server) — most roles: `commit`, `note`, `say`, `dm`, `evidence`. Delivery roles (developer / qa / documenter / cell_pm / main_pm) also get `draft_playbook` (draft a curated playbook for the KB). Auditor is restricted to `note` (scope=reflect) + `evidence`, plus the playbook-curation verbs `approve_playbook` / `reject_playbook` / `archive_playbook` (a bounded, deliberate expansion — KB curation, not agent comms, so its no-`say`/no-`dm` restriction holds). The `pr_reviewer` posts its change-request on the PR itself (no agent comms). The `prompter` (intake) and `secretary` are restricted to `note` + `evidence` — human-only, no `say`/`dm`/`notify`. The `note`/journal write returns as soon as the entry is persisted; RAG indexing (Ollama embedding) runs fire-and-forget, so the tool no longer times out under concurrent load.
|
||||
Content tools (do_server) — most roles: `commit`, `note`, `dm`, `read_a2a`, `evidence`. Delivery roles (developer / qa / documenter / cell_pm / main_pm) also get `draft_playbook` (draft a curated playbook for the KB). Auditor is restricted to `note` (scope=reflect) + `evidence`, plus the playbook-curation verbs `approve_playbook` / `reject_playbook` / `archive_playbook` (a bounded, deliberate expansion — KB curation, not agent comms, so its no-`dm` restriction holds). The `pr_reviewer` posts its change-request on the PR itself (no agent comms). The `prompter` (intake) and `secretary` are restricted to `note` + `evidence` — human-only, no `dm`/`notify`. The `note`/journal write returns as soon as the entry is persisted; RAG indexing (Ollama embedding) runs fire-and-forget, so the tool no longer times out under concurrent load.
|
||||
|
||||
### MCP servers running per agent container
|
||||
|
||||
| Server | Purpose |
|
||||
|----------------------|----------------------------------------------------------------------|
|
||||
| `roboco-flow` | Intent verbs (give_me_work, i_am_done, claim_review, complete, ...) |
|
||||
| `roboco-do` | Content tools (commit, note, say, dm, evidence) |
|
||||
| `roboco-do` | Content tools (commit, note, dm, read_a2a, evidence) |
|
||||
| `roboco-git-readonly`| Read-only git: status, log, diff, branches |
|
||||
| `roboco-optimal` | RAG: `roboco_ask_mentor`, `roboco_kb_search` |
|
||||
| `roboco-docs` | Project docs file management (selected roles) |
|
||||
@@ -434,7 +423,6 @@ Core services in `roboco/services/`:
|
||||
| `WorkSessionService` | Git session management, PR lifecycle |
|
||||
| `WorkspaceService` | Multi-agent workspace resolution and cloning |
|
||||
| `ProjectService` | Project/repository management |
|
||||
| `MessagingService` | Channels, sessions, messages |
|
||||
| `NotificationService` | Formal notifications |
|
||||
| `JournalService` | Agent journals and entries |
|
||||
| `OptimalService` | RAG queries (in-house pgvector engine) |
|
||||
@@ -514,10 +502,10 @@ The orchestrator exposes WebSocket endpoints under `/ws` (router in `roboco/api/
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|----------|---------|
|
||||
| `/ws/channels/{id}`, `/ws/agents/{id}`, `/ws/sessions/{id}`, `/ws/notifications/{id}` | Per-resource live streams — `/ws/channels` + `/ws/sessions` carry live `message.new` frames (from `EventType.MESSAGE_SENT`), so a session transcript updates without a manual refresh |
|
||||
| `/ws/system` | Operator/system-wide stream (no per-agent keying) — the rate-limit lifecycle (`RATE_LIMIT_HIT` / `RATE_LIMIT_LIFTED`) and live usage (`USAGE_SNAPSHOT`, pushed to the usage dashboard) |
|
||||
| `/ws/agents/{id}`, `/ws/notifications/{id}` | Per-resource live streams |
|
||||
| `/ws/system` | Operator/system-wide stream (no per-agent keying) — the rate-limit lifecycle (`RATE_LIMIT_HIT` / `RATE_LIMIT_LIFTED`), live usage (`USAGE_SNAPSHOT`, pushed to the usage dashboard), and A2A message events (`a2a.message` frames) |
|
||||
|
||||
Server-side events reach these sockets through `roboco/api/websocket_bridge.py`, which subscribes to the `StreamEventBus` and forwards each event to the matching connections. To add a new live event: define an `EventType` (dotted value), publish it to the bus, add a `_handle_*` forwarder in `websocket_bridge`, and consume it on the panel via the `useWebSocket("/<endpoint>", …)` hook — do not stand up a parallel endpoint or client stack. `MESSAGE_SENT` is the worked example: `send_message` publishes it, `_handle_message_event` fans it out to `/ws/sessions/{id}` + `/ws/channels/{id}` as a `message.new` frame, and the panel's `useSessionStream` consumes it.
|
||||
Server-side events reach these sockets through `roboco/api/websocket_bridge.py`, which subscribes to the `StreamEventBus` and forwards each event to the matching connections. To add a new live event: define an `EventType` (dotted value), publish it to the bus, add a `_handle_*` forwarder in `websocket_bridge`, and consume it on the panel via the `useWebSocket("/<endpoint>", …)` hook — do not stand up a parallel endpoint or client stack. `A2A_MESSAGE_SENT` is the worked example: `A2AService.send` publishes it (excerpt-capped payload), the bridge forwards it to `/ws/system` as an `a2a.message` frame, and the panel's `useA2ALiveStream` hook (a second consumer of that same shared `/ws/system` connection) consumes it to invalidate-on-frame.
|
||||
|
||||
### Rate limiting & usage
|
||||
|
||||
|
||||
@@ -21,4 +21,3 @@
|
||||
| `archive_playbook` | `archive_playbook(playbook_id: UUID)` |
|
||||
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
| `channels` | `channels()` |
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
| `i_am_idle` | `i_am_idle()` |
|
||||
| `i_will_plan` | `i_will_plan(task_id: UUID, plan: str, approach: str, sub_tasks: list[str | str] = PydanticUndefined, technical_considerations: list[str] = PydanticUndefined, risks: list[str | str] = PydanticUndefined, open_questions: list[str | str | bool] = PydanticUndefined)` |
|
||||
| `reassign` | `reassign(task_id: UUID, new_assignee: str)` |
|
||||
| `request_changes` | `request_changes(task_id: UUID, issues: list[str])` |
|
||||
| `resume` | `resume(task_id: UUID)` |
|
||||
| `submit_up` | `submit_up(task_id: UUID, notes: str)` |
|
||||
| `triage` | `triage()` |
|
||||
@@ -25,16 +26,13 @@
|
||||
| Tool | Body schema |
|
||||
|------|-------------|
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
| `open_session` | `open_session(task_id: UUID, channel: str, topic: str, relationship_type: str = 'discussion', group_id: UUID | None = None)` |
|
||||
| `link_session` | `link_session(session_id: UUID, task_id: UUID, is_primary: bool = False, relationship_type: str = 'discussion')` |
|
||||
| `pr_update` | `pr_update(see do_server)` |
|
||||
| `draft_playbook` | `draft_playbook(title: str, problem: str, procedure: str, tags: list[str] = PydanticUndefined, source_task_id: UUID | None = None)` |
|
||||
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
| `notify_ack` | `notify_ack(notification_id: UUID)` |
|
||||
| `read_messages` | `read_messages()` |
|
||||
| `channels` | `channels()` |
|
||||
| `read_a2a` | `read_a2a(see do_server)` |
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
|------|-------------|
|
||||
| `commit` | `commit(message: str, files: list[str] | None = None)` |
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
| `progress` | `progress(task_id: UUID, message: str, plan_step: str | None = None, percentage: int | None = None)` |
|
||||
@@ -33,4 +32,4 @@
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
| `notify_ack` | `notify_ack(notification_id: UUID)` |
|
||||
| `read_messages` | `read_messages()` |
|
||||
| `channels` | `channels()` |
|
||||
| `read_a2a` | `read_a2a(see do_server)` |
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
|------|-------------|
|
||||
| `commit` | `commit(message: str, files: list[str] | None = None)` |
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
| `progress` | `progress(task_id: UUID, message: str, plan_step: str | None = None, percentage: int | None = None)` |
|
||||
@@ -31,4 +30,4 @@
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
| `notify_ack` | `notify_ack(notification_id: UUID)` |
|
||||
| `read_messages` | `read_messages()` |
|
||||
| `channels` | `channels()` |
|
||||
| `read_a2a` | `read_a2a(see do_server)` |
|
||||
|
||||
@@ -17,13 +17,11 @@
|
||||
|------|-------------|
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `pitch` | `pitch(title: str, slug: str, problem: str, proposed_solution: str, target_cells: list[str])` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
| `open_session` | `open_session(task_id: UUID, channel: str, topic: str, relationship_type: str = 'discussion', group_id: UUID | None = None)` |
|
||||
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
| `notify_ack` | `notify_ack(notification_id: UUID)` |
|
||||
| `read_messages` | `read_messages()` |
|
||||
| `channels` | `channels()` |
|
||||
| `read_a2a` | `read_a2a(see do_server)` |
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
| `give_me_work` | `give_me_work()` |
|
||||
| `i_am_idle` | `i_am_idle()` |
|
||||
| `i_will_plan` | `i_will_plan(task_id: UUID, plan: str, approach: str, sub_tasks: list[str | str] = PydanticUndefined, technical_considerations: list[str] = PydanticUndefined, risks: list[str | str] = PydanticUndefined, open_questions: list[str | str | bool] = PydanticUndefined)` |
|
||||
| `request_changes` | `request_changes(task_id: UUID, issues: list[str])` |
|
||||
| `resume` | `resume(task_id: UUID)` |
|
||||
| `submit_root` | `submit_root(task_id: UUID, notes: str)` |
|
||||
| `triage` | `triage()` |
|
||||
@@ -26,16 +27,13 @@
|
||||
| Tool | Body schema |
|
||||
|------|-------------|
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
| `open_session` | `open_session(task_id: UUID, channel: str, topic: str, relationship_type: str = 'discussion', group_id: UUID | None = None)` |
|
||||
| `link_session` | `link_session(session_id: UUID, task_id: UUID, is_primary: bool = False, relationship_type: str = 'discussion')` |
|
||||
| `pr_update` | `pr_update(see do_server)` |
|
||||
| `draft_playbook` | `draft_playbook(title: str, problem: str, procedure: str, tags: list[str] = PydanticUndefined, source_task_id: UUID | None = None)` |
|
||||
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
| `notify_ack` | `notify_ack(notification_id: UUID)` |
|
||||
| `read_messages` | `read_messages()` |
|
||||
| `channels` | `channels()` |
|
||||
| `read_a2a` | `read_a2a(see do_server)` |
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
| `post_pr_review` | `post_pr_review(task_id: UUID, body: str, event: str = 'REQUEST_CHANGES', findings: list[str | Any] = PydanticUndefined)` |
|
||||
| `pr_fail` | `pr_fail(task_id: UUID, issues: list[str])` |
|
||||
| `pr_pass` | `pr_pass(task_id: UUID, notes: str)` |
|
||||
| `unclaim` | `unclaim(task_id: UUID)` |
|
||||
|
||||
### Content (do) tools
|
||||
|
||||
@@ -23,4 +24,3 @@
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
| `channels` | `channels()` |
|
||||
|
||||
@@ -17,13 +17,12 @@
|
||||
|------|-------------|
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `pitch` | `pitch(title: str, slug: str, problem: str, proposed_solution: str, target_cells: list[str])` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
| `open_session` | `open_session(task_id: UUID, channel: str, topic: str, relationship_type: str = 'discussion', group_id: UUID | None = None)` |
|
||||
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
| `notify_ack` | `notify_ack(notification_id: UUID)` |
|
||||
| `read_messages` | `read_messages()` |
|
||||
| `channels` | `channels()` |
|
||||
| `read_a2a` | `read_a2a(see do_server)` |
|
||||
| `propose_roadmap` | `propose_roadmap(cycle_goal: str, items: list[RoadmapItemInput])` |
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
| Tool | Body schema |
|
||||
|------|-------------|
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
| `draft_playbook` | `draft_playbook(title: str, problem: str, procedure: str, tags: list[str] = PydanticUndefined, source_task_id: UUID | None = None)` |
|
||||
@@ -29,4 +28,4 @@
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
| `notify_ack` | `notify_ack(notification_id: UUID)` |
|
||||
| `read_messages` | `read_messages()` |
|
||||
| `channels` | `channels()` |
|
||||
| `read_a2a` | `read_a2a(see do_server)` |
|
||||
|
||||
@@ -32,7 +32,6 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
|------|-------------|
|
||||
| `commit` | `commit(message: str, files: list[str] | None = None)` |
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
| `progress` | `progress(task_id: UUID, message: str, plan_step: str | None = None, percentage: int | None = None)` |
|
||||
@@ -42,7 +41,7 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
| `notify_ack` | `notify_ack(notification_id: UUID)` |
|
||||
| `read_messages` | `read_messages()` |
|
||||
| `channels` | `channels()` |
|
||||
| `read_a2a` | `read_a2a(see do_server)` |
|
||||
|
||||
## qa
|
||||
|
||||
@@ -64,7 +63,6 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
| Tool | Body schema |
|
||||
|------|-------------|
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
| `draft_playbook` | `draft_playbook(title: str, problem: str, procedure: str, tags: list[str] = PydanticUndefined, source_task_id: UUID | None = None)` |
|
||||
@@ -72,7 +70,7 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
| `notify_ack` | `notify_ack(notification_id: UUID)` |
|
||||
| `read_messages` | `read_messages()` |
|
||||
| `channels` | `channels()` |
|
||||
| `read_a2a` | `read_a2a(see do_server)` |
|
||||
|
||||
## documenter
|
||||
|
||||
@@ -94,7 +92,6 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
|------|-------------|
|
||||
| `commit` | `commit(message: str, files: list[str] | None = None)` |
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
| `progress` | `progress(task_id: UUID, message: str, plan_step: str | None = None, percentage: int | None = None)` |
|
||||
@@ -104,7 +101,7 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
| `notify_ack` | `notify_ack(notification_id: UUID)` |
|
||||
| `read_messages` | `read_messages()` |
|
||||
| `channels` | `channels()` |
|
||||
| `read_a2a` | `read_a2a(see do_server)` |
|
||||
|
||||
## cell_pm
|
||||
|
||||
@@ -119,6 +116,7 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
| `i_am_idle` | `i_am_idle()` |
|
||||
| `i_will_plan` | `i_will_plan(task_id: UUID, plan: str, approach: str, sub_tasks: list[str | str] = PydanticUndefined, technical_considerations: list[str] = PydanticUndefined, risks: list[str | str] = PydanticUndefined, open_questions: list[str | str | bool] = PydanticUndefined)` |
|
||||
| `reassign` | `reassign(task_id: UUID, new_assignee: str)` |
|
||||
| `request_changes` | `request_changes(task_id: UUID, issues: list[str])` |
|
||||
| `resume` | `resume(task_id: UUID)` |
|
||||
| `submit_up` | `submit_up(task_id: UUID, notes: str)` |
|
||||
| `triage` | `triage()` |
|
||||
@@ -130,19 +128,16 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
| Tool | Body schema |
|
||||
|------|-------------|
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
| `open_session` | `open_session(task_id: UUID, channel: str, topic: str, relationship_type: str = 'discussion', group_id: UUID | None = None)` |
|
||||
| `link_session` | `link_session(session_id: UUID, task_id: UUID, is_primary: bool = False, relationship_type: str = 'discussion')` |
|
||||
| `pr_update` | `pr_update(see do_server)` |
|
||||
| `draft_playbook` | `draft_playbook(title: str, problem: str, procedure: str, tags: list[str] = PydanticUndefined, source_task_id: UUID | None = None)` |
|
||||
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
| `notify_ack` | `notify_ack(notification_id: UUID)` |
|
||||
| `read_messages` | `read_messages()` |
|
||||
| `channels` | `channels()` |
|
||||
| `read_a2a` | `read_a2a(see do_server)` |
|
||||
|
||||
## main_pm
|
||||
|
||||
@@ -157,6 +152,7 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
| `give_me_work` | `give_me_work()` |
|
||||
| `i_am_idle` | `i_am_idle()` |
|
||||
| `i_will_plan` | `i_will_plan(task_id: UUID, plan: str, approach: str, sub_tasks: list[str | str] = PydanticUndefined, technical_considerations: list[str] = PydanticUndefined, risks: list[str | str] = PydanticUndefined, open_questions: list[str | str | bool] = PydanticUndefined)` |
|
||||
| `request_changes` | `request_changes(task_id: UUID, issues: list[str])` |
|
||||
| `resume` | `resume(task_id: UUID)` |
|
||||
| `submit_root` | `submit_root(task_id: UUID, notes: str)` |
|
||||
| `triage` | `triage()` |
|
||||
@@ -169,19 +165,16 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
| Tool | Body schema |
|
||||
|------|-------------|
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
| `open_session` | `open_session(task_id: UUID, channel: str, topic: str, relationship_type: str = 'discussion', group_id: UUID | None = None)` |
|
||||
| `link_session` | `link_session(session_id: UUID, task_id: UUID, is_primary: bool = False, relationship_type: str = 'discussion')` |
|
||||
| `pr_update` | `pr_update(see do_server)` |
|
||||
| `draft_playbook` | `draft_playbook(title: str, problem: str, procedure: str, tags: list[str] = PydanticUndefined, source_task_id: UUID | None = None)` |
|
||||
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
| `notify_ack` | `notify_ack(notification_id: UUID)` |
|
||||
| `read_messages` | `read_messages()` |
|
||||
| `channels` | `channels()` |
|
||||
| `read_a2a` | `read_a2a(see do_server)` |
|
||||
|
||||
## product_owner
|
||||
|
||||
@@ -199,16 +192,15 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
|------|-------------|
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `pitch` | `pitch(title: str, slug: str, problem: str, proposed_solution: str, target_cells: list[str])` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
| `open_session` | `open_session(task_id: UUID, channel: str, topic: str, relationship_type: str = 'discussion', group_id: UUID | None = None)` |
|
||||
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
| `notify_ack` | `notify_ack(notification_id: UUID)` |
|
||||
| `read_messages` | `read_messages()` |
|
||||
| `channels` | `channels()` |
|
||||
| `read_a2a` | `read_a2a(see do_server)` |
|
||||
| `propose_roadmap` | `propose_roadmap(cycle_goal: str, items: list[RoadmapItemInput])` |
|
||||
|
||||
## head_marketing
|
||||
|
||||
@@ -226,16 +218,14 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
|------|-------------|
|
||||
| `note` | `note(text: str, scope: str = 'note', task_id: UUID | None = None, title: str | None = None, context: str = '', options: list[str | str] | None = None, chosen: str = '', rationale: str = '', consequences: list[str] | None = None, what_done: str = '', what_learned: str = '', what_struggled: str = '', next_steps: list[str] | None = None, section: str | Any | None = None, done: str = '', next: str = '', where_to_look: list[str] | None = None)` |
|
||||
| `pitch` | `pitch(title: str, slug: str, problem: str, proposed_solution: str, target_cells: list[str])` |
|
||||
| `say` | `say(channel: str, text: str, task_id: UUID | None = None)` |
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
| `open_session` | `open_session(task_id: UUID, channel: str, topic: str, relationship_type: str = 'discussion', group_id: UUID | None = None)` |
|
||||
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
| `notify_ack` | `notify_ack(notification_id: UUID)` |
|
||||
| `read_messages` | `read_messages()` |
|
||||
| `channels` | `channels()` |
|
||||
| `read_a2a` | `read_a2a(see do_server)` |
|
||||
|
||||
## auditor
|
||||
|
||||
@@ -257,7 +247,6 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
| `archive_playbook` | `archive_playbook(playbook_id: UUID)` |
|
||||
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
| `channels` | `channels()` |
|
||||
|
||||
## pr_reviewer
|
||||
|
||||
@@ -272,6 +261,7 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
| `post_pr_review` | `post_pr_review(task_id: UUID, body: str, event: str = 'REQUEST_CHANGES', findings: list[str | Any] = PydanticUndefined)` |
|
||||
| `pr_fail` | `pr_fail(task_id: UUID, issues: list[str])` |
|
||||
| `pr_pass` | `pr_pass(task_id: UUID, notes: str)` |
|
||||
| `unclaim` | `unclaim(task_id: UUID)` |
|
||||
|
||||
### Content (do) tools
|
||||
|
||||
@@ -281,5 +271,4 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
| `channels` | `channels()` |
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ reshapes them and every ingest/search/list fails with
|
||||
``column "content" of relation "chunks_<type>" does not exist``.
|
||||
|
||||
These tables hold derived chunks AND non-rebuildable agent knowledge
|
||||
(journals, decisions, errors, learnings, reviews, conversations) that a docs
|
||||
(journals, decisions, errors, learnings, reviews) that a docs
|
||||
reindex cannot regenerate, so this migration ALTERs in place — renaming
|
||||
``text`` -> ``content`` and adding ``created_at`` — rather than dropping data.
|
||||
The legacy ``chunk_index`` / integer ``id`` columns are left untouched: the
|
||||
@@ -44,7 +44,6 @@ depends_on = None
|
||||
CHUNK_TABLES = (
|
||||
"chunks_code",
|
||||
"chunks_documentation",
|
||||
"chunks_conversations",
|
||||
"chunks_journals",
|
||||
"chunks_errors",
|
||||
"chunks_standards",
|
||||
|
||||
@@ -28,7 +28,6 @@ depends_on = None
|
||||
CHUNK_TABLES = (
|
||||
"chunks_code",
|
||||
"chunks_documentation",
|
||||
"chunks_conversations",
|
||||
"chunks_journals",
|
||||
"chunks_errors",
|
||||
"chunks_standards",
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Drop the channels/groups/sessions/session_tasks/messages subsystem.
|
||||
|
||||
Channels, groups, discussion-sessions, and messages are retired — A2A is
|
||||
now the single directed-message channel agents read, and coordination
|
||||
rests on the task state machine + task details (see
|
||||
docs/internal/specs/2026-07-03-comms-teardown-trace.md). All backend code
|
||||
that read/wrote these tables was removed first (roboco commits preceding
|
||||
this one); this migration is the last step, once nothing touches them.
|
||||
|
||||
Revision ID: 060_drop_messaging
|
||||
Revises: 059_x_credentials
|
||||
Create Date: 2026-07-03
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "060_drop_messaging"
|
||||
down_revision = "059_x_credentials"
|
||||
branch_labels: dict[str, str] | None = None
|
||||
depends_on: dict[str, str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Dropping the column drops its FK automatically, regardless of the
|
||||
# constraint's actual name — sidesteps having to hardcode it. (The
|
||||
# naming-convention name would be fk_journal_entries_session_id_sessions
|
||||
# per db/base.py's convention, and that IS what a live DB shows, but
|
||||
# 001_initial_schema.py created it via a raw op.create_table() against
|
||||
# a throwaway MetaData(), so the convention isn't guaranteed to apply —
|
||||
# op.drop_column is correct either way.)
|
||||
op.drop_column("journal_entries", "session_id")
|
||||
|
||||
# Drop order follows the FK chain: messages -> session_tasks -> sessions
|
||||
# -> groups -> channels.
|
||||
op.drop_table("messages")
|
||||
op.drop_table("session_tasks")
|
||||
op.drop_table("sessions")
|
||||
op.drop_table("groups")
|
||||
op.drop_table("channels")
|
||||
|
||||
# The CONVERSATIONS RAG chunk table is runtime-provisioned (CREATE TABLE
|
||||
# IF NOT EXISTS, migration 030's index plugin) — not alembic-managed, so
|
||||
# it survives the model deletion unless dropped explicitly here.
|
||||
op.execute("DROP TABLE IF EXISTS chunks_conversations")
|
||||
|
||||
# messagetype is dropped because MessageTable (the only column that used
|
||||
# it) is gone, but the MessageType Python enum stays — ExtractedMessage
|
||||
# (kept, extraction pipeline) still uses it, and was never itself
|
||||
# persisted to messages/MessageTable. The DB type and the Python enum
|
||||
# are independent; dropping the now-unused DB type is safe.
|
||||
for t in ("messagetype", "sessionstatus", "sessionscope", "channeltype"):
|
||||
op.execute(f"DROP TYPE IF EXISTS {t}")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# One-way removal — recreating the dropped tables/types/column would
|
||||
# need the full original schema (channels/groups/sessions/session_tasks/
|
||||
# messages + 4 enum types), and nothing depends on restoring it.
|
||||
raise NotImplementedError("060_drop_messaging is a one-way removal")
|
||||
+1125
-809
File diff suppressed because it is too large
Load Diff
+3
-7
@@ -34,7 +34,7 @@
|
||||
15. engines-heal-ciwatch-depupdate
|
||||
16. release-manager
|
||||
17. org-memory-playbooks
|
||||
18. messaging-notification
|
||||
18. notification
|
||||
19. a2a-audit-journal-permissions
|
||||
20. conventions-service-validator
|
||||
21. intake-secretary
|
||||
@@ -143,7 +143,6 @@ flowchart TD
|
||||
SV --> SV2["GitService"]
|
||||
SV --> SV3["WorkSessionService"]
|
||||
SV --> SV4["WorkspaceService"]
|
||||
SV --> SV5["Messaging"]
|
||||
SV --> SV6["Notification"]
|
||||
SV --> SV7["Optimal RAG"]
|
||||
SV --> SV8["Journal Audit Permissions"]
|
||||
@@ -157,7 +156,6 @@ flowchart TD
|
||||
OM --> OM2["briefings injection"]
|
||||
OM --> OM3["playbooks"]
|
||||
RT --> CO["Comms"]
|
||||
CO --> CO1["channels"]
|
||||
CO --> CO2["A2A"]
|
||||
CO --> CO3["notifications dedup"]
|
||||
RT --> PA["Panel"]
|
||||
@@ -191,8 +189,6 @@ erDiagram
|
||||
TASK ||--o{ TASK : "parent_task_id / subtasks"
|
||||
TASK ||--o{ TASK_CELL_PROJECT : "root-subtask map"
|
||||
TASK ||--o{ PLAYBOOK : "draft→approved→indexed (050)"
|
||||
CHANNEL ||--o{ MESSAGE : "channel_id"
|
||||
SESSION ||--o{ MESSAGE : "session_id"
|
||||
AGENT ||--o{ MESSAGE : "sender"
|
||||
AGENT ||--o{ NOTIFICATION : "to_agents / from_agent"
|
||||
AGENT ||--o{ JOURNAL : "owner"
|
||||
@@ -484,13 +480,13 @@ graph LR
|
||||
| workspace | Doc's "fresh claim `git reset --hard`" narrative diverges from the post-F123 worktree model (by design) — doc drift to reconcile. |
|
||||
| choreographer | Verb table omits `sync_branch` from the developer list (added since baseline). Otherwise matches. |
|
||||
| pr-gate-review | None material. |
|
||||
| gateway-support | Auditor surface doc under-states `notify_list`/`notify_get` + `channels` (additive, consistent with footnote). PM coordinator-skip lives in Choreographer not `claim_guards.py`. |
|
||||
| gateway-support | Auditor surface doc under-states `notify_list`/`notify_get` (additive, consistent with footnote). PM coordinator-skip lives in Choreographer not `claim_guards.py`. |
|
||||
| orchestrator | None material (well-instrumented). |
|
||||
| runtime-providers | `ClaudeCodeProvider` is dead reference code; its "default" label in CLAUDE.md is misleading. |
|
||||
| engines-heal-ciwatch-depupdate | Minor framing: engines consume telemetry via `MultiProjectCITelemetrySource`, not `GitService` directly. Engine does not enforce `awaiting_ceo_approval` itself. |
|
||||
| release-manager | None material. |
|
||||
| org-memory-playbooks | None material. |
|
||||
| messaging-notification | None material. |
|
||||
| notification | None material. |
|
||||
| a2a-audit-journal-permissions | Doc lists `PermissionsService` (plural); actual class is `PermissionService` (singular). Legacy A2A-protocol path (`create_a2a_notification` / `TASK_ASSIGNED` re-spawn) undocumented. `AuditService.has_recent_tracing_gap` undocumented. |
|
||||
| conventions-service-validator | None material (all doc claims match code). |
|
||||
| intake-secretary | None material. |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
## Purpose
|
||||
This slice is the agent-to-agent communication, audit forensics, journaling, structured-note persistence, message extraction, and role-based access-control backbone of RoboCo's service layer. A2AService builds Agent Cards and manages persistent slug-keyed A2A conversations/messages plus the legacy A2A-protocol task/notification path. AuditService best-effort persists security/lifecycle events to audit_log and backs the PM-respawn tracing-gap circuit breaker. JournalService owns agent journal CRUD, gateway scope→type entry writes, and fire-and-forget RAG indexing. content_notes is the single chokepoint that validates and persists structured task notes plus their derived TEXT mirrors. ExtractionService classifies raw LLM stream buffers into typed messages. PermissionService enforces channel/notification/task/KB access from agents_config.
|
||||
This slice is the agent-to-agent communication, audit forensics, journaling, structured-note persistence, message extraction, and role-based access-control backbone of RoboCo's service layer. A2AService builds Agent Cards and manages persistent slug-keyed A2A conversations/messages plus the legacy A2A-protocol task/notification path. AuditService best-effort persists security/lifecycle events to audit_log and backs the PM-respawn tracing-gap circuit breaker. JournalService owns agent journal CRUD, gateway scope→type entry writes, and fire-and-forget RAG indexing. content_notes is the single chokepoint that validates and persists structured task notes plus their derived TEXT mirrors. ExtractionService classifies raw LLM stream buffers into typed messages. PermissionService enforces notification/task/KB access from agents_config.
|
||||
|
||||
## Files
|
||||
|
||||
@@ -10,7 +10,7 @@ This slice is the agent-to-agent communication, audit forensics, journaling, str
|
||||
| roboco/services/journal.py | JournalService: journal/entry CRUD, gateway scope-string→JournalEntryType adapter, fire-and-forget RAG indexing (private excluded), tracing-gate existence checks, board review brief, growth analytics | 1029 |
|
||||
| roboco/services/content_notes.py | Single chokepoint applying structured notes: validate via foundation ContentModel, store in notes_structured, regenerate derived TEXT mirror column (dev_notes/qa_notes/etc.) | 74 |
|
||||
| roboco/services/extraction.py | ExtractionService + ExtractionPipeline: regex-pattern (and optional LLM/TOON) classification of raw agent LLM buffers into typed ExtractedMessages; transcription pipeline callback fan-out | 508 |
|
||||
| roboco/services/permissions.py | PermissionService singleton + async helpers: channel read/write, notification scope (all/cell/board-chain), task-action and KB-action RBAC from agents_config; privileged/PM-role DB lookups | 425 |
|
||||
| roboco/services/permissions.py | PermissionService singleton + async helpers: notification scope (all/cell/board-chain), task-action and KB-action RBAC from agents_config; privileged/PM-role DB lookups | 425 |
|
||||
|
||||
## Key Symbols
|
||||
|
||||
@@ -104,10 +104,7 @@ This slice is the agent-to-agent communication, audit forensics, journaling, str
|
||||
| _call_anthropic_with_retry | method | roboco/services/extraction.py:316 | Anthropic messages.create with MAX_RATE_LIMIT_RETRIES 429 backoff honoring Retry-After |
|
||||
| extract_with_llm | method | roboco/services/extraction.py:361 | LLM/TOON classification (claude-3-haiku); falls back to pattern extract on any non-RateLimit error |
|
||||
| ExtractionPipeline | class | roboco/services/extraction.py:459 | Wraps ExtractionService; process_buffer fans results to registered callbacks |
|
||||
| PermissionService | class | roboco/services/permissions.py:130 | Singleton RBAC enforcement from agents_config: channels, notifications, task actions, KB actions |
|
||||
| _check_channel_access_for_agent | method | roboco/services/permissions.py:156 | Resolve role+team to slugs and check CHANNEL_ACCESS read/write/silent lists |
|
||||
| can_read_channel | method | roboco/services/permissions.py:188 | Auditor/CEO/Main_PM bypass; else _check_channel_access_for_agent read |
|
||||
| can_write_channel | method | roboco/services/permissions.py:208 | CEO/Main_PM bypass; Auditor hard-deny (silent observer); else write check |
|
||||
| PermissionService | class | roboco/services/permissions.py:130 | Singleton RBAC enforcement from agents_config: notifications, task actions, KB actions |
|
||||
| can_send_notifications | method | roboco/services/permissions.py:255 | Whether role may call notify (foundation.NOTIFY_SENDER_ROLES) |
|
||||
| can_notify | method | roboco/services/permissions.py:259 | Scope rules: all (main_pm/ceo), cell (cell_pm: PMs or same team), board-chain list, else False |
|
||||
| can_perform_task_action | method | roboco/services/permissions.py:296 | CEO bypasses all; else TASK_PERMISSIONS with VIEW_OWN team restriction + VIEW_ALL fallback |
|
||||
@@ -119,7 +116,7 @@ This slice is the agent-to-agent communication, audit forensics, journaling, str
|
||||
| _get_agents_for_role_team | function | roboco/services/permissions.py:64 | All agent slugs matching a (role, team) pair from the precomputed lookup |
|
||||
|
||||
## Data Flow
|
||||
CONTROL FLOW: (1) A2A — HTTP routes in roboco/api/routes/a2a.py construct A2AService(db) per request for card discovery, task get/list/cancel, conversation CRUD, message send, mark-read, inbox/pairs; the gateway Choreographer/content_actions use A2AService.send (UUID→slug resolved) for directed agent messaging. Legacy A2A-protocol path: create_a2a_notification requires a task_id, requires both from_agent and target_agent to be present/resolvable (raises distinct ValueError if either missing), enforces hierarchy unconditionally via validate_a2a_access (raises A2AAccessDeniedError), parses priority via foundation.policy.communications.parse_priority, then delegates to NotificationService.send_a2a_notification (which now runs the loop-prone 60s Redis re-fire guard). Bidirectional responses: update_task_from_message appends to dev_notes and, if dev_notes contains the 'A2A Request' marker, _notify_original_requester publishes a TASK_ASSIGNED event to the StreamEventBus to spawn the offline requester. CEO live view (wave 2/2c): every send() publishes A2A_MESSAGE_SENT (_publish_a2a_message_sent) which websocket_bridge forwards to /ws/system as an a2a.message frame for the panel's /a2a switchboard; the CEO-only /chat/admin/* routes (_require_ceo) read across all conversations via get_conversation_admin/list_conversations_admin/get_messages_admin/list_admin_pairs (no participant check) and reply_as_ceo chimes in via the normal send() path, which routes a reply-to-CEO through _get_conversation_for_reply_to_ceo and gates every non-CEO send to the CEO through _enforce_ceo_reply_budget. (2) Audit — TaskService (log_task_event at every transition chokepoint), task routes (log_task_action_denial on 403 for action denials; log_task_creation_denial on 403 for pre-task create denials — distinct target_type="task_creation" with no task_id), the orchestrator (log_agent_event on spawn/stop + has_recent_tracing_gap for the PM-respawn circuit breaker) and the Choreographer (log_event for gateway.rejected) all call get_audit_service(); _persist opens its own session+commit so audit writes never roll back the caller's transaction. (3) Journal — gateway content_actions.note → JournalService.write_entry (scope string→type via foundation SCOPE_TO_TYPE), and write_struggle/write_decision for PM write-then-gate verbs; create_entry commits the row then _schedule_rag_index fires asyncio.create_task (strong-ref in _RAG_INDEX_TASKS) that calls OptimalService.index_journal_entry (skipped for is_private) and record_learning for LEARNING entries; tracing-gate existence checks (has_decision_for_task/latest_decision_at/has_note_for_task/...) feed the Choreographer's gate decisions. (4) content_notes — TaskService._set_structured_note and gateway content_actions handoff path call apply_structured_note(task, content_type, payload); it validates via foundation.policy.content.validate_content BEFORE mutating, reassigns notes_structured (to flag the JSON column dirty), and writes render_markdown() into the derived TEXT mirror column. (5) Extraction — app lifespan builds ExtractionPipeline(ExtractionService()); stream route process_buffer → ExtractionService.extract (regex classify) → callbacks store/broadcast messages; extract_with_llm is the optional Anthropic/TOON path. (6) Permissions — messaging/notification/task/KB routes and gateway kb_authz call PermissionService methods synchronously (no DB) from an AgentContext; has_privileged_access/is_pm_role are async DB lookups used by route deps. DATA: inputs are AsyncSession + UUIDs/slugs/payloads; outputs are Pydantic models (A2ATask, A2AConversation, Journal, JournalEntry), audit rows, structured note columns, ExtractedMessage lists, and bool permission decisions.
|
||||
CONTROL FLOW: (1) A2A — HTTP routes in roboco/api/routes/a2a.py construct A2AService(db) per request for card discovery, task get/list/cancel, conversation CRUD, message send, mark-read, inbox/pairs; the gateway Choreographer/content_actions use A2AService.send (UUID→slug resolved) for directed agent messaging. Legacy A2A-protocol path: create_a2a_notification requires a task_id, requires both from_agent and target_agent to be present/resolvable (raises distinct ValueError if either missing), enforces hierarchy unconditionally via validate_a2a_access (raises A2AAccessDeniedError), parses priority via foundation.policy.communications.parse_priority, then delegates to NotificationService.send_a2a_notification (which now runs the loop-prone 60s Redis re-fire guard). Bidirectional responses: update_task_from_message appends to dev_notes and, if dev_notes contains the 'A2A Request' marker, _notify_original_requester publishes a TASK_ASSIGNED event to the StreamEventBus to spawn the offline requester. CEO live view (wave 2/2c): every send() publishes A2A_MESSAGE_SENT (_publish_a2a_message_sent) which websocket_bridge forwards to /ws/system as an a2a.message frame for the panel's /a2a switchboard; the CEO-only /chat/admin/* routes (_require_ceo) read across all conversations via get_conversation_admin/list_conversations_admin/get_messages_admin/list_admin_pairs (no participant check) and reply_as_ceo chimes in via the normal send() path, which routes a reply-to-CEO through _get_conversation_for_reply_to_ceo and gates every non-CEO send to the CEO through _enforce_ceo_reply_budget. (2) Audit — TaskService (log_task_event at every transition chokepoint), task routes (log_task_action_denial on 403 for action denials; log_task_creation_denial on 403 for pre-task create denials — distinct target_type="task_creation" with no task_id), the orchestrator (log_agent_event on spawn/stop + has_recent_tracing_gap for the PM-respawn circuit breaker) and the Choreographer (log_event for gateway.rejected) all call get_audit_service(); _persist opens its own session+commit so audit writes never roll back the caller's transaction. (3) Journal — gateway content_actions.note → JournalService.write_entry (scope string→type via foundation SCOPE_TO_TYPE), and write_struggle/write_decision for PM write-then-gate verbs; create_entry commits the row then _schedule_rag_index fires asyncio.create_task (strong-ref in _RAG_INDEX_TASKS) that calls OptimalService.index_journal_entry (skipped for is_private) and record_learning for LEARNING entries; tracing-gate existence checks (has_decision_for_task/latest_decision_at/has_note_for_task/...) feed the Choreographer's gate decisions. (4) content_notes — TaskService._set_structured_note and gateway content_actions handoff path call apply_structured_note(task, content_type, payload); it validates via foundation.policy.content.validate_content BEFORE mutating, reassigns notes_structured (to flag the JSON column dirty), and writes render_markdown() into the derived TEXT mirror column. (5) Extraction — app lifespan builds ExtractionPipeline(ExtractionService()); stream route process_buffer → ExtractionService.extract (regex classify) → callbacks store/broadcast messages; extract_with_llm is the optional Anthropic/TOON path. (6) Permissions — notification/task/KB routes and gateway kb_authz call PermissionService methods synchronously (no DB) from an AgentContext; has_privileged_access/is_pm_role are async DB lookups used by route deps. DATA: inputs are AsyncSession + UUIDs/slugs/payloads; outputs are Pydantic models (A2ATask, A2AConversation, Journal, JournalEntry), audit rows, structured note columns, ExtractedMessage lists, and bool permission decisions.
|
||||
|
||||
## Mermaid
|
||||
```mermaid
|
||||
@@ -152,8 +149,7 @@ graph TD
|
||||
AUD[AuditService] -->|own session commit| DB[(audit_log)]
|
||||
JRN -->|commit| DB2[(journals/journal_entries)]
|
||||
A2A -->|flush/commit| DB3[(a2a_conversations/a2a_messages)]
|
||||
PERM[PermissionService] -->|derive| AC[agents_config.CHANNEL_ACCESS]
|
||||
PERM -->|can_notify| FND[foundation.NOTIFY_SENDER_ROLES]
|
||||
PERM[PermissionService] -->|can_notify| FND[foundation.NOTIFY_SENDER_ROLES]
|
||||
end
|
||||
EX[ExtractionService] -->|messages| CB[stream callbacks]
|
||||
NOT -->|dedup + re-fire guard| DB4[(notifications)]
|
||||
@@ -197,7 +193,6 @@ a2a-audit-journal-permissions
|
||||
│ ├── LLM path: extract_with_llm, _call_anthropic_with_retry (TOON)
|
||||
│ └── ExtractionPipeline.process_buffer + on_message callbacks
|
||||
└── PermissionService + helpers (permissions.py)
|
||||
├── Channel: can_read_channel, can_write_channel, get_accessible/writable_channels, _check_channel_access_for_agent
|
||||
├── Notification: can_send_notifications, can_notify, _can_role_send_notifications, _get_notification_scope, _BOARD_NOTIFY_TARGETS
|
||||
├── Task/KB: can_perform_task_action, get_task_actions, can_perform_kb_action, get_kb_actions
|
||||
├── Utility: get_permission_level, check_all
|
||||
@@ -214,7 +209,7 @@ a2a-audit-journal-permissions
|
||||
|---|---|---|
|
||||
| HTTP routes /api/a2a/* | roboco/api/routes/a2a.py | Agent card, task, conversation, message, inbox REST endpoints construct A2AService(db) per request |
|
||||
| HTTP routes /api/a2a/chat/admin/* (CEO-only) | roboco/api/routes/a2a.py | Wave-2/2c: org-wide live view — list_admin_conversations, list_admin_pairs (switchboard), list_admin_chat_messages, reply_as_ceo — all gated by _require_ceo, delegating to A2AService.list_conversations_admin / list_admin_pairs / get_messages_admin / send |
|
||||
| Gateway content_actions.note / handoff | roboco/services/gateway/content_actions.py | Agent note/say/dm verbs → JournalService.write_entry + content_type_for_role + apply_structured_note |
|
||||
| Gateway content_actions.note / handoff | roboco/services/gateway/content_actions.py | Agent note/dm verbs → JournalService.write_entry + content_type_for_role + apply_structured_note |
|
||||
| Choreographer send | roboco/services/gateway/choreographer/ | Directed A2A verb → A2AService.send (UUID→slug) |
|
||||
| Choreographer tracing gates | roboco/services/gateway/choreographer/ | i_will_work_on / delegate / submit gates query JournalService.has_*_for_task and latest_decision_at |
|
||||
| TaskService transition chokepoint | roboco/services/task.py | Every status transition → AuditService.log_task_event + log_task_action_denial; structured note writes → apply_structured_note |
|
||||
@@ -222,7 +217,7 @@ a2a-audit-journal-permissions
|
||||
| HTTP routes /api/journals, /api/tasks board-review | roboco/api/routes/journals.py | Journal/entry/list/search/board-review-brief REST via get_journal_service(db) |
|
||||
| HTTP route /api/stream process_buffer | roboco/api/routes/stream.py | Transcription buffer ready → ExtractionPipeline.process_buffer |
|
||||
| FastAPI lifespan | roboco/api/app.py | App startup constructs ExtractionPipeline(ExtractionService()) on _AppServices |
|
||||
| Route deps + messaging/notification/KB routes | roboco/api/deps.py | Per-request service bag wires A2A/Journal/Audit/Permission; routes call PermissionService + has_privileged_access/is_pm_role |
|
||||
| Route deps + notification/KB routes | roboco/api/deps.py | Per-request service bag wires A2A/Journal/Audit/Permission; routes call PermissionService + has_privileged_access/is_pm_role |
|
||||
| CLI / direct module use | roboco/services/extraction.py | extract_with_llm runnable standalone (Anthropic key) |
|
||||
|
||||
## Gotchas
|
||||
@@ -246,14 +241,13 @@ a2a-audit-journal-permissions
|
||||
- content_notes._MIRROR_COLUMN maps only 6 content types; a content type absent from the map is stored structured-only with NO TEXT mirror, so any legacy reader of the TEXT column sees stale/empty for that type.
|
||||
- extraction._classify_segment defaults to REASONING@0.5 when no pattern matches — every unclassified utterance becomes 'reasoning', inflating reasoning counts.
|
||||
- extraction.extract_with_llm hardcodes model='claude-3-haiku-20240307' (stale model id) and only falls back to pattern extract on non-RateLimit errors; a persistent 429 re-raises RateLimitError after MAX_RATE_LIMIT_RETRIES.
|
||||
- permissions._check_channel_access_for_agent resolves role+team to slugs via _ROLE_TEAM_LOOKUP and checks agents_config.CHANNEL_ACCESS — it is NOT the foundation.policy.communications.CHANNELS catalog, so the two can diverge (the recent 15effce0 fix to foundation CHANNELS auditor write_roles does NOT touch this path; permissions.can_write_channel already hard-denies auditor).
|
||||
- permissions.can_perform_task_action CEO-bypasses ALL actions including team-scoped VIEW_OWN — the CEO operates across teams by design (panel), but any route gating through this helper lets the CEO through.
|
||||
- permissions.has_privileged_access / is_pm_role query by (id == agent_id) OR (slug == str(agent_id)) because the CEO uses a UUID-style slug; passing a non-UUID slug that happens to collide with a slug column value can match the wrong row.
|
||||
|
||||
|
||||
## Drift from CLAUDE.md
|
||||
- CLAUDE.md Services table lists 'PermissionsService' (plural) as the RBAC service; the actual class is 'PermissionService' (singular) at roboco/services/permissions.py:130. The re-export PM_ROLES and the async helpers has_privileged_access/is_pm_role are not mentioned in CLAUDE.md.
|
||||
- CLAUDE.md states the Auditor has 'silent read access to ALL channels' and the notification table says notifications are 'sent by PMs/Board only'. permissions.py matches this (can_read_channel bypasses for AUDITOR, can_write_channel hard-denies AUDITOR, _can_role_send_notifications excludes AUDITOR) — no behavioral drift, but CLAUDE.md does not document that the Auditor write-deny is enforced in this service rather than only via the verb surface.
|
||||
- CLAUDE.md's notification table says notifications are 'sent by PMs/Board only'; permissions.py matches this (_can_role_send_notifications excludes AUDITOR) — no behavioral drift.
|
||||
- CLAUDE.md describes the A2A/conversation surface only via the gateway Choreographer; it does not document the legacy A2A-protocol path (create_a2a_notification / update_task_with_message / dev_notes 'A2A Request' marker / TASK_ASSIGNED re-spawn) which still lives in A2AService.
|
||||
- CLAUDE.md says journal 'note' write returns immediately and RAG indexing is fire-and-forget — journal.py:324 _schedule_rag_index matches this exactly (no drift). CLAUDE.md also says 'journal indexing excludes is_private reflections from the shared corpus' — journal.py:343 matches (skips index_journal_entry when is_private). No drift.
|
||||
- CLAUDE.md does not mention AuditService.has_recent_tracing_gap (the PM-respawn circuit-breaker query) or that audit._persist uses its own session/commit — both are undocumented audit behaviors.
|
||||
@@ -264,7 +258,6 @@ a2a-audit-journal-permissions
|
||||
| Title | File:Line | Claim | Severity |
|
||||
|---|---|---|---|
|
||||
| A2A legacy notification suppressed by new loop-prone re-fire guard | roboco/services/a2a.py:640 | create_a2a_notification delegates to NotificationService.send_a2a_notification. Since 3aff6e04, send_a2a_notification runs all_recipients_recently_notified (60s Redis SET-NX) for loop-prone types before creating the notification. A legitimate A2A peer notification re-sent within 60s (e.g. after a real state change, not a respawn loop) can be silently dropped, so the target agent is never notified/spawned. The A2A path has no awareness of which notification_type it produces being loop-prone, and cannot bypass the guard. | medium |
|
||||
| Board-channel auditor write divergence between foundation catalog and permissions service | roboco/services/permissions.py:208 | 15effce0 removed AUDITOR from write_roles of main-pm-board and board-private in foundation.policy.communications.CHANNELS (the catalog-only enforcement path). permissions.py derives from agents_config.CHANNEL_ACCESS, NOT foundation.CHANNELS, and already hard-denied auditor writes, so this slice's behavior is unchanged. Risk: any consumer that assumed the two catalogs are identical now sees a divergence — an auditor write to main-pm-board is allowed by neither path (correct), but a future caller bypassing permissions.py and going straight to foundation validate_channel_access would have been allowed pre-15effce0 and is now denied. No regression in this slice, but the two-source-of-truth split is a latent drift. | low |
|
||||
| PrReviewContent schema change could invalidate stale structured-note payloads | roboco/services/content_notes.py:65 | 15effce0 added optional 'issues' and 'head_sha' fields to PrReviewContent (foundation.policy.content.models). apply_structured_note calls validate_content which routes to PrReviewContent. The new fields have defaults (empty list / None) so existing payloads still validate and existing TEXT mirrors regenerate unchanged (Issues section only appended when issues present). Low risk, but any code that round-trips notes_structured.pr_review and assumed the exact key set may now encounter new keys. | low |
|
||||
| Journal fire-and-forget RAG index silent on no-event-loop | roboco/services/journal.py:367 | _schedule_rag_index catches RuntimeError (no running event loop) and returns silently — no indexing, no log. Unchanged since baseline, but if a caller (e.g. a sync test or a non-async lifespan hook) writes a journal entry outside a running loop, the entry is persisted with zero RAG indexing and no warning. Combined with the new private-learning dual-sink rule this is easy to mis-verify. | low |
|
||||
| Audit actor-role override can mismatch test expectations | roboco/services/audit.py:126 | log_task_action_denial resolves actual role from agents.role and overrides the caller-supplied agent_role. If a test seeds an agent with a role but passes a different agent_role param and asserts the persisted details.agent_role, it will see the DB role, not the param. Unchanged since baseline but a known foot-gun for regression tests added in the 15effce0 gap-fill batch. | low |
|
||||
@@ -280,4 +273,4 @@ a2a-audit-journal-permissions
|
||||
> - **876e19b3** `A2A switchboard (pair cards), Secretary/PM task access + closed over-permission hole, MegaTask conventions fix (#298)` — a2a.py adds `list_admin_pairs` (the switchboard's one-bulk-query pair+conversation join over `agents_config.A2A_ALLOWED_PAIRS`); `routes/a2a.py` adds the CEO-gated `/chat/admin/pairs` route. This commit also tightened `roboco/api/routes/tasks.py` (`_pm_editor_scope` / `_enforce_pm_lighter_fields`, out of this slice) and gave `SecretaryService` its `edit` directive action — see `docs/map/intake-secretary.md`.
|
||||
|
||||
## Health
|
||||
This slice is mature and internally consistent: the six services have clear separation of concerns (A2A transport/conversation, audit forensics, journal CRUD+RAG, note persistence chokepoint, stream extraction, RBAC), and the gateway/HTTP/orchestrator entry points map cleanly onto them. The code is defensive in the right places — audit._persist is best-effort with its own session, journal RAG indexing is fire-and-forget with a strong-ref guard, content_notes validates before mutating, and A2A conversation dedup prevents respawn re-emit storms. The main integrity concerns are cross-layer, not in-slice: (1) the new 60s Redis loop-prone notification re-fire guard (3aff6e04) sits between A2A's create_a2a_notification and delivery and can silently drop legitimate A2A notifications; (2) two parallel channel-permission sources (agents_config.CHANNEL_ACCESS vs foundation.policy.communications.CHANNELS) drifted further apart in 15effce0, with this slice correctly insulated but the broader system carrying latent divergence; (3) the legacy A2A-protocol path (dev_notes 'A2A Request' marker, TASK_ASSIGNED re-spawn) is undocumented in CLAUDE.md and coexists with the gateway conversation path, a known source of future confusion. No in-slice file changed since the fd10cc86 baseline, so there is no direct regression surface; the risks above are all dependency-mediated. Recommend a regression test that an A2A notification fired twice within 60s for genuinely different reasons still delivers, and a single-source-of-truth reconciliation of the two channel catalogs.
|
||||
This slice is mature and internally consistent: the six services have clear separation of concerns (A2A transport/conversation, audit forensics, journal CRUD+RAG, note persistence chokepoint, stream extraction, RBAC), and the gateway/HTTP/orchestrator entry points map cleanly onto them. The code is defensive in the right places — audit._persist is best-effort with its own session, journal RAG indexing is fire-and-forget with a strong-ref guard, content_notes validates before mutating, and A2A conversation dedup prevents respawn re-emit storms. The main integrity concerns are cross-layer, not in-slice: (1) the new 60s Redis loop-prone notification re-fire guard (3aff6e04) sits between A2A's create_a2a_notification and delivery and can silently drop legitimate A2A notifications; (2) the legacy A2A-protocol path (dev_notes 'A2A Request' marker, TASK_ASSIGNED re-spawn) is undocumented in CLAUDE.md and coexists with the gateway conversation path, a known source of future confusion. No in-slice file changed since the fd10cc86 baseline, so there is no direct regression surface; the risks above are all dependency-mediated. Recommend a regression test that an A2A notification fired twice within 60s for genuinely different reasons still delivers.
|
||||
|
||||
@@ -4,7 +4,7 @@ Slice key: `api-core-websocket` Repo root: `/Users/renzof/Documents/GitHub/ZZZ/r
|
||||
|
||||
## Purpose
|
||||
|
||||
The FastAPI application shell, request pipeline, and real-time WebSocket fan-out layer for RoboCo. `app.py` builds the ASGI app, wires ~40 route routers, and runs the async lifespan (DB migrations, feature-flag overlay, transcription/extraction/RAG/learning service init, ordered shutdown). `middleware.py` adds correlation IDs, request logging, and a full exception-handler chain mapping domain/service/HTTP errors to structured JSON. `websocket.py` + `websocket_bridge.py` own the live panel streams (channels, agents, sessions, notifications, system) with per-connection bounded send queues and an event-bus bridge. `deps.py` is the dependency-injection spine: agent header auth, role-gate helpers, and Choreographer/ContentActions wiring. `utils/` provides route-layer error factories and get-or-404/ownership helpers. `middleware_docs.py` enforces the docs-path permission matrix. `roboco/security.py` (outside `api/` but wired here) supplies the optional fastapi-guard HTTP security layer: `apply_guard(app)` mounts `SecurityMiddleware` last — outermost — in `create_app`, and `guarded_lifespan(lifespan)` wraps the async lifespan, both gated by `ROBOCO_GUARD_ENABLED` (default off, byte-for-byte unchanged request path while off).
|
||||
The FastAPI application shell, request pipeline, and real-time WebSocket fan-out layer for RoboCo. `app.py` builds the ASGI app, wires ~40 route routers, and runs the async lifespan (DB migrations, feature-flag overlay, transcription/extraction/RAG/learning service init, ordered shutdown). `middleware.py` adds correlation IDs, request logging, and a full exception-handler chain mapping domain/service/HTTP errors to structured JSON. `websocket.py` + `websocket_bridge.py` own the live panel streams (agents, notifications, system) with per-connection bounded send queues and an event-bus bridge. `deps.py` is the dependency-injection spine: agent header auth, role-gate helpers, and Choreographer/ContentActions wiring. `utils/` provides route-layer error factories and get-or-404/ownership helpers. `middleware_docs.py` enforces the docs-path permission matrix. `roboco/security.py` (outside `api/` but wired here) supplies the optional fastapi-guard HTTP security layer: `apply_guard(app)` mounts `SecurityMiddleware` last — outermost — in `create_app`, and `guarded_lifespan(lifespan)` wraps the async lifespan, both gated by `ROBOCO_GUARD_ENABLED` (default off, byte-for-byte unchanged request path while off).
|
||||
|
||||
## Files
|
||||
|
||||
@@ -15,7 +15,7 @@ The FastAPI application shell, request pipeline, and real-time WebSocket fan-out
|
||||
| `roboco/api/middleware.py` | Correlation-id + request-logging middleware, exception handlers, 422 secret-scrub + UUID remediation | ~469 |
|
||||
| `roboco/api/middleware_docs.py` | Docs-path access-control matrix (read/write per role/team/slug) | ~330 |
|
||||
| `roboco/api/websocket.py` | WS routes + ConnectionManager with bounded per-connection send queues, panel-token gate, idle timeout | ~692 |
|
||||
| `roboco/api/websocket_bridge.py` | Event-bus → WS forwarders (notifications, sessions, **messages**, agents, rate-limit, usage) | ~252 |
|
||||
| `roboco/api/websocket_bridge.py` | Event-bus → WS forwarders (notifications, agents, rate-limit, usage, A2A) | ~252 |
|
||||
| `roboco/api/utils/__init__.py` | Re-export surface for error factories + resource helpers | ~43 |
|
||||
| `roboco/api/utils/errors.py` | HTTPException factories + `handle_service_error` + `service_error_handler` decorator | ~214 |
|
||||
| `roboco/api/utils/resources.py` | `get_or_404`, `get_by_field_or_404`, `require_ownership`/`require_recipient`/`require_membership` | ~180 |
|
||||
@@ -46,7 +46,7 @@ The FastAPI application shell, request pipeline, and real-time WebSocket fan-out
|
||||
| `get_agent_context` | func | deps.py:452 | Builds `AgentContext` from X-Agent-* headers + token (+ the `roboco_session` cookie); byte-for-byte header-trust when `cloud_auth_enabled` is False, else delegates to `_cloud_auth_agent_context` |
|
||||
| `require_pm_or_above`/`require_developer_or_above`/`require_cell_access` | funcs | deps.py:403/428/443 | Coarse role-gate guards (403) |
|
||||
| `require_ceo_role` | func | deps.py:412 | Single CEO-check: raises 403 unless `role` is CEO; accepts `AgentRole`/`Role`/lowercase string; unifies orchestrator-router + release-handler CEO gates into one source of truth (`536bbb64`) |
|
||||
| `require_channel_read`/`require_channel_write`/`require_notification_permission`/`require_task_action` | dep factories | deps.py:442/470/490/508 | PermissionService-backed dependency factories |
|
||||
| `require_notification_permission`/`require_task_action` | dep factories | deps.py:490/508 | PermissionService-backed dependency factories |
|
||||
| `get_choreographer`/`get_content_actions` | funcs | deps.py:545/575 | Build Choreographer / ContentActions with all service deps + orchestrator/bus |
|
||||
| `get_pagination` | func | deps.py:599 | Clamp limit 1-100, offset ≥0 |
|
||||
| `CorrelationIdMiddleware` | class | middleware.py:54 | X-Correlation-ID in/out + structlog bind |
|
||||
@@ -65,14 +65,14 @@ The FastAPI application shell, request pipeline, and real-time WebSocket fan-out
|
||||
| `_require_panel_token` | func | websocket.py:61 | WS upgrade CEO-HMAC gate (returns False to close) |
|
||||
| `validate_agent_exists` | func | websocket.py:337 | DB existence check for claimed agent_id |
|
||||
| `_register_sender`/`_run_sender`/`_enqueue_or_send`/`_send_with_timeout` | methods | websocket.py:122/129/244/270 | Non-blocking fan-out plumbing |
|
||||
| `connect_channel`/`connect_agent`/`connect_session`/`connect_notifications`/`connect_system` | methods | websocket.py:158-212 | Accept + register per stream type |
|
||||
| `disconnect` | method | websocket.py:214 | Remove socket from all sets + cancel sender task |
|
||||
| `broadcast_to_channel`/`broadcast_to_agent_watchers`/`broadcast_to_session`/`broadcast_system` | methods | websocket.py:283-322 | Fan-out enqueues |
|
||||
| `channel_stream`/`agent_stream`/`session_stream`/`notification_stream`/`system_stream` | routes | websocket.py:360/428/493/556/608 | WS endpoints under `/ws` |
|
||||
| `connect_agent`/`connect_notifications`/`connect_system` | methods | websocket.py:187/213/224 | Accept + register per stream type |
|
||||
| `disconnect` | method | websocket.py:230 | Remove socket from all sets + cancel sender task |
|
||||
| `broadcast_to_agent_watchers`/`broadcast_system` | methods | websocket.py:310/332 | Fan-out enqueues |
|
||||
| `agent_stream`/`notification_stream`/`system_stream` | routes | websocket.py:445/573/625 | WS endpoints under `/ws` |
|
||||
| `broadcast_agent_chunk`/`broadcast_notification` | funcs | websocket.py:650/665 | Helper broadcasters for external callers |
|
||||
| `IDLE_TIMEOUT_SECONDS`/`MAX_SEND_QUEUE`/`SEND_TIMEOUT_SECONDS` | consts | websocket.py:35/41/42 | 90s / 256 / 10s tunables |
|
||||
| `register_websocket_bridge_handlers`/`start_websocket_bridge` | funcs | websocket_bridge.py:169/203 | Subscribe bus handlers |
|
||||
| `_handle_notification_sent`/`_handle_session_event`/`_handle_message_event`/`_handle_agent_event`/`_handle_rate_limit_event`/`_handle_usage_event` | funcs | websocket_bridge.py | Event-bus → WS forwarders (`_handle_message_event` fans `MESSAGE_SENT` to `/ws/sessions` + `/ws/channels` as a `message.new` frame) |
|
||||
| `_handle_notification_sent`/`_handle_agent_event`/`_handle_rate_limit_event`/`_handle_usage_event`/`_handle_a2a_message_event` | funcs | websocket_bridge.py | Event-bus → WS forwarders (`_handle_a2a_message_event` fans `A2A_MESSAGE_SENT` to `/ws/system` as an `a2a.message` frame — the CEO's live view of every agent-to-agent chat) |
|
||||
| `_RATE_LIMIT_WS_TYPES`/`_USAGE_WS_TYPES` | consts | websocket_bridge.py:18/23 | EventType → panel `type` string maps |
|
||||
| `not_found`/`forbidden`/`unauthorized`/`validation_error`/`conflict`/`service_unavailable` | funcs | utils/errors.py:29-142 | HTTPException factories |
|
||||
| `handle_service_error`/`service_error_handler` | func/deco | utils/errors.py:150/191 | ServiceError → HTTPException translation |
|
||||
@@ -91,7 +91,7 @@ The FastAPI application shell, request pipeline, and real-time WebSocket fan-out
|
||||
|
||||
**Lifespan startup**: `init_db` (alembic upgrade + create_all fallback) → `apply_persisted_feature_flags` (panel settings overlay, best-effort) → `TranscriptionService.start()` + `ExtractionPipeline` → `get_optimal_service()` (BLOCKS 30-90s for RAG) → `LearningPropagationService.initialize(optimal)`. `app.state.*` holds singletons. **Shutdown**: stop orchestrator (drains bg DB writes) → `close_optimal_service` → `close_db`. The orchestrator-stop-before-DB order is load-bearing.
|
||||
|
||||
**WebSocket**: panel → `wss://.../ws/{kind}/{id}` → `_require_panel_token` (CEO HMAC, except `/ws/system`) → query-param `agent_id`/`viewer_id` UUID parse (+ DB existence check for agent/session/notifications) → `manager.connect_*` accepts, registers in subscription set, spawns per-connection `_run_sender` task draining a bounded queue. Receive loop `await asyncio.wait_for(receive_text(), IDLE_TIMEOUT_SECONDS)`; "ping"→"pong"; timeout/disconnect → `finally disconnect`. **Broadcasts**: service code calls `manager.broadcast_to_*` → `_enqueue_or_send` per conn → either `conn.queue.put_nowait` (drop+warn on full) or legacy fire-and-forget `_send_with_timeout`. **Event-bus bridge**: `StreamEventBus` publishes `NOTIFICATION_SENT`/`SESSION_*`/`MESSAGE_SENT`/`AGENT_*`/`RATE_LIMIT_*`/`USAGE_SNAPSHOT` → `websocket_bridge` handlers → `manager.broadcast_*` → panel. `MESSAGE_SENT` (published best-effort by `send_message`) fans out to both `/ws/sessions/{id}` and `/ws/channels/{id}` as a `message.new` frame — the live transcript-update path.
|
||||
**WebSocket**: panel → `wss://.../ws/{kind}/{id}` → `_require_panel_token` (CEO HMAC, except `/ws/system`) → query-param `agent_id`/`viewer_id` UUID parse (+ DB existence check for agent/notifications) → `manager.connect_*` accepts, registers in subscription set, spawns per-connection `_run_sender` task draining a bounded queue. Receive loop `await asyncio.wait_for(receive_text(), IDLE_TIMEOUT_SECONDS)`; "ping"→"pong"; timeout/disconnect → `finally disconnect`. **Broadcasts**: service code calls `manager.broadcast_to_*` → `_enqueue_or_send` per conn → either `conn.queue.put_nowait` (drop+warn on full) or legacy fire-and-forget `_send_with_timeout`. **Event-bus bridge**: `StreamEventBus` publishes `NOTIFICATION_SENT`/`AGENT_*`/`RATE_LIMIT_*`/`USAGE_SNAPSHOT`/`A2A_MESSAGE_SENT` → `websocket_bridge` handlers → `manager.broadcast_*` → panel. `A2A_MESSAGE_SENT` (published on every agent-to-agent DM) fans out to `/ws/system` as an `a2a.message` frame carrying conversation_id/message_id/task_id/from_agent/to_agent/skill + a capped body excerpt — the CEO's live view of every agent-to-agent chat, and the canonical pattern for wiring a new live event to the panel.
|
||||
|
||||
## Mermaid
|
||||
|
||||
@@ -124,13 +124,13 @@ sequenceDiagram
|
||||
MW-->>Nginx: + X-Correlation-ID, X-Response-Time-Ms
|
||||
Nginx-->>Panel: response
|
||||
|
||||
Panel->>Nginx: wss /ws/channels/{id}
|
||||
Panel->>Nginx: wss /ws/system
|
||||
Nginx->>ASGI: upgrade
|
||||
ASGI->>Mgr: _require_panel_token -> connect_channel
|
||||
ASGI->>Mgr: _require_panel_token -> connect_system
|
||||
Mgr->>Mgr: _register_sender (queue + task)
|
||||
Bus-->>ASGI: NOTIFICATION_SENT event
|
||||
ASGI->>Mgr: _handle_notification_sent
|
||||
Mgr->>WS: enqueue -> send_text
|
||||
Bus-->>ASGI: A2A_MESSAGE_SENT event
|
||||
ASGI->>Mgr: _handle_a2a_message_event
|
||||
Mgr->>WS: enqueue -> send_text (a2a.message)
|
||||
|
||||
Note over ASGI: lifespan shutdown
|
||||
ASGI->>Chor: orchestrator.stop() (drains DB writes)
|
||||
@@ -152,7 +152,7 @@ roboco/api/
|
||||
│ ├── agent identity (get_current_agent_id / slug / optional / context)
|
||||
│ ├── _check_agent_auth_token / require_panel_token (HMAC)
|
||||
│ ├── role gates (require_pm_or_above / developer_or_above / cell_access)
|
||||
│ ├── permission dep factories (channel read/write, notification, task action)
|
||||
│ ├── permission dep factories (notification, task action)
|
||||
│ ├── get_choreographer / get_content_actions
|
||||
│ └── get_pagination
|
||||
├── middleware.py
|
||||
@@ -169,14 +169,13 @@ roboco/api/
|
||||
├── websocket.py
|
||||
│ ├── _ClientConnection (queue + sender)
|
||||
│ ├── _require_panel_token
|
||||
│ ├── ConnectionManager (channel/agent/session/notification/system sets + senders)
|
||||
│ ├── ConnectionManager (agent/notification/system sets + senders)
|
||||
│ ├── manager singleton
|
||||
│ ├── validate_agent_exists
|
||||
│ ├── routes: /channels/{id} /agents/{id} /sessions/{id}
|
||||
│ │ /notifications/{id} /system
|
||||
│ ├── routes: /agents/{id} /notifications/{id} /system
|
||||
│ └── broadcast_agent_chunk / broadcast_notification helpers
|
||||
├── websocket_bridge.py
|
||||
│ ├── _handle_notification_sent / _handle_session_event / _handle_message_event / _handle_agent_event
|
||||
│ ├── _handle_notification_sent / _handle_agent_event / _handle_a2a_message_event
|
||||
│ ├── _handle_rate_limit_event / _handle_usage_event
|
||||
│ └── register_websocket_bridge_handlers / start_websocket_bridge
|
||||
└── utils/
|
||||
@@ -187,7 +186,7 @@ roboco/api/
|
||||
|
||||
## Dependencies
|
||||
|
||||
**Internal**: `roboco.config.settings`; `roboco.db.base` (init_db/close_db/get_db/get_session_factory); `roboco.db.tables.AgentTable`; `roboco.foundation.identity` (BOARD_ROLES/DEV_ROLES/PM_ROLES/Role); `roboco.models` (AgentRole/Team); `roboco.runtime.AgentOrchestrator`; `roboco.agents_config` (CEO_AGENT_ID, verify_agent_token, AGENT_ROLE_MAP/AGENT_TEAM_MAP, ALL_DOCS, _resolve_to_slug); `roboco.events` (Event/EventType/get_event_bus); `roboco.exceptions` (RobocoError tree); `roboco.services.base` (ServiceError tree); `roboco.services.exceptions.RateLimitError`; `roboco.services.{permissions,messaging,task,work_session,git,workspace,journal,a2a,product,notification,notification_delivery,audit,settings,extraction,learning,optimal,transcription}`; `roboco.services.gateway.{choreographer,content_actions,evidence_repo}`; `roboco.services.repositories` (resolve_agent_uuid/resolve_agent_identity); `roboco.api.schemas.{optimal.PaginationParams,common.ErrorCode}`; ~40 `roboco.api.routes.*` routers; `roboco.api.routes.v1.*` flow modules.
|
||||
**Internal**: `roboco.config.settings`; `roboco.db.base` (init_db/close_db/get_db/get_session_factory); `roboco.db.tables.AgentTable`; `roboco.foundation.identity` (BOARD_ROLES/DEV_ROLES/PM_ROLES/Role); `roboco.models` (AgentRole/Team); `roboco.runtime.AgentOrchestrator`; `roboco.agents_config` (CEO_AGENT_ID, verify_agent_token, AGENT_ROLE_MAP/AGENT_TEAM_MAP, ALL_DOCS, _resolve_to_slug); `roboco.events` (Event/EventType/get_event_bus); `roboco.exceptions` (RobocoError tree); `roboco.services.base` (ServiceError tree); `roboco.services.exceptions.RateLimitError`; `roboco.services.{permissions,task,work_session,git,workspace,journal,a2a,product,notification,notification_delivery,audit,settings,extraction,learning,optimal,transcription}`; `roboco.services.gateway.{choreographer,content_actions,evidence_repo}`; `roboco.services.repositories` (resolve_agent_uuid/resolve_agent_identity); `roboco.api.schemas.{optimal.PaginationParams,common.ErrorCode}`; ~40 `roboco.api.routes.*` routers; `roboco.api.routes.v1.*` flow modules.
|
||||
|
||||
**External**: `fastapi` (FastAPI, APIRouter, WebSocket, HTTPException, Depends, Header, status), `starlette.middleware.base.BaseHTTPMiddleware`, `starlette` responses, `sqlalchemy` (select, async session), `structlog`, `pydantic` (via schemas), `asyncio`, `uuid`, `json`, `time`, `contextlib`. (`httpx` was REMOVED from websocket.py in the baseline→head diff — the self-call `validate_channel_access` is gone.)
|
||||
|
||||
@@ -197,7 +196,7 @@ roboco/api/
|
||||
- `lifespan` — FastAPI async context manager; runs on startup/shutdown.
|
||||
- `setup_middleware(app)` — called from `create_app` after CORS.
|
||||
- `register_websocket_bridge_handlers()` / `start_websocket_bridge()` — called by orchestrator/bootstrap after the event bus is up (NOT in `lifespan`; the lifespan does not register bridge handlers).
|
||||
- WS routes — invoked by the panel via `wss://.../ws/{channels|agents|sessions|notifications|system}/...`, mounted at prefix `/ws` in `create_app`.
|
||||
- WS routes — invoked by the panel via `wss://.../ws/{agents|notifications|system}/...`, mounted at prefix `/ws` in `create_app`.
|
||||
- `manager` singleton — imported directly by services and the bridge to broadcast.
|
||||
- DI deps — resolved per-request by FastAPI (`get_agent_context`, `get_choreographer`, `get_content_actions`, `get_pagination`, `require_*` factories).
|
||||
- `require_panel_token` — HTTP dep on the live intake/secretary chat bridges.
|
||||
@@ -216,8 +215,8 @@ roboco/api/
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **~~`/ws/system` was the only WS endpoint WITHOUT `_require_panel_token`~~ — RESOLVED (`536bbb64`)**: `/ws/system` now calls `_require_panel_token` before subscribing (websocket.py:621); all five `/ws/*` endpoints are consistently gated. In strict mode a missing token closes with `WS_1008_POLICY_VIOLATION`; a forged token is rejected even in dev mode.
|
||||
- **`websocket.py` module docstring (lines 8-13) is STALE** — it claims WS "validates agent_id via query params and verify the agent exists in the database. In production, this should be enhanced with proper token-based authentication." Actual security is now the CEO HMAC panel token via `_require_panel_token`, and `validate_agent_exists` is only called on `/agents`, `/sessions`, `/notifications` (NOT `/channels`). The docstring misleads.
|
||||
- **~~`/ws/system` was the only WS endpoint WITHOUT `_require_panel_token`~~ — RESOLVED (`536bbb64`)**: `/ws/system` now calls `_require_panel_token` before subscribing (websocket.py:621); all `/ws/*` endpoints are consistently gated (five at the time of `536bbb64`; three after the comms-subsystem teardown removed `/ws/channels` + `/ws/sessions`). In strict mode a missing token closes with `WS_1008_POLICY_VIOLATION`; a forged token is rejected even in dev mode.
|
||||
- **`websocket.py` module docstring (lines 8-13) is STALE** — it claims WS "validates agent_id via query params and verify the agent exists in the database. In production, this should be enhanced with proper token-based authentication." Actual security is now the CEO HMAC panel token via `_require_panel_token`, and `validate_agent_exists` is only called on `/agents`, `/notifications`. The docstring misleads.
|
||||
- **`get_choreographer` passes `stream_bus=None` when no orchestrator is set** (deps.py:557) — fine, but means the rate-limit park path is inert during the startup window before bootstrap sets the orchestrator.
|
||||
- **`_check_agent_auth_token` dev mode**: a missing token is allowed; a presented-but-invalid token is rejected. The header-trust warning at app.py:94-102 is the only signal. In dev, any reachable client can act as any role (including `ceo`) by setting headers.
|
||||
- **`_resolve_agent_identity` `system` role special-case** (deps.py:281) returns `UUID(x_agent_id)` with NO DB lookup. Combined with dev-mode no-auth, a caller can claim `role=system` with an arbitrary UUID and bypass agent resolution entirely. `ROBOCO_CLOUD_AUTH_ENABLED` does not add a DB lookup here either — it only requires that a `system`/any-role claim carry a *verified* HMAC token first (`_cloud_auth_agent_context` rejects any non-CEO role claim without one), so the identity-bypass itself is unchanged, just gated behind a valid signature.
|
||||
@@ -235,9 +234,9 @@ roboco/api/
|
||||
|
||||
## Drift from CLAUDE.md
|
||||
|
||||
- `CLAUDE.md` "WebSocket streams" section lists `/ws/channels/{id}`, `/ws/agents/{id}`, `/ws/sessions/{id}`, `/ws/notifications/{id}`, `/ws/system` — **matches** `websocket.py` routes. No drift.
|
||||
- `CLAUDE.md` "WebSocket streams" section lists `/ws/agents/{id}`, `/ws/notifications/{id}`, `/ws/system` — **matches** `websocket.py` routes. No drift: both sides now agree the surviving set is these three. `/ws/channels/{id}` and `/ws/sessions/{id}` are removed from `websocket.py` itself as part of the comms-subsystem teardown (a real route deletion, not a stale doc omission).
|
||||
- `CLAUDE.md` says `/ws/system` carries "the rate-limit lifecycle (`RATE_LIMIT_HIT` / `RATE_LIMIT_LIFTED`) and live usage (`USAGE_SNAPSHOT`)" — **matches** `websocket_bridge.py:138-166` (`_handle_rate_limit_event` + `_handle_usage_event`). No drift.
|
||||
- `CLAUDE.md` says "To add a new live event: define an `EventType`, publish it to the bus, add a `_handle_*` forwarder in `websocket_bridge`, and consume it on the panel via the `useWebSocket(...)` hook" — **matches** the `_handle_*` pattern. No drift.
|
||||
- `CLAUDE.md` says "To add a new live event: define an `EventType`, publish it to the bus, add a `_handle_*` forwarder in `websocket_bridge`, and consume it on the panel via the `useWebSocket(...)` hook" — **matches** the `_handle_*` pattern. CLAUDE.md's worked example is now `A2A_MESSAGE_SENT` → `_handle_a2a_message_event` → `/ws/system` (replacing the retired `MESSAGE_SENT`/`useSessionStream` example), consistent with this slice's implementation. No drift.
|
||||
- `CLAUDE.md` "Startup Sequence" says "FastAPI lifespan indexes documents using Ollama (~30-60s)" — `app.py:137-147` initializes `OptimalService` (RAG) with a 30-90s blocking init and logs "OptimalService (RAG) initialized successfully"; the "indexes documents" framing is approximate but consistent. No material drift.
|
||||
- `CLAUDE.md` does NOT document the `_require_panel_token` CEO-HMAC WS gate or the HTTP `require_panel_token` dep. The `websocket.py` module docstring (lines 8-13) describes the OLD query-param model, contradicting the actual HMAC-token implementation. The CLAUDE.md "Agent Gateway" section's "Agents do not call the API or per-domain MCP tools directly" is consistent with `/ws/*` being operator-only. **Drift: in-file docstring vs actual code (websocket.py:8-13); CLAUDE.md itself is silent on WS auth, so no CLAUDE.md contradiction.**
|
||||
- `CLAUDE.md` "Orchestrator runtime-state durability" notes the respawn_tracker DB-durable writes are drained on `stop()` — `app.py:170-186` implements the required ordering (stop before close_db). Consistent.
|
||||
@@ -253,9 +252,9 @@ Only ONE commit in `fd10cc86..HEAD` touched this slice: `15effce0` "Chore: 141 G
|
||||
|
||||
Diff stat: `app.py +20`, `deps.py +44`, `middleware.py +49`, `websocket.py +309/-66` (net), `middleware_docs.py` / `websocket_bridge.py` / `utils/*` UNCHANGED.
|
||||
|
||||
> **Post-snapshot update (2026-07-01, chat-subsystem live-delivery work `76ce53e3`):** `websocket_bridge.py` is no longer unchanged — it gained `_handle_message_event` (forwards `EventType.MESSAGE_SENT` to `/ws/sessions/{id}` + `/ws/channels/{id}` as a `message.new` frame) and a `MESSAGE_SENT` subscription in `register_websocket_bridge_handlers`. This is the live transcript-update path that was previously dead (`send_message` never broadcast).
|
||||
> **Post-snapshot update (2026-07-01, chat-subsystem live-delivery work `76ce53e3`):** `websocket_bridge.py` is no longer unchanged — it gained `_handle_message_event` (forwards `EventType.MESSAGE_SENT` to `/ws/sessions/{id}` + `/ws/channels/{id}` as a `message.new` frame) and a `MESSAGE_SENT` subscription in `register_websocket_bridge_handlers`. This is the live transcript-update path that was previously dead (`send_message` never broadcast). **Now removed** by the comms-subsystem teardown (`docs/internal/specs/2026-07-03-comms-teardown-trace.md`): the `/ws/sessions` + `/ws/channels` routes, the `_handle_message_event` forwarder, and its `MESSAGE_SENT` subscription are all gone; `EventType.MESSAGE_SENT` itself is left as a dead/inert enum member, not deleted. The surviving bridge worked example is `_handle_a2a_message_event` (`EventType.A2A_MESSAGE_SENT` → `/ws/system` as an `a2a.message` frame).
|
||||
|
||||
> **Post-snapshot update (2026-07-01, logical-gap sweep `536bbb64`):** `deps.py` gained `require_ceo_role` (deps.py:412) — single source-of-truth CEO-role check shared by the orchestrator router gate and the release handler, replacing two diverged inline comparisons. `websocket.py` gated `/ws/system` with `_require_panel_token` (websocket.py:621), closing the medium regression risk; all five `/ws/*` endpoints are now consistently gated.
|
||||
> **Post-snapshot update (2026-07-01, logical-gap sweep `536bbb64`):** `deps.py` gained `require_ceo_role` (deps.py:412) — single source-of-truth CEO-role check shared by the orchestrator router gate and the release handler, replacing two diverged inline comparisons. `websocket.py` gated `/ws/system` with `_require_panel_token` (websocket.py:621), closing the medium regression risk; all `/ws/*` endpoints are now consistently gated (five at the time of this commit; the comms-subsystem teardown since removed `/ws/channels` + `/ws/sessions`, leaving `/ws/agents`, `/ws/notifications`, `/ws/system`).
|
||||
|
||||
> **Local branch (not on master, NOT deployed):** `feature/fastapi-guard-hardening` (6 fastapi-guard commits `896532a3`..`99ee666e`, branched off `ab69851d`, plus 2 unrelated bundled commits) adds `roboco/security.py` and wires it into this slice — `apply_guard(app)` mounts `SecurityMiddleware` last in `create_app` (app.py:234) and `guarded_lifespan(lifespan)` wraps the async lifespan (app.py:212), both gated by `ROBOCO_GUARD_ENABLED` (default off, byte-for-byte unchanged request path when off). Per-route `@guard_deco.*` decorators (rate_limit/max_request_size/content_type_filter/behavior_analysis/block_clouds/honeypot_detection/usage_monitor/suspicious_detection/custom_validation — 9 kinds) are applied across 21 route files outside this slice (api-routes-schemas + v1 flow/do). `build_security_config` also carries a WAF false-positive calibration: `excluded_detection_body_fields` (75 free-text top-level body fields, including container fields like plan/risks/findings/section/payload) plus `enable_penetration_detection=True`, dropping active-mode false positives on RoboCo's own code/SQL/diff/URL-bearing traffic to zero while leaving the three custom validators and the WAF on non-excluded (id/enum/slug/branch) fields fully in force. New tests: `tests/unit/test_security.py` (unit) + `tests/unit/test_security_middleware.py` (integration — mounts the real middleware end-to-end). Both NAS composes (`docker-compose.yml`/`.yaml`) arm the layer passive/log-only (`c496b677`, Phase 5) — see deployment-tooling.
|
||||
|
||||
@@ -281,18 +280,17 @@ Logic-touching changes in that commit, scoped to this slice:
|
||||
|
||||
| Title | File:Line | Claim | Severity |
|
||||
|-------|-----------|-------|----------|
|
||||
| `/ws/system` ungated while siblings require panel token | websocket.py:608 | ~~The rate-limit/usage operator stream has no `_require_panel_token` call; if nginx does not edge-gate `/ws/system`, any reachable client gets RATE_LIMIT_HIT/LIFTED + USAGE_SNAPSHOT telemetry. The other four streams gate.~~ **RESOLVED (`536bbb64`)**: `/ws/system` now calls `_require_panel_token` (websocket.py:621); all five `/ws/*` endpoints are consistently gated. | ~~medium~~ resolved |
|
||||
| `/ws/system` ungated while siblings require panel token | websocket.py:608 | ~~The rate-limit/usage operator stream has no `_require_panel_token` call; if nginx does not edge-gate `/ws/system`, any reachable client gets RATE_LIMIT_HIT/LIFTED + USAGE_SNAPSHOT telemetry. The other four streams gate.~~ **RESOLVED (`536bbb64`)**: `/ws/system` now calls `_require_panel_token` (websocket.py:621); all `/ws/*` endpoints are consistently gated (five then; the comms-subsystem teardown since removed `/ws/channels` + `/ws/sessions`, leaving three). | ~~medium~~ resolved |
|
||||
| `_run_sender` self-cancels its own task on send error | websocket.py:155 | `self.disconnect(ws)` pops `conn.sender` and calls `.cancel()` on the task currently running `_run_sender`. The immediate `return` mitigates, but a cancellation landing on a returning task is a subtle race; under heavy churn could mask a later send or trip a CancelledError in an `except Exception` handler. | low |
|
||||
| Concurrent set mutation between sender-task `disconnect` and receive-loop `finally disconnect` | websocket.py:155 / 425 | Two tasks (sender + receive loop) can call `disconnect(ws)` on the same socket concurrently. `set.discard` is idempotent, but a `broadcast_to_*` iterating the same set on the loop thread during the sender-task's `disconnect` could raise `Set changed size during iteration`. Single-loop asyncio makes this rare but not impossible. | low |
|
||||
| `validate_agent_exists` opens its own DB session via `async for db in get_db()` per WS upgrade | websocket.py:347 | Each `/agents`, `/sessions`, `/notifications` upgrade grabs a session just to confirm the viewer exists — extra session pressure under many concurrent panel connections; the check is also bypassable in dev (no token). | low |
|
||||
| `validate_agent_exists` opens its own DB session via `async for db in get_db()` per WS upgrade | websocket.py:347 | Each `/agents`, `/notifications` upgrade grabs a session just to confirm the viewer exists — extra session pressure under many concurrent panel connections; the check is also bypassable in dev (no token). | low |
|
||||
| Lifespan shutdown hangs if `orchestrator.stop()` blocks | app.py:184 | The new stop-before-close-db order is correct, but a hung `stop()` now blocks `close_optimal_service` + `close_db` (wrapped in try/except, so a hang — not an exception — is the failure mode). Pre-baseline, a hung stop only affected bootstrap's finally. | low |
|
||||
| 422 response body still echoes unscrubbed secrets | middleware.py:434 | `_scrub_secrets` only scrubs the LOG, not the response. A client sending a `git_token` that fails validation gets it back in the 422 `body` field. By design but a leak surface if logs/responses are captured. | low |
|
||||
| `_resolve_agent_identity` `system` role bypasses DB lookup | deps.py:281 | `role=system` returns `UUID(x_agent_id)` with no DB check. In dev (no auth) a caller can claim `system` with any UUID and get an `AgentContext` for a non-existent agent. Auth-required mode still needs a valid HMAC, mitigating prod. | low |
|
||||
| `_enqueue_or_send` legacy fallback creates unbounded fire-and-forget tasks | websocket.py:266 | For an unregistered socket (shouldn't happen since every `connect_*` registers), a `_send_with_timeout` task is spawned per message with no queue cap. Only the legacy path; held in `_pending_sends` for GC safety. | low |
|
||||
| `broadcast_notification` double-iterates notification connections | websocket.py:665-691 | It reads `manager.notification_connections.get(agent_id)` then calls `manager._enqueue_or_send` per conn, bypassing the `broadcast_to_*` serializer. If a disconnect happens between the snapshot and the enqueue, the enqueue targets a dead socket whose sender was cancelled — `_enqueue_or_send` falls to the legacy fire-and-forget path. Low. | low |
|
||||
| Channel WS no longer checks channel read permission | websocket.py:360-425 | The `validate_channel_access` httpx self-call was removed; channel access is now ONLY the panel token. A panel client can subscribe to ANY channel regardless of role. Acceptable (panel is operator), but a behavior change vs pre-baseline. | low |
|
||||
| Stale module docstring misleads future edits | websocket.py:8-13 | Describes query-param agent validation as the security model; actual model is CEO HMAC. A future edit trusting the docstring could weaken the gate. | low |
|
||||
|
||||
## Health
|
||||
|
||||
This slice is the API spine and is in **good structural shape**. The baseline→head refactor (`15effce0`) materially hardened it: the WebSocket fan-out no longer back-pressures on a slow client (bounded per-connection queues + sender tasks), half-open sockets are reaped by an idle timeout, the lifespan shutdown ordering fix stops silent final-write drops, 422 logs no longer leak credentials, and the WS + HTTP panel-token gates close the operator-only invariant. Post-snapshot, `536bbb64` closed the remaining medium risk by gating `/ws/system` (all five `/ws/*` endpoints are now consistently gated) and added `require_ceo_role` as a single-source CEO check; `76ce53e3` wired the live message-delivery path (`_handle_message_event`). The event-bus bridge is clean and follows the documented `_handle_*` extension pattern. The main remaining integrity concerns are minor: the `websocket.py` module docstring is stale vs the HMAC implementation, and the concurrent-set-mutation window between the sender task's error-path `disconnect` and the receive loop's `finally disconnect` is theoretically present under asyncio single-threadedness. No logic-touching change since baseline looks broken; the regression risks above are edge-case and mostly low severity. The unchanged files (`middleware_docs.py`, `utils/*`) are stable; `websocket_bridge.py` gained `_handle_message_event` (`76ce53e3`). Recommended follow-ups: refresh the `websocket.py` docstring, and consider a lock or snapshot-iteration in `ConnectionManager` broadcast paths.
|
||||
This slice is the API spine and is in **good structural shape**. The baseline→head refactor (`15effce0`) materially hardened it: the WebSocket fan-out no longer back-pressures on a slow client (bounded per-connection queues + sender tasks), half-open sockets are reaped by an idle timeout, the lifespan shutdown ordering fix stops silent final-write drops, 422 logs no longer leak credentials, and the WS + HTTP panel-token gates close the operator-only invariant. Post-snapshot, `536bbb64` closed the remaining medium risk by gating `/ws/system` (all `/ws/*` endpoints are now consistently gated — five then, three after the comms-subsystem teardown) and added `require_ceo_role` as a single-source CEO check; `76ce53e3` wired the live message-delivery path (`_handle_message_event`, since removed by the teardown). The event-bus bridge is clean and follows the documented `_handle_*` extension pattern. The main remaining integrity concerns are minor: the `websocket.py` module docstring is stale vs the HMAC implementation, and the concurrent-set-mutation window between the sender task's error-path `disconnect` and the receive loop's `finally disconnect` is theoretically present under asyncio single-threadedness. No logic-touching change since baseline looks broken; the regression risks above are edge-case and mostly low severity. The unchanged files (`middleware_docs.py`, `utils/*`) are stable; `websocket_bridge.py` gained `_handle_message_event` (`76ce53e3`, since removed by the comms-subsystem teardown — the surviving forwarder is `_handle_a2a_message_event`). Recommended follow-ups: refresh the `websocket.py` docstring, and consider a lock or snapshot-iteration in `ConnectionManager` broadcast paths.
|
||||
@@ -9,10 +9,6 @@ The FastAPI surface of RoboCo: every HTTP route under `roboco/api/routes/` (the
|
||||
|------|------|
|
||||
| roboco/api/routes/health.py | Liveness/readiness (DB + Redis probes). |
|
||||
| roboco/api/routes/agents.py | List/get agents. |
|
||||
| roboco/api/routes/channels.py | Channel CRUD + member ops. |
|
||||
| roboco/api/routes/groups.py | Group create/list. |
|
||||
| roboco/api/routes/sessions.py | Communication sessions + messages. |
|
||||
| roboco/api/routes/messages.py | Message list/create/patch/delete. |
|
||||
| roboco/api/routes/notifications.py | Notification list/ack/send. |
|
||||
| roboco/api/routes/stream.py | Agent stream chunk/complete/extract + permissions. |
|
||||
| roboco/api/routes/journals.py | Journal entries, search, growth stats. |
|
||||
@@ -85,7 +81,7 @@ The FastAPI surface of RoboCo: every HTTP route under `roboco/api/routes/` (the
|
||||
| POST | /api/v1/flow/main_pm/{submit_root,triage_all,escalate_to_ceo,complete} | flow_main_pm.py | `require_main_pm` |
|
||||
| POST | /api/v1/flow/pr_reviewer/{claim_pr_review,claim_gate_review,pr_pass,pr_fail,post_pr_review} | flow_pr_reviewer.py | `require_pr_reviewer` |
|
||||
| POST | /api/v1/do/{commit,note,say,dm,notify,evidence,draft_playbook,approve_playbook,...} | do.py | `require_any_authenticated_agent` (HMAC, any role) |
|
||||
| GET | /ws/{channels,agents,sessions,notifications,system}/{id} | websocket.py | WS panel/HMAC token |
|
||||
| GET | /ws/{agents,notifications,system}/{id} | websocket.py | WS panel/HMAC token |
|
||||
|
||||
## Key Symbols
|
||||
|
||||
@@ -144,10 +140,6 @@ roboco/api/
|
||||
│ ├── operator-panel (api/*)
|
||||
│ │ ├── health.py liveness/readiness
|
||||
│ │ ├── agents.py agent list/get
|
||||
│ │ ├── channels.py channel CRUD + members
|
||||
│ │ ├── groups.py group create/list
|
||||
│ │ ├── sessions.py comms sessions + messages
|
||||
│ │ ├── messages.py message CRUD
|
||||
│ │ ├── notifications.py notification ack/send
|
||||
│ │ ├── stream.py agent stream chunks/extract
|
||||
│ │ ├── journals.py journal entries + growth
|
||||
@@ -232,7 +224,7 @@ roboco/api/
|
||||
|
||||
## Drift from CLAUDE.md
|
||||
- CLAUDE.md lists `pr_pass`/`pr_fail` under `pr_reviewer` verbs and the in-path gate; code matches (`flow_pr_reviewer.py` exposes `claim_gate_review`, `pr_pass`, `pr_fail`). No drift found.
|
||||
- CLAUDE.md says agent comms use `say`/`dm`/`notify` via do_server; code matches (`v1/do.py` exposes all three). No drift.
|
||||
- CLAUDE.md says agent comms use `dm`/`read_a2a`/`notify` via do_server (the channel/session `say`/`open_session`/`link_session` surface was removed in the comms-subsystem teardown); code matches. No drift.
|
||||
- CLAUDE.md lists `sync_branch` as a developer verb; present in `flow_dev.py:125`. No drift.
|
||||
- CLAUDE.md's verb table omits `flow_pr_reviewer.post_pr_review` (external PR comment) — present in code; additive, not contradictory.
|
||||
- None material.
|
||||
|
||||
@@ -117,7 +117,7 @@ Choreographer (composed class, _impl.py)
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
- **Internal services**: `TaskService`, `WorkSessionService`, `GitService`, `EvidenceRepo`/`evidence_builder`, `MessagingService`/a2a, `JournalService`, `AuditService`, `ProjectService`/product, orchestrator handle, `StreamEventBus`.
|
||||
- **Internal services**: `TaskService`, `WorkSessionService`, `GitService`, `EvidenceRepo`/`evidence_builder`, `a2a`, `JournalService`, `AuditService`, `ProjectService`/product, orchestrator handle, `StreamEventBus`.
|
||||
- **Policy (pure)**: `roboco.foundation.policy.lifecycle` (`can_invoke_intent`, `Context`, `Role`, `_INTENT_VERBS`), `foundation.policy.batch` (`is_batch_umbrella`), `foundation.policy.content` markers + `reject_trivial`.
|
||||
- **Gateway helpers**: `claim_guards` (`already_active_guard`, `paused_tasks_guard`, `unmet_dependency_guard`), `merge_chain.resolve_parent_branch`, `envelope.Envelope`, `remediation` hints.
|
||||
- **External**: `structlog`, `asyncpg` (via task_service session / SAVEPOINT), `redis` (rate-limit parking), `git`/`gh` CLI (via git_service).
|
||||
|
||||
@@ -10,7 +10,7 @@ The DB layer is async SQLAlchemy 2.0 over PostgreSQL+asyncpg, with pgvector for
|
||||
| `roboco/db/__init__.py` | Re-exports `Base`, session helpers, `bootstrap_database`, table classes. |
|
||||
| `roboco/db/base.py` | `Base` (DeclarativeBase + naming convention), `get_engine`, `get_session_factory`, `get_db` (FastAPI dep), `get_db_context`, `run_migrations`, `init_db`, `_db_has_*` probes. Stamps a pre-Alembic DB at 001 then upgrades head. |
|
||||
| `roboco/db/tables.py` | All 37 ORM table classes (single module). |
|
||||
| `roboco/db/seed.py` | `bootstrap_database()` — runs `init_db` then seeds agents, channels, groups, initial messages. |
|
||||
| `roboco/db/seed.py` | `bootstrap_database()` — runs `init_db` then seeds agents. |
|
||||
| `alembic/env.py` | Async Alembic env; imports `roboco.db.tables` to register metadata, overrides `sqlalchemy.url` from settings, `compare_type` + `compare_server_default` on. |
|
||||
| `alembic.ini` | Standard config; `script_location=alembic`, `prepend_sys_path=.`, no URL (set in env.py). |
|
||||
| `alembic/versions/` | 59 migration files 001..059 (two share number 026 — chained, not a collision). |
|
||||
@@ -25,7 +25,7 @@ The DB layer is async SQLAlchemy 2.0 over PostgreSQL+asyncpg, with pgvector for
|
||||
| `get_db_context` | fn | db/base.py | Out-of-request async session context. |
|
||||
| `init_db` | fn | db/base.py:180 | Boot entry: stamp pre-Alembic DB at 001 then `run_migrations`. |
|
||||
| `run_migrations` | fn | db/base.py:141 | Runs `alembic upgrade head` via `command.upgrade` in a thread. |
|
||||
| `bootstrap_database` | fn | db/seed.py:282 | `init_db` + seed default agents/channels/groups/messages. |
|
||||
| `bootstrap_database` | fn | db/seed.py:282 | `init_db` + seed default agents. |
|
||||
| `TaskTable` | class | tables.py:157 | Core task entity (largest table, drives lifecycle). |
|
||||
| `WorkSessionTable` | class | tables.py:798 | Per-claim session; single-active enforced by 047 partial-unique index. |
|
||||
| `AgentTable` | class | tables.py:95 | Agent identity, role, team, model provider assignment. |
|
||||
@@ -47,7 +47,7 @@ The DB layer is async SQLAlchemy 2.0 over PostgreSQL+asyncpg, with pgvector for
|
||||
|
||||
| Num | File | What it adds/changes |
|
||||
|-----|------|---------------------|
|
||||
| 001 | 001_initial_schema.py | All initial tables (agents, tasks, work_sessions, channels, sessions, messages, notifications, journals, audit_log, a2a_*). |
|
||||
| 001 | 001_initial_schema.py | All initial tables (agents, tasks, work_sessions, notifications, journals, audit_log, a2a_*); also originally created channels/sessions/messages, later dropped by the comms-teardown migration. |
|
||||
| 002 | 002_persistence_tables.py | Persistence tables + `NotificationType.APPROVAL`. |
|
||||
| 003 | 003_blocker_resolver_type.py | `tasks.blocker_resolver_type` + `blockerresolvertype` enum. |
|
||||
| 004 | 004_provider_routing.py | `provider_configs` + `model_assignments`; `modelprovider`/`assignmentscope` enums (create_type=False). |
|
||||
@@ -129,7 +129,7 @@ graph LR
|
||||
```
|
||||
Migration chain 001..059
|
||||
├── Initial schema
|
||||
│ └── 001 initial schema (agents, tasks, work_sessions, channels, sessions, messages, notifications, journals, audit_log, a2a_*)
|
||||
│ └── 001 initial schema (agents, tasks, work_sessions, notifications, journals, audit_log, a2a_*; also originally channels/sessions/messages, later dropped by the comms-teardown migration)
|
||||
├── Persistence
|
||||
│ └── 002 persistence tables + NotificationType.APPROVAL
|
||||
├── Blocker metadata
|
||||
|
||||
@@ -15,7 +15,7 @@ This slice is the packaging, build, and runtime-tooling layer of RoboCo: the Doc
|
||||
| roboco/bootstrap.py | Async bootstrap: DB init, Redis event bus, websocket bridge, orchestrator construction, uvicorn API server task, wait-for-ready poll, optional agent spawn, graceful shutdown | 161 |
|
||||
| roboco/cli.py | argparse CLI wrapper around bootstrap.main / db-only; the ENTRYPOINT invoked by `python -m roboco.cli` | 59 |
|
||||
| roboco/logging.py | structlog setup (dev ConsoleRenderer / prod JSONRenderer), secret-redaction processor, rotating file handler under /data/logs, LogContext context-manager | 252 |
|
||||
| roboco/exceptions.py | Exception hierarchy (RobocoError base + NotFound/Validation/InvalidState/Permission/Auth/Task/TaskLifecycle/Agent/Channel/Session/Notification/Service/Database/Git/MergeConflict/GitCommand/GitTimeout); includes TaskLifecycle transition hints and git-secret scrubbing | 497 |
|
||||
| roboco/exceptions.py | Exception hierarchy (RobocoError base + NotFound/Validation/InvalidState/Permission/Auth/Task/TaskLifecycle/Agent/Notification/Service/Database/Git/MergeConflict/GitCommand/GitTimeout); includes TaskLifecycle transition hints and git-secret scrubbing | 497 |
|
||||
| docker/orchestrator.Dockerfile | Multi-stage build: uv venv builder (python:3.13-slim) + runner with docker-cli/git/make/node/npm/pnpm, ENTRYPOINT python -m roboco.cli | 99 |
|
||||
| docker/agent-base.Dockerfile | Shared agent runtime: uv venv + Node 22 + @anthropic-ai/claude-code, agent user, hook scripts, safe.directory *, ENTRYPOINT claude | 108 |
|
||||
| docker/agent-pm.Dockerfile | PM agent — FROM roboco-agent-base, no extra tools (MCP-only) | 11 |
|
||||
@@ -51,7 +51,7 @@ This slice is the packaging, build, and runtime-tooling layer of RoboCo: the Doc
|
||||
| scripts/verify_postgres_enums.py | Foundation drift gate: compare postgres agentrole/team enum labels to foundation identity; exit 0 on match/skip(unreachable or unmigrated), 1 on drift | 127 |
|
||||
| scripts/reflow_md.py | Reflow hard-wrapped markdown prose to one line per paragraph (token-invariant safety check); --apply / --check modes for the CI gate | 214 |
|
||||
| scripts/reset_runtime_state.sh | Host/container smoke-test reset: stop agent containers, run reset_runtime_state.sql, FLUSH Redis, optional FULL_RESET data wipe, per-workspace git hard-reset + stray-branch prune | 263 |
|
||||
| scripts/reset_runtime_state.sql | Transactional DELETE of runtime tables (tasks/sessions/messages/.../a2a_*) preserving agents/projects/channels/alembic_version; resets agents.metrics + groups.active_session_id | 159 |
|
||||
| scripts/reset_runtime_state.sql | Transactional DELETE of runtime tables (tasks/.../a2a_*) preserving agents/projects/alembic_version; resets agents.metrics | 159 |
|
||||
|
||||
## Key Symbols
|
||||
|
||||
@@ -86,9 +86,6 @@ This slice is the packaging, build, and runtime-tooling layer of RoboCo: the Doc
|
||||
| TaskError | class | roboco/exceptions.py:171 | Base task error (code TASK_ERROR), carries task_id |
|
||||
| TaskLifecycleError | class | roboco/exceptions.py:191 | Invalid transition; _TRANSITION_HINTS table appends procedural tool-call hints for common footguns |
|
||||
| AgentError | class | roboco/exceptions.py:273 | Base agent error carrying agent_id |
|
||||
| ChannelError | class | roboco/exceptions.py:298 | Base channel/messaging error |
|
||||
| ChannelAccessDeniedError | class | roboco/exceptions.py:318 | No read/write access to a channel (code CHANNEL_ACCESS_DENIED) |
|
||||
| SessionClosedError | class | roboco/exceptions.py:340 | Session is closed (code SESSION_CLOSED) |
|
||||
| NotificationError | class | roboco/exceptions.py:364 | Notification error base |
|
||||
| ServiceError | class | roboco/exceptions.py:381 | External service error carrying service name |
|
||||
| DatabaseError | class | roboco/exceptions.py:400 | Database operation failed |
|
||||
@@ -261,7 +258,6 @@ deployment-tooling
|
||||
- ROBOCO_TRANSCRIPT_RETENTION_DAYS / ROBOCO_TRANSCRIPT_PRUNE_ENABLED / _INTERVAL_SECONDS
|
||||
- ROBOCO_IMAGE_PRUNE_ENABLED / _INTERVAL_SECONDS
|
||||
- ROBOCO_GIT_COMMAND_TIMEOUT_SECONDS / _COMMIT_TIMEOUT_SECONDS / _NETWORK_TIMEOUT_SECONDS
|
||||
- ROBOCO_SESSION_IDLE_TIMEOUT_SECONDS
|
||||
- ROBOCO_PROTECTED_GIT_URLS
|
||||
- ROBOCO_AGENT_TOOL_CALL_WARN/HALT / ROBOCO_AGENT_LOOP_THRESHOLD/WINDOW / ROBOCO_AGENT_STOP_ATTEMPT_ALLOWANCE / ROBOCO_AGENT_SLA_* / ROBOCO_CLAUDE_STUCK_KILL_SECONDS
|
||||
- ROBOCO_QA_NOTES_MIN_CHARS / DOCS / DEV / PR_REVIEWER / QUICK_CONTEXT_MIN_CHARS
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
## Purpose
|
||||
The canonical source of truth for RoboCo's task state machine, per-role action/verb permissions, claim rules, team-match rules, and self-review prevention. The spec module is pure data + lookups (no DB, no I/O); the enforcement package re-exports a backwards-compat view of the same tables plus the git-workflow gates, SLA keys, channel/A2A/journal/ownership access control. Import-time validators in _validate_lifecycle.py make a misconfigured spec fail fast at container start.
|
||||
The canonical source of truth for RoboCo's task state machine, per-role action/verb permissions, claim rules, team-match rules, and self-review prevention. The spec module is pure data + lookups (no DB, no I/O); the enforcement package re-exports a backwards-compat view of the same tables plus the git-workflow gates, SLA keys, A2A/journal/ownership access control. Import-time validators in _validate_lifecycle.py make a misconfigured spec fail fast at container start.
|
||||
|
||||
## Files
|
||||
|
||||
@@ -8,9 +8,8 @@ The canonical source of truth for RoboCo's task state machine, per-role action/v
|
||||
| /Users/renzof/Documents/GitHub/ZZZ/roboco-master/roboco/roboco/foundation/policy/lifecycle.py | Canonical lifecycle/permissions spec: Status, TaskType, Decision, Precondition, ActionSpec, IntentSpec, StatusTransition tables + can_invoke_action/intent lookups + CLAIM_RULES/ROLE_TEAM_RULES + PR_OPEN_STATES + UNMIGRATED debt set; import-time self-validates. | 1943 |
|
||||
| /Users/renzof/Documents/GitHub/ZZZ/roboco-master/roboco/roboco/foundation/_validate_lifecycle.py | Import-time validators (BFS reachability, terminal exits, intent composition chains, claim-rule coverage, self_review symmetry, slug/team agreement, Status/TaskStatus ORM parity, UNMIGRATED subset); LifecycleSpecError aborts container start on a bad spec. | 356 |
|
||||
| /Users/renzof/Documents/GitHub/ZZZ/roboco-master/roboco/roboco/enforcement/task_lifecycle.py | Backwards-compat shim over the spec: derives VALID_TRANSITIONS / ROLE_RESTRICTED_TRANSITIONS (merging _LEGACY_OPERATIONAL_EDGES + _LEGACY_ROLE_GATES), predicate helpers, SLA keys, GitContext + validate_git_requirements (doc-phase / CEO-escalation / claim-branch gates). | 441 |
|
||||
| /Users/renzof/Documents/GitHub/ZZZ/roboco-master/roboco/roboco/enforcement/__init__.py | Package re-export aggregator: exposes task_lifecycle, channel_access, a2a_access, journal_perms, task_ownership public symbols under one namespace. | 87 |
|
||||
| /Users/renzof/Documents/GitHub/ZZZ/roboco-master/roboco/roboco/enforcement/__init__.py | Package re-export aggregator: exposes task_lifecycle, a2a_access, journal_perms, task_ownership public symbols under one namespace. | 87 |
|
||||
| /Users/renzof/Documents/GitHub/ZZZ/roboco-master/roboco/roboco/enforcement/a2a_access.py | Agent-to-agent direct-message permission gate (delegates to roboco.agents_config.can_a2a_direct); A2AAccessDeniedError. | 91 |
|
||||
| /Users/renzof/Documents/GitHub/ZZZ/roboco-master/roboco/roboco/enforcement/channel_access.py | Channel read/write/silent-observer access gate over CHANNEL_ACCESS table; ChannelAccessDeniedError. | 109 |
|
||||
| /Users/renzof/Documents/GitHub/ZZZ/roboco-master/roboco/roboco/enforcement/journal_perms.py | Journal read permissions derived from foundation.policy.journaling ReadTier (ALL/ALL_CELLS/CELL_AND_PMS/CELL/OWN) + PROTECTED_JOURNALS; JournalAccessDeniedError. | 197 |
|
||||
| /Users/renzof/Documents/GitHub/ZZZ/roboco-master/roboco/roboco/enforcement/task_ownership.py | Task ownership + reassign + view + self-review guard (can_review_task); TaskOwnershipError. | 118 |
|
||||
|
||||
@@ -70,13 +69,12 @@ The canonical source of truth for RoboCo's task state machine, per-role action/v
|
||||
| _LEGACY_ROLE_GATES | dict | /Users/renzof/Documents/GitHub/ZZZ/roboco-master/roboco/roboco/enforcement/task_lifecycle.py:99 | Role pins for the legacy edges; UNION-merged with spec-derived ROLE_RESTRICTED_TRANSITIONS (overwrite once dropped pr_reviewer). |
|
||||
| _build_role_restricted_transitions | function | /Users/renzof/Documents/GitHub/ZZZ/roboco-master/roboco/roboco/enforcement/task_lifecycle.py:156 | Merges spec role_constraint pins with _LEGACY_ROLE_GATES via union (not overwrite). |
|
||||
| validate_a2a_access | function | /Users/renzof/Documents/GitHub/ZZZ/roboco-master/roboco/roboco/enforcement/a2a_access.py:38 | A2A direct-message gate (delegates to agents_config.can_a2a_direct). |
|
||||
| validate_channel_access | function | /Users/renzof/Documents/GitHub/ZZZ/roboco-master/roboco/roboco/enforcement/channel_access.py:36 | Channel read/write access gate with wildcard + silent-observer read. |
|
||||
| can_read_journal | function | /Users/renzof/Documents/GitHub/ZZZ/roboco-master/roboco/roboco/enforcement/journal_perms.py:121 | Journal read decision by ReadTier + protected-journal + same-cell/owner-is-pm rules. |
|
||||
| validate_task_ownership | function | /Users/renzof/Documents/GitHub/ZZZ/roboco-master/roboco/roboco/enforcement/task_ownership.py:35 | Ownership/reassign/view gate; non-PM must be the assignee. |
|
||||
| can_review_task | function | /Users/renzof/Documents/GitHub/ZZZ/roboco-master/roboco/roboco/enforcement/task_ownership.py:102 | Self-review prevention: agent_id != task_developed_by. |
|
||||
|
||||
## Data Flow
|
||||
Inputs: a (role, verb_or_action, task, Context) tuple. The Choreographer (services/gateway/choreographer/_impl.py + per-verb modules qa/pr_review/pr_gate) builds a Context (actor_id, plan, original_developer_slug, journal flags) per request, resolves the task row, and calls can_invoke_intent(role, verb, task, ctx). Control flow inside can_invoke_intent: verb lookup -> role gate -> _check_intent_preconditions (extra_preconditions, honoring per-precondition rejection_kind) -> if composes non-empty, can_invoke_action on the FIRST composed action only (subsequent actions chained by the runner after state transitions); if composes empty + verb in {claim_review, claim_doc_task, claim_gate_review}, _check_claim_rules_narrow; else allow. can_invoke_action order: action exists -> _check_role_status_type (role/source-status/task_type) -> _check_self_review_and_preconditions -> _check_claim_rules_narrow for claim. Output: a frozen Decision (allow / reject(kind,...) / tracing_gap(missing,remediate)) mapped by envelope.py onto the gateway Envelope (status/error/remediate/missing) and by valid_next_verbs onto the envelope's current_state introspection. Side-export: role_config.py calls intents_for_role at import to build per-role MCP tool manifests; GitService imports PR_OPEN_STATES to derive its HTTP PR-create str set; task_lifecycle shim derives VALID_TRANSITIONS/ROLE_RESTRICTED_TRANSITIONS consumed by TaskService._validate_and_set_status; validate_git_requirements consumed by TaskService transition path; enforcement.__init__ re-exports channel/a2a/journal/ownership gates consumed by messaging/journal services. At module load the spec calls run_all_lifecycle_validators() which BFS-validates the graph + compositions + claim/team tables, aborting container start on inconsistency.
|
||||
Inputs: a (role, verb_or_action, task, Context) tuple. The Choreographer (services/gateway/choreographer/_impl.py + per-verb modules qa/pr_review/pr_gate) builds a Context (actor_id, plan, original_developer_slug, journal flags) per request, resolves the task row, and calls can_invoke_intent(role, verb, task, ctx). Control flow inside can_invoke_intent: verb lookup -> role gate -> _check_intent_preconditions (extra_preconditions, honoring per-precondition rejection_kind) -> if composes non-empty, can_invoke_action on the FIRST composed action only (subsequent actions chained by the runner after state transitions); if composes empty + verb in {claim_review, claim_doc_task, claim_gate_review}, _check_claim_rules_narrow; else allow. can_invoke_action order: action exists -> _check_role_status_type (role/source-status/task_type) -> _check_self_review_and_preconditions -> _check_claim_rules_narrow for claim. Output: a frozen Decision (allow / reject(kind,...) / tracing_gap(missing,remediate)) mapped by envelope.py onto the gateway Envelope (status/error/remediate/missing) and by valid_next_verbs onto the envelope's current_state introspection. Side-export: role_config.py calls intents_for_role at import to build per-role MCP tool manifests; GitService imports PR_OPEN_STATES to derive its HTTP PR-create str set; task_lifecycle shim derives VALID_TRANSITIONS/ROLE_RESTRICTED_TRANSITIONS consumed by TaskService._validate_and_set_status; validate_git_requirements consumed by TaskService transition path; enforcement.__init__ re-exports a2a/journal/ownership gates consumed by journal services. At module load the spec calls run_all_lifecycle_validators() which BFS-validates the graph + compositions + claim/team tables, aborting container start on inconsistency.
|
||||
|
||||
## Mermaid
|
||||
```mermaid
|
||||
@@ -147,13 +145,12 @@ foundation-lifecycle
|
||||
│ ├── ROLE_STATE_SLA_KEYS / sla_seconds_for
|
||||
│ └── GitContext / GitRequirementError / validate_git_requirements / check_parallel_completion
|
||||
├── a2a_access.py (A2A direct-message gate)
|
||||
├── channel_access.py (channel read/write/silent gate)
|
||||
├── journal_perms.py (ReadTier-based journal read gate)
|
||||
└── task_ownership.py (ownership + reassign + self-review)
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
- Internal: roboco.foundation.identity (Role, Team), roboco.foundation.policy.journaling (ReadTier, ROLE_READ_TIERS, PROTECTED_JOURNALS), roboco.seeds.initial_data (AGENT_UUIDS, DEFAULT_AGENTS) for slug/team validators, roboco.agents_config (CHANNEL_ACCESS, can_a2a_direct, get_a2a_route_hint, get_agent_cell/role/team), roboco.config.settings (SLA key resolution), roboco.exceptions (RobocoError, TaskLifecycleError)
|
||||
- Internal: roboco.foundation.identity (Role, Team), roboco.foundation.policy.journaling (ReadTier, ROLE_READ_TIERS, PROTECTED_JOURNALS), roboco.seeds.initial_data (AGENT_UUIDS, DEFAULT_AGENTS) for slug/team validators, roboco.agents_config (can_a2a_direct, get_a2a_route_hint, get_agent_cell/role/team), roboco.config.settings (SLA key resolution), roboco.exceptions (RobocoError, TaskLifecycleError)
|
||||
- External: dataclasses (frozen dataclasses), enum.StrEnum, collections.deque (BFS validator), itertools.pairwise (intent chain validator), typing (Literal, TYPE_CHECKING, Callable, Any)
|
||||
|
||||
## Entry Points
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
## Purpose
|
||||
The "misc" foundation-policy slice holds the pure, service-agnostic rule catalogs that the gateway/services layer composes: channel topology + notification/urgency rules (communications.py), journal scope + read-tier permissions (journaling.py), task completeness field rules + placeholder denylists (task_completeness.py), the verb→required-set tracing gate table (tracing.py), per-agent budget/loop/circuit-breaker thresholds (agent_loop.py), and the structured agent-content schema (content/ — typed PR-review/QA/doc/dev/auditor/resumption/task-description models with validators). It is data + validators, no I/O, no DB — the single source of truth that enforcement/services consume.
|
||||
The "misc" foundation-policy slice holds the pure, service-agnostic rule catalogs that the gateway/services layer composes: notification/urgency rules (communications.py), journal scope + read-tier permissions (journaling.py), task completeness field rules + placeholder denylists (task_completeness.py), the verb→required-set tracing gate table (tracing.py), per-agent budget/loop/circuit-breaker thresholds (agent_loop.py), and the structured agent-content schema (content/ — typed PR-review/QA/doc/dev/auditor/resumption/task-description models with validators). It is data + validators, no I/O, no DB — the single source of truth that enforcement/services consume.
|
||||
|
||||
## Files
|
||||
|
||||
| Path | Role | LOC |
|
||||
|---|---|---|
|
||||
| roboco/foundation/policy/communications.py | Channel topology catalog (CHANNELS), notification sender allowlist, NotificationType→requires_ack map, A2A priority parser | 280 |
|
||||
| roboco/foundation/policy/communications.py | Notification sender allowlist, NotificationType→requires_ack map, A2A priority parser | 280 |
|
||||
| roboco/foundation/policy/journaling.py | Journal Scope enum, scope→JournalEntryType map, per-role journal ReadTier, protected-journal slugs | 77 |
|
||||
| roboco/foundation/policy/task_completeness.py | Task field completeness rules + placeholder denylist + auto-fill helpers (team/priority/parent) | 299 |
|
||||
| roboco/foundation/policy/tracing.py | Tracing-gate: Requirement enum, per-requirement checkers, VERB_REQUIREMENTS table, VERBS_WITHOUT_TRACING set, check_requirements entry | 416 |
|
||||
@@ -23,9 +23,6 @@ The "misc" foundation-policy slice holds the pure, service-agnostic rule catalog
|
||||
| parse_priority | function | roboco/foundation/policy/communications.py:23 | Resolve a NotificationPriority from raw_priority string or legacy urgent flag; unknown→NORMAL |
|
||||
| NOTIFY_SENDER_ROLES | constant | roboco/foundation/policy/communications.py:51 | frozenset of roles permitted to call notify() (CELL_PM, MAIN_PM, PRODUCT_OWNER, HEAD_MARKETING, CEO) |
|
||||
| ACK_REQUIRED_BY_TYPE | constant | roboco/foundation/policy/communications.py:66 | NotificationType→requires_ack mapping (action-required vs informational) |
|
||||
| ChannelSpec | dataclass | roboco/foundation/policy/communications.py:99 | Frozen spec binding a channel slug to read/write/silent roles, type, read-only flag, optional team_scope |
|
||||
| TEAM_SCOPED_ROLES | constant | roboco/foundation/policy/communications.py:122 | frozenset of cell-member roles filtered by team_scope (DEVELOPER, QA, DOCUMENTER, CELL_PM); cross-cell roles (MAIN_PM, AUDITOR, CEO, board) are exempt — single source of truth imported by agents_config and seeds/initial_data |
|
||||
| CHANNELS | constant | roboco/foundation/policy/communications.py:164 | Canonical channel topology dict: 9 channels (3 cell, 4 cross-cell, 2 management, 2 special) |
|
||||
| Scope | enum | roboco/foundation/policy/journaling.py:21 | Journal entry scope StrEnum (note/decision/reflect/learning/struggle) |
|
||||
| SCOPE_TO_TYPE | constant | roboco/foundation/policy/journaling.py:32 | Scope→JournalEntryType single-source mapping |
|
||||
| ReadTier | enum | roboco/foundation/policy/journaling.py:41 | Journal read-breadth StrEnum (own/cell/cell_and_pms/all_cells/all) |
|
||||
@@ -119,13 +116,13 @@ The "misc" foundation-policy slice holds the pure, service-agnostic rule catalog
|
||||
| _extract_strs_from_dict | function | roboco/foundation/policy/content/validators.py:128 | Resolve dict element to strings via first recognized text key else bare string values |
|
||||
|
||||
## Data Flow
|
||||
This slice is pure policy/data: no I/O, no DB, no async. Control flows IN from the gateway/services layer at call sites. (1) communications.py: MessagingService / PermissionService.can_write_channel / notification_delivery / api routes (messages.py validate_channel_access) import CHANNELS, NOTIFY_SENDER_ROLES, ACK_REQUIRED_BY_TYPE, parse_priority; a2a.py imports parse_priority to resolve A2A urgency tristate. agents_config.py + seeds/initial_data.py derive channel membership from CHANNELS. (2) journaling.py: JournalService + enforcement/journal_perms.py import SCOPE_TO_TYPE, ROLE_READ_TIERS, PROTECTED_JOURNALS; gateway content_actions imports Scope for the note scope validation. (3) task_completeness.py: TaskService.create / PrompterService.create_task_from_draft / choreographer._impl._create_subtask_from_inputs call check(TASK_AT_CREATE, task) and the fill_* helpers; on failure raise TaskCompletenessError → gateway returns Envelope.incomplete_input with field_hints. (4) tracing.py: choreographer/_impl.py, qa.py, doc.py, pr_gate.py, pr_review.py call requirements_for(verb) then check_requirements(task=..., requirements=..., ctx=GateContext(...)) before allowing a state transition; failure → Envelope.tracing_gap with missing list. The parity test test_every_intent_verb_has_a_tracing_decision asserts every intent verb is in either VERB_REQUIREMENTS or VERBS_WITHOUT_TRACING. (5) agent_loop.py: agent_sdk/server.py reads DEFAULT_BUDGET thresholds (env-overridable) and retry_limit_for(verb); orchestrator.py reads pm_respawn_max_unproductive + pm_respawn_max_tracing_resets for the respawn circuit breaker. (6) content/: gateway content_actions / choreographer / prompter / intake_driver / flow_server call validate_content(content_type, payload) → model instance → render_markdown() written to Task note columns + PR comment bodies; pr_review_conflict guards the GitHub review event against findings; markers.py accessors are called across task.py, orchestrator, self_heal_engine, release_proposal, release_manager_engine to read/write orchestration_markers. coerce_str_list is applied at the intake/flow boundary (intake_driver, flow_server, flow schemas, prompter) BEFORE payloads reach the models, so models only need coerce_to_list as a backstop.
|
||||
This slice is pure policy/data: no I/O, no DB, no async. Control flows IN from the gateway/services layer at call sites. (1) communications.py: notification_delivery.py / notification.py import ACK_REQUIRED_BY_TYPE; agents_config.py / services/permissions.py / gateway/content_actions.py import NOTIFY_SENDER_ROLES for the notify-sender allowlist; a2a.py imports parse_priority to resolve A2A urgency tristate. (2) journaling.py: JournalService + enforcement/journal_perms.py import SCOPE_TO_TYPE, ROLE_READ_TIERS, PROTECTED_JOURNALS; gateway content_actions imports Scope for the note scope validation. (3) task_completeness.py: TaskService.create / PrompterService.create_task_from_draft / choreographer._impl._create_subtask_from_inputs call check(TASK_AT_CREATE, task) and the fill_* helpers; on failure raise TaskCompletenessError → gateway returns Envelope.incomplete_input with field_hints. (4) tracing.py: choreographer/_impl.py, qa.py, doc.py, pr_gate.py, pr_review.py call requirements_for(verb) then check_requirements(task=..., requirements=..., ctx=GateContext(...)) before allowing a state transition; failure → Envelope.tracing_gap with missing list. The parity test test_every_intent_verb_has_a_tracing_decision asserts every intent verb is in either VERB_REQUIREMENTS or VERBS_WITHOUT_TRACING. (5) agent_loop.py: agent_sdk/server.py reads DEFAULT_BUDGET thresholds (env-overridable) and retry_limit_for(verb); orchestrator.py reads pm_respawn_max_unproductive + pm_respawn_max_tracing_resets for the respawn circuit breaker. (6) content/: gateway content_actions / choreographer / prompter / intake_driver / flow_server call validate_content(content_type, payload) → model instance → render_markdown() written to Task note columns + PR comment bodies; pr_review_conflict guards the GitHub review event against findings; markers.py accessors are called across task.py, orchestrator, self_heal_engine, release_proposal, release_manager_engine to read/write orchestration_markers. coerce_str_list is applied at the intake/flow boundary (intake_driver, flow_server, flow schemas, prompter) BEFORE payloads reach the models, so models only need coerce_to_list as a backstop.
|
||||
|
||||
## Mermaid
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "foundation/policy (this slice)"
|
||||
COMM["communications.py\nCHANNELS + NOTIFY_SENDER_ROLES\nACK_REQUIRED_BY_TYPE + parse_priority"]
|
||||
COMM["communications.py\nNOTIFY_SENDER_ROLES\nACK_REQUIRED_BY_TYPE + parse_priority"]
|
||||
JOURN["journaling.py\nScope + SCOPE_TO_TYPE\nROLE_READ_TIERS + PROTECTED_JOURNALS"]
|
||||
TC["task_completeness.py\nTASK_AT_CREATE + check\nfill_* helpers + denylists"]
|
||||
TRAC["tracing.py\nVERB_REQUIREMENTS\nVERBS_WITHOUT_TRACING\ncheck_requirements"]
|
||||
@@ -140,7 +137,7 @@ graph TD
|
||||
|
||||
subgraph "consumers"
|
||||
GW["gateway/choreographer + content_actions"]
|
||||
SVC["TaskService / JournalService /\nMessagingService / PermissionService"]
|
||||
SVC["TaskService / JournalService / PermissionService"]
|
||||
ORCH["runtime/orchestrator"]
|
||||
SDK["agent_sdk/server"]
|
||||
INTAKE["intake_driver / prompter / flow_server"]
|
||||
@@ -170,13 +167,6 @@ foundation/policy (misc slice)
|
||||
parse_priority()
|
||||
NOTIFY_SENDER_ROLES
|
||||
ACK_REQUIRED_BY_TYPE
|
||||
ChannelSpec (dataclass)
|
||||
TEAM_SCOPED_ROLES
|
||||
CHANNELS
|
||||
cell: backend-cell, frontend-cell, uxui-cell
|
||||
cross-cell: dev-all, qa-all, pm-all, doc-all
|
||||
management: main-pm-board, board-private
|
||||
special: announcements (read-only-for-others), all-hands
|
||||
journaling.py
|
||||
Scope (note/decision/reflect/learning/struggle)
|
||||
SCOPE_TO_TYPE
|
||||
@@ -214,7 +204,7 @@ foundation/policy (misc slice)
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
- Internal: roboco.foundation.identity (Role, Team, CELL_TEAMS, team_for_slug), roboco.models.base (ChannelType, NotificationPriority, NotificationType, JournalEntryType)
|
||||
- Internal: roboco.foundation.identity (Role, Team, CELL_TEAMS, team_for_slug), roboco.models.base (NotificationPriority, NotificationType, JournalEntryType)
|
||||
- External: pydantic (BaseModel, ConfigDict, Field, ValidationError, ValidationInfo, field_validator), dataclasses (dataclass, field), enum (StrEnum), typing (Any, Literal, Protocol), collections.abc (Callable), re, string, contextlib
|
||||
|
||||
## Entry Points
|
||||
@@ -248,7 +238,6 @@ foundation/policy (misc slice)
|
||||
- tracing.py _check_subtasks_terminal reads task._subtasks_all_terminal, a synthetic attribute the choreographer must set from a DB query before calling check_requirements. Forgetting to set it makes the gate fail with 'subtasks_terminal' (safe direction, but a false negative that blocks a legitimate submit).
|
||||
- task_completeness.py check() uses getattr(task, req.field, None) — works for Pydantic models, dataclasses, and SimpleNamespace in tests, but a Task ORM row that hasn't refreshed a relationship may return None for a populated field, producing a false 'missing'.
|
||||
- task_completeness.py _matches_denylist_description uses re.fullmatch on the lowercased STRIPPED text — a description like 'See Title.' (with trailing period) strips to 'see title.' and matches ^see title$; but 'See title for details' does NOT match (fullmatch), so the denylist only catches the exact placeholder, not prose containing it. Intentional, but easy to misread as a bypass.
|
||||
- communications.py announcements uses read_only_for_others=True with silent_roles empty — the auditor's read on announcements comes via read_roles (_ALL_ROLES), not the silent bucket. Two different mechanisms for 'auditor reads silently' across channels (silent_roles on cell channels, read_roles on announcements/management) — a consumer that consults only silent_roles to decide auditor visibility will miss announcements/management.
|
||||
- content/models.py PrReviewContent.head_sha is optional and stored in a JSON column (no migration) — render_markdown does NOT emit it; it is machine-only. A reader expecting the SHA in the rendered pr_reviewer_notes will not find it.
|
||||
- content/validators.py reject_trivial raises ValueError (not ContentValidationError) so it composes inside Pydantic field validators; validate_content converts the aggregated ValidationError back to ContentValidationError. Calling reject_trivial outside a Pydantic validator context yields a bare ValueError, not the gateway-facing exception.
|
||||
- content/models.py models use coerce_to_list (backstop) but NOT coerce_str_list; the SDK $text-wrapper flattening is done at the intake/flow boundary (intake_driver/flow_server/prompter). A caller that bypasses the intake boundary and hands a raw SDK-parsed list-of-dicts directly to validate_content will hit Pydantic str-coercion failure on dict items.
|
||||
@@ -259,7 +248,6 @@ foundation/policy (misc slice)
|
||||
- CLAUDE.md 'Agent learnings (note scope=learning) broadcast as knowledge-share notifications only to other agents — the human/human-driven roles (CEO, prompter, secretary) are excluded' is enforced in notification_delivery, NOT in this slice's journaling.py — journaling.py defines Scope.LEARNING but places no role exclusion on writing it. No drift in this slice, just noting the exclusion lives elsewhere.
|
||||
- CLAUDE.md verb-surface table lists pr_reviewer verbs as 'claim_pr_review, post_pr_review, claim_gate_review, pr_pass, pr_fail'. tracing.VERB_REQUIREMENTS includes exactly post_pr_review/pr_pass/pr_fail with requirements; claim_* are in VERBS_WITHOUT_TRACING. Consistent — no drift.
|
||||
- CLAUDE.md does not mention agent_loop.py VERB_RETRY_LIMITS / per-verb circuit breaker / pm_respawn_max_tracing_resets at all — additive, not drift, but the doc's 'Self-Healing & Feature Flags' and verb-surface sections under-document the circuit breaker that this slice owns.
|
||||
- CLAUDE.md 'Auditor has silent read access to ALL channels' — communications.py (after 919aa7e2) implements this via read_roles on every channel (auditor is in read_roles of all 9 channels) and via silent_roles on cell/cross-cell channels; main-pm-board/board-private/announcements/all-hands have silent_roles empty. The end-state (auditor reads all, writes none) matches the claim; the mechanism is split across two fields. No drift.
|
||||
|
||||
|
||||
## Changes Since Baseline
|
||||
@@ -269,17 +257,15 @@ foundation/policy (misc slice)
|
||||
| 53d60da3 | Bunch of runtime fixes for MegaTask and other issues | Added coerce_str_list + _extract_strs to validators.py — new helper that flattens LLM XML-ish $text-wrapped list-of-strings fields to flat list[str] at the intake boundary (used by intake_driver/flow_server/prompter); prevents asyncpg DataError on VARCHAR[] and str(dict) in markdown |
|
||||
| e202ce39 | Make main_pm + task_type=code impossible | Added PrReviewContent.issues (additive list[str] slot) + _coerce_issues validator + '## Issues' section in render_markdown — lets the in-path gate record free-text change-requests distinctly from structured findings |
|
||||
| e52fd05d | submit_root: hard unchanged-PR gate stops the pr_fail re-submit loop | Added PrReviewContent.head_sha (optional str, JSON col → no migration) — captured on pr_fail so submit_root can hard-refuse a byte-identical re-submit; render_markdown does not emit it (machine-only) |
|
||||
| 919aa7e2 | F090 drop auditor from write_roles on main-pm-board / board-private | Removed Role.AUDITOR from write_roles on main-pm-board and board-private in CHANNELS — closes the catalog-only enforcement path (HTTP messaging route validate_channel_access) that authorized an auditor write both the say/dm guard and PermissionService would block; auditor stays in read_roles (silent read unchanged) |
|
||||
| cf7603f3 | register sync_branch in VERBS_WITHOUT_TRACING | Added 'sync_branch' to VERBS_WITHOUT_TRACING in tracing.py — fixes the parity-test failure (sync_branch is a git-only rebase+force-push with inline preconditions, mirrors open_pr); no tracing requirement |
|
||||
| 3a4a3fe5 | reduce xenon C-rank blocks to A (behavior-preserving) | Extracted _extract_strs_from_dict helper from inline logic in validators._extract_strs — pure move-and-call refactor, no control flow / return values / side effects changed |
|
||||
|
||||
> Post-snapshot updates (since 2026-06-29): two squash-merge commits landed on this slice. `15effce0` (Chore: 141 Gaps fill-in, 2026-06-29) squash-merged the pre-snapshot baseline commits (53d60da3/e202ce39/e52fd05d/919aa7e2/cf7603f3/3a4a3fe5) into a single PR — those hashes no longer exist in history but their effects are fully reflected in the Key Symbols above (coerce_str_list/PrReviewContent.issues+head_sha/sync_branch in VERBS_WITHOUT_TRACING/auditor removed from write_roles). `536bbb64` (Chore/all/logical gaps sweep, 2026-06-30) added `TEAM_SCOPED_ROLES` frozenset to communications.py (line 122) — the new single-source-of-truth for cell-member roles subject to team_scope, imported by agents_config and seeds/initial_data to replace drifted local copies. No other symbols in this slice changed.
|
||||
> Post-snapshot updates (since 2026-06-29): a squash-merge commit landed on this slice. `15effce0` (Chore: 141 Gaps fill-in, 2026-06-29) squash-merged the pre-snapshot baseline commits (53d60da3/e202ce39/e52fd05d/cf7603f3/3a4a3fe5) into a single PR — those hashes no longer exist in history but their effects are fully reflected in the Key Symbols above (coerce_str_list/PrReviewContent.issues+head_sha/sync_branch in VERBS_WITHOUT_TRACING). No other symbols in this slice changed.
|
||||
|
||||
## Regression Risks
|
||||
|
||||
| Title | File:Line | Claim | Severity |
|
||||
|---|---|---|---|
|
||||
| Auditor dropped from write_roles may break catalog-derived seed/permission paths | roboco/foundation/policy/communications.py:237 | 919aa7e2 removed AUDITOR from write_roles on main-pm-board and board-private. Any consumer that previously derived 'auditor can write here' from the catalog (validate_channel_access, seed generation, CHANNEL_ACCESS mirror) now gets False. The fix aligns with the say/dm guard, but a seed-bootstrap or test that asserted auditor in write_roles will regress; and any path that cached the old CHANNEL_ACCESS dict derived from CHANNELS before this commit will be stale until re-derived. | medium |
|
||||
| PrReviewContent.issues changes rendered pr_reviewer_notes markdown shape | roboco/foundation/policy/content/models.py:199 | e202ce39 added an '## Issues' section to render_markdown whenever issues is non-empty. Downstream readers/parsers of pr_reviewer_notes (or notes_structured.pr_review markdown mirror) that did not expect a second H2 section after Findings could mis-parse; the section ordering is Findings → Issues → Verdict, and a reader that took the first H2 as the body could drop the issues list. | medium |
|
||||
| VERB_RETRY_LIMITS keyed by 'pass'/'fail' but tracing uses 'pass_review'/'fail_review' | roboco/foundation/policy/agent_loop.py:65 | The circuit-breaker keys ('pass','fail') are the MCP-exposed verb names, while VERB_REQUIREMENTS keys the same verbs as 'pass_review'/'fail_review'. The docstring flags this, but a future rename on either side without the other silently disables the per-verb retry cap for QA handoffs — the agent could retry-storm pass/fail without ever hitting circuit_open. No regression today, but a loaded footgun. | low |
|
||||
| _extract_strs_from_dict refactor could change dict-without-text-key behavior | roboco/foundation/policy/content/validators.py:128 | 3a4a3fe5 extracted the inline dict-fallback into _extract_strs_from_dict. Behavior is intended to be identical, but the extracted helper now returns the bare-string-values list for ANY dict lacking a _TEXT_KEYS match — including a dict whose only values are non-str (returns []). Previously the same inline logic ran; verbatim move, so no real regression, but the helper is now reusable and a new caller could feed it a dict expecting different semantics. | low |
|
||||
@@ -287,4 +273,4 @@ foundation/policy (misc slice)
|
||||
| PrReviewContent.head_sha optional field not surfaced in required_shape hints | roboco/foundation/policy/content/models.py:167 | e52fd05d added head_sha as an optional field. required_shape() (line 496) iterates model_fields and emits a type hint for EVERY field, so head_sha now appears in remediation shape hints for 'pr_review' even though it is machine-set by pr_gate, not agent-supplied. An agent receiving a remediation envelope that lists head_sha could attempt to set it, which is not the intended agent write-path. Minor confusion risk. | low |
|
||||
|
||||
## Health
|
||||
This slice is in good shape: it is pure data + validators with no I/O or concurrency of its own, well-documented intent at each site, and a parity test guarding the tracing table against drift. The recent changes are small, additive, and mostly behavior-preserving (the 919aa7e2 auditor write-role removal is the only one that changes an enforced permission, and it aligns the catalog with the pre-existing guard — a tightening, not a loosening). The main latent risks are naming-convention footguns (agent_loop 'pass'/'fail' keys vs tracing 'pass_review'/'fail_review'; sync_branch missing from VERB_RETRY_LIMITS) and the split mechanism for auditor-silent-read across silent_roles vs read_roles — none are active bugs today but each is one rename or one new consumer away from silently degrading. The markers accessors correctly handle SQLAlchemy dirty tracking via dict reassignment, and the content models correctly delegate SDK-wrapper flattening to the intake boundary via coerce_str_list. No active regressions observed in the slice itself.
|
||||
This slice is in good shape: it is pure data + validators with no I/O or concurrency of its own, well-documented intent at each site, and a parity test guarding the tracing table against drift. The recent changes are small, additive, and mostly behavior-preserving. The main latent risk is a naming-convention footgun (agent_loop 'pass'/'fail' keys vs tracing 'pass_review'/'fail_review'; sync_branch missing from VERB_RETRY_LIMITS) — not an active bug today, but one rename away from silently degrading. The markers accessors correctly handle SQLAlchemy dirty tracking via dict reassignment, and the content models correctly delegate SDK-wrapper flattening to the intake boundary via coerce_str_list. No active regressions observed in the slice itself.
|
||||
|
||||
+10
-13
@@ -1,5 +1,5 @@
|
||||
## Purpose
|
||||
The support layer of the agent gateway: pure/cheap components the Choreographer composes into intent-verb sequences. Envelope is the wire contract; claim_guards/claimant_lock/trigger_filter gate concurrency and spawn decisions; content_actions smart-wraps the do-tools (commit/note/say/dm/notify/evidence/progress/pr_update/playbook/pitch/session); evidence_builder/evidence_repo assemble briefings; role_config is the per-role verb/tool manifest source; rate_limit_tracker persists provider park state in Redis; quality_gate runs the pre-submit fast checks; merge_chain resolves PR targets; commit_validator/remediation/kb_authz are small policy shims. None of these own the verb state machine — they are invoked BY the Choreographer and the MCP route handlers.
|
||||
The support layer of the agent gateway: pure/cheap components the Choreographer composes into intent-verb sequences. Envelope is the wire contract; claim_guards/claimant_lock/trigger_filter gate concurrency and spawn decisions; content_actions smart-wraps the do-tools (commit/note/dm/notify/evidence/progress/pr_update/playbook/pitch); evidence_builder/evidence_repo assemble briefings; role_config is the per-role verb/tool manifest source; rate_limit_tracker persists provider park state in Redis; quality_gate runs the pre-submit fast checks; merge_chain resolves PR targets; commit_validator/remediation/kb_authz are small policy shims. None of these own the verb state machine — they are invoked BY the Choreographer and the MCP route handlers.
|
||||
|
||||
## Files
|
||||
|
||||
@@ -11,7 +11,7 @@ The support layer of the agent gateway: pure/cheap components the Choreographer
|
||||
| roboco/services/gateway/claimant_lock.py | Pure single-claimant acquire decision + heartbeat staleness test; caller owns DB writes. | 49 |
|
||||
| roboco/services/gateway/trigger_filter.py | Pure spawn-gating decision (stale/provider-rate/claimant/cooldown/role-rate) for (task,trigger) pairs. | 130 |
|
||||
| roboco/services/gateway/commit_validator.py | Commit-message subject gate: length, banned single-words, conventional-commits soft hint. | 101 |
|
||||
| roboco/services/gateway/content_actions.py | ContentActions: smart-wrapped do-tools (commit/note/say/dm/notify/evidence/progress/pr_update/playbook/pitch/session/inbox) with RBAC, ownership, anti-soup, heartbeat refresh. | 1873 |
|
||||
| roboco/services/gateway/content_actions.py | ContentActions: smart-wrapped do-tools (commit/note/dm/notify/evidence/progress/pr_update/playbook/pitch/inbox) with RBAC, ownership, anti-soup, heartbeat refresh. | 1873 |
|
||||
| roboco/services/gateway/evidence_builder.py | Pure assembly of EvidencePayload + context_briefing + task_handoff (incl. pr_review verdict) + role-shaped memory query. | 221 |
|
||||
| roboco/services/gateway/evidence_repo.py | Capped DB queries for context_briefing: unread a2a/mentions/notifications, team activity, blockers, journal highlights, company goals, similar_memory. | 364 |
|
||||
| roboco/services/gateway/kb_authz.py | KB/docs authorization -> Envelope.not_authorized with role-list remediate hint. | 91 |
|
||||
@@ -49,7 +49,7 @@ The support layer of the agent gateway: pure/cheap components the Choreographer
|
||||
| _stale_trigger_decision | function | roboco/services/gateway/trigger_filter.py:68 | DROP for terminal task or stale a2a code_review trigger; else None. |
|
||||
| validate_commit_message | function | roboco/services/gateway/commit_validator.py:48 | Validate commit subject: empty/length/banned-word/conventional-shape soft hint. |
|
||||
| ValidationResult | dataclass | roboco/services/gateway/commit_validator.py:40 | ok/reason/hint/remediate outcome of commit message validation. |
|
||||
| ContentActionsDeps | dataclass | roboco/services/gateway/content_actions.py:282 | Bundled service deps: task/git/messaging/a2a/journal/workspace/notifications + notification_delivery + evidence_repo. |
|
||||
| ContentActionsDeps | dataclass | roboco/services/gateway/content_actions.py:282 | Bundled service deps: task/git/a2a/journal/workspace/notifications + notification_delivery + evidence_repo. |
|
||||
| ContentActions | class | roboco/services/gateway/content_actions.py:329 | Smart-wrapped do-tools; validates input, auto-injects task_id, calls service, returns Envelope. |
|
||||
| ContentActions.commit | method | roboco/services/gateway/content_actions.py:462 | Validate msg, RBAC (developer/documenter only), active-task + active-claimant gates, git commit, add progress, heartbeat. |
|
||||
| ContentActions.note | method | roboco/services/gateway/content_actions.py:593 | Route scope=handoff to section write; else journal note with soup-guard + structured normalize + ownership. |
|
||||
@@ -61,7 +61,6 @@ The support layer of the agent gateway: pure/cheap components the Choreographer
|
||||
| ContentActions.archive_playbook | method | roboco/services/gateway/content_actions.py:784 | Auditor archives an approved playbook -> retired. |
|
||||
| ContentActions._curate_playbook | method | roboco/services/gateway/content_actions.py:792 | Shared Auditor-only curation; commit status BEFORE RAG index/unindex; ConflictError->invalid_state. |
|
||||
| ContentActions.pitch | method | roboco/services/gateway/content_actions.py:935 | Board (PO/Head Marketing) proposes a product; validates cells, ConflictError/ValidationError->invalid_state. |
|
||||
| ContentActions.say | method | roboco/services/gateway/content_actions.py:1006 | Post to channel; no-comms RBAC defence-in-depth; ChannelAccessDenied->not_authorized with writable list. |
|
||||
| ContentActions.dm | method | roboco/services/gateway/content_actions.py:1080 | A2A direct message; requires task_id; no-comms RBAC; A2AAccessDenied->not_authorized. |
|
||||
| ContentActions.notify | method | roboco/services/gateway/content_actions.py:1150 | Formal ack-required notification (PMs/Board only); rejects bad priority, no-comms sender, disallowed recipient. |
|
||||
| ContentActions._reject_disallowed_recipient | method | roboco/services/gateway/content_actions.py:1230 | Reject notify to prompter/secretary (no ack path) then defer to CEO-dependency-notify check. |
|
||||
@@ -70,13 +69,11 @@ The support layer of the agent gateway: pure/cheap components the Choreographer
|
||||
| ContentActions.evidence | method | roboco/services/gateway/content_actions.py:1324 | Inspect task PR diff/commits/files; allowed for assignee/unassigned/board-co-review/dependency; builds EvidencePayload. |
|
||||
| ContentActions._is_caller_dependency | method | roboco/services/gateway/content_actions.py:1313 | True when task is a dependency of a task the caller is assigned to (read-only evidence exemption). |
|
||||
| ContentActions.progress | method | roboco/services/gateway/content_actions.py:1424 | Append progress update; plan_step marks checklist; ownership+active-claim+active-status gate. |
|
||||
| ContentActions.open_session | method | roboco/services/gateway/content_actions.py:1492 | PM-or-up creates discussion session linked to task (dedupes on ancestor primary session). |
|
||||
| ContentActions.link_session | method | roboco/services/gateway/content_actions.py:1558 | Link existing session to task (idempotent); caller must own task. |
|
||||
| ContentActions.notify_list | method | roboco/services/gateway/content_actions.py:1607 | Read agent notification inbox via NotificationDeliveryService. |
|
||||
| ContentActions.notify_get | method | roboco/services/gateway/content_actions.py:1649 | Read one notification + mark read. |
|
||||
| ContentActions.notify_ack | method | roboco/services/gateway/content_actions.py:1818 | Acknowledge a notification; non-recipient -> not_authorized. |
|
||||
| ContentActions.channels | method | roboco/services/gateway/content_actions.py:1681 | Return readable/writable channel slugs (stops invented-slug pattern). |
|
||||
| ContentActions.read_messages | method | roboco/services/gateway/content_actions.py:1852 | Mark all caller's unread A2A DMs as read (clears i_am_idle soft-block). |
|
||||
| ContentActions.read_a2a | method | roboco/services/gateway/content_actions.py:1846 | Return caller's unread incoming A2A message bodies (content, not just the counter), then clear them; excludes the caller's own sends. |
|
||||
| ContentActions.pr_update | method | roboco/services/gateway/content_actions.py:1737 | Update existing PR title/body/reviewers; authorized for assignee/main_pm/cell_pm on team. |
|
||||
| ContentActions._pr_update_is_authorized | staticmethod | roboco/services/gateway/content_actions.py:1718 | True iff caller is assignee, main_pm, or cell_pm on matching team. |
|
||||
| ContentActions._active_claim_violation | method | roboco/services/gateway/content_actions.py:379 | Refuse write when caller is not active_claimant (board co-reviewer exempt). |
|
||||
@@ -140,7 +137,7 @@ The support layer of the agent gateway: pure/cheap components the Choreographer
|
||||
| get_role_config | function | roboco/services/gateway/role_config.py:268 | Lookup role config; KeyError on unknown role. |
|
||||
|
||||
## Data Flow
|
||||
Inbound: MCP servers (roboco-flow/roboco-do) receive agent tool calls, delegate to the Choreographer (sibling choreographer/ package), which calls into this slice. Claim verbs invoke claim_guards.already_active_guard/paused_tasks_guard/unmet_dependency_guard + claimant_lock.try_acquire (caller resolves unmet ids + persists active_claimant_id/last_heartbeat_at). Spawn ticks invoke trigger_filter.decide_spawn with counts the caller queried from gateway_triggers; rate-limit park/unpark routes through RateLimitStateTracker (Redis). The do-tools route through ContentActions: commit -> commit_validator + git.commit + task.add_progress + heartbeat; note -> journal.write_entry or record_section_note; say/dm/notify -> messaging/a2a/notifications with no-comms + ownership gates; evidence -> workspace.fetch_branch_for_inspection + git.diff + evidence_repo.journal_highlights_for_task + build_evidence_for_task. Briefing assembly: Choreographer queries EvidenceRepo (unread a2a/mentions/notifications, team activity, blockers, journal highlights, company_goals, similar_memory), packs into BriefingInputs, evidence_builder.build_context_briefing + build_task_handoff shape it, and the Envelope carries context_briefing. i_am_done pre-submit runs quality_gate.run_quality_commands in the dev workspace; merge steps call merge_chain.resolve_parent_branch. Outbound: every verb returns an Envelope; route handler stamps correlation_id and calls as_dict for the wire. remediation.py strings are injected into Envelope.remediate by the Choreographer on tracing-gap/invalid-state rejections. role_config feeds the spawn manifest builder (tool-manifest.json) and MCP tool registration. kb_authz is consulted by docs/optimal routes.
|
||||
Inbound: MCP servers (roboco-flow/roboco-do) receive agent tool calls, delegate to the Choreographer (sibling choreographer/ package), which calls into this slice. Claim verbs invoke claim_guards.already_active_guard/paused_tasks_guard/unmet_dependency_guard + claimant_lock.try_acquire (caller resolves unmet ids + persists active_claimant_id/last_heartbeat_at). Spawn ticks invoke trigger_filter.decide_spawn with counts the caller queried from gateway_triggers; rate-limit park/unpark routes through RateLimitStateTracker (Redis). The do-tools route through ContentActions: commit -> commit_validator + git.commit + task.add_progress + heartbeat; note -> journal.write_entry or record_section_note; dm/notify -> a2a/notifications with no-comms + ownership gates; evidence -> workspace.fetch_branch_for_inspection + git.diff + evidence_repo.journal_highlights_for_task + build_evidence_for_task. Briefing assembly: Choreographer queries EvidenceRepo (unread a2a/mentions/notifications, team activity, blockers, journal highlights, company_goals, similar_memory), packs into BriefingInputs, evidence_builder.build_context_briefing + build_task_handoff shape it, and the Envelope carries context_briefing. i_am_done pre-submit runs quality_gate.run_quality_commands in the dev workspace; merge steps call merge_chain.resolve_parent_branch. Outbound: every verb returns an Envelope; route handler stamps correlation_id and calls as_dict for the wire. remediation.py strings are injected into Envelope.remediate by the Choreographer on tracing-gap/invalid-state rejections. role_config feeds the spawn manifest builder (tool-manifest.json) and MCP tool registration. kb_authz is consulted by docs/optimal routes.
|
||||
|
||||
## Mermaid
|
||||
```mermaid
|
||||
@@ -234,10 +231,10 @@ gateway-support
|
||||
ContentActions
|
||||
commit / note / _write_journal_note / _record_section_handoff
|
||||
draft_playbook / approve_playbook / reject_playbook / archive_playbook / _curate_playbook
|
||||
pitch / say / dm / notify / _reject_disallowed_recipient / _reject_ceo_dependency_notify / _dependency_block_reason
|
||||
pitch / dm / notify / _reject_disallowed_recipient / _reject_ceo_dependency_notify / _dependency_block_reason
|
||||
evidence / _is_caller_dependency / _active_claim_violation / _verify_explicit_task_ownership / _board_may_co_review
|
||||
progress / open_session / link_session
|
||||
notify_list / notify_get / notify_ack / channels / read_messages
|
||||
progress
|
||||
notify_list / notify_get / notify_ack / read_messages / read_a2a
|
||||
pr_update / _pr_update_is_authorized
|
||||
evidence_builder.py
|
||||
EvidencePayload / BriefingInputs
|
||||
@@ -272,7 +269,7 @@ gateway-support
|
||||
|---|---|---|
|
||||
| Choreographer verb composition | roboco/services/gateway/choreographer/ | every agent flow/do verb call composes these helpers; the choreographer owns the state machine, this slice is the support layer |
|
||||
| claim verbs (give_me_work / i_will_work_on / i_will_plan / claim_review / claim_doc_task) | roboco/services/gateway/claim_guards.py | Choreographer runs claim_guards + claimant_lock.try_acquire before any task-status mutation |
|
||||
| do-tool verbs (commit/note/say/dm/notify/evidence/progress/pr_update/draft_playbook/pitch/open_session/...) | roboco/services/gateway/content_actions.py | roboco-do MCP server -> Choreographer -> ContentActions method |
|
||||
| do-tool verbs (commit/note/dm/notify/evidence/progress/pr_update/draft_playbook/pitch/...) | roboco/services/gateway/content_actions.py | roboco-do MCP server -> Choreographer -> ContentActions method |
|
||||
| orchestrator spawn tick | roboco/services/gateway/trigger_filter.py | per dispatch tick the orchestrator calls decide_spawn for each (task,trigger) |
|
||||
| provider park/unpark + probe loop | roboco/services/gateway/rate_limit_tracker.py | i_am_blocked(rate_limited) -> activate; background probe loop -> increment/reset/clear |
|
||||
| i_am_done pre-submit gate | roboco/services/gateway/quality_gate.py | Choreographer runs run_quality_commands in dev workspace before accepting submit |
|
||||
@@ -315,7 +312,7 @@ gateway-support
|
||||
|
||||
## Drift from CLAUDE.md
|
||||
- CLAUDE.md: 'pr_reviewer ... no agent comms' — content_actions._NO_COMMS_ROLES enforces this at the handler (defence-in-depth), matching the doc. No drift.
|
||||
- CLAUDE.md: 'Auditor is restricted to note (scope=reflect) + evidence, plus approve_playbook/reject_playbook/archive_playbook'. role_config._AUDITOR_DO also adds notify_list/notify_get + channels (read-only inbox + channel map). This is an additive expansion beyond the doc's literal 'note + evidence' but is consistent with the doc's 'Wave 1 receivers get inbox read' footnote; arguably doc under-states the surface. Not a code drift.
|
||||
- CLAUDE.md: 'Auditor is restricted to note (scope=reflect) + evidence, plus approve_playbook/reject_playbook/archive_playbook'. role_config._AUDITOR_DO also adds notify_list/notify_get (read-only inbox). This is an additive expansion beyond the doc's literal 'note + evidence' but is consistent with the doc's 'Wave 1 receivers get inbox read' footnote; arguably doc under-states the surface. Not a code drift.
|
||||
- CLAUDE.md: 'The note/journal write returns as soon as the entry is persisted; RAG indexing (Ollama embedding) runs fire-and-forget'. content_actions._curate_playbook instead does an EXPLICIT await self.task.session.commit() before svc.index_approved / unindex_playbook — commit-before-index is intentional (gates the corpus). Not drift but a deliberate exception to the fire-and-forget pattern for the playbook curation path.
|
||||
- CLAUDE.md: claim-time concurrency guards 'are skipped for the coordinator PM roles (_COORDINATOR_ROLES = {main_pm, cell_pm}, consulted in _run_claim_guards)'. That skip lives in the Choreographer, NOT in claim_guards.py (this slice). The predicates in this slice have no PM exclusion — importing them directly elsewhere would re-introduce the PM self-block. By design, but the module boundary is easy to misread.
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ The CEO-facing intake and chief-of-staff slice. PrompterService turns a confirme
|
||||
| SecretaryService._pending_or_raise | method | roboco/services/secretary.py:171 | Fetch directive; NotFoundError if missing, ConflictError if not PENDING |
|
||||
| SecretaryService._validate_payload | staticmethod | roboco/services/secretary.py:182 | Require the per-kind payload keys from _REQUIRED_PAYLOAD else ValidationError |
|
||||
| SecretaryService._run | method | roboco/services/secretary.py:188 | Execute a directive: set result, EXECUTED on success, FAILED+error message on caught domain errors |
|
||||
| SecretaryService._execute | method | roboco/services/secretary.py:246 | Dispatch by kind: relay/announce post to channel, update_charter upsert, approve_pitch approve+provision, else _control_task |
|
||||
| SecretaryService._execute | method | roboco/services/secretary.py:246 | Dispatch by kind: relay/announce deliver via notification, update_charter upsert, approve_pitch approve+provision, else _control_task |
|
||||
| SecretaryService._EDITABLE_TASK_FIELDS | ClassVar[frozenset] | roboco/services/secretary.py:278 | Wave-1: the full content-field allowlist the Secretary may edit on CEO confirmation (title/description/acceptance_criteria/priority/team/estimated_complexity/nature/assigned_to) — status is deliberately excluded (its own audited start/cancel/override path); git fields (branch/PR) are never editable |
|
||||
| SecretaryService._control_task | method | roboco/services/secretary.py:302 | Task control action dispatch: **edit** (wave-1, full content surface via _edit_task), start (approve_and_start), cancel, or override status with CEO as actor |
|
||||
| SecretaryService._edit_task | method | roboco/services/secretary.py:325 | Wave-1: apply the Secretary's edit — allowlisted content fields go through TaskService.update after enum coercion (team/estimated_complexity/nature); assigned_to is popped out and routed through claim-aware reassignment (_reassign_task) instead of a plain field set |
|
||||
@@ -98,7 +98,7 @@ The CEO-facing intake and chief-of-staff slice. PrompterService turns a confirme
|
||||
| _search_past_tasks (Claude SDK in-process tool) | tool | roboco/agent_sdk/intake_driver.py:512 | Claude SDK driver's in-process parity tool for the same feature — imports query_past_tasks/format_search_results from intake_server.py directly (one implementation, both runtimes) |
|
||||
|
||||
## Data Flow
|
||||
Intake: the orchestrator spawns a prompter container and calls PrompterLiveRegistry.open(session_id, INTAKE_AGENT_ID); the panel SSE endpoint calls stream() and the message endpoint calls deliver() -> POST http://roboco-agent-{agent_id}:{SDK_PORT}/turn. The container driver POSTs normalized StreamChunks to the relay push() endpoint. When the agent emits a roboco-meta fence, parse_readiness extracts ReadinessTag (covered/ready/scale) used by the orchestrator to decide proposal readiness. The CEO confirms via panel: confirm_live_draft (single) or confirm_live_batch (MegaTask) -> PrompterService.create_task_from_draft -> TaskService.create (DB). For a batch, _sequence_drafts (pure SequencingService.analyze) computes waves/edges, the umbrella is created branchless via _compose_umbrella_draft, N root-subtasks are created with BatchPlacement(parent=umbrella, batch_id, sequence=wave_index), then TaskService.add_dependency wires each edge (b depends on a). preview_batch returns the same waves without creating. update_live_draft applies board feedback to an existing task (update + approve_and_start or re-board). On board review, registry.park(session_id, task_id) keeps the chat alive; find_by_task recovers it for the re-draft injection. Idle sweep: orchestrator calls idle_session_ids(threshold) and close()s abandoned chats; close_by_agent fires on forced kill. Secretary: routes /api/secretary/* call read_company_state/read_task/submit_directive/confirm_directive/reject_directive -> SecretaryService -> TaskService/CompanyGoalsService/PitchService/MessagingService/NotificationService; gated kinds (UPDATE_CHARTER/CONTROL_TASK/APPROVE_PITCH/ANNOUNCE) persist PENDING + notify CEO, then confirm_directive runs _execute with the CEO as actor; RELAY_MESSAGE runs immediately. A CONTROL_TASK directive's payload["action"] fans out inside _control_task: "edit" (wave-1) is the new full-content path — _edit_task applies the allowlisted fields via TaskService.update after enum coercion, and pops assigned_to for claim-aware reassignment (_reassign_task) rather than a plain field set; "start"/"cancel"/"override" are the pre-existing status-only actions. The CEO refers to tasks by NAME in the Secretary chat, so GET /api/secretary/tasks?q= (TaskService.search_tasks) resolves a name to a concrete id before a directive targets it. Prompter memory (wave 1/2): at intake spawn, the orchestrator's `_resolve_history_digest_ambient` calls `history_digest_layer`, which fans out `project_history_digest` per in-scope project — each pulling `TaskService.list_recent_for_project` and rendering it via `build_history_digest` — into one ambient "Recent tasks" block injected into the spawn prompt; mid-conversation, the intake agent's `search_past_tasks` tool (routed through `query_past_tasks`/`format_search_results` in roboco/mcp/intake_server.py, shared byte-for-byte with the Claude SDK's in-process `_search_past_tasks` tool in roboco/agent_sdk/intake_driver.py) hits GET /live/{session}/search-tasks -> TaskService.search_tasks -> compact_task_rows.
|
||||
Intake: the orchestrator spawns a prompter container and calls PrompterLiveRegistry.open(session_id, INTAKE_AGENT_ID); the panel SSE endpoint calls stream() and the message endpoint calls deliver() -> POST http://roboco-agent-{agent_id}:{SDK_PORT}/turn. The container driver POSTs normalized StreamChunks to the relay push() endpoint. When the agent emits a roboco-meta fence, parse_readiness extracts ReadinessTag (covered/ready/scale) used by the orchestrator to decide proposal readiness. The CEO confirms via panel: confirm_live_draft (single) or confirm_live_batch (MegaTask) -> PrompterService.create_task_from_draft -> TaskService.create (DB). For a batch, _sequence_drafts (pure SequencingService.analyze) computes waves/edges, the umbrella is created branchless via _compose_umbrella_draft, N root-subtasks are created with BatchPlacement(parent=umbrella, batch_id, sequence=wave_index), then TaskService.add_dependency wires each edge (b depends on a). preview_batch returns the same waves without creating. update_live_draft applies board feedback to an existing task (update + approve_and_start or re-board). On board review, registry.park(session_id, task_id) keeps the chat alive; find_by_task recovers it for the re-draft injection. Idle sweep: orchestrator calls idle_session_ids(threshold) and close()s abandoned chats; close_by_agent fires on forced kill. Secretary: routes /api/secretary/* call read_company_state/read_task/submit_directive/confirm_directive/reject_directive -> SecretaryService -> TaskService/CompanyGoalsService/PitchService/NotificationService (relay/announce deliver via notification to the target agent(s)); gated kinds (UPDATE_CHARTER/CONTROL_TASK/APPROVE_PITCH/ANNOUNCE) persist PENDING + notify CEO, then confirm_directive runs _execute with the CEO as actor; RELAY_MESSAGE runs immediately. A CONTROL_TASK directive's payload["action"] fans out inside _control_task: "edit" (wave-1) is the new full-content path — _edit_task applies the allowlisted fields via TaskService.update after enum coercion, and pops assigned_to for claim-aware reassignment (_reassign_task) rather than a plain field set; "start"/"cancel"/"override" are the pre-existing status-only actions. The CEO refers to tasks by NAME in the Secretary chat, so GET /api/secretary/tasks?q= (TaskService.search_tasks) resolves a name to a concrete id before a directive targets it. Prompter memory (wave 1/2): at intake spawn, the orchestrator's `_resolve_history_digest_ambient` calls `history_digest_layer`, which fans out `project_history_digest` per in-scope project — each pulling `TaskService.list_recent_for_project` and rendering it via `build_history_digest` — into one ambient "Recent tasks" block injected into the spawn prompt; mid-conversation, the intake agent's `search_past_tasks` tool (routed through `query_past_tasks`/`format_search_results` in roboco/mcp/intake_server.py, shared byte-for-byte with the Claude SDK's in-process `_search_past_tasks` tool in roboco/agent_sdk/intake_driver.py) hits GET /live/{session}/search-tasks -> TaskService.search_tasks -> compact_task_rows.
|
||||
|
||||
## Mermaid
|
||||
```mermaid
|
||||
@@ -195,7 +195,7 @@ intake-secretary
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
- Internal: roboco.services.task.get_task_service / TaskService, roboco.services.sequencing.SequencingService, roboco.services.company_goals.get_company_goals_service, roboco.services.messaging.get_messaging_service, roboco.services.pitch.get_pitch_service, roboco.services.notification.NotificationService, roboco.services.base.BaseService/NotFoundError/ConflictError/ValidationError/ServiceError, roboco.db.tables.AgentTable/TaskTable/SecretaryDirectiveTable, roboco.foundation.identity.CELL_TEAMS/AGENTS/role_for_uuid_or_none, roboco.foundation.policy.batch.is_batch_umbrella/main_pm_cannot_own_code/pm_cannot_own_code, roboco.foundation.policy.content.validators.coerce_str_list, roboco.foundation.policy.sequencing.models.DraftSurface/SequencePlan/SequencingError, roboco.foundation.policy.lifecycle (TaskStatus source), roboco.models.base (AgentRole/Complexity/TaskNature/TaskStatus/TaskType/Team), roboco.models.product.ProductCellMapping, roboco.models.task.TaskCreateRequest, roboco.models.secretary (DirectiveKind/DirectiveStatus/GATED_KINDS), roboco.seeds.initial_data.AGENT_UUIDS, roboco.utils.converters.require_uuid
|
||||
- Internal: roboco.services.task.get_task_service / TaskService, roboco.services.sequencing.SequencingService, roboco.services.company_goals.get_company_goals_service, roboco.services.pitch.get_pitch_service, roboco.services.notification.NotificationService, roboco.services.base.BaseService/NotFoundError/ConflictError/ValidationError/ServiceError, roboco.db.tables.AgentTable/TaskTable/SecretaryDirectiveTable, roboco.foundation.identity.CELL_TEAMS/AGENTS/role_for_uuid_or_none, roboco.foundation.policy.batch.is_batch_umbrella/main_pm_cannot_own_code/pm_cannot_own_code, roboco.foundation.policy.content.validators.coerce_str_list, roboco.foundation.policy.sequencing.models.DraftSurface/SequencePlan/SequencingError, roboco.foundation.policy.lifecycle (TaskStatus source), roboco.models.base (AgentRole/Complexity/TaskNature/TaskStatus/TaskType/Team), roboco.models.product.ProductCellMapping, roboco.models.task.TaskCreateRequest, roboco.models.secretary (DirectiveKind/DirectiveStatus/GATED_KINDS), roboco.seeds.initial_data.AGENT_UUIDS, roboco.utils.converters.require_uuid
|
||||
- External: sqlalchemy (select, AsyncSession), structlog, httpx, asyncio, contextlib, json, re, dataclasses, uuid, datetime
|
||||
|
||||
## Entry Points
|
||||
|
||||
@@ -14,7 +14,7 @@ The `roboco/mcp` package is the agent-side MCP gateway: a set of `FastMCP` serve
|
||||
| `roboco/mcp/utils.py` | Shared HTTP helpers: `_get_agent_headers`, `format_error_response`, `ApiResponse`, `ApiClient` (async httpx wrapper). Used by `optimal_server`, `docs_server`, `search_server`. | 383 |
|
||||
| `roboco/mcp/schemas/__init__.py` | Pydantic input models. After Phase-4 T9 deletions only `WriteDocInput` remains. | 35 |
|
||||
| `roboco/mcp/flow_server.py` | `roboco-flow` MCP server — intent verbs (lifecycle). Manifest-scoped registration, role-scoped path `/api/v1/flow/<route>/<verb>`, per-verb circuit breaker + 404-route synthesis. | 1028 |
|
||||
| `roboco/mcp/do_server.py` | `roboco-do` MCP server — content tools (commit, note, pitch, propose_roadmap, say, dm, notify, evidence, progress, sessions, playbook curation, pr_update). Manifest-scoped; fixed `/api/v1/do/<verb>` path; mirror circuit breaker. | 976 |
|
||||
| `roboco/mcp/do_server.py` | `roboco-do` MCP server — content tools (commit, note, pitch, propose_roadmap, dm, notify, evidence, progress, playbook curation, pr_update). Manifest-scoped; fixed `/api/v1/do/<verb>` path; mirror circuit breaker. | 976 |
|
||||
| `roboco/mcp/optimal_server.py` | `roboco-optimal` MCP server — RAG / KB / mentor / error / decision / standards / learnings / index-mgmt / proactive-context. Factory `create_optimal_mcp_server(agent_id)`; calls `/optimal/*`. | 1102 |
|
||||
| `roboco/mcp/docs_server.py` | `roboco-docs` MCP server — docs write/read/list/delete via `/docs/*`. Factory `create_docs_mcp_server(agent_id)`. RAG-based dedup on write. | 251 |
|
||||
| `roboco/mcp/git_readonly.py` | `roboco-git-readonly` MCP server — four read-only git views (status/log/diff/branch list) via `/api/git/*`. No breaker, no manifest. | 123 |
|
||||
@@ -60,10 +60,10 @@ The `roboco/mcp` package is the agent-side MCP gateway: a set of `FastMCP` serve
|
||||
| `claim_pr_review` / `post_pr_review` / `claim_gate_review` / `pr_pass` / `pr_fail` | verb funcs | `flow_server.py:656–876` | PR-reviewer verbs (inbound + in-path gate). |
|
||||
| `i_will_plan` / `delegate` / `submit_up` / `submit_root` | verb funcs | `flow_server.py:759–856` | PM coordination verbs. |
|
||||
| `note` | verb func | `do_server.py:428` | Journal entry + handoff section writer (top-level `done`/`next` strings — the meltdown-#1 fix). |
|
||||
| `commit` / `say` / `dm` / `notify` / `evidence` | verb funcs | `do_server.py:423–598` | Core content tools. |
|
||||
| `commit` / `dm` / `notify` / `evidence` | verb funcs | `do_server.py:423–598` | Core content tools. |
|
||||
| `propose_roadmap` | verb func | `do_server.py:531` | Product Owner (board-roadmap-only, `_PRODUCT_OWNER_DO`): propose a themed roadmap cycle (goal + 3-7 item drafts) exactly once per exploration task. |
|
||||
| `draft_playbook` / `approve_playbook` / `reject_playbook` / `archive_playbook` | verb funcs | `do_server.py:600–644` | Playbook curation (delivery + Auditor). |
|
||||
| `progress` / `open_session` / `link_session` / `notify_list` / `notify_get` / `notify_ack` / `channels` / `pr_update` / `read_messages` | verb funcs | `do_server.py:646–840` | Wave-1 parity content tools. |
|
||||
| `progress` / `notify_list` / `notify_get` / `notify_ack` / `pr_update` / `read_messages` | verb funcs | `do_server.py:646–840` | Wave-1 parity content tools. |
|
||||
| `create_optimal_mcp_server` | factory | `optimal_server.py:1068` | Build `roboco-optimal-{agent_id}` server; registers 8 tool groups. |
|
||||
| `roboco_kb_search` / `roboco_rag_query` / `roboco_kb_stats` | tools | `optimal_server.py:70–213` | Search / RAG / stats. |
|
||||
| `roboco_ask_mentor` | tool | `optimal_server.py:371` | Primary conversational RAG tool (65s timeout). |
|
||||
@@ -173,8 +173,8 @@ roboco/mcp/
|
||||
│ └── _load_manifest_flow_tools / _register_tools (fails loud if no manifest; ROBOCO_ALLOW_FULL_TOOLSET escape hatch)
|
||||
├── do_server.py # roboco-do (content tools)
|
||||
│ ├── mirror breaker machinery (_CIRCUIT_REJECTION_KINDS, _DICT_ERROR_CODE_MAP, _classify_*, _remediate_for_kind, _normalize_exception_envelope, _record_and_check_circuit)
|
||||
│ ├── commit, note (handoff done/next), pitch, propose_roadmap, say, dm, notify, evidence
|
||||
│ ├── progress, open_session, link_session, notify_list/get/ack, channels, pr_update, read_messages
|
||||
│ ├── commit, note (handoff done/next), pitch, propose_roadmap, dm, notify, evidence
|
||||
│ ├── progress, notify_list/get/ack, pr_update, read_messages
|
||||
│ ├── draft_playbook, approve_playbook, reject_playbook, archive_playbook
|
||||
│ └── _TOOLS / _load_manifest_do_tools / _register_tools (fails loud; ROBOCO_ALLOW_FULL_TOOLSET escape hatch)
|
||||
├── optimal_server.py # roboco-optimal (RAG/KB)
|
||||
@@ -288,7 +288,7 @@ CLAUDE.md "MCP servers running per agent container" table lists 5 servers (`robo
|
||||
|
||||
CLAUDE.md "roboco-optimal" row says the server exposes `roboco_ask_mentor`, `roboco_kb_search` only. The actual `optimal_server.py` registers **18** tools (search/rag/stats/index_code/index_docs/tokens_estimate/ask_mentor/search_error/record_error_solution/check_decision/record_decision/get_standards/validate_action/review_code/record_learning/search_learnings/clear_index/reindex_all/index_status/get_proactive_context). Understatement, not contradiction.
|
||||
|
||||
CLAUDE.md "roboco-do" row lists `commit, note, say, dm, evidence` and (in the Agent Gateway section) `draft_playbook` for delivery roles + `approve_playbook`/`reject_playbook`/`archive_playbook` for the Auditor. The actual `do_server.py` `do_tools` registry also contains `pitch`, `propose_roadmap` (Product Owner only), `progress`, `open_session`, `link_session`, `notify`, `notify_list`, `notify_get`, `notify_ack`, `channels`, `pr_update`, `read_messages` — none mentioned in CLAUDE.md (`_TOOLS` is 21 entries, not the 5 named). Understatement.
|
||||
CLAUDE.md "roboco-do" row lists `commit, note, say, dm, evidence` and (in the Agent Gateway section) `draft_playbook` for delivery roles + `approve_playbook`/`reject_playbook`/`archive_playbook` for the Auditor. The actual `do_server.py` `do_tools` registry also contains `pitch`, `propose_roadmap` (Product Owner only), `progress`, `notify`, `notify_list`, `notify_get`, `notify_ack`, `pr_update`, `read_messages`, `read_a2a` — none mentioned in CLAUDE.md (`do_tools` is 18 entries, not the 5 named). Understatement.
|
||||
|
||||
CLAUDE.md says the `note`/journal write "returns as soon as the entry is persisted; RAG indexing runs fire-and-forget." The MCP `note` tool itself is synchronous w.r.t. the orchestrator (a single POST); the fire-and-forget behavior is server-side, not visible in this slice — consistent, not drift.
|
||||
|
||||
@@ -335,7 +335,7 @@ No other commits in this slice since baseline.
|
||||
| `_register_tools` raises at import if manifest missing — local dev breakage | `flow_server.py:965`, `do_server.py:896` | **Mitigated (536bbb64):** `ROBOCO_ALLOW_FULL_TOOLSET` env var added as a dev/test escape hatch that bypasses the `RuntimeError` and registers the full tool set. Production behaviour is unchanged (ROBOCO_ALLOW_FULL_TOOLSET is not set in the orchestrator manifest). The mitigant must not leak into production containers. | low |
|
||||
| `propose_batch` well-formed filter could silently drop intended drafts | `intake_server.py:146` | **Partially mitigated (536bbb64 #163):** `_draft_title` now accepts `name` as a fallback for `title`, and `_normalize_batch_drafts` normalizes `name`-only drafts onto `title` before posting. Residual risk: a draft using a different key (e.g. `label`) is still dropped silently; the `dropped` count is sent but the CEO may not notice. | low |
|
||||
| Breaker SDK timeout (2s) may be too tight under load | `flow_server.py:55`, `do_server.py:37` | `_SDK_TIMEOUT=2.0` for the loopback `/verb/attempted` POST. Under container CPU contention the SDK could exceed 2s and the breaker fails open (returns original payload) — re-introducing the unbounded-retry condition the breaker was added to stop. Fail-open is safe but defeats the protection. | low |
|
||||
| Circuit-breaker forwarding does not pass `task_id` for content tools that lack one | `do_server.py:379` | `body.get("task_id")` is sent to the SDK. Several do-tools (`channels`, `read_messages`, `notify_list`) have no `task_id` — the breaker records `task_id=None`, so the per-verb (not per-task) breaker still works, but any future per-task breaker logic would mis-attribute these. | low |
|
||||
| Circuit-breaker forwarding does not pass `task_id` for content tools that lack one | `do_server.py:379` | `body.get("task_id")` is sent to the SDK. Several do-tools (`read_messages`, `notify_list`) have no `task_id` — the breaker records `task_id=None`, so the per-verb (not per-task) breaker still works, but any future per-task breaker logic would mis-attribute these. | low |
|
||||
|
||||
## Health
|
||||
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
## Purpose
|
||||
This slice implements RoboCo's communication + formal-notification backbone: MessagingService manages channels/groups/sessions/messages (with keyset pagination, session lifecycle, per-task threading, and session-task linking), NotificationService is the typed notification factory (blocker, QA-ready, A2A, board-review, ack), NotificationDeliveryService handles delivery (transactional-outbox bus publish), ACK tracking, expiry sweeps, and PM/CEO task-handoff notifications, and notification_dedup is a bounded Redis SET-NX re-fire guard for loop-prone notification types. Together they turn lifecycle events into both a durable DB record and a real-time push, with multiple dedup layers (Redis re-fire window + DB purpose-dedup) to keep agent inboxes from flooding under coordinator loops.
|
||||
|
||||
## Files
|
||||
|
||||
| Path | Role | LOC |
|
||||
|---|---|---|
|
||||
| roboco/services/messaging.py | Channel/Group/Session/Message CRUD, session-task linking, gateway post_to_channel adapter, message keyset pagination, session sweeper | 2040 |
|
||||
| roboco/services/notification.py | Typed notification factory (blocker/QA/docs/handoff/A2A/board-review/ack) with slug→UUID recipient resolution, DB purpose-dedup + Redis re-fire guard, owns its own DB context and commit | 574 |
|
||||
| roboco/services/notification_dedup.py | Bounded Redis SET-NX re-fire guard for loop-prone notification types (TASK_ASSIGNMENT/REVIEW_REQUEST/DOCUMENTATION_REQUEST/BROADCAST); 60s TTL, fail-open | 91 |
|
||||
| roboco/services/notification_delivery.py | Delivery (transactional-outbox deferred bus publish), ACK/read tracking, expiry sweep, PM/CEO task-handoff notifications (notify_pm_of_block, escalate_and_notify, etc.), API-facing list/CRUD | 1034 |
|
||||
|
||||
## Data Flow
|
||||
Two create-and-deliver paths exist. (A) NotificationService._create_notification (notification.py) opens its OWN get_db_context, resolves sender + recipients to UUIDs via _resolve_agent_uuid, runs the Redis re-fire guard (all_recipients_recently_notified), then DB purpose-dedup (ack-required types only, same sender+type+task+overlapping recipients not yet acked), builds NotificationTable with requires_ack from ACK_REQUIRED_BY_TYPE, flushes, calls NotificationDeliveryService.deliver (which defers NOTIFICATION_SENT bus events to after_commit), and finally commits — the commit triggers the deferred bus drain. (B) NotificationDeliveryService._persist_and_deliver (notification_delivery.py) is used by the task-handoff helpers (notify_pm_of_block, escalate_and_notify, etc.): it runs inside the CALLER's open transaction, applies only the Redis re-fire guard (no DB purpose-dedup), adds+flushes+delivers, and leaves the commit to the caller (api/routes/tasks.py). MessagingService._notify_mentions builds MENTION notifications inline and calls deliver directly within the caller's transaction. On the messaging side, send_message (called directly by routes and via the gateway post_to_channel adapter) resolves session/group/channel (transparently redirecting to the active session if the requested one closed — and reply_to is validated against the EFFECTIVE session, not the requested one), validates channel write access, inserts the MessageTable, bumps stats, publishes EventType.MESSAGE_SENT to the StreamEventBus (best-effort — a bus outage never rolls back the durable row), fires _notify_mentions, kicks a fire-and-forget RAG index task, and closes the session if boundaries are exceeded. Sweeper loops in the orchestrator call sweep_timed_out_sessions (TOCTOU re-check) and sweep_expired_notifications periodically. Real-time push: send_message's MESSAGE_SENT is forwarded by websocket_bridge._handle_message_event as a message.new frame to /ws/sessions/{id} AND /ws/channels/{id} (the live transcript-update path — previously send_message never broadcast); separately, deliver defers per-recipient NOTIFICATION_SENT events; the after_commit listener schedules _drain_pending_publishes which publishes to the StreamEventBus; websocket_bridge forwards to /ws/notifications/{id} sockets. ACKs flow acknowledge → acked_by/read_by mutation + NOTIFICATION_ACKED event.
|
||||
|
||||
## Mermaid
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Messaging
|
||||
GW["gateway say verb / secretary"] --> PTC["MessagingService.post_to_channel"]
|
||||
PTC -->|"resolve by slug, validate write"| GOC["get_or_create_channel_by_slug"]
|
||||
PTC -->|"per-task or default group"| TGF["_task_group_for_channel / _default_group_for_channel"]
|
||||
PTC --> GOAS["get_or_create_active_session"]
|
||||
GOAS --> CS["create_session (FOR UPDATE group lock)"]
|
||||
PTC --> SM["send_message"]
|
||||
SM --> GMC["_get_message_context<br/>redirect closed→active"]
|
||||
SM --> MSE["publish MESSAGE_SENT<br/>(best-effort)"]
|
||||
SM --> NM["_notify_mentions"]
|
||||
SM --> RAG["_index_message_async (fire-and-forget)"]
|
||||
SM --> BND["_check_session_boundaries → close_session"]
|
||||
Orch["orchestrator loop"] --> SWP["sweep_timed_out_sessions (TOCTOU re-check)"]
|
||||
Orch --> SWN["sweep_expired_notifications"]
|
||||
end
|
||||
|
||||
subgraph Notification
|
||||
TYPED["send_blocker / send_qa_ready / send_a2a / send_ack ..."] --> CN["_create_notification"]
|
||||
CN --> RES["_resolve_agent_uuid (sender+recipients)"]
|
||||
CN --> RD["all_recipients_recently_notified<br/>Redis SET-NX 60s"]
|
||||
CN --> DD["DB purpose-dedup<br/>ack-required only"]
|
||||
CN --> NT["NotificationTable (requires_ack=ACK_REQUIRED_BY_TYPE)"]
|
||||
CN --> DLV["deliver"]
|
||||
HANDOFF["notify_pm_of_block / escalate_and_notify / notify_ceo_of_escalation"] --> PAD["_persist_and_deliver"]
|
||||
PAD --> RD
|
||||
PAD --> NT2["NotificationTable"]
|
||||
PAD --> DLV
|
||||
NM --> MN["NotificationTable (MENTION)"]
|
||||
NM --> DLV
|
||||
end
|
||||
|
||||
subgraph Delivery
|
||||
DLV -->|"in-tx"| DA["delivered_at = now"]
|
||||
DLV --> DEF["defer_bus_publish<br/>per-recipient NOTIFICATION_SENT"]
|
||||
DEF -.->|"after_commit"| DRN["_drain_pending_publishes"]
|
||||
DEF -.->|"after_rollback"| DIS["_discard_pending_publishes (no phantom)"]
|
||||
DRN --> BUS["StreamEventBus"]
|
||||
BUS --> WS["websocket_bridge → /ws/notifications/{id}"]
|
||||
ACK["acknowledge"] --> AB["acked_by / read_by / acked_at"]
|
||||
ACK --> BUS2["NOTIFICATION_ACKED event"]
|
||||
end
|
||||
|
||||
MSE --> BUS
|
||||
BUS --> WSM["websocket_bridge._handle_message_event → /ws/sessions + /ws/channels (message.new)"]
|
||||
RD --> REDIS[("Redis SET-NX")]
|
||||
DD --> PG[("PostgreSQL notifications")]
|
||||
NT --> PG
|
||||
DRN --> REDIS
|
||||
```
|
||||
|
||||
## Logical Tree
|
||||
```
|
||||
messaging-notification
|
||||
├── messaging.py (MessagingService)
|
||||
│ ├── Channel ops
|
||||
│ │ ├── create_channel (slug uniqueness)
|
||||
│ │ ├── get_channel / get_channel_or_raise / get_channel_with_groups_or_raise
|
||||
│ │ ├── list_channels_paginated (accessible slugs + total)
|
||||
│ │ ├── update_channel_fields (allowlist setattr)
|
||||
│ │ ├── add/remove_channel_member_or_raise
|
||||
│ │ ├── get_channel_by_slug (#-strip) / get_channel_by_slug_or_raise
|
||||
│ │ └── get_or_create_channel_by_slug (seed auto-create, savepoint race-safe)
|
||||
│ ├── Group ops
|
||||
│ │ ├── create_group / get_group / list_groups_in_channel
|
||||
│ │ ├── _lock_group (SELECT FOR UPDATE + populate_existing)
|
||||
│ │ ├── _default_group_for_channel
|
||||
│ │ ├── _task_group_name / _task_group_for_channel
|
||||
│ ├── Session ops
|
||||
│ │ ├── _resolve_session_timeout (configurable default)
|
||||
│ │ ├── create_session (lock group, reuse active, flush-before-link)
|
||||
│ │ ├── get_session / get_session_or_raise
|
||||
│ │ ├── get_session_with_links / get_session_with_links_or_raise (eager-load task_links + task in one query)
|
||||
│ │ ├── require_group_read_access / require_session_read_access (read IDOR guard: channel member / silent observer / privileged)
|
||||
│ │ ├── get_session_with_links_for_agent (with-links + require_group_read_access check)
|
||||
│ │ ├── _session_still_timed_out (TOCTOU re-check)
|
||||
│ │ ├── sweep_timed_out_sessions
|
||||
│ │ ├── close_session / close_session_or_raise
|
||||
│ │ ├── list_group_sessions_for_agent (auth)
|
||||
│ │ ├── create_session_with_access_check (+ proactive context)
|
||||
│ │ ├── _inject_proactive_context (fire-and-forget)
|
||||
│ │ └── get_or_create_active_session
|
||||
│ ├── Session-task links
|
||||
│ │ ├── link_session_to_task (idempotent, primary uniqueness)
|
||||
│ │ ├── unlink_session_from_task
|
||||
│ │ ├── get_sessions_for_task / get_primary_session_for_task / get_tasks_for_session
|
||||
│ │ ├── propagate_sessions_to_subtask
|
||||
│ │ ├── _walk_task_ancestors (cycle-safe)
|
||||
│ │ ├── _primary_session_link_for_task
|
||||
│ │ ├── _resolve_group_from_parent_tasks / _find_ancestor_session_on_channel
|
||||
│ │ ├── _resolve_group_for_session / _build_session_request
|
||||
│ │ ├── _link_tasks_to_session / _link_tasks_to_existing_session
|
||||
│ │ └── create_session_for_tasks (PM op, reuse ancestor session)
|
||||
│ ├── Message ops
|
||||
│ │ ├── _get_message_context (closed-session redirect)
|
||||
│ │ ├── _validate_reply_target / _update_message_stats
|
||||
│ │ ├── _notify_mentions (MENTION notifications)
|
||||
│ │ ├── _index_message_async (RAG fire-and-forget)
|
||||
│ │ ├── _assert_content (blank / >10000 chars)
|
||||
│ │ ├── send_message
|
||||
│ │ ├── get_message / get_message_or_raise / get_messages (keyset)
|
||||
│ │ ├── list_messages_for_session (keyset, 404)
|
||||
│ │ ├── MessageCursor (timestamp, id tie-break)
|
||||
│ │ ├── edit_message / edit_message_or_raise
|
||||
│ │ ├── delete_message (soft) / delete_message_or_raise (hard)
|
||||
│ │ └── _check_session_boundaries
|
||||
│ └── post_to_channel (gateway say adapter)
|
||||
├── notification.py (NotificationService)
|
||||
│ ├── _resolve_agent_uuid (slug/UUID → UUID; 'system' seed)
|
||||
│ ├── send_blocker / send_stuck_agent / send_qa_ready / send_docs_ready
|
||||
│ ├── send_handoff / send_qa_failed / send_board_review_complete
|
||||
│ ├── send_external_pr_reviewed / send_ack / send_a2a (tristate priority)
|
||||
│ ├── _notification_type_label / _resolve_recipients
|
||||
│ └── _create_notification (own DB context, re-fire guard, DB dedup, requires_ack, commit)
|
||||
├── notification_dedup.py
|
||||
│ ├── _LOOP_PRONE_TYPES (4 types)
|
||||
│ ├── _DEDUP_TTL_SECONDS (60)
|
||||
│ ├── _key (type:from:recipient:task)
|
||||
│ └── all_recipients_recently_notified (per-recipient SET-NX, fail-open)
|
||||
└── notification_delivery.py (NotificationDeliveryService)
|
||||
├── Transactional outbox (F107)
|
||||
│ ├── defer_bus_publish (enqueue + register listeners)
|
||||
│ ├── _schedule_pending_publishes (after_commit → loop.create_task)
|
||||
│ ├── _drain_pending_publishes (best-effort publish)
|
||||
│ └── _discard_pending_publishes (after_rollback)
|
||||
├── EscalationError / EscalationOutcome / BlockerDetails
|
||||
├── deliver (delivered_at in-tx, defer per-recipient events)
|
||||
├── get_notification / _notification_is_fully_acked / _log_expired_notification
|
||||
├── sweep_expired_notifications (log stale unacked)
|
||||
├── get_pending_for_agent / get_unacknowledged_for_agent / get_notification_count
|
||||
├── acknowledge / mark_read / bulk_acknowledge / get_ack_status / get_delivery_summary
|
||||
├── Task-handoff notifications
|
||||
│ ├── notify_pm_of_block / notify_pm_of_docs_complete / notify_pm_of_review_submission
|
||||
│ ├── notify_assignee_of_unblock / notify_assignee_of_ceo_rejection
|
||||
│ ├── escalate_and_notify (EscalationError/EscalationOutcome)
|
||||
│ ├── notify_ceo_of_escalation
|
||||
│ └── _persist_and_deliver (re-fire guard only, caller commits)
|
||||
├── Recipient helpers: _resolve_team_pm / _resolve_pm_for_agent_or_team / _get_agent_by_id/slug / _get_ceo_agent
|
||||
└── API-facing: list_system_notifications / list_for_agent / get_for_recipient_and_mark_read / acknowledge_for_recipient / mark_read_for_recipient
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
- Internal: roboco.config.settings (session_idle_timeout_seconds, redis_url), roboco.db.tables (ChannelTable, GroupTable, MessageTable, NotificationTable, SessionTable, SessionTaskTable, AgentTable, TaskTable), roboco.db.base.get_db_context, roboco.enforcement.validate_channel_access / ChannelAccessDeniedError, roboco.events (Event, EventType, get_event_bus), roboco.foundation.policy.communications.ACK_REQUIRED_BY_TYPE, roboco.models.base (MessageType, NotificationPriority, NotificationType, SessionStatus, AgentRole, ChannelType), roboco.models.messaging (Channel/Group/Message/SessionCreateRequest), roboco.models.session (SessionForTasksCreate, SessionTaskRelationshipType), roboco.models.notification.CreateNotificationParams, roboco.models.optimal.IndexConversationParams, roboco.seeds.DEFAULT_CHANNELS, roboco.services.base (BaseService, ConflictError, NotFoundError), roboco.services.optimal.get_optimal_service, roboco.services.proactive.get_proactive_service, roboco.services.permissions.has_privileged_access, roboco.services.repositories.get_agent_slug, roboco.agents_config (get_escalation_target, get_pm_for_agent, get_pm_for_team), roboco.utils.converters (require_uuid, to_python_uuid)
|
||||
- External: sqlalchemy (select, and_, or_, func, event, joinedload, selectinload, with_for_update, IntegrityError, AsyncSession), redis.asyncio (from_url, set NX EX, aclose), asyncio (create_task, get_running_loop), structlog, datetime (UTC, datetime, timedelta), uuid.UUID, dataclasses
|
||||
|
||||
## Entry Points
|
||||
|
||||
| Name | File | Trigger |
|
||||
|---|---|---|
|
||||
| post_to_channel | roboco/services/messaging.py | gateway `say` content verb (content_actions.py) + secretary.py directive broadcast |
|
||||
| send_message | roboco/services/messaging.py | API POST /api/sessions/{id}/messages + post_to_channel adapter |
|
||||
| create_session_for_tasks | roboco/services/messaging.py | gateway `i_will_plan` / delegate PM op (content_actions.py) |
|
||||
| sweep_timed_out_sessions | roboco/services/messaging.py | orchestrator periodic loop (orchestrator.py:5771) |
|
||||
| NotificationService.send_*_notification | roboco/services/notification.py | TaskService / orchestrator lifecycle transitions (blocker, qa-ready, docs, a2a, board-review) |
|
||||
| NotificationService.send_ack_notification | roboco/services/notification.py | gateway `notify` content verb (PM/Board only) |
|
||||
| NotificationDeliveryService.notify_pm_of_block / escalate_and_notify / notify_ceo_of_escalation | roboco/services/notification_delivery.py | api/routes/tasks.py i_am_blocked / escalate / ceo-approval routes |
|
||||
| NotificationDeliveryService.acknowledge / list_for_agent / get_for_recipient_and_mark_read | roboco/services/notification_delivery.py | api/routes/notifications.py ACK + list endpoints |
|
||||
| sweep_expired_notifications | roboco/services/notification_delivery.py | orchestrator periodic loop (orchestrator.py:5780) |
|
||||
|
||||
## Config Flags
|
||||
- ROBOCO_SESSION_IDLE_TIMEOUT_SECONDS (settings.session_idle_timeout_seconds) — default session idle timeout used by _resolve_session_timeout (messaging.py)
|
||||
- settings.redis_url — Redis URL used by notification_dedup for the SET-NX re-fire guard (derived from ROBOCO_REDIS_HOST/_PORT)
|
||||
|
||||
|
||||
## Gotchas
|
||||
- Two notification create paths with DIFFERENT dedup strength: NotificationService._create_notification runs BOTH the Redis re-fire guard AND the DB purpose-dedup; NotificationDeliveryService._persist_and_deliver (task-handoff helpers) runs ONLY the Redis re-fire guard and explicitly skips DB purpose-dedup. A reworded BLOCKER_ESCALATION from the handoff path within 60s is suppressed by Redis, but beyond 60s a duplicate can be re-created since there is no DB dedup on that path.
|
||||
- notification_dedup fail-open: a Redis error returns False (never suppress) — correct for not dropping notifications, but a sustained Redis outage re-opens the per-tick re-fire storm the guard was added to stop.
|
||||
- notification_dedup.all_recipients_recently_notified has a side effect: it SET-NX-marks recipients NOT yet notified, so the FIRST call for a fresh recipient returns False (delivers) but acquires the key; a concurrent second call within 60s for the same recipient then returns True (suppresses). The marking happens even on the call that decides to deliver — so a suppressed 'all already held' verdict requires every recipient to have been marked by a prior call. Partial-fresh mixed-recipient calls deliver and mark the fresh ones.
|
||||
- NotificationService._create_notification opens its OWN get_db_context and commits (line 568), while NotificationDeliveryService._persist_and_deliver operates in the CALLER's transaction and does NOT commit. Mixing the two in one outer transaction would double-commit / cross-session.
|
||||
- MessagingService.create_session flushes the new SessionTable BEFORE assigning group.active_session_id because active_session_id is a plain scalar FK with no relationship — assigning session.id pre-flush persists NULL and re-opens a new session every post (load-bearing ordering, documented L495-499).
|
||||
- _lock_group uses SELECT FOR UPDATE with populate_existing to serialize concurrent active-session creation; without it two concurrent posts both miss active_session_id and orphan the loser's session as forever-ACTIVE.
|
||||
- _session_still_timed_out closes a TOCTOU: the sweeper's candidate SELECT may be stale by the time close runs; a message that refreshed last_activity_at in between must not be closed. sweep_timed_out_sessions depends on this re-check.
|
||||
- get_or_create_channel_by_slug handles the concurrent-auto-create race via a savepoint (begin_nested) + IntegrityError catch + re-fetch; a conflict that produced no row on re-fetch is re-raised (not masked as None).
|
||||
- requires_ack is now set from ACK_REQUIRED_BY_TYPE (notification.py L555) rather than the column default True; MENTION/KNOWLEDGE_SHARE/BROADCAST etc. are False. MessagingService._notify_mentions explicitly sets requires_ack=False for MENTION (L1501) — consistent with the map but set inline.
|
||||
- DB purpose-dedup query uses NotificationTable.to_agents.overlap(to_agents_uuids) AND ~acked_by.contains(to_agents_uuids) — overlap matches ANY recipient; a notification to [A,B] with A acked but B not is NOT suppressed for a new send to [A,B] because acked_by does not contain [A,B] (contains is element-wise). The dedup is per-(sender,type,task) not per-recipient, so a third recipient C added on resend goes through.
|
||||
- defer_bus_publish registers after_commit/after_rollback listeners keyed on session.info[_DRAIN_REGISTERED_KEY]; listeners are bound to sync_session and accumulate only once per AsyncSession instance. A session reused across multiple commit cycles will re-register only once (guard), but the pending queue is popped each commit — if a second deliver happens after the first commit in the same session, the listeners are already registered and the new events append and fire on the next commit.
|
||||
- acknowledge publishes NOTIFICATION_ACKED directly to the bus (NOT deferred via after_commit) — unlike deliver. An ACK that is rolled back after publish could emit a phantom ACK event. The ACK path does not use the transactional outbox.
|
||||
- list_system_notifications filters pending_ack_only POST-fetch because 'not fully acked' is not SQL-friendly on PostgreSQL array columns. For pending_ack_only=True the SQL `limit` is NOT applied — applying it before the Python filter let a window of newer fully-acked rows mask older unacked ones the operator still needs to act on (correctness bug fixed in 115061f3); the full ack-required set is fetched ordered newest-first, Python-filtered to unacked, then sliced to `limit`. The non-pending branch retains the SQL limit.
|
||||
- get_notification_count loads ALL notifications for an agent into memory (no SQL count) to compute total/unread/pending_ack — O(n) per call, no pagination.
|
||||
- send_message content cap is 10_000 chars (_MAX_MSG_CHARS); blank/whitespace-only content raises EMPTY_MESSAGE.
|
||||
|
||||
|
||||
## Drift from CLAUDE.md
|
||||
- CLAUDE.md does not describe the notification_dedup Redis re-fire guard, the transactional-outbox (defer_bus_publish / F107) in notification_delivery, or the DB purpose-dedup in NotificationService — all are real, load-bearing behavior added since the baseline and not reflected in the doc's Services table or Communication Model section.
|
||||
- CLAUDE.md's Services table lists MessagingService as 'Channels, sessions, messages' and NotificationService as 'Formal notifications' but does not mention NotificationDeliveryService at all, nor that NotificationService owns its own DB context+commit while NotificationDeliveryService runs in the caller's txn.
|
||||
- CLAUDE.md Communication Model says 'Notifications = formal signals (require acknowledgment, sent by PMs/Board only)' — actual code: requires_ack is False for TASK_ASSIGNMENT/REVIEW_REQUEST/DOCUMENTATION_REQUEST/BROADCAST/KNOWLEDGE_SHARE/MENTION/A2A_REQUEST (ACK_REQUIRED_BY_TYPE), so most notification types do NOT require ack; and Notifications can be sent by 'system' (orchestrator-generated) not only PMs/Board.
|
||||
- CLAUDE.md mentions `roboco/services/messaging.py`, `notification.py` but not `notification_dedup.py` or `notification_delivery.py` in the Services table.
|
||||
|
||||
|
||||
## Changes Since Baseline
|
||||
|
||||
| SHA | Subject | Impact |
|
||||
|---|---|---|
|
||||
| 15effce0 | Chore: 141 Gaps fill-in (#283) — added MessageCursor keyset pagination, _lock_group FOR UPDATE, _session_still_timed_out TOCTOU re-check, requires_ack from ACK_REQUIRED_BY_TYPE, DB purpose-dedup gated to ack-required types, re-fire guard + notification_dedup.py (new file), transactional-outbox defer_bus_publish in notification_delivery, content blank/length asserts, per-task group threading | Major hardening: session creation race fixed (FOR UPDATE), session sweeper TOCTOU closed, message pagination no longer skips equal-timestamp rows, notifications no longer flood inboxes (Redis re-fire + DB dedup scoped), phantom WebSocket pushes eliminated (deferred bus publish), MENTION/BROADCAST no longer inflate unacked sets (requires_ack=False) |
|
||||
| 3aff6e04 | Chore: Close gaps (#285) — follow-on gap closure touching the same four files | Refinement of the #283 changes (exact hunks not isolated per-file in this merge commit; consolidated the dedup/outbox/cursor/lock behavior above) |
|
||||
|
||||
> Post-snapshot updates (since 2026-06-29): 76ce53e3 wired MESSAGE_SENT end-to-end (EventType, best-effort bus publish in send_message, bridge _handle_message_event forwarding to /ws/sessions+/ws/channels, panel useSessionStream subscription — the live-transcript path was dead before this); 0065ecbb added get_session_with_links/_or_raise (eager-loads task_links+task in one query) and session_to_response_with_links so GET /sessions/{id} returns task_links populated; 2da72f3f fixed reply_to validation to check against the EFFECTIVE (possibly-redirected) session instead of req.session_id, and guards the panel composer on a closed session; 77958c1e added require_group_read_access/require_session_read_access/get_session_with_links_for_agent to close read IDOR on GET /groups/{id}, GET /sessions/{id}, GET /sessions/{id}/tasks, GET /messages (any agent could read any private channel's transcripts), and fixed three doubled-404 messages; 115061f3 fixed list_system_notifications pending_ack_only correctness: SQL limit is now dropped for that branch so newer fully-acked rows can't mask older unacked ones (see Gotcha update above); 536bbb64 is the logical-gap sweep merge commit that bundled several of these.
|
||||
|
||||
## Regression Risks
|
||||
|
||||
| Title | File:Line | Claim | Severity |
|
||||
|---|---|---|---|
|
||||
| DB purpose-dedup now gated to ack-required types only — informational duplicates no longer suppressed | roboco/services/notification.py:521 | is_ack_required = ACK_REQUIRED_BY_TYPE.get(params.notification_type, True); the DB dup_q is only run when is_ack_required. REVIEW_REQUEST/DOCUMENTATION_REQUEST/TASK_ASSIGNMENT are ack-required=False, so they skip DB dedup and rely SOLELY on the 60s Redis window. Beyond 60s, a coordinator can re-fire the same REVIEW_REQUEST every tick and each one persists (the original bug the dedup was meant to stop). The Redis guard coalesces within 60s but a tick interval >60s re-opens the flood. Severity medium because the Redis guard covers the common per-tick storm. | medium |
|
||||
| _persist_and_deliver skips DB purpose-dedup entirely — task-handoff duplicates not DB-deduped | roboco/services/notification_delivery.py:875 | _persist_and_deliver applies only all_recipients_recently_notified (Redis) and then add+flush+deliver with no DB dup_q. notify_pm_of_block / escalate_and_notify / notify_assignee_of_unblock can each be re-triggered (e.g. a retried i_am_blocked, a re-issued escalate) and, past the 60s Redis window, create a second BLOCKER_ESCALATION for the same (sender, type, task) while the first is unacked — exactly the inbox inflation + i_am_idle soft-block the DB dedup was added to prevent on the other path. Two paths for the same notification type with different dedup strength is a real hole. | medium |
|
||||
| acknowledge publishes NOTIFICATION_ACKED directly, not via the transactional outbox | roboco/services/notification_delivery.py:451 | deliver was migrated to defer_bus_publish (after_commit) to kill phantom pushes, but acknowledge still does `await bus.publish(...)` inside the open transaction before the caller commits. A rollback after a successful ACK publish emits a phantom NOTIFICATION_ACKED for an ACK that didn't persist — the same class of bug F107 fixed for deliver, left unfixed for the ACK path. | medium |
|
||||
| all_recipients_recently_notified marks recipients as a side effect on the deciding call | roboco/services/notification_dedup.py:78 | The function SET-NX-marks each fresh recipient while computing the verdict, so the call that DECIDES TO DELIVER also acquires keys for the fresh recipients. A subsequent resend within 60s then sees all-held and suppresses — intended — but it means the very first notification in a window consumes the TTL for recipients who genuinely received it, and a legit follow-up to a subset within 60s is suppressed if all of that subset were marked by the prior send. For BROADCAST this can drop a legitimately re-targeted broadcast within the window. | low |
|
||||
| create_session reuses ANY active session without verifying it belongs to the same scope/task | roboco/services/messaging.py:480 | Under the group lock, if group.active_session_id is set and that session.status == ACTIVE, create_session returns it verbatim — regardless of the req.scope or task context of the new request. Two unrelated PM ops targeting the same group (e.g. different tasks threaded into the default group via post_to_channel with task_id=None) will share one session/scope. The per-task threading in post_to_channel mitigates this only when task_id is supplied; the default-group path does not. | low |
|
||||
| get_notification_count loads all agent notifications into memory | roboco/services/notification_delivery.py:379 | base_query selects all NotificationTable rows where to_agents contains agent_id with no limit, then counts in Python. For a long-running agent this row count grows unbounded; called via get_delivery_summary on the panel it is an O(n) DB read per dashboard load. Not a correctness regression from the baseline but the slice's new dedup reduces new-row growth, masking the unbounded-scan risk. | low |
|
||||
| defer_bus_publish listener registration tied to session.info on the AsyncSession — session reuse hazard | roboco/services/notification_delivery.py:116 | _DRAIN_REGISTERED_KEY is set once per AsyncSession and the SQLAlchemy event.listens_for(sync_session, ...) is bound to sync_session. If an AsyncSession is reused for multiple independent transactions (connection-pool recycling), the listener stays registered and fires _schedule_pending_publishes on every subsequent commit even when no new events were deferred — _schedule_pending_publishes pops an empty queue and no-ops, so it is benign, but the listener is never removed and accumulates on the sync_session for the session's lifetime. A long-lived sync_session with many AsyncSession wraps could accumulate listeners. | low |
|
||||
|
||||
## Health
|
||||
This slice is substantially hardened since the baseline: the session-creation race (FOR UPDATE + flush-before-link), the sweeper TOCTOU, the message keyset pagination tie-break, the transactional-outbox for delivery (F107), the Redis re-fire guard, and the ACK_REQUIRED_BY_TYPE-driven requires_ack are all real, well-documented fixes that close prior meltdowns. The main integrity gap is dedup-path fragmentation: NotificationService._create_notification runs two dedup layers (Redis + DB purpose-dedup) while NotificationDeliveryService._persist_and_deliver runs only the Redis layer, so the task-handoff notifications (blocker/escalation/ceo-rejection) are not protected by DB purpose-dedup past the 60s Redis window — a retried i_am_blocked or escalate beyond 60s can re-create an unacked duplicate, the exact inbox-inflation + i_am_idle soft-block the DB dedup was added to prevent. A secondary consistency gap is that acknowledge publishes NOTIFICATION_ACKED directly to the bus instead of through the deferred outbox, leaving the same phantom-event class F107 fixed for deliver. Neither is a crash bug; both are correctness drift between two paths that should behave identically. Code quality is high (terse comments, clear docstrings, explicit race handling), and the slice is well-covered by the orchestrator sweeper integration and route-level callers.
|
||||
@@ -9,7 +9,7 @@ The metrics & observability slice is the read-only measurement layer of RoboCo:
|
||||
| Path | Role | approx LOC |
|
||||
|---|---|---|
|
||||
| `roboco/services/metrics.py` | `MetricsService` — velocity, blockers, team/agent metrics, health, cycle-time/bottleneck/rework/scorecard observability | 905 |
|
||||
| `roboco/services/dashboard.py` | `DashboardService` — auditor flags/reports (in-memory singleton), CEO overview, channel feeds, audit queue, agent status, recent activity | 457 |
|
||||
| `roboco/services/dashboard.py` | `DashboardService` — auditor flags/reports (in-memory singleton), CEO overview, audit queue, agent status, recent activity | 457 |
|
||||
| `roboco/services/cockpit.py` | `CockpitService` — read-only CEO "is the business winning?" summary (goals+delivery+spend+signals) | 97 |
|
||||
| `roboco/services/usage.py` | `UsageService` — token usage summary, time-series, by-agent/team/model, projection, cache efficiency, today summary, recent sessions | 478 |
|
||||
| `roboco/services/usage_events.py` | `UsageSnapshot` dataclass + `publish_usage_snapshot` — publishes USAGE_SNAPSHOT to the StreamEventBus | 52 |
|
||||
@@ -29,7 +29,6 @@ The metrics & observability slice is the read-only measurement layer of RoboCo:
|
||||
| `MetricsService.get_team_metrics` | method | metrics.py:229 | Per-team active/completed/blocked + doc-coverage (dev_notes proxy) |
|
||||
| `MetricsService.get_all_team_metrics` | method | metrics.py:322 | Loop over BACKEND/FRONTEND/UX_UI |
|
||||
| `MetricsService.get_agent_metrics` | method | metrics.py:333 | Per-agent weekly completed, avg hours, messages |
|
||||
| `MetricsService.get_communication_volume` | method | metrics.py:402 | Messages by type, active channels, notifications in window |
|
||||
| `MetricsService.get_health_status` | method | metrics.py:487 | ok/slow/critical from blocked ratio + stale-active heuristic |
|
||||
| `MetricsService._determine_health_status` | method | metrics.py:451 | Threshold logic (CRITICAL_BLOCKED_RATIO=0.3, SLOW=0.15, STALE=5) |
|
||||
| `MetricsService.get_cycle_time_by_stage` | method | metrics.py:529 | Per-stage dwell reconstructed from `audit_log` `task.<status>` events via LEAD window; excludes named qa_fail/pr_fail |
|
||||
@@ -42,12 +41,11 @@ The metrics & observability slice is the read-only measurement layer of RoboCo:
|
||||
| `MetricsService._avg_cycle_hours` | method | metrics.py:869 | Avg started→completed hours for completed tasks |
|
||||
| `_as_hours` | func | metrics.py:47 | Coerce SQL epoch aggregate to rounded float (avoids Decimal→JSON string crash) |
|
||||
| `ACTIVE_STATUSES` | const | metrics.py:61 | CLAIMED/IN_PROGRESS/VERIFYING/AWAITING_QA (BLOCKED excluded — note) |
|
||||
| `DashboardService` | class | dashboard.py:58 | Auditor flags/reports + CEO overview + channel/agent/activity feeds |
|
||||
| `DashboardService` | class | dashboard.py:58 | Auditor flags/reports + CEO overview + agent/activity feeds |
|
||||
| `_DashboardStorageHolder` | class | dashboard.py:35 | Process-singleton in-memory flag/report store |
|
||||
| `get_storage` / `reset_storage` | func | dashboard.py:41/48 | Singleton accessor + test reset |
|
||||
| `DashboardService.create_flag/get_flags/resolve_flag` | methods | dashboard.py:87/102/124 | Auditor flag CRUD over in-memory store |
|
||||
| `DashboardService.create_report/send_report` | methods | dashboard.py:146/184 | Auditor report CRUD + send marking |
|
||||
| `DashboardService.get_channel_feeds` | method | dashboard.py:203 | Per-channel streaming/idle/offline status |
|
||||
| `DashboardService.get_audit_queue` | method | dashboard.py:241 | Blocked + awaiting-QA tasks as queue items |
|
||||
| `DashboardService.get_team_health_list` | method | dashboard.py:279 | Health for BACKEND/FRONTEND/UX_UI/BOARD |
|
||||
| `DashboardService.get_key_metrics` | method | dashboard.py:299 | Velocity + doc coverage + blockers summary |
|
||||
@@ -140,7 +138,7 @@ metrics-observability
|
||||
│ └── pricing.py # _PRICING table, _lookup_prices, calculate_cost, _is_anthropic_model
|
||||
├── roboco/services/
|
||||
│ ├── metrics.py # MetricsService (velocity/blockers/team/agent/comm/health/observability)
|
||||
│ ├── dashboard.py # DashboardService + _DashboardStorageHolder (flags/reports/CEO/channel/queue)
|
||||
│ ├── dashboard.py # DashboardService + _DashboardStorageHolder (flags/reports/CEO/queue)
|
||||
│ ├── cockpit.py # CockpitService (summary/signals)
|
||||
│ ├── usage.py # UsageService (summary/series/by-dim/projection/cache/today/sessions)
|
||||
│ ├── usage_events.py # UsageSnapshot + publish_usage_snapshot
|
||||
@@ -152,7 +150,7 @@ metrics-observability
|
||||
## Dependencies
|
||||
|
||||
**Internal (roboco):**
|
||||
- `roboco.db.tables` — `AgentSpawnSessionTable`, `DailyUsageRollupTable`, `AgentTable`, `AuditLogTable`, `TaskTable`, `MessageTable`, `NotificationTable`, `ChannelTable`
|
||||
- `roboco.db.tables` — `AgentSpawnSessionTable`, `DailyUsageRollupTable`, `AgentTable`, `AuditLogTable`, `TaskTable`, `NotificationTable`
|
||||
- `roboco.models.base` — `TaskStatus`, `Team`, `AgentStatus`
|
||||
- `roboco.models.metrics` — all metric result schemas (VelocityMetrics, StageTiming, ReworkReport, Scorecard, …)
|
||||
- `roboco.models.dashboard` — `FlagData`, `ReportData`, `DashboardStorage`, `CreateFlagParams`, …
|
||||
|
||||
+13
-34
@@ -2,14 +2,14 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
The Pydantic/dataclass domain surface of RoboCo — the typed contract the API, services, orchestrator, and agent runtimes all speak. These are **not** the ORM tables (`roboco/db/tables.py` owns persistence); models here are request/response schemas, domain aggregates, runtime DTOs, and enums that get crossed into SQLAlchemy rows by the service layer. Validation (`RobocoBase`: `extra="forbid"`, `validate_assignment`, `use_enum_values`) lives here, and several files (`agents.py`, `runtime.py`, `optimal.py`, `metrics.py`, `llm.py`, `audit.py`, `dashboard.py`, `transcription.py`, `extraction.py`, `messaging.py`, `permissions.py`) are pure dataclasses/StrEnums with no Pydantic model at all — runtime value types the orchestrator and services pass around.
|
||||
The Pydantic/dataclass domain surface of RoboCo — the typed contract the API, services, orchestrator, and agent runtimes all speak. These are **not** the ORM tables (`roboco/db/tables.py` owns persistence); models here are request/response schemas, domain aggregates, runtime DTOs, and enums that get crossed into SQLAlchemy rows by the service layer. Validation (`RobocoBase`: `extra="forbid"`, `validate_assignment`, `use_enum_values`) lives here, and several files (`agents.py`, `runtime.py`, `optimal.py`, `metrics.py`, `llm.py`, `audit.py`, `dashboard.py`, `transcription.py`, `extraction.py`, `permissions.py`) are pure dataclasses/StrEnums with no Pydantic model at all — runtime value types the orchestrator and services pass around.
|
||||
|
||||
## Files
|
||||
|
||||
| Path | Role | approx LOC |
|
||||
|------|------|------------|
|
||||
| `__init__.py` | Public re-export surface (`Agent`, `Task`, `Session`, `Channel`, `Notification`, `Journal`, `CommitRef`, enums, `get_column_config`, …) | 149 |
|
||||
| `base.py` | All shared StrEnums (`TaskStatus`, `TaskType`, `AgentStatus`, `ModelProvider`, `ChannelType`, `JournalEntryType`, …) + `RobocoBase`/`TimestampMixin` + `AgentRole`/`Team` aliases to `foundation.identity` | 275 |
|
||||
| `__init__.py` | Public re-export surface (`Agent`, `Task`, `Notification`, `Journal`, `CommitRef`, enums, `get_column_config`, …) | 149 |
|
||||
| `base.py` | All shared StrEnums (`TaskStatus`, `TaskType`, `AgentStatus`, `ModelProvider`, `JournalEntryType`, …) + `RobocoBase`/`TimestampMixin` + `AgentRole`/`Team` aliases to `foundation.identity` | 275 |
|
||||
| `task.py` | `Task` aggregate + `CommitRef`/`DocRef`/`ProgressUpdate`/`Checkpoint`/`SubTask`/`TaskPlan` + `TaskCreate`/`TaskUpdate`/`TaskCreateRequest` | 502 |
|
||||
| `a2a.py` | A2A protocol wire models (`AgentCard`, `A2ATask`, `A2AMessage`, parts) + persistent conversation models (`A2AConversation`, `A2AChatMessage`) + state mappers | 592 |
|
||||
| `agents.py` | Agent **runtime** domain types — per-role phase enums (`DevTaskPhase`, `QATaskPhase`, `CellPMPhase`, …), `AgentConfig`/`AgentState`, `TaskContext`/`ReviewContext`/`DocContext`, `AuditFlag`/`AuditReport` | 405 |
|
||||
@@ -17,28 +17,24 @@ The Pydantic/dataclass domain surface of RoboCo — the typed contract the API,
|
||||
| `permissions.py` | `PermissionLevel` IntEnum, `ROLE_LEVELS` (built from `agents_config`), `AgentContext`, `COMMUNICATION_MATRIX`, `TASK_PERMISSIONS`, `KB_PERMISSIONS` | 284 |
|
||||
| `optimal.py` | RAG domain types — `IndexType`, `SearchResult`/`SearchOutcome`/`RAGResponse`, `ErrorPattern`/`Decision`/`Standard`, `MentorResponse`, `CodeReviewResult` | 275 |
|
||||
| `metrics.py` | `VelocityMetrics`/`BlockerMetrics`/`TeamMetrics`/`AgentMetrics` + v0.10.0 observability: `StageTiming`/`StageBottleneck`/`BottleneckReport`/`AgentReworkRate`/`ReworkReport`/`Scorecard` | 249 |
|
||||
| `session.py` | `Session` + `SessionScope`/`SessionConfig`/`SessionTaskRelationshipType` + `SessionTaskLink` + create schemas | 214 |
|
||||
| `events.py` | `EventType` StrEnum + `Event` dataclass (JSON round-trip) + `NotificationServiceProtocol`/`OrchestratorAccessProtocol`/`EventContext` DI container | 199 |
|
||||
| `handoff.py` | `DocumenterHandoff` + `CodeSample`/`DocumentationItem`/`ConversationRef` + `HandoffCreate` (RESERVED — see file header) | 202 |
|
||||
| `project.py` | `Project` + `BranchReason` + `ProjectCreate`/`ProjectUpdate` (CI-watch, dep-update, quality_command fields) | 178 |
|
||||
| `agent.py` | `Agent` API model + `ModelConfig`/`AgentPermissions`/`AgentMetrics` + `AgentCreate`/`AgentUpdate` | 172 |
|
||||
| `kanban.py` | `KanbanBoard`/`KanbanColumn`/`KanbanCard`/`KanbanSwimlane` + per-role column configs + `get_column_config` | 159 |
|
||||
| `llm_catalog.py` | `CatalogEntry` + `MODEL_CATALOG`/`MODEL_CATALOG_BY_NAME`/`provider_type_for_model` + `OLLAMA_ROLE_DEFAULTS`/`OLLAMA_DEFAULT_MODEL` (Settings dropdown source of truth) | 132 |
|
||||
| `message.py` | `ExtractedMessage` + `MessageEdit`/`RawStream` + `MessageCreate` | 137 |
|
||||
| `message.py` | `ExtractedMessage` + `RawStream` | 137 |
|
||||
| `runtime.py` | Orchestrator runtime types — `OrchestratorAgentState`, `SpawnGitContext`, `OrchestratorAgentConfig`, `AgentInstance`, `WaitingRecord`, `MODEL_MAP`, `ROLE_MODEL_MAP` | 128 |
|
||||
| `transcription.py` | `StreamBuffer` (flush heuristic) + `TranscriptionConfig` | 119 |
|
||||
| `llm.py` | `LLMUsage`/`ToonConfig`/`EncodedBlock`/`ToonMetrics` (token + TOON serialization metrics) | 118 |
|
||||
| `notification.py` | `Notification` + `NotificationCreate` + `CreateNotificationParams` | 117 |
|
||||
| `channel.py` | `Channel` + `ChannelCreate`/`ChannelUpdate` | 115 |
|
||||
| `work_session.py` | `WorkSession` + `WorkSessionStatus` + `WorkSessionCreate`/`WorkSessionUpdate` | 117 |
|
||||
| `pitch.py` | `Pitch` + `PitchStatus` + `PitchCreate` (Board proposal → provisioning) | 73 |
|
||||
| `extraction.py` | `ExtractionContext`/`ExtractionResult`/`ExtractionConfig` | 68 |
|
||||
| `product.py` | `Product` + `ProductCellMapping` (cell→Project map; validator enforces cell-only) + create/update DTOs | 69 |
|
||||
| `messaging.py` | `ChannelCreateRequest`/`GroupCreateRequest`/`SessionCreateRequest`/`MessageCreateRequest` (service-layer DTOs) | 61 |
|
||||
| `playbook.py` | `Playbook` + `PlaybookCreate`/`PlaybookUpdate` (curated procedure; `from_attributes` for ORM load) | 58 |
|
||||
| `audit.py` | `AuditEventType` StrEnum + `PermissionDenialContext`/`StateTransitionDenialContext` dataclasses | 58 |
|
||||
| `dashboard.py` | `FlagData`/`ReportData`/`ChannelFeedData`/`TeamHealthData`/`AuditQueueItem`/`CreateFlagParams`/`DashboardStorage` | 96 |
|
||||
| `group.py` | `Group` (role-based chat container, hierarchy_level 0–4) + `GroupCreate`/`GroupUpdate` | 100 |
|
||||
| `dashboard.py` | `FlagData`/`ReportData`/`TeamHealthData`/`AuditQueueItem`/`CreateFlagParams`/`DashboardStorage` | 96 |
|
||||
| `secretary.py` | `DirectiveKind`/`DirectiveStatus` StrEnums + `GATED_KINDS` frozenset | 41 |
|
||||
| `README.md` | Architecture doc for the models package | ~250 |
|
||||
|
||||
@@ -60,17 +56,11 @@ The Pydantic/dataclass domain surface of RoboCo — the typed contract the API,
|
||||
| `Team` | alias | base.py:24 | `= identity.Team` — canonical Team enum lives in `foundation/identity` |
|
||||
| `ModelProvider` | StrEnum | base.py:197 | anthropic/ollama_cloud/openai/local/grok |
|
||||
| `ModelConfig` | Pydantic model | agent.py:27 | provider + name + fallback + temperature + max_tokens |
|
||||
| `AgentPermissions` | Pydantic model | agent.py:45 | can_notify + channels_read/write |
|
||||
| `AgentPermissions` | Pydantic model | agent.py:45 | can_notify |
|
||||
| `WorkSession` | Pydantic model | work_session.py:25 | Branch/PR/merge tracking for a (project, task, agent) work episode |
|
||||
| `WorkSessionStatus` | StrEnum | work_session.py:17 | active/completed/abandoned |
|
||||
| `Project` | Pydantic model | project.py:47 | Git repo config + CI/dep-update/`sandbox_services` opt-ins + `assigned_cell` |
|
||||
| `BranchReason` | StrEnum | project.py:18 | feature/bug/chore/docs/hotfix (branch-name prefixes) |
|
||||
| `Session` | Pydantic model | session.py:90 | Bounded message group (time/count/length); `scope` for context loading |
|
||||
| `SessionScope` | StrEnum | session.py:30 | initiative/cell/task |
|
||||
| `SessionTaskLink` | Pydantic model | session.py:157 | Many-to-many session↔task with `is_primary` + relationship_type |
|
||||
| `Channel` | Pydantic model | channel.py:24 | Top-level comms unit; members/writers/silent_observers |
|
||||
| `ChannelType` | StrEnum | base.py:163 | cell/cross_cell/management/special |
|
||||
| `Group` | Pydantic model | group.py:25 | Role-based chat container with hierarchy_level 0–4 |
|
||||
| `ExtractedMessage` | Pydantic model | message.py:51 | Stored message with embedding, edit_history, mentions, task_id |
|
||||
| `MessageType` | StrEnum | base.py:128 | reasoning/dialogue/decision/action/blocker/technical |
|
||||
| `RawStream` | Pydantic model | message.py:32 | Ephemeral WebSocket chunk payload |
|
||||
@@ -121,7 +111,7 @@ The Pydantic/dataclass domain surface of RoboCo — the typed contract the API,
|
||||
|
||||
## Data Flow
|
||||
|
||||
API request bodies → Pydantic `*Create`/`*Update` schemas (validation boundary, `extra="forbid"`) → route handlers → services. Services translate between these models and the SQLAlchemy ORM tables in `roboco/db/tables.py` (e.g. `TaskTable`, `WorkSessionTable`, `ProjectTable`, `SessionTable`, `MessageTable`, `NotificationTable`, `JournalTable`, `JournalEntryTable`, `AgentTable`, `PlaybookTable`, `ProductTable`, `PitchTable`): the ORM row is the persistence shape; the Pydantic model is the contract shape. Several ORM tables load back into a model via `model_config = ConfigDict(from_attributes=True)` (`Playbook` at playbook.py:20, `Product`/`ProductCellMapping` via `RobocoBase`). Runtime-only DTOs (`AgentInstance`, `WaitingRecord`, `SpawnGitContext`, `Event`, `ExtractionResult`, `StreamBuffer`, the metrics/observability dataclasses, `AuditFlag`/`AuditReport`) never touch the DB directly — they are orchestrator/service in-process values. Enum parity between model and ORM is enforced by the test gate (`enum` columns in `tables.py` reference the same `StrEnum` classes from `base.py`/`foundation.identity`). `agent.py` `Agent` is the API/persistence model; `agents.py` `AgentConfig` is the runtime analogue used by the agent implementations — the comment at agents.py:7 calls this split out explicitly. Validation lives almost entirely on the Pydantic schemas (`min_length`, `ge`/`le`, `pattern`, `model_validator`); the dataclass models are intentionally validation-light.
|
||||
API request bodies → Pydantic `*Create`/`*Update` schemas (validation boundary, `extra="forbid"`) → route handlers → services. Services translate between these models and the SQLAlchemy ORM tables in `roboco/db/tables.py` (e.g. `TaskTable`, `WorkSessionTable`, `ProjectTable`, `NotificationTable`, `JournalTable`, `JournalEntryTable`, `AgentTable`, `PlaybookTable`, `ProductTable`, `PitchTable`): the ORM row is the persistence shape; the Pydantic model is the contract shape. Several ORM tables load back into a model via `model_config = ConfigDict(from_attributes=True)` (`Playbook` at playbook.py:20, `Product`/`ProductCellMapping` via `RobocoBase`). Runtime-only DTOs (`AgentInstance`, `WaitingRecord`, `SpawnGitContext`, `Event`, `ExtractionResult`, `StreamBuffer`, the metrics/observability dataclasses, `AuditFlag`/`AuditReport`) never touch the DB directly — they are orchestrator/service in-process values. Enum parity between model and ORM is enforced by the test gate (`enum` columns in `tables.py` reference the same `StrEnum` classes from `base.py`/`foundation.identity`). `agent.py` `Agent` is the API/persistence model; `agents.py` `AgentConfig` is the runtime analogue used by the agent implementations — the comment at agents.py:7 calls this split out explicitly. Validation lives almost entirely on the Pydantic schemas (`min_length`, `ge`/`le`, `pattern`, `model_validator`); the dataclass models are intentionally validation-light.
|
||||
|
||||
## Mermaid
|
||||
|
||||
@@ -145,12 +135,6 @@ erDiagram
|
||||
Agent ||--o{ Journal : "journal_id"
|
||||
Journal ||--o{ JournalEntry : entries
|
||||
JournalEntry }o--o| Task : task_id
|
||||
Channel ||--o{ Group : "channel_id"
|
||||
Group ||--o{ Session : "group_id"
|
||||
Session ||--o{ SessionTaskLink : "session_id"
|
||||
SessionTaskLink }o--|| Task : "task_id"
|
||||
Channel ||--o{ ExtractedMessage : "channel_id"
|
||||
Session ||--o{ ExtractedMessage : "session_id"
|
||||
ExtractedMessage }o--|| Agent : "agent_id"
|
||||
Agent ||--o{ Notification : "from_agent"
|
||||
Notification }o--o{ Task : "related_task_id"
|
||||
@@ -173,11 +157,7 @@ models/
|
||||
│ ├── agents.py AgentConfig, AgentState, DevTaskPhase/QATaskPhase/CellPMPhase/MainPMPhase/DocTaskPhase/ProductOwnerPhase/HeadMarketingPhase/AuditorPhase, TaskContext/ReviewContext/DocContext, AuditFlag, AuditReport
|
||||
│ └── permissions.py PermissionLevel, ROLE_LEVELS, AgentContext, COMMUNICATION_MATRIX, TASK_PERMISSIONS, KB_PERMISSIONS
|
||||
├── comms
|
||||
│ ├── session.py Session, SessionScope, SessionConfig, SessionTaskLink, SessionTaskRelationshipType
|
||||
│ ├── channel.py Channel, ChannelCreate, ChannelUpdate
|
||||
│ ├── group.py Group, GroupCreate, GroupUpdate
|
||||
│ ├── message.py ExtractedMessage, MessageCreate, MessageEdit, RawStream
|
||||
│ ├── messaging.py ChannelCreateRequest, GroupCreateRequest, SessionCreateRequest, MessageCreateRequest
|
||||
│ ├── message.py ExtractedMessage, RawStream
|
||||
│ ├── notification.py Notification, NotificationCreate, CreateNotificationParams
|
||||
│ ├── a2a.py AgentCard, A2ATask, A2AMessage, parts, A2AConversation, A2AChatMessage, state mappers
|
||||
│ ├── extraction.py ExtractionContext, ExtractionResult, ExtractionConfig
|
||||
@@ -188,7 +168,7 @@ models/
|
||||
├── journal / audit
|
||||
│ ├── journal.py Journal, JournalEntry, JournalEntryCreate, factory params, create_*_entry, JournalStats, GrowthMetrics
|
||||
│ ├── audit.py AuditEventType, PermissionDenialContext, StateTransitionDenialContext
|
||||
│ └── dashboard.py FlagData, ReportData, ChannelFeedData, TeamHealthData, AuditQueueItem, DashboardStorage
|
||||
│ └── dashboard.py FlagData, ReportData, TeamHealthData, AuditQueueItem, DashboardStorage
|
||||
├── llm
|
||||
│ ├── llm.py LLMUsage, ToonConfig, EncodedBlock, ToonMetrics
|
||||
│ ├── llm_catalog.py CatalogEntry, MODEL_CATALOG, provider_type_for_model, OLLAMA_ROLE_DEFAULTS, OLLAMA_DEFAULT_MODEL
|
||||
@@ -214,7 +194,6 @@ models/
|
||||
- **`roboco.foundation.identity`** — `base.py:21` imports `identity` and aliases `AgentRole = identity.Role`, `Team = identity.Team`. `product.py` and `pitch.py` import `CELL_TEAMS`/`Team` directly from `foundation.identity`.
|
||||
- **`roboco.agents_config`** — `permissions.py:11` imports `ROLE_PERMISSION_LEVELS` to build `ROLE_LEVELS` at import time.
|
||||
- **`roboco.models.runtime`** — `llm_catalog.py:22` imports `MODEL_MAP` to derive Anthropic catalog entries.
|
||||
- **`roboco.models.session`** — `group.py:18` imports `SessionConfig`; `messaging.py:11` imports `SessionScope`.
|
||||
- **`roboco.models.message`** — `extraction.py:12` imports `ExtractedMessage`.
|
||||
- **`roboco.models.product`** — `task.py:24` imports `ProductCellMapping` (the `cell_projects` field).
|
||||
- Internal cross-imports are otherwise minimal; `__init__.py` is the single aggregation point.
|
||||
@@ -233,7 +212,7 @@ None — pure models, no flags. (The `Project` model *carries* opt-in fields `ci
|
||||
|
||||
- `AgentRole` and `Team` are **not defined in `models/base.py`** — they are `identity.Role` / `identity.Team` aliased at base.py:23–24. The comment says "Removed in Phase 4 housekeeping after every consumer is migrated." SQLAlchemy `sa.Enum(AgentRole, name="agentrole")` still works because Python identity is preserved. New code should import from `roboco.foundation.identity` directly.
|
||||
- `ProductCellMapping` deliberately overrides `use_enum_values=False` (product.py:22) so `team` stays a real `Team` enum for `is`-checks and the validator's `.value` error — the only model that deviates from `RobocoBase`'s `use_enum_values=True`.
|
||||
- `Task` mutations are not done on the model; `task.py:337` directs callers to `TaskService` (`claim`, `start`, `block`, `complete`, …). Same pattern for `Session`, `Channel`, `Notification`, `Journal`, `WorkSession` — service-owned state.
|
||||
- `Task` mutations are not done on the model; `task.py:337` directs callers to `TaskService` (`claim`, `start`, `block`, `complete`, …). Same pattern for `Notification`, `Journal`, `WorkSession` — service-owned state.
|
||||
- `TaskCreate` has **no silent defaults** for `task_type` / `nature` / `estimated_complexity` (task.py:350 docstring) — mirrors `foundation.policy.task_completeness.TASK_AT_CREATE`. The 2026-05-08 trace of agents omitting `task_type` and deadlocking the lifecycle is the reason.
|
||||
- `TaskCreate._exactly_one_target` (task.py:405) enforces exactly one of `project_id` / `product_id` / `cell_projects`. Old callers passing `cell_projects` alongside either of the others now fail.
|
||||
- The "one active WorkSession per task" invariant is **not** in the model — it's a DB partial-unique index (migration 047) + service-layer guard. The model alone won't stop you constructing two active `WorkSession`s.
|
||||
@@ -251,7 +230,7 @@ None — pure models, no flags. (The `Project` model *carries* opt-in fields `ci
|
||||
- CLAUDE.md lists the `Task` model key fields (`task_type`, `project_id`, `branch_name`, `work_session_id`, `pr_number`, `pr_url`, `docs_complete`, `pr_created`, `commits: list[CommitRef]`) — all present on `Task` (task.py:163–255). CLAUDE.md does **not** mention `cell_projects` (task.py:179) or `batch_id`/`intends_to_touch`/`adds_migration`/`touches_shared` (task.py:225–236), which the MegaTask/sequencing sections elsewhere in CLAUDE.md do cover — so the model is ahead of the "Data Models" prose but consistent with the MegaTask section.
|
||||
- CLAUDE.md "A task has at most one active WorkSession" — enforced by DB partial-unique index (migration 047) + service layer, not by the `WorkSession` model itself (work_session.py has no such constraint). Consistent with CLAUDE.md's "enforced both at the service layer and by a DB partial-unique index".
|
||||
- The slice prompt named `AuditEvent` and `A2AEnvelope` as landmarks; the actual symbols are `AuditEventType` (audit.py:13, no `AuditEvent` class) and there is no `A2AEnvelope` in `a2a.py` (the gateway `Envelope` lives in `services/gateway/`, not here). Listed the real landmarks instead.
|
||||
- Otherwise: `TaskStatus` 15-state enum, `TaskType` 6 values, `NotificationType`/`ChannelType`/`JournalEntryType` all match CLAUDE.md verbatim.
|
||||
- Otherwise: `TaskStatus` 15-state enum, `TaskType` 6 values, `NotificationType`/`JournalEntryType` all match CLAUDE.md verbatim.
|
||||
- v0.17.0 delta: the three new ORM tables backing cloud auth + the X engine (`UserTable`, `XCredentialsTable`, `XSeenMentionTable` — migrations 058/059) have **no Pydantic counterpart in this package** — cloud auth's `UserTable` is consumed directly by `fastapi_users`/`roboco.api.auth.*` and the X engine's two tables are read/written directly off the ORM row by `roboco.services.x_*`. They are documented as ORM `Key Symbols` in `db-migrations.md`, not here, consistent with this file's own Purpose statement ("these are not the ORM tables").
|
||||
|
||||
## Changes Since Baseline
|
||||
@@ -276,7 +255,7 @@ Logic-touching commits:
|
||||
> - **c71f9b3b** `[chore] logical-gaps: kanban board column coverage + status-class fixes` — `kanban.py`: `DEV_COLUMNS` expanded from 7 to 15 entries (all `TaskStatus` values now covered); `QA_COLUMNS` dropped the `VERIFYING→"In Review"` entry (VERIFYING is the dev's self-check, not a QA state); `PM_COLUMNS` widened to include all gate/revision/paused/cancelled/backlog columns. No model API surface change; internal column configs only.
|
||||
> - **d8a5bb48** `[chore] logical-gaps: a2a service hierarchy gate + persist skill on message row` — `a2a.py`: `A2AChatMessage` gained `skill: str | None` field (migration 054 adds the DB column). The `send()` verb now persists which capability the A2A is about so the receiver and inbox can surface it.
|
||||
> - **536bbb64** `[chore] logical-gaps sweep (#286)` — `task.py`: `DocRef` gained `commit_status: str | None` (whether the doc reached the project repo: `committed`/`skipped`/`failed`); this 8-line insertion shifts all subsequent task.py line numbers by +8 vs the baseline annotations above. `playbook.py`: `Playbook` gained `archived_by: UUID | None` and `archived_at: datetime | None` to support the archive curation path.
|
||||
> - **76ce53e3** `[fix] chat: wire live message delivery end-to-end (MESSAGE_SENT)` — `events.py`: added `EventType.MESSAGE_SENT = "message.sent"`; the bridge now forwards it to `/ws/sessions/{id}` and `/ws/channels/{id}` subscribers.
|
||||
> - **76ce53e3** `[fix] chat: wire live message delivery end-to-end (MESSAGE_SENT)` — `events.py`: added `EventType.MESSAGE_SENT = "message.sent"`; the bridge forwarded it to `/ws/sessions/{id}` and `/ws/channels/{id}` subscribers. **Now removed** by the comms-subsystem teardown (`docs/internal/specs/2026-07-03-comms-teardown-trace.md`) — the `/ws/sessions`/`/ws/channels` bridge and the tables it served are gone; `EventType.MESSAGE_SENT` itself is left as a dead/inert enum member, not deleted.
|
||||
|
||||
## Regression Risks
|
||||
|
||||
@@ -291,4 +270,4 @@ Logic-touching commits:
|
||||
|
||||
## Health
|
||||
|
||||
The models package is coherent and well-layered: a single `RobocoBase` config drives consistency, enums are centralized in `base.py` (with the `AgentRole`/`Team` alias-to-`foundation.identity` migration clearly commented), and the API/Pydantic vs runtime/dataclass split is explicit (`agent.py` vs `agents.py`, with a docstring calling it out). The recent MegaTask per-cell-map change is small, additive, and validator-guarded; the main follow-on risk is migration 052 parity and test assertions on the renamed validator message — both mechanical. Two long-standing cleanliness items linger: `handoff.py` is a reserved-but-unwired model (file header says so), and several files (`dashboard.py`, `transcription.py`, `extraction.py`, `messaging.py`) are pure dataclasses that read as service-layer DTOs rather than domain models — harmless but slightly muddies the "models = typed contract surface" framing. Enum parity with the ORM (`tables.py`) is enforced by the test gate. No blocking issues; the package is in good shape.
|
||||
The models package is coherent and well-layered: a single `RobocoBase` config drives consistency, enums are centralized in `base.py` (with the `AgentRole`/`Team` alias-to-`foundation.identity` migration clearly commented), and the API/Pydantic vs runtime/dataclass split is explicit (`agent.py` vs `agents.py`, with a docstring calling it out). The recent MegaTask per-cell-map change is small, additive, and validator-guarded; the main follow-on risk is migration 052 parity and test assertions on the renamed validator message — both mechanical. Two long-standing cleanliness items linger: `handoff.py` is a reserved-but-unwired model (file header says so), and several files (`dashboard.py`, `transcription.py`, `extraction.py`) are pure dataclasses that read as service-layer DTOs rather than domain models — harmless but slightly muddies the "models = typed contract surface" framing. Enum parity with the ORM (`tables.py`) is enforced by the test gate. No blocking issues; the package is in good shape.
|
||||
@@ -0,0 +1,144 @@
|
||||
## Purpose
|
||||
This slice implements RoboCo's formal-notification backbone: NotificationService is the typed notification factory (blocker, QA-ready, A2A, board-review, ack), NotificationDeliveryService handles delivery (transactional-outbox bus publish), ACK tracking, expiry sweeps, and PM/CEO task-handoff notifications, and notification_dedup is a bounded Redis SET-NX re-fire guard for loop-prone notification types. Together they turn lifecycle events into both a durable DB record and a real-time push, with multiple dedup layers (Redis re-fire window + DB purpose-dedup) to keep agent inboxes from flooding under coordinator loops.
|
||||
|
||||
## Files
|
||||
|
||||
| Path | Role | LOC |
|
||||
|---|---|---|
|
||||
| roboco/services/notification.py | Typed notification factory (blocker/QA/docs/handoff/A2A/board-review/ack) with slug→UUID recipient resolution, DB purpose-dedup + Redis re-fire guard, owns its own DB context and commit | 574 |
|
||||
| roboco/services/notification_dedup.py | Bounded Redis SET-NX re-fire guard for loop-prone notification types (TASK_ASSIGNMENT/REVIEW_REQUEST/DOCUMENTATION_REQUEST/BROADCAST); 60s TTL, fail-open | 91 |
|
||||
| roboco/services/notification_delivery.py | Delivery (transactional-outbox deferred bus publish), ACK/read tracking, expiry sweep, PM/CEO task-handoff notifications (notify_pm_of_block, escalate_and_notify, etc.), API-facing list/CRUD | 1034 |
|
||||
|
||||
## Data Flow
|
||||
Two create-and-deliver paths exist. (A) NotificationService._create_notification (notification.py) opens its OWN get_db_context, resolves sender + recipients to UUIDs via _resolve_agent_uuid, runs the Redis re-fire guard (all_recipients_recently_notified), then DB purpose-dedup (ack-required types only, same sender+type+task+overlapping recipients not yet acked), builds NotificationTable with requires_ack from ACK_REQUIRED_BY_TYPE, flushes, calls NotificationDeliveryService.deliver (which defers NOTIFICATION_SENT bus events to after_commit), and finally commits — the commit triggers the deferred bus drain. (B) NotificationDeliveryService._persist_and_deliver (notification_delivery.py) is used by the task-handoff helpers (notify_pm_of_block, escalate_and_notify, etc.): it runs inside the CALLER's open transaction, applies only the Redis re-fire guard (no DB purpose-dedup), adds+flushes+delivers, and leaves the commit to the caller (api/routes/tasks.py). Sweeper loops in the orchestrator call sweep_expired_notifications periodically. Real-time push: deliver defers per-recipient NOTIFICATION_SENT events; the after_commit listener schedules _drain_pending_publishes which publishes to the StreamEventBus; websocket_bridge forwards to /ws/notifications/{id} sockets. ACKs flow acknowledge → acked_by/read_by mutation + NOTIFICATION_ACKED event.
|
||||
|
||||
## Mermaid
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Notification
|
||||
TYPED["send_blocker / send_qa_ready / send_a2a / send_ack ..."] --> CN["_create_notification"]
|
||||
CN --> RES["_resolve_agent_uuid (sender+recipients)"]
|
||||
CN --> RD["all_recipients_recently_notified<br/>Redis SET-NX 60s"]
|
||||
CN --> DD["DB purpose-dedup<br/>ack-required only"]
|
||||
CN --> NT["NotificationTable (requires_ack=ACK_REQUIRED_BY_TYPE)"]
|
||||
CN --> DLV["deliver"]
|
||||
HANDOFF["notify_pm_of_block / escalate_and_notify / notify_ceo_of_escalation"] --> PAD["_persist_and_deliver"]
|
||||
PAD --> RD
|
||||
PAD --> NT2["NotificationTable"]
|
||||
PAD --> DLV
|
||||
end
|
||||
|
||||
subgraph Delivery
|
||||
DLV -->|"in-tx"| DA["delivered_at = now"]
|
||||
DLV --> DEF["defer_bus_publish<br/>per-recipient NOTIFICATION_SENT"]
|
||||
DEF -.->|"after_commit"| DRN["_drain_pending_publishes"]
|
||||
DEF -.->|"after_rollback"| DIS["_discard_pending_publishes (no phantom)"]
|
||||
DRN --> BUS["StreamEventBus"]
|
||||
BUS --> WS["websocket_bridge → /ws/notifications/{id}"]
|
||||
ACK["acknowledge"] --> AB["acked_by / read_by / acked_at"]
|
||||
ACK --> BUS2["NOTIFICATION_ACKED event"]
|
||||
end
|
||||
|
||||
RD --> REDIS[("Redis SET-NX")]
|
||||
DD --> PG[("PostgreSQL notifications")]
|
||||
NT --> PG
|
||||
DRN --> REDIS
|
||||
```
|
||||
|
||||
## Logical Tree
|
||||
```
|
||||
notification
|
||||
├── notification.py (NotificationService)
|
||||
│ ├── _resolve_agent_uuid (slug/UUID → UUID; 'system' seed)
|
||||
│ ├── send_blocker / send_stuck_agent / send_qa_ready / send_docs_ready
|
||||
│ ├── send_handoff / send_qa_failed / send_board_review_complete
|
||||
│ ├── send_external_pr_reviewed / send_ack / send_a2a (tristate priority)
|
||||
│ ├── _notification_type_label / _resolve_recipients
|
||||
│ └── _create_notification (own DB context, re-fire guard, DB dedup, requires_ack, commit)
|
||||
├── notification_dedup.py
|
||||
│ ├── _LOOP_PRONE_TYPES (4 types)
|
||||
│ ├── _DEDUP_TTL_SECONDS (60)
|
||||
│ ├── _key (type:from:recipient:task)
|
||||
│ └── all_recipients_recently_notified (per-recipient SET-NX, fail-open)
|
||||
└── notification_delivery.py (NotificationDeliveryService)
|
||||
├── Transactional outbox (F107)
|
||||
│ ├── defer_bus_publish (enqueue + register listeners)
|
||||
│ ├── _schedule_pending_publishes (after_commit → loop.create_task)
|
||||
│ ├── _drain_pending_publishes (best-effort publish)
|
||||
│ └── _discard_pending_publishes (after_rollback)
|
||||
├── EscalationError / EscalationOutcome / BlockerDetails
|
||||
├── deliver (delivered_at in-tx, defer per-recipient events)
|
||||
├── get_notification / _notification_is_fully_acked / _log_expired_notification
|
||||
├── sweep_expired_notifications (log stale unacked)
|
||||
├── get_pending_for_agent / get_unacknowledged_for_agent / get_notification_count
|
||||
├── acknowledge / mark_read / bulk_acknowledge / get_ack_status / get_delivery_summary
|
||||
├── Task-handoff notifications
|
||||
│ ├── notify_pm_of_block / notify_pm_of_docs_complete / notify_pm_of_review_submission
|
||||
│ ├── notify_assignee_of_unblock / notify_assignee_of_ceo_rejection
|
||||
│ ├── escalate_and_notify (EscalationError/EscalationOutcome)
|
||||
│ ├── notify_ceo_of_escalation
|
||||
│ └── _persist_and_deliver (re-fire guard only, caller commits)
|
||||
├── Recipient helpers: _resolve_team_pm / _resolve_pm_for_agent_or_team / _get_agent_by_id/slug / _get_ceo_agent
|
||||
└── API-facing: list_system_notifications / list_for_agent / get_for_recipient_and_mark_read / acknowledge_for_recipient / mark_read_for_recipient
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
- Internal: roboco.config.settings (redis_url), roboco.db.tables (NotificationTable, AgentTable, TaskTable), roboco.db.base.get_db_context, roboco.events (Event, EventType, get_event_bus), roboco.foundation.policy.communications.ACK_REQUIRED_BY_TYPE, roboco.models.base (NotificationPriority, NotificationType, AgentRole), roboco.models.notification.CreateNotificationParams, roboco.services.base (BaseService, ConflictError, NotFoundError), roboco.services.permissions.has_privileged_access, roboco.services.repositories.get_agent_slug, roboco.agents_config (get_escalation_target, get_pm_for_agent, get_pm_for_team), roboco.utils.converters (require_uuid, to_python_uuid)
|
||||
- External: sqlalchemy (select, and_, or_, func, event, joinedload, selectinload, with_for_update, IntegrityError, AsyncSession), redis.asyncio (from_url, set NX EX, aclose), asyncio (create_task, get_running_loop), structlog, datetime (UTC, datetime, timedelta), uuid.UUID, dataclasses
|
||||
|
||||
## Entry Points
|
||||
|
||||
| Name | File | Trigger |
|
||||
|---|---|---|
|
||||
| NotificationService.send_*_notification | roboco/services/notification.py | TaskService / orchestrator lifecycle transitions (blocker, qa-ready, docs, a2a, board-review) |
|
||||
| NotificationService.send_ack_notification | roboco/services/notification.py | gateway `notify` content verb (PM/Board only) |
|
||||
| NotificationDeliveryService.notify_pm_of_block / escalate_and_notify / notify_ceo_of_escalation | roboco/services/notification_delivery.py | api/routes/tasks.py i_am_blocked / escalate / ceo-approval routes |
|
||||
| NotificationDeliveryService.acknowledge / list_for_agent / get_for_recipient_and_mark_read | roboco/services/notification_delivery.py | api/routes/notifications.py ACK + list endpoints |
|
||||
| sweep_expired_notifications | roboco/services/notification_delivery.py | orchestrator periodic loop (orchestrator.py:5780) |
|
||||
|
||||
## Config Flags
|
||||
- settings.redis_url — Redis URL used by notification_dedup for the SET-NX re-fire guard (derived from ROBOCO_REDIS_HOST/_PORT)
|
||||
|
||||
|
||||
## Gotchas
|
||||
- Two notification create paths with DIFFERENT dedup strength: NotificationService._create_notification runs BOTH the Redis re-fire guard AND the DB purpose-dedup; NotificationDeliveryService._persist_and_deliver (task-handoff helpers) runs ONLY the Redis re-fire guard and explicitly skips DB purpose-dedup. A reworded BLOCKER_ESCALATION from the handoff path within 60s is suppressed by Redis, but beyond 60s a duplicate can be re-created since there is no DB dedup on that path.
|
||||
- notification_dedup fail-open: a Redis error returns False (never suppress) — correct for not dropping notifications, but a sustained Redis outage re-opens the per-tick re-fire storm the guard was added to stop.
|
||||
- notification_dedup.all_recipients_recently_notified has a side effect: it SET-NX-marks recipients NOT yet notified, so the FIRST call for a fresh recipient returns False (delivers) but acquires the key; a concurrent second call within 60s for the same recipient then returns True (suppresses). The marking happens even on the call that decides to deliver — so a suppressed 'all already held' verdict requires every recipient to have been marked by a prior call. Partial-fresh mixed-recipient calls deliver and mark the fresh ones.
|
||||
- NotificationService._create_notification opens its OWN get_db_context and commits (line 568), while NotificationDeliveryService._persist_and_deliver operates in the CALLER's transaction and does NOT commit. Mixing the two in one outer transaction would double-commit / cross-session.
|
||||
- requires_ack is set from ACK_REQUIRED_BY_TYPE (notification.py L555) rather than the column default True; MENTION/KNOWLEDGE_SHARE/BROADCAST etc. are False.
|
||||
- DB purpose-dedup query uses NotificationTable.to_agents.overlap(to_agents_uuids) AND ~acked_by.contains(to_agents_uuids) — overlap matches ANY recipient; a notification to [A,B] with A acked but B not is NOT suppressed for a new send to [A,B] because acked_by does not contain [A,B] (contains is element-wise). The dedup is per-(sender,type,task) not per-recipient, so a third recipient C added on resend goes through.
|
||||
- defer_bus_publish registers after_commit/after_rollback listeners keyed on session.info[_DRAIN_REGISTERED_KEY]; listeners are bound to sync_session and accumulate only once per AsyncSession instance. A session reused across multiple commit cycles will re-register only once (guard), but the pending queue is popped each commit — if a second deliver happens after the first commit in the same session, the listeners are already registered and the new events append and fire on the next commit.
|
||||
- acknowledge publishes NOTIFICATION_ACKED directly to the bus (NOT deferred via after_commit) — unlike deliver. An ACK that is rolled back after publish could emit a phantom ACK event. The ACK path does not use the transactional outbox.
|
||||
- list_system_notifications filters pending_ack_only POST-fetch because 'not fully acked' is not SQL-friendly on PostgreSQL array columns. For pending_ack_only=True the SQL `limit` is NOT applied — applying it before the Python filter let a window of newer fully-acked rows mask older unacked ones the operator still needs to act on (correctness bug fixed in 115061f3); the full ack-required set is fetched ordered newest-first, Python-filtered to unacked, then sliced to `limit`. The non-pending branch retains the SQL limit.
|
||||
- get_notification_count loads ALL notifications for an agent into memory (no SQL count) to compute total/unread/pending_ack — O(n) per call, no pagination.
|
||||
|
||||
|
||||
## Drift from CLAUDE.md
|
||||
- CLAUDE.md does not describe the notification_dedup Redis re-fire guard, the transactional-outbox (defer_bus_publish / F107) in notification_delivery, or the DB purpose-dedup in NotificationService — all are real, load-bearing behavior added since the baseline and not reflected in the doc's Services table.
|
||||
- CLAUDE.md's Services table lists NotificationService as 'Formal notifications' but does not mention NotificationDeliveryService at all, nor that NotificationService owns its own DB context+commit while NotificationDeliveryService runs in the caller's txn.
|
||||
- CLAUDE.md no longer describes notification ack semantics after the channels/groups/discussion-sessions/messages teardown removed the Communication Model section — for the record: requires_ack is False for TASK_ASSIGNMENT/REVIEW_REQUEST/DOCUMENTATION_REQUEST/BROADCAST/KNOWLEDGE_SHARE/MENTION/A2A_REQUEST (ACK_REQUIRED_BY_TYPE), so most notification types do NOT require ack; and notifications can be sent by 'system' (orchestrator-generated), not only PMs/Board.
|
||||
- CLAUDE.md mentions `roboco/services/notification.py` in the Services table but not `notification_dedup.py` or `notification_delivery.py`.
|
||||
|
||||
|
||||
## Changes Since Baseline
|
||||
|
||||
| SHA | Subject | Impact |
|
||||
|---|---|---|
|
||||
| 15effce0 | Chore: 141 Gaps fill-in (#283) — added requires_ack from ACK_REQUIRED_BY_TYPE, DB purpose-dedup gated to ack-required types, re-fire guard + notification_dedup.py (new file), transactional-outbox defer_bus_publish in notification_delivery | Major hardening: notifications no longer flood inboxes (Redis re-fire + DB dedup scoped), phantom WebSocket pushes eliminated (deferred bus publish), MENTION/BROADCAST no longer inflate unacked sets (requires_ack=False) |
|
||||
| 3aff6e04 | Chore: Close gaps (#285) — follow-on gap closure touching notification.py / notification_dedup.py / notification_delivery.py | Refinement of the #283 changes (exact hunks not isolated per-file in this merge commit; consolidated the dedup/outbox behavior above) |
|
||||
|
||||
> Post-snapshot updates (since 2026-06-29): 115061f3 fixed list_system_notifications pending_ack_only correctness: SQL limit is now dropped for that branch so newer fully-acked rows can't mask older unacked ones (see Gotcha update above).
|
||||
|
||||
## Regression Risks
|
||||
|
||||
| Title | File:Line | Claim | Severity |
|
||||
|---|---|---|---|
|
||||
| DB purpose-dedup now gated to ack-required types only — informational duplicates no longer suppressed | roboco/services/notification.py:521 | is_ack_required = ACK_REQUIRED_BY_TYPE.get(params.notification_type, True); the DB dup_q is only run when is_ack_required. REVIEW_REQUEST/DOCUMENTATION_REQUEST/TASK_ASSIGNMENT are ack-required=False, so they skip DB dedup and rely SOLELY on the 60s Redis window. Beyond 60s, a coordinator can re-fire the same REVIEW_REQUEST every tick and each one persists (the original bug the dedup was meant to stop). The Redis guard coalesces within 60s but a tick interval >60s re-opens the flood. Severity medium because the Redis guard covers the common per-tick storm. | medium |
|
||||
| _persist_and_deliver skips DB purpose-dedup entirely — task-handoff duplicates not DB-deduped | roboco/services/notification_delivery.py:875 | _persist_and_deliver applies only all_recipients_recently_notified (Redis) and then add+flush+deliver with no DB dup_q. notify_pm_of_block / escalate_and_notify / notify_assignee_of_unblock can each be re-triggered (e.g. a retried i_am_blocked, a re-issued escalate) and, past the 60s Redis window, create a second BLOCKER_ESCALATION for the same (sender, type, task) while the first is unacked — exactly the inbox inflation + i_am_idle soft-block the DB dedup was added to prevent on the other path. Two paths for the same notification type with different dedup strength is a real hole. | medium |
|
||||
| acknowledge publishes NOTIFICATION_ACKED directly, not via the transactional outbox | roboco/services/notification_delivery.py:451 | deliver was migrated to defer_bus_publish (after_commit) to kill phantom pushes, but acknowledge still does `await bus.publish(...)` inside the open transaction before the caller commits. A rollback after a successful ACK publish emits a phantom NOTIFICATION_ACKED for an ACK that didn't persist — the same class of bug F107 fixed for deliver, left unfixed for the ACK path. | medium |
|
||||
| all_recipients_recently_notified marks recipients as a side effect on the deciding call | roboco/services/notification_dedup.py:78 | The function SET-NX-marks each fresh recipient while computing the verdict, so the call that DECIDES TO DELIVER also acquires keys for the fresh recipients. A subsequent resend within 60s then sees all-held and suppresses — intended — but it means the very first notification in a window consumes the TTL for recipients who genuinely received it, and a legit follow-up to a subset within 60s is suppressed if all of that subset were marked by the prior send. For BROADCAST this can drop a legitimately re-targeted broadcast within the window. | low |
|
||||
| get_notification_count loads all agent notifications into memory | roboco/services/notification_delivery.py:379 | base_query selects all NotificationTable rows where to_agents contains agent_id with no limit, then counts in Python. For a long-running agent this row count grows unbounded; called via get_delivery_summary on the panel it is an O(n) DB read per dashboard load. Not a correctness regression from the baseline but the slice's new dedup reduces new-row growth, masking the unbounded-scan risk. | low |
|
||||
| defer_bus_publish listener registration tied to session.info on the AsyncSession — session reuse hazard | roboco/services/notification_delivery.py:116 | _DRAIN_REGISTERED_KEY is set once per AsyncSession and the SQLAlchemy event.listens_for(sync_session, ...) is bound to sync_session. If an AsyncSession is reused for multiple independent transactions (connection-pool recycling), the listener stays registered and fires _schedule_pending_publishes on every subsequent commit even when no new events were deferred — _schedule_pending_publishes pops an empty queue and no-ops, so it is benign, but the listener is never removed and accumulates on the sync_session for the session's lifetime. A long-lived sync_session with many AsyncSession wraps could accumulate listeners. | low |
|
||||
|
||||
## Health
|
||||
This slice is substantially hardened since the baseline: the transactional-outbox for delivery (F107), the Redis re-fire guard, and the ACK_REQUIRED_BY_TYPE-driven requires_ack are all real, well-documented fixes that close prior meltdowns. The main integrity gap is dedup-path fragmentation: NotificationService._create_notification runs two dedup layers (Redis + DB purpose-dedup) while NotificationDeliveryService._persist_and_deliver runs only the Redis layer, so the task-handoff notifications (blocker/escalation/ceo-rejection) are not protected by DB purpose-dedup past the 60s Redis window — a retried i_am_blocked or escalate beyond 60s can re-create an unacked duplicate, the exact inbox-inflation + i_am_idle soft-block the DB dedup was added to prevent. A secondary consistency gap is that acknowledge publishes NOTIFICATION_ACKED directly to the bus instead of through the deferred outbox, leaving the same phantom-event class F107 fixed for deliver. Neither is a crash bug; both are correctness drift between two paths that should behave identically. Code quality is high (terse comments, clear docstrings, explicit race handling), and the slice is well-covered by the orchestrator sweeper integration and route-level callers.
|
||||
+10
-13
@@ -17,10 +17,9 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
|
||||
| `panel/src/app/(dashboard)/prompter/page.tsx` | Intake chat (single + MegaTask batch scope) |
|
||||
| `panel/src/app/(dashboard)/a2a/page.tsx` | A2A Live: org-wide switchboard/list + transcript + CEO reply composer, live via `/ws/system` `a2a.message` frames |
|
||||
| `panel/src/app/(dashboard)/settings/page.tsx` + `settings/ai-providers/page.tsx` | Settings: feature flags, AI routing, transcript retention, self-hosted |
|
||||
| `panel/src/app/(dashboard)/{agents,projects,products,business,journals,communications,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx` | Per-domain pages |
|
||||
| `panel/src/app/(dashboard)/{agents,projects,products,business,journals,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx` | Per-domain pages |
|
||||
| `panel/src/app/(auth)/login/page.tsx` | Cloud-auth login form (email/password → `useLogin` → `/auth/login`); only reachable/relevant once `proxy.ts` starts gating the `(dashboard)` group |
|
||||
| `panel/src/proxy.ts` | Next 16's rename of `middleware.ts`: probes `/auth/status` (docker-internal orchestrator URL, fails open to "off" on any error/timeout) and redirects to `/login` when cloud auth is on and no session cookie is present |
|
||||
| `panel/src/app/(dashboard)/communications/[sessionId]/page.tsx` | Per-session chat view: live transcript (useSessionStream on `/ws/sessions/{id}`), closed-session read-only notice, redirect toast on stale-send |
|
||||
| `panel/src/components/dashboard/` | Overview cards: command-center, key-metrics, release-proposal, playbook-review-queue, ceo-approval-queue, pr-review-queue, usage-overview, team-health, active-blockers, auditor-alerts, strategy-signals, quick-actions, recent-activity, `x-post-queue.tsx`, `roadmap-review-queue.tsx` |
|
||||
| `panel/src/components/metrics/` | delivery-tab, usage-time-series-chart, agent/team-usage-chart, model-usage-donut, sessions-table |
|
||||
| `panel/src/components/kanban/{core,shared,views}/` | core: kanban-board/column/card + bypass-preconditions; views: dev/qa/pm/pr-review kanban |
|
||||
@@ -29,9 +28,9 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
|
||||
| `panel/src/components/tasks/` + `tasks/task-detail/` | task-table, create/edit-task-dialog, task-filters, acceptance-criteria-editor, dependency-selector, task-detail tabs (overview/plan/progress/commits/sessions/notes/dependencies) |
|
||||
| `panel/src/components/settings/` | feature-flags-card, ai-routing-card, transcript-retention-card, self-hosted-section, `x-credentials-card.tsx` (write-only OAuth 1.0a secrets, mounted in `settings/page.tsx`) |
|
||||
| `panel/src/components/conventions/conventions-tab.tsx` | Per-project architecture map + health (in edit-project dialog) |
|
||||
| `panel/src/components/projects/`, `agents/`, `business/`, `auditor/`, `knowledge-base/`, `communications/`, `git/`, `journals/`, `work-sessions/`, `notifications/`, `rate-limit/`, `layout/`, `ui/` | Per-domain component groups; `ui/` = Radix-based primitives (dialog, table, tabs, select, switch, required-notes-dialog, sonner toaster, markdown) |
|
||||
| `panel/src/components/projects/`, `agents/`, `business/`, `auditor/`, `knowledge-base/`, `git/`, `journals/`, `work-sessions/`, `notifications/`, `rate-limit/`, `layout/`, `ui/` | Per-domain component groups; `ui/` = Radix-based primitives (dialog, table, tabs, select, switch, required-notes-dialog, sonner toaster, markdown) |
|
||||
| `panel/src/hooks/use-websocket.ts` | Shared `useWebSocket<T>(path, handlers?, isSystem?)` hook (auto-reconnect, heartbeat) |
|
||||
| `panel/src/hooks/use-{tasks,agents,projects,products,usage,prompter,secretary,dashboard,git,journals,channels,notifications,knowledge-base,observability,work-sessions,providers,rate-limit-{sync,websocket}}.ts` | TanStack Query + zustand data hooks |
|
||||
| `panel/src/hooks/use-{tasks,agents,projects,products,usage,prompter,secretary,dashboard,git,journals,notifications,knowledge-base,observability,work-sessions,providers,rate-limit-{sync,websocket}}.ts` | TanStack Query + zustand data hooks |
|
||||
| `panel/src/hooks/use-a2a-live.ts` | `useA2AConversations` / `useA2AAdminPairs` / `useA2AMessages` (TanStack Query over `a2aApi`) + `useReplyAsCeo` mutation; `a2aLiveKeys` query-key namespace |
|
||||
| `panel/src/lib/api/*.ts` | Per-domain axios clients (`client.ts` shared instance; `release.ts`, `playbooks.ts`, `prompter-live.ts`, `tasks.ts`, `settings.ts`, `usage.ts`, `cockpit.ts`, `a2a.ts`, `auth.ts` (status/login/logout), `x.ts` (post queue + credentials), `roadmap.ts` (cycles + item approve/reject), …) |
|
||||
| `panel/src/lib/websocket/connection.ts` | `WebSocketConnection` class + `getWebSocketUrl` |
|
||||
@@ -67,7 +66,6 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
|
||||
| Name | Kind | File | Responsibility |
|
||||
|---|---|---|---|
|
||||
| `useWebSocket<T>` | hook | `hooks/use-websocket.ts` | Single shared WS per path; auto-reconnect, heartbeat, message dispatch |
|
||||
| `useSessionStream` | hook | `hooks/use-websocket.ts` | Subscribes `/ws/sessions/{id}`; filters `message.new` frames; added post-snapshot (76ce53e3) to drive live transcript refresh in the session detail page |
|
||||
| `WebSocketConnection` | class | `lib/websocket/connection.ts` | Low-level WS lifecycle; `getWebSocketUrl` builds `/ws/<path>` |
|
||||
| `api` (axios instance) | const | `lib/api/client.ts` | Shared client; baseURL `API_URL`, injects `X-Agent-ID/Role=CEO`, rate-limit retry (3) |
|
||||
| `releaseApi` | module | `lib/api/release.ts` | `getProposal/approve/reject`; 404→null, non-404 rethrow |
|
||||
@@ -96,7 +94,7 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
|
||||
| `DeliveryTabContent` | comp | `components/metrics/delivery-tab.tsx` | Cycle-time/bottleneck/rework/scorecard panels |
|
||||
|
||||
## Data Flow
|
||||
Browser → nginx :3000 → (panel Next.js server for pages; `/api/*` and `/ws/*` proxied to `orchestrator:8000`). All client calls use relative URLs: `API_URL="/api"` (axios `baseURL`) and `WS_URL="/ws"` (`getWebSocketUrl`) — no CORS because the browser sees one origin. When cloud auth is armed (`ROBOCO_CLOUD_AUTH_ENABLED`), every navigation to a `(dashboard)` route first runs `proxy.ts` (Next 16's rename of `middleware.ts`), which probes `/auth/status` directly against the docker-internal orchestrator URL (not through nginx) and redirects to `/login` when no `roboco_session` cookie is present; a probe failure/timeout fails OPEN to "cloud auth off" so a slow/unreachable backend never blocks navigation. The login page (`(auth)/login/page.tsx`) posts credentials via `authApi.login` (OAuth2 form body, FastAPI Users' cookie route) and the session cookie rides back on the response. The shared axios client injects `X-Agent-ID=<CEO_AGENT_ID>` + `X-Agent-Role=CEO_ROLE` headers for API authorization. Live events flow: orchestrator `StreamEventBus` → `websocket_bridge` → per-resource `/ws/{agents,channels,sessions,notifications,system}` sockets → panel `useWebSocket` hooks → zustand stores / TanStack Query cache. Usage snapshots (`USAGE_SNAPSHOT`) and rate-limit lifecycle (`RATE_LIMIT_HIT/LIFTED`) arrive on the single shared `/ws/system` stream mounted in providers; on any non-`connected` state the usage store clears its snapshot so the panel falls back to HTTP-polling summary until a fresh frame lands. The A2A page's `useA2ALiveStream` is a second, independent consumer of that same shared `/ws/system` connection (not a new socket): every persisted A2A message publishes an `a2a.message` frame, which the page uses purely to invalidate-on-frame (REST via `a2aApi` stays the source of truth for full message bodies, since the frame's excerpt is capped) and to drive the switchboard's 45s pulse fade on the matching pair card.
|
||||
Browser → nginx :3000 → (panel Next.js server for pages; `/api/*` and `/ws/*` proxied to `orchestrator:8000`). All client calls use relative URLs: `API_URL="/api"` (axios `baseURL`) and `WS_URL="/ws"` (`getWebSocketUrl`) — no CORS because the browser sees one origin. When cloud auth is armed (`ROBOCO_CLOUD_AUTH_ENABLED`), every navigation to a `(dashboard)` route first runs `proxy.ts` (Next 16's rename of `middleware.ts`), which probes `/auth/status` directly against the docker-internal orchestrator URL (not through nginx) and redirects to `/login` when no `roboco_session` cookie is present; a probe failure/timeout fails OPEN to "cloud auth off" so a slow/unreachable backend never blocks navigation. The login page (`(auth)/login/page.tsx`) posts credentials via `authApi.login` (OAuth2 form body, FastAPI Users' cookie route) and the session cookie rides back on the response. The shared axios client injects `X-Agent-ID=<CEO_AGENT_ID>` + `X-Agent-Role=CEO_ROLE` headers for API authorization. Live events flow: orchestrator `StreamEventBus` → `websocket_bridge` → per-resource `/ws/{agents,notifications,system}` sockets → panel `useWebSocket` hooks → zustand stores / TanStack Query cache. Usage snapshots (`USAGE_SNAPSHOT`) and rate-limit lifecycle (`RATE_LIMIT_HIT/LIFTED`) arrive on the single shared `/ws/system` stream mounted in providers; on any non-`connected` state the usage store clears its snapshot so the panel falls back to HTTP-polling summary until a fresh frame lands. The A2A page's `useA2ALiveStream` is a second, independent consumer of that same shared `/ws/system` connection (not a new socket): every persisted A2A message publishes an `a2a.message` frame, which the page uses purely to invalidate-on-frame (REST via `a2aApi` stays the source of truth for full message bodies, since the frame's excerpt is capped) and to drive the switchboard's 45s pulse fade on the matching pair card.
|
||||
|
||||
## Mermaid
|
||||
```mermaid
|
||||
@@ -110,7 +108,7 @@ graph TD
|
||||
Routes --> Prompter["prompter (single + MegaTask)"]
|
||||
Routes --> A2ALive["a2a (switchboard + reply)"]
|
||||
Routes --> Settings["settings + ai-providers"]
|
||||
Routes --> Domain["agents/projects/products/business/journals/communications/git/kb/auditor/work-sessions/notifications"]
|
||||
Routes --> Domain["agents/projects/products/business/journals/git/kb/auditor/work-sessions/notifications"]
|
||||
Overview --> Dash["components/dashboard/*"]
|
||||
Dash --> Release["ReleaseProposalCard"]
|
||||
Dash --> Playbook["PlaybookReviewQueue"]
|
||||
@@ -143,8 +141,7 @@ panel/ (Next.js 16, package roboco-panel v0.14.0)
|
||||
│ ├── prompter/page.tsx (intake chat: single + MegaTask batch)
|
||||
│ ├── a2a/page.tsx (A2A Live: switchboard/list + transcript + CEO reply)
|
||||
│ ├── settings/page.tsx + settings/ai-providers/page.tsx
|
||||
│ ├── {agents,projects,products,business,journals,communications,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx
|
||||
│ └── communications/[sessionId]/page.tsx (per-session chat view; live via useSessionStream)
|
||||
│ └── {agents,projects,products,business,journals,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx
|
||||
├── src/components/
|
||||
│ ├── dashboard/ (command-center, key-metrics, release-proposal, playbook-review-queue, ceo-approval-queue, pr-review-queue, x-post-queue, roadmap-review-queue, usage-overview, team-health, active-blockers, auditor-alerts, strategy-signals, quick-actions, recent-activity)
|
||||
│ ├── metrics/ (delivery-tab, usage-time-series-chart, agent/team-usage-chart, model-usage-donut, sessions-table)
|
||||
@@ -157,11 +154,11 @@ panel/ (Next.js 16, package roboco-panel v0.14.0)
|
||||
│ ├── tasks/ + tasks/task-detail/ (task-table, create/edit-task-dialog, task-filters, acceptance-criteria-editor, dependency-selector; detail tabs: overview/plan/progress/commits/sessions/notes/dependencies)
|
||||
│ ├── settings/ (feature-flags-card, ai-routing-card, transcript-retention-card, self-hosted-section, x-credentials-card)
|
||||
│ ├── conventions/conventions-tab.tsx (per-project architecture map + health)
|
||||
│ ├── projects/ agents/ business/ auditor/ knowledge-base/ communications/ git/ journals/ work-sessions/ notifications/ rate-limit/ layout/
|
||||
│ ├── projects/ agents/ business/ auditor/ knowledge-base/ git/ journals/ work-sessions/ notifications/ rate-limit/ layout/
|
||||
│ └── ui/ (Radix-based primitives: dialog, table, tabs, select, switch, required-notes-dialog, sonner toaster, markdown)
|
||||
├── src/hooks/
|
||||
│ ├── use-websocket.ts (shared useWebSocket<T>: auto-reconnect, heartbeat)
|
||||
│ └── use-{tasks,agents,projects,products,usage,prompter,secretary,dashboard,git,journals,channels,notifications,knowledge-base,observability,work-sessions,providers,rate-limit-{sync,websocket}}.ts
|
||||
│ └── use-{tasks,agents,projects,products,usage,prompter,secretary,dashboard,git,journals,notifications,knowledge-base,observability,work-sessions,providers,rate-limit-{sync,websocket}}.ts
|
||||
├── src/lib/
|
||||
│ ├── api/*.ts (per-domain axios clients; client.ts shared instance; release, playbooks, prompter-live, tasks, settings, usage, cockpit, a2a, auth, x, roadmap, …)
|
||||
│ ├── websocket/connection.ts (WebSocketConnection + getWebSocketUrl)
|
||||
@@ -216,7 +213,7 @@ Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime tog
|
||||
- **`/ws/system` is a single shared instance** mounted once in providers; a second `useWebSocket("/system")` would open a second socket. `getWebSocketUrl` already supplies `/ws`, so pass only the path (passing `/ws/system` doubled to `/ws/ws/system` — now fixed and commented).
|
||||
- **Usage snapshot cleared on any non-`connected` state** so a reconnect can't render the prior session's totals as live; panel falls back to HTTP polling summary during the gap.
|
||||
- **Intake composer SSE** uses `EventSource` (no custom headers — auth is server-side via session id); a transport-level `error` event loop-reconnects a dead session — guard kept in `use-prompter.ts` (a stale localStorage payload or malformed frame could still surface a phantom draft).
|
||||
- **Secretary live chat (`use-secretary.ts`)** now mirrors that intake-composer resilience (post-2026-06-30): a transport-level SSE `error` resets the `streaming` spinner + surfaces a notice (no permanent "thinking…" hang), `send` is blocked while a reply is streaming (no mid-reply clobber), and the chat persists to `localStorage` and reconnects on reload (status-alive gated). The session view (`useSessionStream` from `use-websocket.ts`) also subscribes `/ws/sessions/{id}` for live `message.new` frames — there is no separate `use-session-stream.ts` file.
|
||||
- **Secretary live chat (`use-secretary.ts`)** now mirrors that intake-composer resilience (post-2026-06-30): a transport-level SSE `error` resets the `streaming` spinner + surfaces a notice (no permanent "thinking…" hang), `send` is blocked while a reply is streaming (no mid-reply clobber), and the chat persists to `localStorage` and reconnects on reload (status-alive gated).
|
||||
- **MegaTask intake card** historically had crash/disappears bugs (list[str] nest, depth ValueError→500); confirm-batch path is the multi-project branch (`project_ids`).
|
||||
- **Kanban drag = admin status-override** which bypasses the in-band lifecycle validator; `skippedPreconditions` only warns on what the panel can detect (PR/docs/subtasks-terminal) — precision over recall, an empty list does NOT mean the move is safe, only that nothing detectable is skipped.
|
||||
- ~~`ui-store` exists under both `store/ui-store.ts` and `lib/stores/ui-store.ts`~~ — **FIXED** (536bbb64): `lib/stores/ui-store.ts` was removed and replaced with `scroll-restoration-store.ts`; `store/ui-store.ts` is now the sole canonical location.
|
||||
@@ -243,7 +240,7 @@ Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime tog
|
||||
> - `536bbb64` Chore/all/logical gaps sweep (#286) — kanban sends `force: true` for hatch states (COMPLETED/AWAITING_QA/AWAITING_PM_REVIEW); `TaskUpdate` gains `{ force?: boolean }`; `lib/stores/ui-store.ts` removed, replaced by `scroll-restoration-store.ts`; `lib/stores/index.ts` re-exports scroll-restoration only; `prompter-live.ts` comment hardened to treat session id as bearer credential.
|
||||
> - `aba57359` lifecycle artifacts: `panel/lib/lifecycle.json` regenerated to match spec.
|
||||
> - `76ce53e3` chat: live MESSAGE_SENT delivery — adds `useSessionStream` to `use-websocket.ts`; new `communications/[sessionId]/page.tsx` session detail route consumes it; backend adds `EventType.MESSAGE_SENT`, bridge `_handle_message_event`, `messaging.send_message` bus publish.
|
||||
> - `0065ecbb` chat: session task_links in one read — `sessionsApi` drops `getTasksForSession`; `use-channels.ts` `useSession` relies on the single populated response; adds `use-session.test.tsx`.
|
||||
> - `0065ecbb` chat: session task_links in one read — `sessionsApi` drops `getTasksForSession`; `use-channels.ts` (since removed in the comms teardown) `useSession` relies on the single populated response; adds `use-session.test.tsx`.
|
||||
> - `2da72f3f` chat: closed-session guard + reply_to validation — `communications/[sessionId]/page.tsx` renders read-only notice for closed sessions; stale-send toasts rather than silently vanishing.
|
||||
> - `5cb4e85f` secretary: stuck-spinner + mid-reply + reload hardening (`use-secretary.ts`, `secretary-tab.tsx`); adds `use-secretary.test.tsx`.
|
||||
> - `a1127daf` chat: `linkTask`/`unlinkTask` corrected to real backend routes; phantom `updateTaskLink` removed from `sessions.ts`.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
## Purpose
|
||||
This slice is the prompt-composition pipeline and the role/team/permission taxonomy that feeds it. At agent spawn the orchestrator resolves an agent's role+team from the canonical foundation-derived maps in agents_config.py, then compose_prompt layers (in order) a tool-load directive, the autogenerated lifecycle verb surface, the universal base rules, the role prompt, the autogenerated per-role verb-signature table, the team prompt, the agent identity, and an optional architectural-conventions ambient block — writing the result to a per-agent .md file the runtime mounts. A separate prompt-injection guard (prompt_guard.py) denies poisoned incoming turns at the interactive input boundary for both Claude-SDK and Grok sessions, mirroring the bash UserPromptSubmit hook. agents_config.py is also the MCP-layer permission taxonomy (HMAC agent tokens, role/team helpers, escalation chain, channel ACLs, A2A routing) that gates tool visibility and identity-binding at spawn.
|
||||
This slice is the prompt-composition pipeline and the role/team/permission taxonomy that feeds it. At agent spawn the orchestrator resolves an agent's role+team from the canonical foundation-derived maps in agents_config.py, then compose_prompt layers (in order) a tool-load directive, the autogenerated lifecycle verb surface, the universal base rules, the role prompt, the autogenerated per-role verb-signature table, the team prompt, the agent identity, and an optional architectural-conventions ambient block — writing the result to a per-agent .md file the runtime mounts. A separate prompt-injection guard (prompt_guard.py) denies poisoned incoming turns at the interactive input boundary for both Claude-SDK and Grok sessions, mirroring the bash UserPromptSubmit hook. agents_config.py is also the MCP-layer permission taxonomy (HMAC agent tokens, role/team helpers, escalation chain, A2A routing) that gates tool visibility and identity-binding at spawn.
|
||||
|
||||
## Files
|
||||
|
||||
@@ -7,9 +7,9 @@ This slice is the prompt-composition pipeline and the role/team/permission taxon
|
||||
|---|---|---|
|
||||
| roboco/agents/factories/_base.py | Layered prompt composer: loads/concatenates tool-directive + lifecycle + base + role + autogen-verbs + team + identity + ambient layers; exports PROMPTS_BASE_PATH, role/team/builtin-tool maps, compose_prompt, conventions_ambient_layer, make_slug | 298 |
|
||||
| roboco/agents/factories/__init__.py | One-line re-export shim pointing to _base | 1 |
|
||||
| roboco/agents_config.py | 691-line MCP-layer permission/taxonomy module: HMAC agent-token issue/verify, role+team+cell maps derived from foundation, escalation chain, channel ACL derivation, ROLE_PERMISSION_LEVELS, ROLE_SKILLS, A2A routing helpers | 691 |
|
||||
| roboco/agents_config.py | 691-line MCP-layer permission/taxonomy module: HMAC agent-token issue/verify, role+team+cell maps derived from foundation, escalation chain, ROLE_PERMISSION_LEVELS, ROLE_SKILLS, A2A routing helpers | 691 |
|
||||
| roboco/agent_sdk/prompt_guard.py | Reusable Python port of the bash injection guard: _PATTERNS regex list, detect_injection, refusal_message, CLI main (exit 1 on injection) for the grok entrypoint | 93 |
|
||||
| agents/prompts/base.md | Universal base layer: identity separation, gateway-verb-only action, envelope shapes, missing-key cheatsheet, resume-from-briefing, charter alignment, channel/todo rules, ground rules | 93 |
|
||||
| agents/prompts/base.md | Universal base layer: identity separation, gateway-verb-only action, envelope shapes, missing-key cheatsheet, resume-from-briefing, charter alignment, todo rules, ground rules | 93 |
|
||||
| agents/prompts/roles/developer.md | Developer role prompt: implement-only identity, verb table (give_me_work/i_will_work_on/commit/open_pr/i_am_done/sync_branch/...), workspace path, behind-base -> sync_branch guidance, conventions waiver path | 25176 |
|
||||
| agents/prompts/roles/qa.md | QA role prompt: review-only identity, claim_review/pass/fail/i_am_blocked verbs, ac_verdicts per criterion, circuit-breaker guidance | 13355 |
|
||||
| agents/prompts/roles/documenter.md | Documenter role prompt: docs-on-same-branch identity, claim_doc_task/commit/i_documented/i_am_blocked verbs, circuit-breaker guidance | 10739 |
|
||||
@@ -19,9 +19,9 @@ This slice is the prompt-composition pipeline and the role/team/permission taxon
|
||||
| agents/prompts/roles/board.md | Board role prompt (PO/HoM/Auditor): strategic overseer, triage/escalate_to_ceo, no unblock verb, Auditor silent | 12253 |
|
||||
| agents/prompts/roles/prompter.md | Intake interviewer role prompt: CEO-only chat, propose_draft/propose_batch, MegaTask batch + per-cell-project-map drafting, root-subtask coordination-level AC guidance | 13825 |
|
||||
| agents/prompts/roles/secretary.md | Secretary role prompt: CEO chief-of-staff, gated-action confirm protocol, reading-free/prepare-direct/high-impact-bounce discipline | 4338 |
|
||||
| agents/prompts/teams/backend.md | Backend team layer: channels, Python/FastAPI/Postgres stack, teammates, uv quality commands | 983 |
|
||||
| agents/prompts/teams/frontend.md | Frontend team layer: channels, TS/Next.js stack, pnpm quality commands | 976 |
|
||||
| agents/prompts/teams/ux_ui.md | UX/UI team layer: channels, design-system focus areas, teammates | 1009 |
|
||||
| agents/prompts/teams/backend.md | Backend team layer: Python/FastAPI/Postgres stack, teammates, uv quality commands | 983 |
|
||||
| agents/prompts/teams/frontend.md | Frontend team layer: TS/Next.js stack, pnpm quality commands | 976 |
|
||||
| agents/prompts/teams/ux_ui.md | UX/UI team layer: design-system focus areas, teammates | 1009 |
|
||||
| agents/prompts/identities/ | 19 per-agent identity files (be-dev-1/2, fe-dev-1/2, ux-dev-1/2, be/fe/ux -qa/-doc/-pm, main-pm, product-owner, head-marketing, auditor): YAML id/name/role/team/cell/reports_to + scope blurb; loaded by slug | 0 |
|
||||
| agents/prompts/_generated/lifecycle-developer.md | Autogenerated lifecycle verb list for developer (regenerated from lifecycle spec by make lifecycle); lists sync_branch etc. | 1498 |
|
||||
| agents/prompts/_generated/lifecycle-main_pm.md | Autogenerated lifecycle verbs for main_pm; submit_root now branch-keyed not task_type-keyed | 2034 |
|
||||
@@ -42,7 +42,7 @@ This slice is the prompt-composition pipeline and the role/team/permission taxon
|
||||
| agents/prompts/_generated/cell_pm.md | Per-role autogenerated verb-signature table for cell_pm; delegate signature includes intends_to_touch/adds_migration/touches_shared/depends_on | 3270 |
|
||||
| agents/prompts/_generated/main_pm.md | Per-role autogenerated verb-signature table for main_pm; delegate signature includes collision-surface fields | 3316 |
|
||||
| agents/prompts/_generated/pr_reviewer.md | Per-role autogenerated verb-signature table for pr_reviewer (claim_gate_review/pr_pass/pr_fail + claim_pr_review/post_pr_review) | 1461 |
|
||||
| agents/prompts/_generated/product_owner.md | Per-role autogenerated verb-signature table for product_owner (triage/escalate_to_ceo + pitch/notify/open_session) | 1765 |
|
||||
| agents/prompts/_generated/product_owner.md | Per-role autogenerated verb-signature table for product_owner (triage/escalate_to_ceo + pitch/notify) | 1765 |
|
||||
| agents/prompts/_generated/head_marketing.md | Per-role autogenerated verb-signature table for head_marketing | 1765 |
|
||||
| agents/prompts/_generated/auditor.md | Per-role autogenerated verb-signature table for auditor (triage/i_am_idle + note/evidence + approve/reject/archive_playbook) | 1273 |
|
||||
| agents/prompts/_generated/verbs.md | Aggregate reference doc of all per-role verb shapes (NOT injected at spawn; _base.py loads the per-role file instead); notes driver-based roles omitted | 18302 |
|
||||
@@ -100,9 +100,7 @@ This slice is the prompt-composition pipeline and the role/team/permission taxon
|
||||
| get_escalation_target | function | roboco/agents_config.py:290 | Next escalation slug from ESCALATION_CHAIN |
|
||||
| get_pm_for_team | function | roboco/agents_config.py:295 | Cell PM slug for a team |
|
||||
| get_pm_for_agent | function | roboco/agents_config.py:305 | Responsible PM: cell PM for members, main-pm for cell PMs, product-owner for main PM |
|
||||
| _TEAM_SCOPED_ROLES | frozenset | roboco/agents_config.py:346 | Cell-member roles (dev/qa/doc/cell_pm) subject to team_scope filtering in channel ACL; re-exported from foundation.policy.communications.TEAM_SCOPED_ROLES (was inline-defined; deduped in 536bbb64) |
|
||||
| _slugs_for_role_set | function | roboco/agents_config.py:355 | Expand a role-set to sorted slugs honoring optional team_scope; excludes system sentinel |
|
||||
| CHANNEL_ACCESS | dict | roboco/agents_config.py:381 | slug -> {read,write,silent} slug lists derived from foundation.policy.communications.CHANNELS |
|
||||
| ROLE_PERMISSION_LEVELS | dict | roboco/agents_config.py:404 | Single source of truth role->permission-level (CEO/BOARD/AUDITOR/MAIN_PM/CELL_PM/CELL_MEMBER) used by PermissionService |
|
||||
| VALID_NOTIFICATION_TYPES | frozenset | roboco/agents_config.py:425 | Valid NotificationType values |
|
||||
| VALID_NOTIFICATION_PRIORITIES | frozenset | roboco/agents_config.py:428 | Valid NotificationPriority values |
|
||||
@@ -129,7 +127,7 @@ Identity binding at spawn: agents_config.issue_agent_token(agent_id, role, team)
|
||||
|
||||
Injection guard (runtime, per turn): IntakeDriver.send_turn calls detect_injection(text) before sending to the model; on a match it emits an error chunk with refusal_message(reason) and returns without forwarding. The grok one-shot entrypoint runs `python -m roboco.agent_sdk.prompt_guard <text>` and refuses start (exit 1) on a match. The same five patterns run in docker/scripts/user-prompt-hook.sh for non-SDK Claude sessions.
|
||||
|
||||
Callers: orchestrator.py:2971 (compose_prompt), orchestrator.py:3018/3029 (conventions_ambient_layer), intake_driver.py:374-377 (detect_injection/refusal_message). Callees from this slice: roboco.foundation.identity (AGENTS/Role/Team/slugs_for_team), roboco.foundation.policy.communications (CHANNELS/NOTIFY_SENDER_ROLES), roboco.seeds.initial_data (AGENT_UUIDS/CEO_AGENT_ID), roboco.services.conventions (get_conventions_service), roboco.config.settings, roboco.models.base (NotificationType/Priority).
|
||||
Callers: orchestrator.py:2971 (compose_prompt), orchestrator.py:3018/3029 (conventions_ambient_layer), intake_driver.py:374-377 (detect_injection/refusal_message). Callees from this slice: roboco.foundation.identity (AGENTS/Role/Team/slugs_for_team), roboco.foundation.policy.communications (NOTIFY_SENDER_ROLES), roboco.seeds.initial_data (AGENT_UUIDS/CEO_AGENT_ID), roboco.services.conventions (get_conventions_service), roboco.config.settings, roboco.models.base (NotificationType/Priority).
|
||||
|
||||
## Mermaid
|
||||
```mermaid
|
||||
@@ -192,10 +190,9 @@ prompts-roles-taxonomy slice
|
||||
│ ├── HMAC token layer: _auth_secret, _signing_payload, issue_agent_token, verify_agent_token, issue_panel_token
|
||||
│ ├── UUID<->slug: _UUID_TO_SLUG, _resolve_to_slug
|
||||
│ ├── Derived maps: AGENT_ROLE_MAP, AGENT_TEAM_MAP, CELL_MEMBERS, ALL_AGENTS, BOARD_MEMBERS, ALL_DOCS
|
||||
│ ├── Role sets: TASK_CREATOR_ROLES, _CANCEL_ROLES, ROLE_PERMISSION_LEVELS, _BOARD_ROLES, _MAIN_PM_TARGETS, _TEAM_SCOPED_ROLES
|
||||
│ ├── Role sets: TASK_CREATOR_ROLES, _CANCEL_ROLES, ROLE_PERMISSION_LEVELS, _BOARD_ROLES, _MAIN_PM_TARGETS
|
||||
│ ├── Escalation: ESCALATION_CHAIN, get_escalation_target, get_pm_for_team, get_pm_for_agent
|
||||
│ ├── Helpers: get_agent_role/team/cell, get_cell_members, is_pm/board_member/management/ceo, can_send_notifications/create/assign/cancel_tasks
|
||||
│ ├── Channel ACL: _slugs_for_role_set, CHANNEL_ACCESS (from foundation.communications.CHANNELS)
|
||||
│ ├── Notification enums: VALID_NOTIFICATION_TYPES/PRIORITIES
|
||||
│ ├── A2A skills: ROLE_SKILLS, get_agent_skills
|
||||
│ └── A2A routing: _check_cell_pm/cell_member/main_pm_a2a, can_a2a_direct, get_a2a_route_hint
|
||||
@@ -215,7 +212,7 @@ prompts-roles-taxonomy slice
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
- Internal: roboco.foundation.identity (AGENTS, Role, Team, CELL_TEAMS, BOARD_ROLES, slugs_for_team), roboco.foundation.policy.communications (CHANNELS, NOTIFY_SENDER_ROLES, TEAM_SCOPED_ROLES), roboco.seeds.initial_data (AGENT_UUIDS, CEO_AGENT_ID), roboco.models.base (AgentRole, Team, NotificationType, NotificationPriority), roboco.config.settings (conventions_enabled), roboco.services.conventions (get_conventions_service, ConventionsService.render_ambient_block/resolve_workspace), roboco.db.base (get_session_factory), roboco.db.tables (ProjectTable), roboco.runtime.orchestrator (_generate_prompt, _resolve_conventions_ambient, _resolve_ambient_projects), roboco.agent_sdk.intake_driver (IntakeDriver.send_turn consumer), scripts/regenerate_verb_tables.py (regenerates _generated/<role>.md + verbs.md), scripts/build_lifecycle_artifacts.py (regenerates _generated/lifecycle-<role>.md; make lifecycle), docker/scripts/user-prompt-hook.sh (canonical bash guard mirrored by prompt_guard.py), roboco.api.schemas.v1 (Pydantic verb schemas the autogen tables derive from), roboco.services.gateway.role_config (role->verb config the autogen tables derive from)
|
||||
- Internal: roboco.foundation.identity (AGENTS, Role, Team, CELL_TEAMS, BOARD_ROLES, slugs_for_team), roboco.foundation.policy.communications (NOTIFY_SENDER_ROLES), roboco.seeds.initial_data (AGENT_UUIDS, CEO_AGENT_ID), roboco.models.base (AgentRole, Team, NotificationType, NotificationPriority), roboco.config.settings (conventions_enabled), roboco.services.conventions (get_conventions_service, ConventionsService.render_ambient_block/resolve_workspace), roboco.db.base (get_session_factory), roboco.db.tables (ProjectTable), roboco.runtime.orchestrator (_generate_prompt, _resolve_conventions_ambient, _resolve_ambient_projects), roboco.agent_sdk.intake_driver (IntakeDriver.send_turn consumer), scripts/regenerate_verb_tables.py (regenerates _generated/<role>.md + verbs.md), scripts/build_lifecycle_artifacts.py (regenerates _generated/lifecycle-<role>.md; make lifecycle), docker/scripts/user-prompt-hook.sh (canonical bash guard mirrored by prompt_guard.py), roboco.api.schemas.v1 (Pydantic verb schemas the autogen tables derive from), roboco.services.gateway.role_config (role->verb config the autogen tables derive from)
|
||||
- External: pathlib.Path, hmac / hashlib (HMAC-SHA256 tokens), os.environ (ROBOCO_AGENT_AUTH_SECRET), re (injection regexes), sys (prompt_guard CLI), sqlalchemy.ext.asyncio.AsyncSession (ambient layer), typing.Final, argparse-less sys.argv CLI
|
||||
|
||||
## Entry Points
|
||||
|
||||
@@ -4,7 +4,7 @@ Slice key: `support-services` Repo root: `/Users/renzof/Documents/GitHub/ZZZ/rob
|
||||
|
||||
## Purpose
|
||||
|
||||
Cross-cutting support layer beneath the delivery services: the service-base/error hierarchy every service inherits, Fernet crypto + UUID converters, the Redis-Streams event bus + workflow trigger handlers, the static seed data that bootstraps agents/channels/memberships, infrastructure health probes, the runtime-editable settings/feature-flag store, agent-record lookups, per-project Python interpreter resolution, provider-config CRUD + per-agent model routing, proactive knowledge injection, and raw-stream transcription buffering. None of these own the task lifecycle; they are the plumbing the lifecycle services, orchestrator, and API routes compose on top.
|
||||
Cross-cutting support layer beneath the delivery services: the service-base/error hierarchy every service inherits, Fernet crypto + UUID converters, the Redis-Streams event bus + workflow trigger handlers, the static seed data that bootstraps agents, infrastructure health probes, the runtime-editable settings/feature-flag store, agent-record lookups, per-project Python interpreter resolution, provider-config CRUD + per-agent model routing, proactive knowledge injection, and raw-stream transcription buffering. None of these own the task lifecycle; they are the plumbing the lifecycle services, orchestrator, and API routes compose on top.
|
||||
|
||||
## Files
|
||||
|
||||
@@ -25,7 +25,7 @@ Cross-cutting support layer beneath the delivery services: the service-base/erro
|
||||
| `roboco/events/handlers.py` | Workflow trigger handlers (task status → notifications, QA result, blocker, question, auditor spawn) + `register_default_handlers` | 419 |
|
||||
| `roboco/events/stream_bus.py` | `StreamEventBus`: Redis Streams durable event bus with consumer groups, ACK, pending recovery, periodic reclaim loop, dead-letter for undecodable messages | 604 |
|
||||
| `roboco/seeds/__init__.py` | Re-exports seed constants | 25 |
|
||||
| `roboco/seeds/initial_data.py` | Static seed data (agents, channels, memberships, initial messages) derived from `foundation` catalogs | 332 |
|
||||
| `roboco/seeds/initial_data.py` | Static seed data (agents) derived from `foundation` catalogs | 332 |
|
||||
| `roboco/utils/__init__.py` | Re-exports crypto + converter helpers | 23 |
|
||||
| `roboco/utils/converters.py` | `InvalidIdentifierError` + `require_uuid` / `to_python_uuid` / `to_python_uuid_list` + `repo_key` | 99 |
|
||||
| `roboco/utils/crypto.py` | Fernet `encrypt_token` / `decrypt_token` / `is_encryption_configured` + `EncryptionError` | 111 |
|
||||
@@ -87,9 +87,6 @@ Cross-cutting support layer beneath the delivery services: the service-base/erro
|
||||
| `register_default_handlers` | func | `events/handlers.py:372` | Subscribes all default handlers on the bus |
|
||||
| `set_event_context` / `get_event_context` | funcs | `events/handlers.py:26,37` | DI of `notification_service` + `orchestrator` into module-global `_context` |
|
||||
| `DEFAULT_AGENTS` | const | `seeds/initial_data.py:156` | Composed from `foundation.AGENTS` + presentation names; system sentinel appended literally (team=None) |
|
||||
| `DEFAULT_CHANNELS` | const | `seeds/initial_data.py:57` | Composed from `foundation.policy.communications.CHANNELS` + display names |
|
||||
| `CHANNEL_MEMBERSHIPS` | const | `seeds/initial_data.py:224` | Per-channel member slugs derived from `CHANNELS.read_roles` + team_scope |
|
||||
| `AUDITOR_SILENT_ACCESS` | const | `seeds/initial_data.py:229` | Channels where AUDITOR ∈ `silent_roles` |
|
||||
| `AGENT_UUIDS` / `CEO_AGENT_ID` | consts | `seeds/initial_data.py:83,173` | String-keyed UUID map for legacy consumers |
|
||||
| `encrypt_token` / `decrypt_token` | funcs | `utils/crypto.py:40,67` | Fernet symmetric encrypt/decrypt; `EncryptionError` on empty/bad key |
|
||||
| `is_encryption_configured` | func | `utils/crypto.py:102` | True iff `settings.encryption_key` yields a valid Fernet |
|
||||
@@ -106,11 +103,11 @@ Cross-cutting support layer beneath the delivery services: the service-base/erro
|
||||
|
||||
**Events.** `bootstrap.py:92` calls `init_event_bus()` → `init_stream_event_bus` → `connect()` + `recover_pending()` (reclaims idle ≥60s messages from crashed consumers via `xclaim`). `register_default_handlers` subscribes task/session/handoff/QA/blocker/question/auditor handlers. Publishers call `bus.publish(Event)` → `xadd` to `roboco:stream:{category}` (trimmed to 10000). `start_listening` spawns both `_listen_loop` and `_reclaim_loop`; the reclaim loop re-runs `recover_pending` every 60s so a runtime handler failure is retried without waiting for a restart. `_listen_loop` blocks on `xreadgroup` (count=10, block=5000); `_handle_message` first tries `Event.from_json` in its own try/except — an undecodable payload (unknown `EventType`, bad UUID/timestamp, malformed JSON) is dead-lettered to `DEAD_LETTER_STREAM` then ACKed so a poison pill never wedges the stream. Successfully decoded events are dispatched via `asyncio.gather` with a per-(event.id, handler) SET-NX idempotency guard (`_run_handler_guarded`), and the message is ACKed only if every handler succeeded — failed handlers leave the message pending for the reclaim loop. `_run_handler_guarded` catches `BaseException` (including `asyncio.CancelledError`) so a mid-flight cancellation clears the idempotency marker and allows replay. Handlers use the injected `_context` (notification_service + orchestrator).
|
||||
|
||||
**Proactive injection.** `TaskService.claim_task` (`services/task.py:2548`) and `MessagingService` (`services/messaging.py:797`) lazily `get_proactive_service()`, which singleton-inits with `OptimalService`. `on_task_claimed` runs five best-effort RAG searches (similar tasks, learnings, standards, decisions, known issues), each in its own try/except, builds a `ContextPackage` + summary. The `code_patterns` field is retained in `ContextPackage` for API/schema back-compat but is always empty — `_find_code_patterns` was removed. `api/routes/optimal.py:1255` exposes `get_context_for_task`/`get_context_for_session` which own DB lookups so routes don't query directly.
|
||||
**Proactive injection.** `TaskService.claim_task` (`services/task.py:2548`) lazily calls `get_proactive_service()`, which singleton-inits with `OptimalService`. `on_task_claimed` runs five best-effort RAG searches (similar tasks, learnings, standards, decisions, known issues), each in its own try/except, builds a `ContextPackage` + summary. The `code_patterns` field is retained in `ContextPackage` for API/schema back-compat but is always empty — `_find_code_patterns` was removed. `api/routes/optimal.py:1255` exposes `get_context_for_task`/`get_context_for_session` which own DB lookups so routes don't query directly.
|
||||
|
||||
**Transcription.** `api/app.py:124` constructs `TranscriptionService()` at lifespan; `ExtractionService` (`services/extraction.py`) composes it. `process_chunk` appends to the per-(agent,connection) `StreamBuffer`; when `is_ready_for_extraction` (min chars / idle threshold / max chars) it returns the buffer for extraction. A `_periodic_flush` background task wakes every `flush_interval_seconds` and pushes ready buffers to registered callbacks.
|
||||
|
||||
**Seeds.** `db/seed.py` iterates `DEFAULT_AGENTS` / `DEFAULT_CHANNELS` / `CHANNEL_MEMBERSHIPS` to populate the DB on bootstrap; `foundation/_validate_lifecycle.py:237` validates against `DEFAULT_AGENTS`. All three derive from `foundation.AGENTS` / `foundation.policy.communications.CHANNELS` so adding an agent/channel is a single foundation edit.
|
||||
**Seeds.** `db/seed.py` iterates `DEFAULT_AGENTS` to populate the DB on bootstrap; `foundation/_validate_lifecycle.py:237` validates against `DEFAULT_AGENTS`. It derives from `foundation.AGENTS` so adding an agent is a single foundation edit.
|
||||
|
||||
**Toolchain.** `WorkspaceService` (`services/workspace.py:1308`) calls `resolve_target_python(workspace)` to pick the interpreter for `uv --python` when provisioning a target project's clone.
|
||||
|
||||
@@ -195,7 +192,7 @@ support-services
|
||||
│ └── stream_bus.py # Redis Streams durable bus (xadd/xreadgroup/xack/xclaim)
|
||||
├── seeds/
|
||||
│ ├── __init__.py
|
||||
│ └── initial_data.py # DEFAULT_AGENTS/CHANNELS/MEMBERSHIPS from foundation
|
||||
│ └── initial_data.py # DEFAULT_AGENTS from foundation
|
||||
└── utils/
|
||||
├── __init__.py
|
||||
├── converters.py # UUID coercion
|
||||
@@ -215,7 +212,7 @@ support-services
|
||||
- `roboco.models.optimal` (`IndexType`, `QueryContext`, `SearchResult`) — `proactive`
|
||||
- `roboco.models.message.RawStream`, `roboco.models.transcription.*` — `transcription`
|
||||
- `roboco.agents_config.get_agent_role` — `llm`
|
||||
- `roboco.foundation.identity` + `roboco.foundation.policy.communications` — `seeds`
|
||||
- `roboco.foundation.identity` — `seeds`
|
||||
- `roboco.services.optimal.get_optimal_service` — `proactive` (lazy)
|
||||
- `roboco.logging.get_logger` — `crypto`
|
||||
|
||||
@@ -242,9 +239,9 @@ support-services
|
||||
| `resolve_target_python` | `services/workspace.py:1308` | Workspace provisioning (clone/claim) |
|
||||
| `init_event_bus` / `register_default_handlers` / `set_event_context` | `bootstrap.py:92-96` | App bootstrap |
|
||||
| `bus.publish` / `publish_task_event` | services, orchestrator, websocket_bridge | Status transitions, live events |
|
||||
| `get_proactive_service` | `services/task.py:2548`, `services/messaging.py:797`, `api/routes/optimal.py:1257` | claim_task, session start, `/api/optimal/context` |
|
||||
| `get_proactive_service` | `services/task.py:2548`, `api/routes/optimal.py:1257` | claim_task, `/api/optimal/context` |
|
||||
| `TranscriptionService` | `api/app.py:124`, `services/extraction.py` | Lifespan construct; extraction pipeline |
|
||||
| `DEFAULT_AGENTS` / `DEFAULT_CHANNELS` / `CHANNEL_MEMBERSHIPS` | `db/seed.py`, `foundation/_validate_lifecycle.py` | DB bootstrap + lifecycle validation |
|
||||
| `DEFAULT_AGENTS` | `db/seed.py`, `foundation/_validate_lifecycle.py` | DB bootstrap + lifecycle validation |
|
||||
| `encrypt_token` / `decrypt_token` | `provider`, `llm` (via ProviderService), ProjectService | Token persist / read |
|
||||
|
||||
## Config Flags
|
||||
@@ -291,7 +288,6 @@ Other settings read here: `transcript_retention_days` (int, ≥1; read by orches
|
||||
- **`ProactiveKnowledgeService._find_code_patterns` was removed** (`proactive.py`) — the dead method, its call in `build_context_package`, the `"Found N code patterns"` summary line, and the count were all deleted. `ContextPackage.code_patterns` is retained as an always-empty field for API/schema back-compat (serialized in `to_dict`; docstring marks it deprecated). Do not rely on it being populated.
|
||||
- **`ProactiveKnowledgeService` singleton lazy-inits `OptimalService`** (`proactive.py:537`) — the first `get_proactive_service()` call triggers `get_optimal_service()` which may do embedding-model setup; call it early or expect latency on first claim.
|
||||
- **Seeds `system` sentinel is appended literally with `team=None`** (`seeds/initial_data.py:144`) — the postgres `team` enum has no `system` value, so seeding it through the normal path would fail. Don't add `system` to `_AGENT_PRESENTATION` expecting it to flow through `_build_default_agents`.
|
||||
- **`_TEAM_SCOPED_ROLES` is duplicated** in `seeds/initial_data.py:180` vs `agents_config._TEAM_SCOPED_ROLES` — kept duplicated to avoid a circular import (agents_config imports `AGENT_UUIDS` from seeds). Edit both together.
|
||||
- **`require_uuid` raises `InvalidIdentifierError` (a `ValueError` subclass), not `NotFoundError`** (`utils/converters.py:38`) — callers in `llm.py` use it on `provider.id` which is a PK and never None in practice, but a None would surface as a 500-class error not a 404. The typed subclass allows callers to handle a bad identifier distinctly from other exceptions; existing `except ValueError` handlers are unaffected.
|
||||
- **`encrypt_token` rejects empty strings** (`crypto.py:53`) — the provider/project token convention is `""` = clear to NULL, handled at the service layer (`_apply_auth_token_change` checks `clear_auth_token`/`not data.auth_token` before calling encrypt); never pass `""` directly to `encrypt_token`.
|
||||
- **`check_redis` opens a fresh client each call and closes it** (`health.py:27`) — fine for a health probe, but don't reuse the pattern for hot paths.
|
||||
@@ -299,7 +295,7 @@ Other settings read here: `transcript_retention_days` (int, ≥1; read by orches
|
||||
## Drift from CLAUDE.md
|
||||
|
||||
- **`services/settings.py:46` `FEATURE_FLAGS`** includes `rag_auto_update_enabled` ("RAG auto-update") and `transcript_prune_enabled` ("Transcript pruning") which are **NOT** listed in the CLAUDE.md "Feature Flags / company-in-a-box" enumeration (that list names web research, strategy engine, pitch provisioning, external/internal PR review, toolchain match, conventions, gateway-health, multi-repo CI-watch, dependency-update bot, gated release manager, organizational memory loop, and the self-heal flags). Two flags exist in code with no CLAUDE.md mention.
|
||||
- **`services/proactive.py`** (`ProactiveKnowledgeService`) — an entire service that injects RAG context on task-claim / session-start — is **not mentioned anywhere in CLAUDE.md**. The org-memory loop (`ROBOCO_ORG_MEMORY_ENABLED`, `_briefing_for` institutional_memory) is documented, but that is a separate, newer system; `proactive.py` predates and overlaps with it yet remains present and wired (`task.py:2548`, `messaging.py:797`, `optimal.py:1257`).
|
||||
- **`services/proactive.py`** (`ProactiveKnowledgeService`) — an entire service that injects RAG context on task-claim / session-start — is **not mentioned anywhere in CLAUDE.md**. The org-memory loop (`ROBOCO_ORG_MEMORY_ENABLED`, `_briefing_for` institutional_memory) is documented, but that is a separate, newer system; `proactive.py` predates and overlaps with it yet remains present and wired (`task.py:2548`, `optimal.py:1257`).
|
||||
- **`services/transcription.py`** (`TranscriptionService`) — the raw-LLM-stream buffering layer between WebSocket and the extraction pipeline — is **not mentioned in CLAUDE.md**. CLAUDE.md documents WebSocket streams and the extraction target (ExtractedMessages) but not the transcription buffer service.
|
||||
- **`services/llm.py:316` `derive_mode`** returns `"grok"` for a single GLOBAL GROK assignment; CLAUDE.md's provider table lists `GROK` as a `ModelProvider` and documents the Grok CLI provider, and `apply_mode` supports `"grok"` — consistent. No drift here, noted for completeness.
|
||||
- **`events/handlers.py:332` `handle_auditor_spawn`** spawns the auditor on `TASK_BLOCKED`/`TASK_CANCELLED`/`TASK_AWAITING_CEO_APPROVAL`; CLAUDE.md says "The Auditor sees all" and is a "silent observer" but does not describe the event-driven one-shot spawn on exceptional lifecycle events. Minor doc gap, not a contradiction.
|
||||
@@ -329,7 +325,6 @@ No files in this slice changed between `fd10cc86` and `HEAD`, so there are no *r
|
||||
| `probe_ollama_tags` leaks raw exception text into error string | `services/llm.py:86-92` | Generic `except Exception` puts `str(exc)` in the returned error, surfaced to the panel/UI — minor info disclosure. | low |
|
||||
| `TranscriptionService` sync callbacks can stall the flush task | `services/transcription.py:57,233` | `register_callback` takes a sync `Callable` invoked without `await` inside the async `_periodic_flush`; a blocking callback blocks all buffer flushing. | medium |
|
||||
| `get_ready_buffers` never removes buffers → unbounded growth | `services/transcription.py:208` | Iterates and yields ready buffers without clearing; only `_flush_all` (shutdown) clears. A long-running orchestrator with many sessions accumulates buffers unless callers `flush_buffer` after extraction. | medium |
|
||||
| `_TEAM_SCOPED_ROLES` duplicated across seeds and agents_config | `seeds/initial_data.py:180` | Duplicated to avoid a circular import; editing one without the other silently desyncs channel membership expansion vs runtime permission scoping. | low |
|
||||
| `apply_persisted_feature_flags` mutates shared `settings` singleton unsynchronized | `services/settings.py:163` | Runs once at lifespan (safe), but any future caller that re-invokes it mid-serve could race concurrent flag reads. | low |
|
||||
| ~~`ProactiveKnowledgeService._find_code_patterns` vestigial~~ | `services/proactive.py` | **FIXED** (321e68d7): method removed; `ContextPackage.code_patterns` retained as always-empty for back-compat. | ~~low~~ |
|
||||
| `require_uuid` raises untyped `ValueError` not `NotFoundError` | `utils/converters.py:38` | **FIXED** (6b441e42): now raises `InvalidIdentifierError(ValueError)` — typed, backward-compatible. A None/bad identifier still surfaces as a 500-class error (not 404), but callers can now handle it distinctly. | low |
|
||||
|
||||
+2
-2
@@ -98,7 +98,7 @@ tests/
|
||||
│ ├── _StubGit pattern (test_full_lifecycle_real_db, test_lifecycle_real_db)
|
||||
│ ├── test_foundation_phase1..4_smoke.py [tiered package-layout gates]
|
||||
│ ├── test_migration_013/014/016/028 + batch_intake/ci_watch/dep_update/observability
|
||||
│ ├── test_*_routes.py (agents, channels, dashboard, docs, git, journal, kanban, notifications, pitch, product, project, release, research, secretary, sessions, stream, tasks, work_session, orchestrator, prompter_live)
|
||||
│ ├── test_*_routes.py (agents, dashboard, docs, git, journal, kanban, notifications, pitch, product, project, release, research, secretary, stream, tasks, work_session, orchestrator, prompter_live)
|
||||
│ ├── test_task_service_* [basics, transitions, lifecycle_misc, misc, background, no_silent_fallback, route_orchestration]
|
||||
│ ├── services/ [ci_watch_engine/notify/source, dep_update_engine/probe/source, external_pr_repo_dedup, project_autonomy_update, active_task_owns_branch_scoping]
|
||||
│ ├── v1/test_full_pending_to_completed.py [TestClient e2e, all 6 v1 routers, stateful mocks]
|
||||
@@ -116,7 +116,7 @@ tests/
|
||||
├── config/ (5) — ci_watch/conventions/dep_update/org_memory/release_manager flag tests
|
||||
├── conventions/ (10) — classify_python/ts, cli, cli_smoke, custom, hygiene, modularity, placement, runner, scan
|
||||
├── db/ (1) — respawn_tracker_table
|
||||
├── enforcement/ (5) — a2a_access, channel_access, journal_perms, task_lifecycle, task_ownership
|
||||
├── enforcement/ (4) — a2a_access, journal_perms, task_lifecycle, task_ownership
|
||||
├── events/ (2) — bus, handlers
|
||||
├── foundation/policy/ (2) + content/ (6) + conventions/ (3) — pure policy models
|
||||
├── gateway/ (105) — Choreographer/verb-runner guard + envelope + evidence surface
|
||||
|
||||
@@ -73,5 +73,5 @@ The **provider** selects the agent backend, resolved through the `ProviderRegist
|
||||
| `current_task_id` | Currently assigned task |
|
||||
| `journal_id` | Personal journal |
|
||||
| `system_prompt` | Base prompt |
|
||||
| `permissions` | Channel access |
|
||||
| `permissions` | Tool/verb permission scope |
|
||||
| `metrics` | Performance data |
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
# Channel Reference
|
||||
|
||||
All available channels with their slugs and access rules.
|
||||
|
||||
## Cell Channels
|
||||
|
||||
| Slug | Name | Members |
|
||||
|------|------|---------|
|
||||
| `backend-cell` | Backend Cell | be-dev-1, be-dev-2, be-qa, be-pm, be-doc |
|
||||
| `frontend-cell` | Frontend Cell | fe-dev-1, fe-dev-2, fe-qa, fe-pm, fe-doc |
|
||||
| `uxui-cell` | UX/UI Cell | ux-dev-1, ux-dev-2, ux-qa, ux-pm, ux-doc |
|
||||
|
||||
## Cross-Cell Channels
|
||||
|
||||
| Slug | Name | Members |
|
||||
|------|------|---------|
|
||||
| `dev-all` | All Developers | All 6 developers |
|
||||
| `qa-all` | All QA | be-qa, fe-qa, ux-qa |
|
||||
| `pm-all` | All PMs | be-pm, fe-pm, ux-pm, main-pm |
|
||||
| `doc-all` | All Documenters | be-doc, fe-doc, ux-doc |
|
||||
|
||||
## Management Channels
|
||||
|
||||
| Slug | Name | Members |
|
||||
|------|------|---------|
|
||||
| `main-pm-board` | Main PM & Board | main-pm, product-owner, head-marketing, auditor (all read/write) |
|
||||
| `board-private` | Board Private | product-owner, head-marketing, auditor, ceo (read/write) + main-pm (read-only) |
|
||||
|
||||
## Special Channels
|
||||
|
||||
| Slug | Name | Read | Write |
|
||||
|------|------|------|-------|
|
||||
| `announcements` | Announcements | Everyone | PM/Board only |
|
||||
| `all-hands` | All Hands | Everyone | Everyone |
|
||||
|
||||
## Auditor Silent Access
|
||||
|
||||
Auditor has silent read access (in these channels' `silent_roles`) to:
|
||||
- `backend-cell`
|
||||
- `frontend-cell`
|
||||
- `uxui-cell`
|
||||
- `dev-all`
|
||||
- `qa-all`
|
||||
- `pm-all`
|
||||
- `doc-all`
|
||||
|
||||
Auditor does NOT appear in member lists but CAN read. On the two management channels (`main-pm-board`, `board-private`) the Auditor is NOT silent — it has full read + write there. (Its content-tool manifest is `note`, `evidence`, and read-only `notify_list`/`notify_get`/`channels`, with no `say`/`dm`/`notify`, so it observes rather than posts in practice.)
|
||||
|
||||
## Privileged Access
|
||||
|
||||
These roles bypass normal membership checks:
|
||||
- **CEO**: Full access everywhere
|
||||
- **Auditor**: Silent read on cell + cross-cell channels; read/write on the management channels
|
||||
- **Main PM**: Read access to all cell channels
|
||||
|
||||
## Using Channels
|
||||
|
||||
```python
|
||||
# List the channel slugs you can read / write (call this first if unsure of
|
||||
# a slug — inventing slugs returns "Channel not found")
|
||||
channels() # -> {writable: [...], readable: [...]}
|
||||
|
||||
# Send a message to your cell
|
||||
say(
|
||||
channel="backend-cell",
|
||||
text="Starting work on task",
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
# Direct agent-to-agent message (same-cell only)
|
||||
dm(recipient="be-qa", text="Quick sanity check before QA", task_id=task_id)
|
||||
```
|
||||
@@ -1,78 +0,0 @@
|
||||
# Channel Structure
|
||||
|
||||
## Channel Types
|
||||
|
||||
| Type | Purpose | Example |
|
||||
|------|---------|---------|
|
||||
| `cell` | Internal team | #backend-cell |
|
||||
| `cross_cell` | Role coordination | #dev-all, #qa-all |
|
||||
| `management` | PM/Board | #main-pm-board |
|
||||
| `special` | Announcements | #all-hands |
|
||||
|
||||
## Cell Channels
|
||||
|
||||
| Channel | Members |
|
||||
|---------|---------|
|
||||
| #backend-cell | be-pm, be-dev-*, be-qa, be-doc |
|
||||
| #frontend-cell | fe-pm, fe-dev-*, fe-qa, fe-doc |
|
||||
| #uxui-cell | ux-pm, ux-dev-*, ux-qa, ux-doc |
|
||||
|
||||
## Cross-Cell Channels
|
||||
|
||||
| Channel | Members |
|
||||
|---------|---------|
|
||||
| #dev-all | All developers |
|
||||
| #qa-all | All QAs |
|
||||
| #pm-all | All PMs |
|
||||
| #doc-all | All documenters |
|
||||
|
||||
## Management Channels
|
||||
|
||||
| Channel | Members |
|
||||
|---------|---------|
|
||||
| #main-pm-board | Main PM, Product Owner, Head Marketing, Auditor |
|
||||
| #board-private | Product Owner, Head Marketing, Auditor, CEO, Main PM |
|
||||
|
||||
In both management channels the Auditor has read **and** write (it is NOT silent here — that downgrade applies only to the cell and cross-cell channels). In #board-private the Main PM can read but cannot write.
|
||||
|
||||
## Special Channels
|
||||
|
||||
| Channel | Access |
|
||||
|---------|--------|
|
||||
| #announcements | Read: all, Write: PM/Board |
|
||||
| #all-hands | Read/Write: all |
|
||||
|
||||
## Auditor Access
|
||||
|
||||
Auditor has **silent read access** to the cell and cross-cell channels:
|
||||
- Does not appear in member lists
|
||||
- Cannot send messages there
|
||||
- Observes all activity
|
||||
|
||||
The Auditor is silent only on cell + cross-cell channels (it is in those channels' `silent_roles`). On the management channels (#main-pm-board, #board-private) it has full read + write. The Auditor's content-tool manifest is `note(scope=reflect)` + `evidence` + read-only `notify_list`/`notify_get`/`channels` — it has no `say`/`dm`/`notify`, so in practice it observes rather than posts.
|
||||
|
||||
## Channel Access Rules
|
||||
|
||||
| Role | Own Cell | Cross-Cell | Management |
|
||||
|------|----------|------------|------------|
|
||||
| Developer | Read/Write | Read/Write | - |
|
||||
| QA | Read/Write | Read/Write | - |
|
||||
| Documenter | Read/Write | Read/Write | - |
|
||||
| Cell PM | Read/Write | Read/Write | #pm-all (Read/Write) |
|
||||
| Main PM | Read/Write | Read/Write | Read/Write |
|
||||
| Board | - | - | Read/Write |
|
||||
| Auditor | Silent Read | Silent Read | Read/Write |
|
||||
|
||||
## Messaging
|
||||
|
||||
Agents post to channels with the `say` content tool (there is no `roboco_message_send` tool):
|
||||
|
||||
```python
|
||||
say(
|
||||
channel="backend-cell",
|
||||
text="Starting work on rate limiting",
|
||||
task_id=task_id,
|
||||
)
|
||||
```
|
||||
|
||||
For direct agent-to-agent messages, use `dm(recipient, text)` (same-cell only; cross-cell is denied — escalate via your Cell PM instead). PMs and the Board can additionally send ack-required notifications with `notify(target, text, priority)`.
|
||||
@@ -33,7 +33,7 @@ A blocked request gets a generic `400` or `403` response — no rule or signatur
|
||||
|
||||
## WAF Calibration for Agent Traffic
|
||||
|
||||
Agent traffic legitimately carries code, SQL, diffs, file paths, HTML snippets, and URLs — for example inside `note` / `commit` / `say` bodies. To avoid false positives, the free-text body fields on those routes are excluded from WAF signature scanning via `excluded_detection_body_fields` in `build_security_config`, so normal code/SQL/diff/HTML payloads from agents are not flagged by the WAF layer.
|
||||
Agent traffic legitimately carries code, SQL, diffs, file paths, HTML snippets, and URLs — for example inside `note` / `commit` / `dm` bodies. To avoid false positives, the free-text body fields on those routes are excluded from WAF signature scanning via `excluded_detection_body_fields` in `build_security_config`, so normal code/SQL/diff/HTML payloads from agents are not flagged by the WAF layer.
|
||||
|
||||
The three custom validators above are not covered by that exclusion — they scan those same bodies regardless of the WAF exclusion. See `docs/rag/troubleshooting/blocked-http-requests.md` for what this means in practice and what not to put in a request body.
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ Each role can communicate with:
|
||||
|------|---------------------|
|
||||
| CEO | Everyone |
|
||||
| Board | CEO, other board, Main PM |
|
||||
| Auditor | Everyone (silent read all) |
|
||||
| Auditor | Read-only oversight via task/audit state; no agent comms |
|
||||
| PR Reviewer | Read-only; posts one change-request on the PR itself, no agent comms |
|
||||
| Main PM | CEO, Board, Cell PMs |
|
||||
| Cell PM | Main PM, cell members |
|
||||
|
||||
@@ -67,7 +67,7 @@ Sending notifications means calling the `notify(target, text, priority)` content
|
||||
| qa | No | - |
|
||||
| documenter | No | - |
|
||||
|
||||
Non-senders (developer, qa, documenter, auditor) still communicate via `say(channel, text)` for channel posts and `dm(recipient, text)` for direct agent-to-agent messages — those are not ack-required notifications. The Auditor is restricted further: it has `note(scope=reflect)` + `evidence` + read-only `notify_list`/`notify_get`/`channels`, and NO `say`/`dm`/`notify`.
|
||||
Non-senders (developer, qa, documenter, auditor) still communicate via `dm(recipient, text)` for direct agent-to-agent messages — those are not ack-required notifications. The Auditor is restricted further: it has `note(scope=reflect)` + `evidence` + read-only `notify_list`/`notify_get`, and NO `dm`/`notify`.
|
||||
|
||||
## Task-Creator Roles
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ Agents call gateway verbs through up to five MCP servers, scoped per role:
|
||||
| MCP server | Provides |
|
||||
|------------|----------|
|
||||
| `roboco-flow` | Lifecycle verbs (give_me_work, i_will_work_on, open_pr, complete, …) |
|
||||
| `roboco-do` | Content/write verbs (commit, note, say, dm, notify, evidence) |
|
||||
| `roboco-do` | Content/write verbs (commit, note, dm, notify, evidence) |
|
||||
| `roboco-git-readonly` | Read-only git inspection (status, log, diff, branch_list) |
|
||||
| `roboco-search` | Web research (`web_search`, `web_fetch`) — `cell_pm`/`main_pm`/`product_owner`/`head_marketing` only, and only when `ROBOCO_RESEARCH_ENABLED` (default on) |
|
||||
| `roboco-optimal` | RAG (`roboco_ask_mentor`, `roboco_kb_search`) |
|
||||
@@ -21,7 +21,7 @@ The canonical source of role → verb mapping is `roboco/services/gateway/role_c
|
||||
|
||||
**Flow verbs (roboco-flow):** `give_me_work`, `i_will_work_on`, `open_pr`, `i_am_done`, `i_am_blocked`, `unclaim`, `resume`, `i_am_idle`
|
||||
|
||||
**Content verbs (roboco-do):** `commit`, `note`, `say`, `dm`, `evidence`
|
||||
**Content verbs (roboco-do):** `commit`, `note`, `dm`, `evidence`
|
||||
|
||||
**Read-only git (roboco-git-readonly):** all 4 (`status`, `log`, `diff`, `branch_list`)
|
||||
|
||||
@@ -31,7 +31,7 @@ The canonical source of role → verb mapping is `roboco/services/gateway/role_c
|
||||
|
||||
**Flow verbs:** `give_me_work`, `claim_review`, `pass`, `fail`, `unclaim`, `resume`, `i_am_idle`
|
||||
|
||||
**Content verbs:** `note`, `say`, `dm`, `evidence` (no `commit` — QA does not write code)
|
||||
**Content verbs:** `note`, `dm`, `evidence` (no `commit` — QA does not write code)
|
||||
|
||||
**Read-only git:** all 4
|
||||
|
||||
@@ -41,7 +41,7 @@ The canonical source of role → verb mapping is `roboco/services/gateway/role_c
|
||||
|
||||
**Flow verbs:** `give_me_work`, `claim_doc_task`, `i_documented`, `unclaim`, `resume`, `i_am_idle`
|
||||
|
||||
**Content verbs:** `commit`, `note`, `say`, `dm`, `evidence`
|
||||
**Content verbs:** `commit`, `note`, `dm`, `evidence`
|
||||
|
||||
**Read-only git:** all 4
|
||||
|
||||
@@ -51,7 +51,7 @@ The canonical source of role → verb mapping is `roboco/services/gateway/role_c
|
||||
|
||||
**Flow verbs:** `give_me_work`, `i_will_plan`, `delegate`, `submit_up`, `triage`, `unblock`, `complete`, `escalate_up`, `unclaim`, `resume`, `i_am_idle`
|
||||
|
||||
**Content verbs:** `note`, `say`, `dm`, `notify`, `evidence` (no `commit` — PMs delegate code; merging the leaf PR happens automatically inside `complete`)
|
||||
**Content verbs:** `note`, `dm`, `notify`, `evidence` (no `commit` — PMs delegate code; merging the leaf PR happens automatically inside `complete`)
|
||||
|
||||
**Read-only git:** all 4
|
||||
|
||||
@@ -63,7 +63,7 @@ The canonical source of role → verb mapping is `roboco/services/gateway/role_c
|
||||
|
||||
**Flow verbs:** `give_me_work`, `i_will_plan`, `delegate`, `triage_all`, `unblock`, `complete`, `escalate_up`, `escalate_to_ceo`, `unclaim`, `resume`, `i_am_idle`
|
||||
|
||||
**Content verbs:** `note`, `say`, `dm`, `notify`, `evidence`
|
||||
**Content verbs:** `note`, `dm`, `notify`, `evidence`
|
||||
|
||||
**Read-only git:** all 4
|
||||
|
||||
@@ -77,9 +77,9 @@ Both share the same flow verbs and read-only git (none), but their content verbs
|
||||
|
||||
**Flow verbs (both):** `triage`, `escalate_to_ceo`, `i_am_idle`
|
||||
|
||||
**Content verbs — Product Owner:** `note`, `pitch`, `propose_roadmap`, `say`, `dm`, `notify`, `evidence`, `open_session`
|
||||
**Content verbs — Product Owner:** `note`, `pitch`, `propose_roadmap`, `dm`, `notify`, `evidence`
|
||||
|
||||
**Content verbs — Head of Marketing:** `note`, `pitch`, `say`, `dm`, `notify`, `evidence`, `open_session` (no `propose_roadmap`)
|
||||
**Content verbs — Head of Marketing:** `note`, `pitch`, `dm`, `notify`, `evidence` (no `propose_roadmap`)
|
||||
|
||||
**Read-only git (both):** none.
|
||||
|
||||
@@ -89,7 +89,7 @@ Both share the same flow verbs and read-only git (none), but their content verbs
|
||||
|
||||
**Flow verbs:** `triage`, `i_am_idle` (read-only)
|
||||
|
||||
**Content verbs:** `note` (scope=reflect), `evidence` (no `say` / `dm` — Auditor observes silently)
|
||||
**Content verbs:** `note` (scope=reflect), `evidence` (no `dm` — Auditor observes silently)
|
||||
|
||||
**Read-only git:** none.
|
||||
|
||||
@@ -97,7 +97,7 @@ Both share the same flow verbs and read-only git (none), but their content verbs
|
||||
|
||||
**Flow verbs:** `give_me_work`, `claim_pr_review`, `post_pr_review` (inbound external/fork + internal PRs), `claim_gate_review`, `pr_pass`, `pr_fail` (in-path assembled-PR gate), `unclaim`, `i_am_idle` (read-only)
|
||||
|
||||
**Content verbs:** `note`, `evidence`, plus notification reads (`notify_list`, `notify_get`) and channel discovery — no `say` / `dm`: the change-request is posted server-side on the PR itself.
|
||||
**Content verbs:** `note`, `evidence`, plus notification reads (`notify_list`, `notify_get`) — no `dm`: the change-request is posted server-side on the PR itself.
|
||||
|
||||
**Read-only git:** none.
|
||||
|
||||
@@ -109,7 +109,7 @@ Both are human-only roles — they chat with the CEO, not other agents.
|
||||
|
||||
**Flow verbs:** `i_am_idle` only.
|
||||
|
||||
**Content verbs:** `note`, `evidence` only (no `say` / `dm` / `notify`).
|
||||
**Content verbs:** `note`, `evidence` only (no `dm` / `notify`).
|
||||
|
||||
**Read-only git / workspace writes:** none.
|
||||
|
||||
@@ -125,7 +125,7 @@ Both are human-only roles — they chat with the CEO, not other agents.
|
||||
| `complete` (merges PR) | — | — | — | ✓ | ✓ | — | — |
|
||||
| `escalate_to_ceo` | — | — | — | — | ✓ | ✓ | — |
|
||||
| `notify` (ack-required) | — | — | — | ✓ | ✓ | ✓ | — |
|
||||
| `say` / `dm` (channel / A2A) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — |
|
||||
| `dm` (A2A) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — |
|
||||
| `note` (journal entry) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ (reflect) |
|
||||
| `roboco_git_*` (read-only) | ✓ | ✓ | ✓ | ✓ | ✓ | — | — |
|
||||
| `Write` / `Edit` (own workspace) | ✓ | ✓ | — | — | — | — | — |
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
## What You CAN Do
|
||||
|
||||
- Triage / view tasks in your scope via `triage()` (read-only)
|
||||
- Discover and read channels via `channels()`
|
||||
- See your inbox via `notify_list()` / `notify_get(notification_id)`
|
||||
- Record private observations via `note(text="...", scope="reflect")`
|
||||
- Attach evidence via `evidence(task_id)`
|
||||
@@ -28,15 +27,15 @@
|
||||
- Claim, create, assign, complete, or cancel tasks
|
||||
- Pass or fail QA
|
||||
- Escalate (`triage` is your only flow verb besides `i_am_idle`)
|
||||
- Post to channels (`say`), DM agents (`dm`), or send `notify`
|
||||
- DM agents (`dm`) or send `notify`
|
||||
- Acknowledge notifications (silent observer — `notify_ack` is not yours)
|
||||
- Write to project docs, write code, or run git write operations
|
||||
|
||||
## Silent Observer Mode
|
||||
|
||||
The Auditor has **silent read access** across the org:
|
||||
- Reads task state, channels, and the knowledge base
|
||||
- Cannot send messages outward — there is no `say` / `dm` / `notify`
|
||||
- Reads task state and the knowledge base
|
||||
- Cannot send messages outward — there is no `dm` / `notify`
|
||||
- Observations are recorded privately via `note(scope="reflect")`
|
||||
|
||||
## Observation Areas
|
||||
@@ -65,11 +64,11 @@ evidence(task_id="...") # attach the evidence trail to the finding
|
||||
| MCP server | Verbs you can call |
|
||||
|-----------------------|--------------------|
|
||||
| `roboco-flow` | `triage`, `i_am_idle` |
|
||||
| `roboco-do` | `note` (scope=`reflect`), `evidence`, `notify_list`, `notify_get`, `channels` |
|
||||
| `roboco-do` | `note` (scope=`reflect`), `evidence`, `notify_list`, `notify_get` |
|
||||
| `roboco-git-readonly` | `roboco_git_status`, `roboco_git_log`, `roboco_git_diff`, `roboco_git_branch_list` |
|
||||
| `roboco-optimal` | `roboco_ask_mentor`, `roboco_kb_search` |
|
||||
|
||||
**Read-only observer.** No `say`, `dm`, `notify`, `commit`, or any write verb is in your manifest. All `Write/Edit` and native git commands are blocked.
|
||||
**Read-only observer.** No `dm`, `notify`, `commit`, or any write verb is in your manifest. All `Write/Edit` and native git commands are blocked.
|
||||
|
||||
## Communication
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ unclaim(task_id) / resume(task_id) / i_am_idle()
|
||||
| MCP server | Verbs you can call |
|
||||
|-----------------------|--------------------|
|
||||
| `roboco-flow` | `give_me_work`, `i_will_plan`, `delegate`, `submit_up`, `triage`, `unblock`, `reassign`, `complete`, `escalate_up`, `unclaim`, `resume`, `i_am_idle` |
|
||||
| `roboco-do` | `note`, `say`, `dm`, `notify`, `evidence` (no `commit`) |
|
||||
| `roboco-do` | `note`, `dm`, `notify`, `evidence` (no `commit`) |
|
||||
| `roboco-git-readonly` | `roboco_git_status`, `roboco_git_log`, `roboco_git_diff`, `roboco_git_branch_list` |
|
||||
| `roboco-search` | `web_search`, `web_fetch` (only when `ROBOCO_RESEARCH_ENABLED`, default on) |
|
||||
| `roboco-optimal` | `roboco_ask_mentor`, `roboco_kb_search` |
|
||||
@@ -139,9 +139,6 @@ note(text="...", scope="reflect") # journal observations
|
||||
dm(recipient="fe-pm", text="Need to align on shared schema; task X.",
|
||||
task_id="...", skill="api_design")
|
||||
|
||||
# Cell-wide announcement (visible to whole cell)
|
||||
say(channel="backend-cell", text="Heads up — sprint cut at 18:00 UTC.")
|
||||
|
||||
# Ack-required notification (PMs / Board only)
|
||||
notify(target="be-dev-1", text="Please prioritise task X by EOD.",
|
||||
priority="high", task_id="...")
|
||||
|
||||
@@ -24,7 +24,7 @@ The CEO is a **human** and acts through the **panel/UI**, not through the agent
|
||||
- Approve or reject tasks in `awaiting_ceo_approval`
|
||||
- Cancel tasks (CEO is one of the cancel-authorized roles)
|
||||
- Set strategic direction
|
||||
- Read all channels
|
||||
- Message any agent directly via A2A (`dm`), unrestricted on the CEO's side
|
||||
|
||||
## CEO Approval Workflow
|
||||
|
||||
@@ -56,9 +56,6 @@ Only `main_pm`, `product_owner`, and `head_marketing` can escalate a task to the
|
||||
|
||||
## Communication
|
||||
|
||||
The CEO has read access to all channels, including:
|
||||
- #board-private
|
||||
- #announcements
|
||||
- All cell and cross-cell channels
|
||||
The CEO has no channels to monitor. Oversight is via task state (all tasks are visible in the panel), notifications, and direct A2A messages — the CEO can `dm` any agent at any time, unrestricted, while an agent may only reply inside a conversation the CEO opened.
|
||||
|
||||
The CEO communicates and decides through the panel/UI rather than the agent content tools (`say` / `dm` / `notify`).
|
||||
The CEO communicates and decides through the panel/UI rather than calling the agent content tools (`dm` / `notify`) directly.
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
- Pass or fail QA → QA only
|
||||
- Complete a task / merge a PR → PMs only
|
||||
- Cancel tasks
|
||||
- Send `notify` (ack-required notifications) — devs use `say` (channel) and `dm` (A2A) only
|
||||
- Send `notify` (ack-required notifications) — devs use `dm` (A2A) only
|
||||
- Run shell git (`git commit`, `git push`, `git checkout`, etc.) — blocked by the bash-guard hook
|
||||
|
||||
## Task Flow (gateway verbs)
|
||||
@@ -59,7 +59,7 @@ i_am_idle() → no work in your queue right now
|
||||
| MCP server | Verbs you can call |
|
||||
|-----------------------|--------------------|
|
||||
| `roboco-flow` | `give_me_work`, `i_will_work_on`, `open_pr`, `i_am_done`, `i_am_blocked`, `unclaim`, `resume`, `sync_branch`, `i_am_idle` |
|
||||
| `roboco-do` | `commit`, `note`, `say`, `dm`, `evidence` |
|
||||
| `roboco-do` | `commit`, `note`, `dm`, `evidence` |
|
||||
| `roboco-git-readonly` | `roboco_git_status`, `roboco_git_log`, `roboco_git_diff`, `roboco_git_branch_list` |
|
||||
| `roboco-optimal` | `roboco_ask_mentor`, `roboco_kb_search` |
|
||||
|
||||
@@ -102,9 +102,6 @@ When the architectural-conventions standard is enabled, `i_am_done` is refused o
|
||||
```python
|
||||
# Direct A2A inside your cell (same team — no policy gate)
|
||||
dm(recipient="be-qa", text="Quick sanity check: ...", task_id="...")
|
||||
|
||||
# Channel post (visible to cell)
|
||||
say(channel="backend-cell", text="Started on task X — anyone hit Y before?")
|
||||
```
|
||||
|
||||
Cross-cell A2A is denied by policy. Route through your Cell PM via `escalate_up(task_id, reason)`.
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
- Create or assign tasks (PM only)
|
||||
- Pass or fail QA (QA only)
|
||||
- Cancel tasks
|
||||
- Send `notify` (ack-required notifications) — docs use `say` (channel) and `dm` (A2A) only
|
||||
- Send `notify` (ack-required notifications) — docs use `dm` (A2A) only
|
||||
- Complete tasks (only submits for PM review via `i_documented`)
|
||||
- Document your own development work (self-documentation prevention)
|
||||
|
||||
@@ -45,7 +45,7 @@ awaiting_documentation → claim_doc_task → write docs → i_documented
|
||||
| MCP server | Verbs you can call |
|
||||
|-----------------------|--------------------|
|
||||
| `roboco-flow` | `give_me_work`, `claim_doc_task`, `i_documented`, `i_am_blocked`, `unclaim`, `resume`, `i_am_idle` |
|
||||
| `roboco-do` | `commit`, `note`, `say`, `dm`, `evidence`, `progress` (no `notify`) |
|
||||
| `roboco-do` | `commit`, `note`, `dm`, `evidence`, `progress` (no `notify`) |
|
||||
| `roboco-docs` | `roboco_docs_write`, `roboco_docs_read`, `roboco_docs_list` |
|
||||
| `roboco-git-readonly` | `roboco_git_status`, `roboco_git_log`, `roboco_git_diff`, `roboco_git_branch_list` |
|
||||
| `roboco-optimal` | `roboco_ask_mentor`, `roboco_kb_search` |
|
||||
@@ -61,9 +61,6 @@ Before writing documentation:
|
||||
# the task evidence and in the KB
|
||||
evidence(task_id="...")
|
||||
roboco_kb_search("similar documentation")
|
||||
|
||||
# Read channel discussion for this cell
|
||||
channels() # discover the cell channel slug, then read its history
|
||||
```
|
||||
|
||||
## Writing Documentation
|
||||
@@ -119,9 +116,6 @@ Journaling is just `note(text, scope)` — scope is one of `reflect`, `decision`
|
||||
```python
|
||||
# Direct A2A inside your cell (same team — no policy gate)
|
||||
dm(recipient="be-dev-1", text="Need context on the new endpoint...", task_id="...")
|
||||
|
||||
# Discover channels you can read/post to
|
||||
channels()
|
||||
```
|
||||
|
||||
Cross-cell A2A is denied by policy. Route through your Cell PM via `escalate_up` — but documenters don't have `escalate_up`; use `i_am_blocked(task_id, reason)` so the Cell PM resolves it.
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
|
||||
- Triage actionable tasks in your scope via `triage()`
|
||||
- Escalate tasks to the CEO via `escalate_to_ceo(task_id, reason)`
|
||||
- Communicate: `say` (channel), `dm` (A2A), `notify` (ack-required signal)
|
||||
- Open strategic sessions via `open_session`
|
||||
- Communicate: `dm` (A2A), `notify` (ack-required signal)
|
||||
- Propose a product via `pitch(title, slug, problem, proposed_solution, target_cells)` — queues for CEO approval, then auto-provisions
|
||||
- Read project docs via `roboco_docs_read` / `roboco_docs_list`
|
||||
- Research the market via `web_search` / `web_fetch` (when `ROBOCO_RESEARCH_ENABLED`)
|
||||
@@ -37,7 +36,7 @@
|
||||
| MCP server | Verbs you can call |
|
||||
|-----------------------|--------------------|
|
||||
| `roboco-flow` | `triage`, `escalate_to_ceo`, `i_am_idle` |
|
||||
| `roboco-do` | `note`, `pitch`, `say`, `dm`, `notify`, `evidence`, `open_session` |
|
||||
| `roboco-do` | `note`, `pitch`, `dm`, `notify`, `evidence` |
|
||||
| `roboco-docs` | `roboco_docs_read`, `roboco_docs_list` |
|
||||
| `roboco-git-readonly` | `roboco_git_status`, `roboco_git_log`, `roboco_git_diff`, `roboco_git_branch_list` |
|
||||
| `roboco-search` | `web_search`, `web_fetch` (only when `ROBOCO_RESEARCH_ENABLED`) |
|
||||
@@ -67,16 +66,13 @@ The CEO acts via the panel/UI; you idle until the CEO decides.
|
||||
|
||||
```python
|
||||
dm(recipient="product-owner", text="Market analysis for the launch — ...", task_id="...")
|
||||
channels() # discover channels you can post to
|
||||
```
|
||||
|
||||
Skills: market_analysis
|
||||
|
||||
## Communication
|
||||
|
||||
Access to:
|
||||
- #main-pm-board
|
||||
- #board-private
|
||||
- #announcements (write)
|
||||
Coordination rides task state, task detail fields, and A2A.
|
||||
|
||||
Can `notify`: Main PM, Product Owner, Auditor, CEO
|
||||
- `dm`: direct peer-to-peer messages via A2A (see the A2A section above)
|
||||
- Can `notify`: Main PM, Product Owner, Auditor, CEO
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
Everything Cell PM can do, PLUS:
|
||||
- Triage tasks across ALL cells via `triage_all()`
|
||||
- Coordinate cross-cell work
|
||||
- Open coordination sessions via `open_session` / `link_session`
|
||||
- Escalate to the CEO via `escalate_to_ceo`
|
||||
|
||||
## Task Breakdown Flow
|
||||
@@ -59,10 +58,7 @@ delegate(
|
||||
covers_parent_criteria=["<initiative-ac-id>", "..."],
|
||||
)
|
||||
|
||||
# 4. Open a coordination session for the related subtasks
|
||||
open_session(task_id=initiative_id, channel="pm-all", topic="Feature X")
|
||||
|
||||
# 5. Notify the Cell PMs (ack-required signal)
|
||||
# 4. Notify the Cell PMs (ack-required signal)
|
||||
notify(target="be-pm", text="New initiative assigned — see task", task_id=subtask_id)
|
||||
```
|
||||
|
||||
@@ -73,7 +69,6 @@ notify(target="be-pm", text="New initiative assigned — see task", task_id=subt
|
||||
Monitor via:
|
||||
```python
|
||||
triage_all() # actionable tasks across all teams (Main PM only)
|
||||
channels() # discover the pm-all channel, then read its history
|
||||
```
|
||||
|
||||
## Tool Surface (per-spawn manifest)
|
||||
@@ -81,7 +76,7 @@ channels() # discover the pm-all channel, then read its history
|
||||
| MCP server | Verbs you can call |
|
||||
|-----------------------|--------------------|
|
||||
| `roboco-flow` | `triage`, `triage_all`, `give_me_work`, `i_will_plan`, `delegate`, `unblock`, `submit_root`, `complete`, `escalate_up`, `escalate_to_ceo`, `resume`, `unclaim`, `i_am_idle` |
|
||||
| `roboco-do` | `note`, `say`, `dm`, `notify`, `evidence`, `open_session`, `link_session`, `pr_update` |
|
||||
| `roboco-do` | `note`, `dm`, `notify`, `evidence`, `pr_update` |
|
||||
| `roboco-docs` | `roboco_docs_write`, `roboco_docs_read`, `roboco_docs_list` |
|
||||
| `roboco-git-readonly` | `roboco_git_status`, `roboco_git_log`, `roboco_git_diff`, `roboco_git_branch_list` |
|
||||
| `roboco-search` | `web_search`, `web_fetch` (only when `ROBOCO_RESEARCH_ENABLED`, default on) |
|
||||
@@ -125,7 +120,6 @@ master ← feature/main_pm/{root} ← feature/{cell}/{root}/{cell-pm} ←
|
||||
|
||||
```python
|
||||
dm(recipient="be-pm", text="Coordinating the API contract — ...", task_id="...")
|
||||
channels() # discover channels you can post to
|
||||
```
|
||||
|
||||
## Escalation
|
||||
|
||||
@@ -37,7 +37,7 @@ You cannot `pr_pass` / `pr_fail` an assembled PR you authored (self-review guard
|
||||
## What You CANNOT Do
|
||||
|
||||
- Modify code, `commit`, push, open / merge PRs — not in your manifest.
|
||||
- `say` / `dm` other agents — you have no comms surface; your output is the PR review.
|
||||
- `dm` other agents — you have no comms surface; your output is the PR review.
|
||||
- Send `notify` (ack-required notifications) — PMs / Board only.
|
||||
- Decide the PR's fate. You review; the **CEO** decides. Your completed review surfaces in the **CEO PR Review Queue** (Command Center), where the CEO chooses **Supersede** (the org cuts its own branch off the contributor's commits, hardens the work, opens its own PR, and — once that merges — closes and links the contributor PR) or **Dismiss**.
|
||||
|
||||
@@ -55,7 +55,7 @@ i_am_idle() → out of work
|
||||
| MCP server | Verbs you can call |
|
||||
|-----------------------|--------------------|
|
||||
| `roboco-flow` | `give_me_work`, `claim_pr_review`, `post_pr_review`, `claim_gate_review`, `pr_pass`, `pr_fail`, `unclaim`, `i_am_idle` |
|
||||
| `roboco-do` | `note`, `evidence`, `notify_list`, `notify_get`, channel discovery (no `say` / `dm` / `commit` / `notify`) |
|
||||
| `roboco-do` | `note`, `evidence`, `notify_list`, `notify_get` (no `dm` / `commit` / `notify`) |
|
||||
| `roboco-git-readonly` | `roboco_git_status`, `roboco_git_log`, `roboco_git_diff`, `roboco_git_branch_list` |
|
||||
| `roboco-optimal` | `roboco_ask_mentor`, `roboco_kb_search` |
|
||||
|
||||
|
||||
@@ -18,8 +18,7 @@
|
||||
|
||||
- Triage actionable tasks in your scope via `triage()`
|
||||
- Escalate tasks to the CEO via `escalate_to_ceo(task_id, reason)`
|
||||
- Communicate: `say` (channel), `dm` (A2A), `notify` (ack-required signal)
|
||||
- Open strategic sessions via `open_session`
|
||||
- Communicate: `dm` (A2A), `notify` (ack-required signal)
|
||||
- Propose a product via `pitch(title, slug, problem, proposed_solution, target_cells)` — queues for CEO approval, then auto-provisions
|
||||
- Author the weekly roadmap-engine exploration cycle via `propose_roadmap(cycle_goal, items)` — see "Roadmap Engine" below
|
||||
- Read project docs via `roboco_docs_read` / `roboco_docs_list`
|
||||
@@ -39,7 +38,7 @@
|
||||
| MCP server | Verbs you can call |
|
||||
|-----------------------|--------------------|
|
||||
| `roboco-flow` | `triage`, `escalate_to_ceo`, `i_am_idle` |
|
||||
| `roboco-do` | `note`, `pitch`, `propose_roadmap`, `say`, `dm`, `notify`, `evidence`, `open_session` |
|
||||
| `roboco-do` | `note`, `pitch`, `propose_roadmap`, `dm`, `notify`, `evidence` |
|
||||
| `roboco-docs` | `roboco_docs_read`, `roboco_docs_list` |
|
||||
| `roboco-git-readonly` | `roboco_git_status`, `roboco_git_log`, `roboco_git_diff`, `roboco_git_branch_list` |
|
||||
| `roboco-search` | `web_search`, `web_fetch` (only when `ROBOCO_RESEARCH_ENABLED`) |
|
||||
@@ -89,16 +88,13 @@ The CEO acts via the panel/UI; you idle until the CEO decides.
|
||||
|
||||
```python
|
||||
dm(recipient="main-pm", text="Coordinating the roadmap — ...", task_id="...")
|
||||
channels() # discover channels you can post to
|
||||
```
|
||||
|
||||
Skills: requirements_clarification, feature_approval
|
||||
|
||||
## Communication
|
||||
|
||||
Access to:
|
||||
- #main-pm-board
|
||||
- #board-private
|
||||
- #announcements (write)
|
||||
Coordination rides task state, task detail fields, and A2A.
|
||||
|
||||
Can `notify`: Main PM, Head Marketing, Auditor, CEO
|
||||
- `dm`: direct peer-to-peer messages via A2A (see the A2A section above)
|
||||
- Can `notify`: Main PM, Head Marketing, Auditor, CEO
|
||||
|
||||
@@ -31,7 +31,7 @@ It runs in its own `agent-prompter` container as a persistent `ClaudeSDKClient`
|
||||
|
||||
## What You CANNOT Do
|
||||
|
||||
- Talk to any agent — there is no `say`, `dm`, or `notify` (human-only)
|
||||
- Talk to any agent — there is no `dm` or `notify` (human-only)
|
||||
- Call lifecycle verbs (claim, plan, delegate, QA, complete) — you have none
|
||||
- Write code, write project docs, or run any git operation
|
||||
- Use `AskUserQuestion` — just ask inline in the chat; the human reads every message live
|
||||
|
||||
@@ -52,7 +52,7 @@ unclaim(task_id) / resume(task_id) / i_am_idle()
|
||||
| MCP server | Verbs you can call |
|
||||
|-----------------------|--------------------|
|
||||
| `roboco-flow` | `give_me_work`, `claim_review`, `pass`, `fail`, `i_am_blocked`, `unclaim`, `resume`, `i_am_idle` |
|
||||
| `roboco-do` | `note`, `say`, `dm`, `evidence` (no `commit`, no `notify`) |
|
||||
| `roboco-do` | `note`, `dm`, `evidence` (no `commit`, no `notify`) |
|
||||
| `roboco-git-readonly` | `roboco_git_status`, `roboco_git_log`, `roboco_git_diff`, `roboco_git_branch_list` |
|
||||
| `roboco-optimal` | `roboco_ask_mentor`, `roboco_kb_search` |
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ It runs in its own `agent-secretary` container, reusing the Intake chat machiner
|
||||
|
||||
## What You CANNOT Do
|
||||
|
||||
- Talk to agents directly — no `say`, `dm`, or `notify` (human-only). To reach a channel, use `submit_directive(kind="relay_message")`
|
||||
- Talk to agents directly — no `dm` or `notify` (human-only). To reach agents, use `submit_directive(kind="relay_message")` (delivers via notification)
|
||||
- Call lifecycle verbs — you have none
|
||||
- Write code or docs, or run git operations
|
||||
- Fire a high-impact directive without the CEO's confirmation (see the gate)
|
||||
@@ -41,7 +41,7 @@ It runs in its own `agent-secretary` container, reusing the Intake chat machiner
|
||||
|
||||
| Kind | Payload | Confirmation |
|
||||
|------|---------|--------------|
|
||||
| `relay_message` | `channel`, `text` | Runs directly |
|
||||
| `relay_message` | `target`, `text` | Runs directly (delivers via notification) |
|
||||
| `update_charter` | `charter` | Queued for the CEO |
|
||||
| `control_task` | `task_id`, `action` (`start`/`cancel`/`override`/`edit`), `status?` (for `override`), `fields?` (for `edit`) | Queued for the CEO |
|
||||
| `approve_pitch` | `pitch_id`, `notes?` | Queued for the CEO |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# A2A (Agent-to-Agent) Tools
|
||||
|
||||
A2A is direct peer-to-peer messaging between agents. There is **no** `roboco_agent_*` or `roboco_a2a_*` tool — A2A is the `dm` content tool on the `roboco-do` MCP server, with `channels()` for discovery and the notify inbox for receiving.
|
||||
A2A is direct peer-to-peer messaging between agents. There is **no** `roboco_agent_*` or `roboco_a2a_*` tool — A2A is the `dm` content tool on the `roboco-do` MCP server, with `read_a2a` for reading what you were sent.
|
||||
|
||||
## Send a direct message — `dm`
|
||||
|
||||
@@ -16,6 +16,7 @@ dm(
|
||||
- Auto-creates the conversation; auto-resolves the skill if needed.
|
||||
- **Same-cell only.** Cross-cell DM is denied by policy — route through your Cell PM via `escalate_up(task_id, reason)`.
|
||||
- The recipient sees it in their notify inbox when offline.
|
||||
- **Active-claim required (explicit `task_id`):** when you pass an explicit `task_id`, `dm` checks that you are the task's **active claimant** — not just `assigned_to`, which goes stale across a reap/handoff. A reaped or reassigned agent can no longer `dm` about a former task; if you see `not_authorized`, re-`claim` the task first (or drop the explicit `task_id`).
|
||||
|
||||
## Messaging the CEO — `dm(recipient="ceo", ...)`
|
||||
|
||||
@@ -27,18 +28,27 @@ The CEO is a special recipient with an asymmetric rule (`_enforce_ceo_reply_budg
|
||||
|
||||
Both refusals surface as a normal tool error (`A2A_ACCESS_DENIED`) with a `remediate` hint — treat them as "wait for the CEO," not a bug to retry around.
|
||||
|
||||
## Discover who/where to message — `channels`
|
||||
## Discover who to message
|
||||
|
||||
There is no agent-directory tool. Use `channels()` to see the channels you can read/write, and post to a channel when the audience is the whole cell rather than one peer:
|
||||
There is no runtime agent-directory tool. Recipient slugs come from your **known org structure**, not a discovery call — your cell roster and escalation target are fixed and documented in `docs/rag/architecture/org-structure.md` (Cells table) and `docs/rag/architecture/escalation-chain.md`:
|
||||
|
||||
```python
|
||||
channels() # -> {"writable": [...], "readable": [...]}
|
||||
say(channel="backend-cell", text="Anyone hit Y before? Starting task X.")
|
||||
dm(recipient="be-qa", text="Anyone hit Y before? Starting task X.")
|
||||
```
|
||||
|
||||
## Receive incoming messages
|
||||
Same-cell peers (e.g. `be-dev-1` alongside `be-dev-2`/`be-qa`/`be-doc`/`be-pm`) are always valid `dm` targets. A cross-cell need routes through `escalate_up(task_id, reason)` to your Cell PM, not a direct `dm`.
|
||||
|
||||
Incoming A2A and @mentions land in your notify inbox. When `i_am_idle()` soft-blocks on unread items, drain the inbox:
|
||||
## Receive incoming messages — `read_a2a`
|
||||
|
||||
When another agent messages you, your claim briefing surfaces it under `unread_a2a` — each entry shows the sender and a preview of their latest incoming message. To read the full bodies (and clear them), call:
|
||||
|
||||
```python
|
||||
read_a2a() # -> {"messages": [{from_agent, content, created_at}, ...]}
|
||||
```
|
||||
|
||||
`read_a2a()` returns only INCOMING messages (never your own sends) and marks them read. `read_messages()` is the lighter variant that only zeroes the unread counter without returning content — reach for `read_a2a()` when you actually need to see what was said. Either clears `i_am_idle()`'s unread-A2A soft-block.
|
||||
|
||||
Formal, ack-required notifications are a separate inbox — see `docs/rag/tools/messaging-tools.md`:
|
||||
|
||||
```python
|
||||
notify_list(unread_only=True) # list pending items
|
||||
@@ -58,4 +68,4 @@ notify_ack(notification_id) # acknowledge after handling
|
||||
| Cross-cell question | `escalate_up(task_id, reason)` — DM is same-cell only |
|
||||
| New work / subtask | Only PMs create work, via `delegate(...)`; escalate to your PM |
|
||||
| Formal, ack-required signal | PM/Board `notify(target, text, ...)` |
|
||||
| Cell-wide broadcast | `say(channel=..., text=...)` |
|
||||
| Cell-wide FYI | No broadcast mechanism — `dm` the specific peer(s) who need it, or record it in the task's notes/progress update |
|
||||
|
||||
@@ -58,15 +58,6 @@ Base URL: `http://{host}:{port}/api/v1`
|
||||
| POST | `/git/pr/create` | Create PR |
|
||||
| POST | `/git/pr/merge` | Merge PR |
|
||||
|
||||
## Channels & Messages
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/channels` | List channels |
|
||||
| GET | `/channels/{slug}/history` | Channel history |
|
||||
| POST | `/messages` | Send message |
|
||||
| GET | `/messages/{id}` | Get message |
|
||||
|
||||
## Notifications
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
@@ -91,7 +82,7 @@ Base URL: `http://{host}:{port}/api/v1`
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/system/rate-limits` | Active per-provider rate-limit state (`{ entries: [...] }`) |
|
||||
| WS | `/ws/system` | Operator stream — rate-limit lifecycle (`RATE_LIMIT_HIT` / `RATE_LIMIT_LIFTED`) and live usage (`USAGE_SNAPSHOT`) pushed to the usage dashboard |
|
||||
| WS | `/ws/agents/{id}`, `/ws/channels/{id}`, `/ws/sessions/{id}`, `/ws/notifications/{id}` | Per-resource live streams |
|
||||
| WS | `/ws/agents/{id}`, `/ws/notifications/{id}` | Per-resource live streams |
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
@@ -1,40 +1,10 @@
|
||||
# Messaging Tools
|
||||
# Notification Tools
|
||||
|
||||
There is **no** `roboco_message_*`, `roboco_notify_send`, or `roboco_session_*` tool. Messaging is a small set of **content tools** on the `roboco-do` MCP server. They are role-scoped at spawn time.
|
||||
|
||||
## Channel post — `say`
|
||||
|
||||
```python
|
||||
say(channel="backend-cell", text="Starting work on rate limiting", task_id=task_id)
|
||||
```
|
||||
|
||||
- `channel` is the slug WITHOUT a leading `#`.
|
||||
- `task_id` is auto-filled from your active task if omitted.
|
||||
- Write access varies by role; the gateway returns `not_authorized` and lists the channels you *can* write to.
|
||||
|
||||
Don't invent channel slugs. Call `channels()` first if unsure:
|
||||
|
||||
```python
|
||||
channels() # -> {"writable": [...], "readable": [...]}
|
||||
```
|
||||
|
||||
**Active-claim required (explicit `task_id`):** when you pass an explicit `task_id`, `say` / `dm` / `note` check that you are the task's **active claimant** — not just `assigned_to`, which goes stale across a reap/handoff. A reaped or reassigned agent can no longer post to a former task; if you see `not_authorized` on a content post, re-`claim` the task first (or drop the explicit `task_id` for a general channel post).
|
||||
|
||||
Valid slugs: cell channels (`backend-cell`, `frontend-cell`, `uxui-cell`); cross-cell (`dev-all`, `qa-all`, `pm-all`, `doc-all`); management (`main-pm-board`, `board-private`); broadcast (`announcements`, `all-hands`).
|
||||
|
||||
## Direct message (A2A) — `dm`
|
||||
|
||||
```python
|
||||
dm(recipient="be-qa", text="Quick sanity check: ...", task_id=task_id)
|
||||
```
|
||||
|
||||
- `recipient` is an agent slug (`be-pm`, `be-dev-1`, `ceo`, ...).
|
||||
- Auto-creates the conversation; `task_id` auto-fills from your active task.
|
||||
- Same-cell only. Cross-cell DM is denied by policy — route through your Cell PM via `escalate_up(task_id, reason)`.
|
||||
There is **no** `roboco_message_*`, `roboco_notify_send`, or `roboco_session_*` tool. Formal notifications are a small set of **content tools** on the `roboco-do` MCP server, role-scoped at spawn time. For agent-to-agent messaging (`dm`, `read_a2a`), see `docs/rag/tools/a2a-tools.md`.
|
||||
|
||||
## Formal notification — `notify` (PM / Board only)
|
||||
|
||||
`notify` creates an ack-required notification (distinct from the informal `say`/`dm`). Only PM roles and the Board may send it; devs / QA / docs use `say` and `dm`.
|
||||
`notify` creates an ack-required notification (distinct from the informal `dm`). Only PM roles and the Board may send it; devs / QA / docs reach peers via `dm` and use the inbox tools below to receive.
|
||||
|
||||
```python
|
||||
notify(target="be-dev-1", text="Task ready for you", priority="normal", task_id=task_id)
|
||||
@@ -54,16 +24,4 @@ notify_get(notification_id) # read one (marks it read)
|
||||
notify_ack(notification_id) # acknowledge after handling
|
||||
```
|
||||
|
||||
When `i_am_idle()` reports unread A2A or @mentions, list -> get -> ack, then idle again. (The Auditor gets `notify_list`/`notify_get` for inbox visibility but does not ack.)
|
||||
|
||||
## Sessions (PM-or-up only)
|
||||
|
||||
Devs / QA / docs participate via channels and DMs and do **not** open sessions. PMs and the Board link discussion threads to tasks:
|
||||
|
||||
```python
|
||||
open_session(task_id, channel="backend-cell", topic="Feature X kickoff",
|
||||
relationship_type="discussion")
|
||||
link_session(session_id, task_id, is_primary=False)
|
||||
```
|
||||
|
||||
`relationship_type` is `discussion | planning | review | retrospective`. `link_session` is idempotent; you must own the task you're linking.
|
||||
When `i_am_idle()` reports unread A2A or @mentions, clear A2A with `read_a2a()` (see `a2a-tools.md`) and clear notifications with list -> get -> ack, then idle again. (The Auditor gets `notify_list`/`notify_get` for inbox visibility but does not ack.)
|
||||
|
||||
@@ -34,7 +34,7 @@ Only works with providers that support content extraction (Tavily, Exa) — the
|
||||
|
||||
Web research is external, unverified-by-the-org information — treat it accordingly:
|
||||
|
||||
1. **Always cite the URL** for any fact you rely on in a decision, a `delegate` description, or a `dm`/`say` message.
|
||||
1. **Always cite the URL** for any fact you rely on in a decision, a `delegate` description, or a `dm` message.
|
||||
2. **Persist key findings** with `note(scope="reflect", ...)` so the source survives beyond your own context window and the team keeps it, not just you.
|
||||
3. Do not treat a search `answer` or a fetched page as ground truth about RoboCo itself — it is about the outside world (competitors, libraries, market trends), not this codebase.
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ triage() # read-only list of actionable tasks
|
||||
i_am_idle()
|
||||
```
|
||||
|
||||
The Auditor is a silent observer: read-only `triage`, no `say`/`dm`/ `notify`, no claim/complete/cancel.
|
||||
The Auditor is a silent observer: read-only `triage`, no `dm`/`notify`, no claim/complete/cancel.
|
||||
|
||||
## PR Reviewer flow
|
||||
|
||||
@@ -120,7 +120,7 @@ unclaim(task_id) # release a claimed inbound or gate review back
|
||||
i_am_idle()
|
||||
```
|
||||
|
||||
The PR Reviewer reviews inbound external/fork (and, behind a flag, internal) PRs the org did not open. It is read-only: no `commit`/`open_pr`/`merge`, no `say`/`dm` — the change-request is posted server-side on the PR itself, and the CEO decides Supersede/Dismiss from the PR Review Queue.
|
||||
The PR Reviewer reviews inbound external/fork (and, behind a flag, internal) PRs the org did not open. It is read-only: no `commit`/`open_pr`/`merge`, no `dm` — the change-request is posted server-side on the PR itself, and the CEO decides Supersede/Dismiss from the PR Review Queue.
|
||||
|
||||
The same role also runs the **in-path PR-review gate** on the org's own assembled delivery PRs — the merge-level review before the PM merges:
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ The guard never returns which rule or signature matched, by design, so the 400/4
|
||||
|
||||
## What Gets Flagged
|
||||
|
||||
Generic WAF signatures (SQL injection, XSS, path traversal, suspicious URL patterns) are excluded from scanning on the free-text body fields of `note` / `commit` / `say` / `dm`, so normal code, SQL, diffs, file paths, and URLs in those bodies are safe from that layer. Three custom validators scan those same bodies regardless of that exclusion:
|
||||
Generic WAF signatures (SQL injection, XSS, path traversal, suspicious URL patterns) are excluded from scanning on the free-text body fields of `note` / `commit` / `dm`, so normal code, SQL, diffs, file paths, and URLs in those bodies are safe from that layer. Three custom validators scan those same bodies regardless of that exclusion:
|
||||
|
||||
- Prompt-injection detection
|
||||
- Secret-exfil detection — literal credential-shaped strings (`sk-ant-...`, `ghp_...`, postgres connection URLs) or phrasing like "reveal your api keys"
|
||||
@@ -20,7 +20,7 @@ Generic WAF signatures (SQL injection, XSS, path traversal, suspicious URL patte
|
||||
|
||||
## Solution: Hygiene Rules
|
||||
|
||||
Follow these when composing `note` / `commit` / `say` / `dm` bodies or any fetch-type payload, regardless of whether enforcement is currently active:
|
||||
Follow these when composing `note` / `commit` / `dm` bodies or any fetch-type payload, regardless of whether enforcement is currently active:
|
||||
|
||||
1. Never paste real secrets or credentials (API keys, tokens, DB connection strings) into a request body, even inside a code snippet or diff.
|
||||
2. Never aim a fetch/HTTP-call body at an internal service host (`roboco-*`) or a cloud metadata endpoint (`169.254.169.254`).
|
||||
|
||||
@@ -86,7 +86,6 @@ Fix all issues before submitting.
|
||||
- Read `quick_context` field on task
|
||||
- Read your journal for this task
|
||||
- Get proactive context: `roboco_get_proactive_context(task_id)`
|
||||
- Read channel history for discussions
|
||||
|
||||
## Documentation Path Confusion
|
||||
|
||||
@@ -127,13 +126,12 @@ roboco_docs_write({
|
||||
|
||||
**Check**:
|
||||
1. Is the recipient in your **own cell**? Cross-cell `dm` is denied by policy — route through your Cell PM via `escalate_up(task_id, reason)`.
|
||||
2. Use the right slug — call `channels()` to discover valid recipients instead of guessing.
|
||||
2. Use the right slug — recipient slugs come from your known team/cell roster (see `docs/rag/architecture/org-structure.md`'s Cells table), not a runtime discovery call.
|
||||
3. Did you include `task_id`? It anchors the message to the work.
|
||||
|
||||
**Solutions**:
|
||||
- Same-cell peer: `dm(recipient="be-qa", text="...", task_id="...")`
|
||||
- Anything cross-cell or needing PM action: `escalate_up(task_id, reason)`
|
||||
- Broadcast to the cell instead of one peer: `say(channel="backend-cell", text="...")`
|
||||
|
||||
## Cross-Cell Message Denied
|
||||
|
||||
|
||||
@@ -2,17 +2,16 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Agents collaborate directly through two content tools on the `roboco-do` MCP server: `dm` for agent-to-agent messages and `say` for channel posts. Use `channels()` to discover the channels you can post to.
|
||||
Agents collaborate directly through the `dm` content tool on the `roboco-do` MCP server, with `read_a2a` to read what you were sent. There is no `roboco_agent_*` or `roboco_a2a_*` tool — A2A is just `dm` + `read_a2a`.
|
||||
|
||||
**Key:** A2A is about *existing* tasks, NOT task creation. Pass the `task_id` you're collaborating on so the message is linked to it.
|
||||
|
||||
## Flow
|
||||
|
||||
```
|
||||
1. Discover → channels() lists the channels visible to you
|
||||
2. Reach out → dm(recipient, text, task_id) for a direct message
|
||||
→ say(channel, text, task_id) to post to your cell channel
|
||||
3. Receive → notify_list() / notify_get(id) to read your inbox
|
||||
1. Reach out → dm(recipient, text, task_id) for a direct message
|
||||
2. Receive → read_a2a() to read incoming A2A message bodies
|
||||
→ notify_list() / notify_get(id) to read your notify inbox
|
||||
```
|
||||
|
||||
## Direct Messages (same cell only)
|
||||
@@ -32,22 +31,19 @@ Cross-cell `dm` is **denied by policy**. If you need something from another cell
|
||||
|
||||
`dm(recipient="ceo", ...)` follows a different rule than same-cell DM: you can never *open* a CEO conversation (only reply inside one the CEO already started), and once it's open you get at most one reply per CEO message before you must wait for the CEO to post again. See `docs/rag/tools/a2a-tools.md` for the full contract and the exact refusal messages.
|
||||
|
||||
## Channel Posts
|
||||
## Receiving Messages — `read_a2a`
|
||||
|
||||
Your claim briefing surfaces incoming A2A under `unread_a2a` — each entry shows the sender and a preview of their latest message. To read the full bodies (and clear them):
|
||||
|
||||
```python
|
||||
# Visible to your whole cell
|
||||
say(
|
||||
channel="backend-cell",
|
||||
text="Started on <task> — anyone hit the Redis failover path before?",
|
||||
task_id="<task>",
|
||||
)
|
||||
read_a2a() # -> {"messages": [{from_agent, content, created_at}, ...]}
|
||||
```
|
||||
|
||||
Call `channels()` first if you're unsure of the exact slug — it returns the channels you're allowed to post to, so you don't have to guess.
|
||||
`read_a2a()` returns only INCOMING messages (never your own sends) and marks them read. It also clears `i_am_idle()`'s unread-A2A soft-block.
|
||||
|
||||
## Task Creation Rules
|
||||
|
||||
**Only PMs create tasks** (via the `delegate` verb). Regular agents cannot create work from a `dm` or `say`.
|
||||
**Only PMs create tasks** (via the `delegate` verb). Regular agents cannot create work from a `dm`.
|
||||
|
||||
If a conversation surfaces work that needs a new task:
|
||||
1. Escalate to your Cell PM: `escalate_up(task_id, reason="Needs a subtask for X")`
|
||||
@@ -55,8 +51,8 @@ If a conversation surfaces work that needs a new task:
|
||||
|
||||
## Permissions
|
||||
|
||||
Most roles can `dm` (same-cell) and `say` to their channels, plus read their inbox with `notify_list` / `notify_get`.
|
||||
Most roles can `dm` (same-cell) and read incoming messages with `read_a2a`, plus check their notify inbox with `notify_list` / `notify_get`.
|
||||
|
||||
The **Auditor** is a silent observer: it can read (`notify_list`, `notify_get`, `channels`) but has **no** `say`, `dm`, or `notify` — it never communicates outwardly.
|
||||
The **Auditor** is a silent observer: it can read (`notify_list`, `notify_get`) but has **no** `dm` or `notify` — it never communicates outwardly.
|
||||
|
||||
Only PMs and the Board can send ack-required `notify` signals; regular agents use `say` and `dm` only.
|
||||
Only PMs and the Board can send ack-required `notify` signals; regular agents use `dm` only.
|
||||
|
||||
@@ -94,9 +94,9 @@ Include:
|
||||
## Handling Escalations (PM)
|
||||
|
||||
1. ACK the notification: `notify_ack(notification_id)`
|
||||
2. Investigate: read the task, journals, and channel messages
|
||||
2. Investigate: read the task and journals
|
||||
3. Decide, or escalate further with `escalate_up`
|
||||
4. Communicate the decision (`say` / `dm` / `notify`)
|
||||
4. Communicate the decision (`dm` / `notify`)
|
||||
5. Unblock if needed: `unblock(task_id, reason)`
|
||||
|
||||
CRITICAL: Verbal resolution is NOT enough. To clear a block you MUST call `unblock(task_id, reason)`. The `reason` (why you are clearing the block) is recorded as your `journal:decision` — no separate `note(scope='decision')` call is required.
|
||||
|
||||
@@ -11,7 +11,6 @@ Commits use conventional format with traceability:
|
||||
Task: {task-id}
|
||||
Root: {root-task-id}
|
||||
Agent: {agent-slug}
|
||||
Session: {session-id}
|
||||
|
||||
Links:
|
||||
- Task: {api}/tasks/{task-id}
|
||||
|
||||
@@ -71,7 +71,6 @@ roboco_ask_mentor(
|
||||
|------|---------|
|
||||
| `code` | Source files |
|
||||
| `docs` | Documentation |
|
||||
| `conversations` | Channel discussions |
|
||||
| `journals` | Agent journal entries |
|
||||
| `errors` | Error patterns & fixes |
|
||||
| `standards` | Coding rules |
|
||||
|
||||
@@ -17,21 +17,15 @@ give_me_work()
|
||||
# guard at pass/fail time)
|
||||
claim_review(task_id="<task>")
|
||||
|
||||
# 3. Announce to your cell channel (optional, but helpful when QA pulls
|
||||
# are slow)
|
||||
say(channel="backend-cell",
|
||||
text="Starting QA review of <task title>",
|
||||
task_id="<task>")
|
||||
|
||||
# 4. Inspect the diff
|
||||
# 3. Inspect the diff
|
||||
roboco_git_diff(project_slug="roboco")
|
||||
roboco_git_log(project_slug="roboco", branch="<dev's branch>")
|
||||
|
||||
# 5. Run the relevant suite
|
||||
# 4. Run the relevant suite
|
||||
# Backend: uv run pytest && uv run ruff check . && uv run mypy roboco/
|
||||
# Frontend: pnpm test && pnpm lint && pnpm typecheck
|
||||
|
||||
# 6. Capture evidence (survives compaction; PMs can audit later)
|
||||
# 5. Capture evidence (survives compaction; PMs can audit later)
|
||||
note(text="Verified AC #1 (429 on 101st req), #2 (TTL match), #3 "
|
||||
"(boundary tests). pytest 1635 passed; ruff clean; mypy clean.",
|
||||
scope="evidence",
|
||||
|
||||
@@ -37,9 +37,8 @@ Exactly one active WorkSession exists per task at a time (enforced in the servic
|
||||
|
||||
## After Claiming
|
||||
|
||||
1. Announce to your cell: `say(channel="backend-cell", text="...", task_id=task_id)`
|
||||
2. Get proactive context: `roboco_get_proactive_context(task_id)`
|
||||
3. Search the KB for similar work: `roboco_kb_search(query="...")`
|
||||
1. Get proactive context: `roboco_get_proactive_context(task_id)`
|
||||
2. Search the KB for similar work: `roboco_kb_search(query="...")`
|
||||
|
||||
## Claiming Rules
|
||||
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { Session } from "@/types";
|
||||
|
||||
// Bundle D / defect 3: posting to a CLOSED session silently redirects the
|
||||
// message to a fresh active session, so it vanishes from the closed-session
|
||||
// view the user is looking at. The page must guard the composer when the
|
||||
// session is not active and tell the user why.
|
||||
|
||||
const { useSession, useSessionMessages, messageKeys, sessionKeys } = vi.hoisted(
|
||||
() => ({
|
||||
useSession: vi.fn(),
|
||||
useSessionMessages: vi.fn(),
|
||||
messageKeys: { list: (id: string) => ["messages", "list", id] },
|
||||
sessionKeys: { detail: (id: string) => ["sessions", "detail", id] },
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useParams: () => ({ sessionId: "s1" }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-channels", () => ({
|
||||
useSession,
|
||||
useSessionMessages,
|
||||
messageKeys,
|
||||
sessionKeys,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-websocket", () => ({
|
||||
useSessionStream: () => ({ lastMessage: null }),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
|
||||
return {
|
||||
...actual,
|
||||
useMutation: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
||||
useQueryClient: vi.fn(() => ({ invalidateQueries: vi.fn() })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/api/messages", () => ({ messagesApi: { send: vi.fn() } }));
|
||||
|
||||
import SessionDetailPage from "../page";
|
||||
|
||||
function buildSession(status: string): Session {
|
||||
return {
|
||||
id: "s1",
|
||||
group_id: "g1",
|
||||
status: status as never,
|
||||
scope: "cell" as never,
|
||||
message_count: 2,
|
||||
total_content_length: 10,
|
||||
started_at: "2026-06-30T00:00:00Z",
|
||||
last_activity_at: "2026-06-30T00:00:00Z",
|
||||
closed_at: status === "closed" ? "2026-06-30T01:00:00Z" : null,
|
||||
task_links: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("SessionDetailPage — closed-session composer guard", () => {
|
||||
beforeEach(() => {
|
||||
useSession.mockReset();
|
||||
useSessionMessages.mockReset();
|
||||
useSessionMessages.mockReturnValue({
|
||||
data: { items: [] },
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a closed-session notice instead of the composer when closed", () => {
|
||||
useSession.mockReturnValue({
|
||||
data: buildSession("closed"),
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(<SessionDetailPage />);
|
||||
expect(screen.getByText(/session is closed/i)).toBeInTheDocument();
|
||||
// The message textarea must not be available for a closed session.
|
||||
expect(
|
||||
screen.queryByPlaceholderText(/type a message/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the composer for an active session", () => {
|
||||
useSession.mockReturnValue({
|
||||
data: buildSession("active"),
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(<SessionDetailPage />);
|
||||
expect(screen.getByPlaceholderText(/type a message/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,364 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useSession,
|
||||
useSessionMessages,
|
||||
messageKeys,
|
||||
sessionKeys,
|
||||
} from "@/hooks/use-channels";
|
||||
import { useSessionStream } from "@/hooks/use-websocket";
|
||||
import { messagesApi } from "@/lib/api/messages";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { MessageComposer } from "@/components/communications/message-composer";
|
||||
import { MessageTypeBadge } from "@/components/communications/message-type-badge";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import { getAgentDisplayName, getAgentInitials } from "@/lib/agent-utils";
|
||||
import {
|
||||
ArrowLeft,
|
||||
MessageSquare,
|
||||
ListTodo,
|
||||
Clock,
|
||||
Hash,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { CopyButton } from "@/components/ui/copy-button";
|
||||
import { formatDistanceToNow, format } from "date-fns";
|
||||
import { toast } from "sonner";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
|
||||
function SessionDetailContent() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const sessionId = params.sessionId as string;
|
||||
|
||||
// Read navigation context from URL params
|
||||
const channelId = searchParams.get("channel");
|
||||
const groupId = searchParams.get("group");
|
||||
|
||||
// Build back URL preserving context
|
||||
const backUrl =
|
||||
channelId && groupId
|
||||
? `/communications?channel=${channelId}&group=${groupId}`
|
||||
: "/communications";
|
||||
const queryClient = useQueryClient();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fetch session details and messages.
|
||||
//
|
||||
// The message query loads the transcript once and then holds it (staleTime
|
||||
// Infinity, no focus/reconnect refetch), so an OPEN session is read exactly
|
||||
// once and a CLOSED session's immutable transcript stays loaded for review.
|
||||
// The hooks treat a 404 (reaped session) as terminal and never retry it, which
|
||||
// is what stops the panel from accumulating a 404 storm across every dead
|
||||
// session it has opened. `refetchMessages` (the manual Refresh button) stays
|
||||
// available for live sessions.
|
||||
const {
|
||||
data: session,
|
||||
isLoading: loadingSession,
|
||||
refetch: refetchSession,
|
||||
} = useSession(sessionId);
|
||||
const {
|
||||
data: messagesData,
|
||||
isLoading: loadingMessages,
|
||||
refetch: refetchMessages,
|
||||
} = useSessionMessages(sessionId);
|
||||
|
||||
// Live updates: subscribe to the session stream. On a new persisted message
|
||||
// (MESSAGE_SENT → bridge → /ws/sessions/{id}) invalidate the transcript +
|
||||
// session-detail queries so the held (staleTime Infinity) views refresh
|
||||
// without the manual Refresh button.
|
||||
const { lastMessage } = useSessionStream(sessionId);
|
||||
useEffect(() => {
|
||||
if (lastMessage?.type !== "message.new") return;
|
||||
queryClient.invalidateQueries({ queryKey: messageKeys.list(sessionId) });
|
||||
queryClient.invalidateQueries({ queryKey: sessionKeys.detail(sessionId) });
|
||||
}, [lastMessage, queryClient, sessionId]);
|
||||
|
||||
// Sort messages chronologically (oldest first for chat UI)
|
||||
const messages = [...(messagesData?.items || [])].sort(
|
||||
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
);
|
||||
|
||||
// Track if we've done the initial scroll
|
||||
const hasScrolledRef = useRef(false);
|
||||
|
||||
// Auto-scroll to bottom only once on initial load
|
||||
useEffect(() => {
|
||||
if (scrollRef.current && messages.length > 0 && !hasScrolledRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
hasScrolledRef.current = true;
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
// Send message mutation
|
||||
const sendMessage = useMutation({
|
||||
mutationFn: async ({
|
||||
content,
|
||||
type,
|
||||
}: {
|
||||
content: string;
|
||||
type: string;
|
||||
}) => {
|
||||
return messagesApi.send(sessionId, content, type);
|
||||
},
|
||||
onSuccess: (sent) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["messages", "list", sessionId],
|
||||
});
|
||||
// If the session closed between load and send, the server redirects the
|
||||
// message to a fresh active session — tell the user it landed elsewhere
|
||||
// instead of letting it appear to vanish from this transcript.
|
||||
if (sent?.session_id && sent.session_id !== sessionId) {
|
||||
toast.warning(
|
||||
"This session had closed — your message was posted to the active session.",
|
||||
);
|
||||
} else {
|
||||
toast.success("Message sent");
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error("Failed to send message: " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSendMessage = (message: { content: string; type: string }) => {
|
||||
sendMessage.mutate(message);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetchSession();
|
||||
refetchMessages();
|
||||
};
|
||||
|
||||
// Get primary task
|
||||
const primaryTask =
|
||||
session?.task_links?.find((t) => t.is_primary) || session?.task_links?.[0];
|
||||
|
||||
if (loadingSession) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link href={backUrl} prefetch={false}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Communications
|
||||
</Button>
|
||||
</Link>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-12">
|
||||
<MessageSquare className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
|
||||
<h3 className="text-lg font-medium mb-2">Session Not Found</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The session you're looking for doesn't exist or has
|
||||
been deleted.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100dvh-7rem)]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={backUrl} prefetch={false}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<MessageSquare className="h-6 w-6" />
|
||||
Session {sessionId.slice(0, 8)}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Started {formatDistanceToNow(new Date(session.started_at))} ago
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleRefresh}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Session Info Bar */}
|
||||
<Card className="mb-4 shrink-0">
|
||||
<CardContent className="py-3">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<Badge
|
||||
variant={session.status === "active" ? "default" : "secondary"}
|
||||
>
|
||||
{session.status}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
{session.message_count} messages
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
{format(new Date(session.started_at), "MMM d, yyyy h:mm a")}
|
||||
</div>
|
||||
{session.closed_at && (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
Closed:{" "}
|
||||
{format(new Date(session.closed_at), "MMM d, yyyy h:mm a")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Linked Tasks */}
|
||||
{session.task_links && session.task_links.length > 0 && (
|
||||
<>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<ListTodo className="h-4 w-4 text-muted-foreground" />
|
||||
{primaryTask && (
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/tasks/${primaryTask.task_id}`}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{primaryTask.task_title ||
|
||||
`Task ${primaryTask.task_id.slice(0, 8)}`}
|
||||
</Link>
|
||||
)}
|
||||
{session.task_links.length > 1 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{session.task_links.length - 1} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Messages Area */}
|
||||
<Card className="flex-1 flex flex-col min-h-0">
|
||||
<CardHeader className="pb-2 shrink-0">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Hash className="h-4 w-4" />
|
||||
Messages
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col p-0 min-h-0">
|
||||
{/* Messages List */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4">
|
||||
{loadingMessages ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="flex-1">
|
||||
<Skeleton className="h-4 w-32 mb-2" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<MessageSquare className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No messages in this session</p>
|
||||
<p className="text-sm">
|
||||
Use the composer below to start the conversation
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className="group relative flex gap-3 p-3 rounded-lg border bg-card hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div className="h-9 w-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0 border">
|
||||
<span className="text-[10px] font-bold tracking-tight">
|
||||
{getAgentInitials(message.agent_id)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="font-semibold text-sm">
|
||||
{getAgentDisplayName(message.agent_id)}
|
||||
</span>
|
||||
<MessageTypeBadge type={message.type} />
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{formatDistanceToNow(new Date(message.timestamp))} ago
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm prose prose-sm dark:prose-invert max-w-none">
|
||||
<Markdown>{message.content}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
{/* Copy button — visible on hover */}
|
||||
<CopyButton
|
||||
value={message.content}
|
||||
className="absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message Composer. A closed session is read-only: posting to it
|
||||
would silently redirect the message to a fresh active session
|
||||
(server-side get-or-create), making it vanish from this view —
|
||||
so guard the composer and say why. */}
|
||||
<div className="shrink-0 border-t">
|
||||
{session.status === "active" ? (
|
||||
<MessageComposer
|
||||
channelId={sessionId}
|
||||
onSend={handleSendMessage}
|
||||
isSending={sendMessage.isPending}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
This session is closed. New messages can't be posted here.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap in Suspense for useSearchParams
|
||||
export default function SessionDetailPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SessionDetailContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,507 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useCallback } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import {
|
||||
useChannels,
|
||||
useChannelGroups,
|
||||
useGroupSessions,
|
||||
} from "@/hooks/use-channels";
|
||||
import type { Channel } from "@/types";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { OfflineState } from "@/components/ui/offline-state";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Hash,
|
||||
Lock,
|
||||
Users,
|
||||
MessageSquare,
|
||||
RefreshCw,
|
||||
Folder,
|
||||
MessageCircle,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import Link from "next/link";
|
||||
|
||||
// =============================================================================
|
||||
// Channel List Panel
|
||||
// =============================================================================
|
||||
|
||||
interface ChannelListProps {
|
||||
channels: Channel[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function ChannelList({
|
||||
channels,
|
||||
selectedId,
|
||||
onSelect,
|
||||
isLoading,
|
||||
}: ChannelListProps) {
|
||||
const cellChannels = channels.filter((c) => c.type === "cell");
|
||||
const crossCellChannels = channels.filter((c) => c.type === "cross_cell");
|
||||
const managementChannels = channels.filter((c) => c.type === "management");
|
||||
const otherChannels = channels.filter(
|
||||
(c) => !["cell", "cross_cell", "management"].includes(c.type),
|
||||
);
|
||||
|
||||
const renderGroup = (title: string, items: Channel[]) => {
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 px-2">
|
||||
{title}
|
||||
</h4>
|
||||
{items.map((channel) => (
|
||||
<Button
|
||||
key={channel.id}
|
||||
onClick={() => onSelect(channel.id)}
|
||||
variant="ghost"
|
||||
className={
|
||||
"w-full h-auto justify-start gap-2 px-3 py-2 text-sm font-normal whitespace-normal " +
|
||||
(selectedId === channel.id
|
||||
? "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground"
|
||||
: "hover:bg-muted")
|
||||
}
|
||||
>
|
||||
{channel.is_private ? (
|
||||
<Lock className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<Hash className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{channel.name}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-2 space-y-2">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2">
|
||||
{renderGroup("Cell Channels", cellChannels)}
|
||||
{renderGroup("Cross-Cell", crossCellChannels)}
|
||||
{renderGroup("Management", managementChannels)}
|
||||
{renderGroup("Other", otherChannels)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Group List Panel
|
||||
// =============================================================================
|
||||
|
||||
interface GroupListProps {
|
||||
channelId: string;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
function GroupList({ channelId, selectedId, onSelect }: GroupListProps) {
|
||||
const { data: groups, isLoading } = useChannelGroups(channelId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-2 space-y-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!groups || groups.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center p-4">
|
||||
<Folder className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No groups in this channel</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2 space-y-1">
|
||||
{groups.map((group) => (
|
||||
<Button
|
||||
key={group.id}
|
||||
onClick={() => onSelect(group.id)}
|
||||
variant="ghost"
|
||||
className={
|
||||
"w-full h-auto justify-between px-3 py-2 text-sm font-normal whitespace-normal " +
|
||||
(selectedId === group.id
|
||||
? "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground"
|
||||
: "hover:bg-muted")
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Users className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{group.name}</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs shrink-0 ml-2">
|
||||
{group.total_messages}
|
||||
</Badge>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Session List Panel
|
||||
// =============================================================================
|
||||
|
||||
interface SessionListProps {
|
||||
channelId: string;
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
function SessionList({ channelId, groupId }: SessionListProps) {
|
||||
const { data: sessions, isLoading } = useGroupSessions(groupId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-2 space-y-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!sessions || sessions.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center p-4">
|
||||
<MessageCircle className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No sessions in this group</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2 space-y-2">
|
||||
{sessions.map((session) => (
|
||||
<Link
|
||||
prefetch={false}
|
||||
key={session.id}
|
||||
href={`/communications/${session.id}?channel=${channelId}&group=${groupId}`}
|
||||
className="block p-3 rounded-lg border bg-card hover:bg-muted/50 hover:border-primary/50 transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-sm truncate">
|
||||
{session.task_links?.length > 0 ? (
|
||||
<>
|
||||
{session.task_links.find((l) => l.is_primary)
|
||||
?.task_title ||
|
||||
session.task_links[0]?.task_title ||
|
||||
`Task ${session.task_links[0]?.task_id.slice(0, 8)}`}
|
||||
</>
|
||||
) : (
|
||||
`Session ${session.id.slice(0, 8)}`
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{formatDistanceToNow(new Date(session.started_at))} ago
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
<Badge
|
||||
variant={
|
||||
session.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{session.status}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{session.message_count} msgs
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Empty State Components
|
||||
// =============================================================================
|
||||
|
||||
function EmptyPanel({
|
||||
icon: Icon,
|
||||
message,
|
||||
}: {
|
||||
icon: typeof MessageSquare;
|
||||
message: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center p-4">
|
||||
<Icon className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Page
|
||||
// =============================================================================
|
||||
|
||||
function CommunicationsPageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const channelId = searchParams.get("channel");
|
||||
const groupId = searchParams.get("group");
|
||||
|
||||
const { data: channels, isLoading, error, refetch } = useChannels();
|
||||
|
||||
const isOffline =
|
||||
error &&
|
||||
(error.message?.includes("Network Error") ||
|
||||
(error as { code?: string })?.code === "ERR_NETWORK");
|
||||
|
||||
const updateParams = useCallback(
|
||||
(updates: Record<string, string | null>) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
});
|
||||
const query = params.toString();
|
||||
router.push(query ? `/communications?${query}` : "/communications");
|
||||
},
|
||||
[router, searchParams],
|
||||
);
|
||||
|
||||
const handleSelectChannel = useCallback(
|
||||
(id: string) => {
|
||||
updateParams({ channel: id, group: null });
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const handleSelectGroup = useCallback(
|
||||
(id: string) => {
|
||||
updateParams({ group: id });
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
// Below `lg` only one pane is shown at a time (list -> detail drill-down);
|
||||
// at `lg`+ all three always show side by side (mobilePane classes below
|
||||
// are overridden by their own `lg:flex`).
|
||||
const handleBack = useCallback(() => {
|
||||
if (groupId) {
|
||||
updateParams({ group: null });
|
||||
} else if (channelId) {
|
||||
updateParams({ channel: null, group: null });
|
||||
}
|
||||
}, [channelId, groupId, updateParams]);
|
||||
|
||||
const selectedChannel = channels?.find((c) => c.id === channelId);
|
||||
|
||||
const showChannelsPane = !channelId;
|
||||
const showGroupsPane = !!channelId && !groupId;
|
||||
const showSessionsPane = !!channelId && !!groupId;
|
||||
|
||||
return (
|
||||
// h-dvh (not h-vh): mobile Safari's dynamic toolbar resizes the viewport,
|
||||
// and this height is unconditional now (not just lg:+) so the single
|
||||
// visible mobile pane also gets a real height for its ScrollArea.
|
||||
<div className="flex flex-col h-[calc(100dvh-7rem)]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Communications</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Browse channels, groups, and sessions
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => refetch()}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isOffline ? (
|
||||
<OfflineState
|
||||
title="Cannot Load Channels"
|
||||
description="Start the RoboCo orchestrator to view communications."
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile-only back affordance — drills back up one level. */}
|
||||
{channelId && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mb-2 w-fit shrink-0 lg:hidden"
|
||||
onClick={handleBack}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="grid flex-1 min-h-0 grid-cols-12 gap-4 lg:gap-6">
|
||||
{/* Panel 1: Channels */}
|
||||
<Card
|
||||
className={cn(
|
||||
"col-span-12 flex-col overflow-hidden lg:col-span-3 lg:flex",
|
||||
showChannelsPane ? "flex" : "hidden",
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-3 flex flex-col h-full">
|
||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
|
||||
<Hash className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Channels</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden -mx-3">
|
||||
<ChannelList
|
||||
channels={channels || []}
|
||||
selectedId={channelId}
|
||||
onSelect={handleSelectChannel}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Panel 2: Groups */}
|
||||
<Card
|
||||
className={cn(
|
||||
"col-span-12 flex-col overflow-hidden lg:col-span-3 lg:flex",
|
||||
showGroupsPane ? "flex" : "hidden",
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-3 flex flex-col h-full">
|
||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Groups</span>
|
||||
{selectedChannel && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-auto text-xs font-normal"
|
||||
>
|
||||
{selectedChannel.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden -mx-3">
|
||||
{channelId ? (
|
||||
<GroupList
|
||||
channelId={channelId}
|
||||
selectedId={groupId}
|
||||
onSelect={handleSelectGroup}
|
||||
/>
|
||||
) : (
|
||||
<EmptyPanel icon={Folder} message="Select a channel" />
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Panel 3: Sessions */}
|
||||
<Card
|
||||
className={cn(
|
||||
"col-span-12 flex-col overflow-hidden lg:col-span-6 lg:flex",
|
||||
showSessionsPane ? "flex" : "hidden",
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-3 flex flex-col h-full">
|
||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
|
||||
<MessageCircle className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Sessions</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden -mx-3">
|
||||
{channelId && groupId ? (
|
||||
<SessionList channelId={channelId} groupId={groupId} />
|
||||
) : (
|
||||
<EmptyPanel
|
||||
icon={MessageSquare}
|
||||
message={
|
||||
channelId
|
||||
? "Select a group"
|
||||
: "Select a channel and group"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap in Suspense for useSearchParams
|
||||
export default function CommunicationsPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex flex-col h-[calc(100dvh-7rem)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-48 mb-2" />
|
||||
<Skeleton className="h-5 w-64" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-4 lg:gap-6">
|
||||
<Card className="col-span-12 lg:col-span-3">
|
||||
<CardContent className="p-3 space-y-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="col-span-12 lg:col-span-3">
|
||||
<CardContent className="p-3 space-y-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="col-span-12 lg:col-span-6" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CommunicationsPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
useAuditorReports,
|
||||
useCreateAuditorReport,
|
||||
} from "@/hooks/use-dashboard";
|
||||
import { LiveFeedsPanel } from "./live-feeds-panel";
|
||||
import { QualityMetricsPanel } from "./quality-metrics-panel";
|
||||
import { FlaggedItemsPanel } from "./flagged-items-panel";
|
||||
import { ReportsPanel } from "./reports-panel";
|
||||
@@ -70,17 +69,11 @@ export function AuditorDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Row: Live Feeds + Quality Metrics */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2 gap-6">
|
||||
<LiveFeedsPanel
|
||||
feeds={dashboard?.live_feeds}
|
||||
isLoading={loadingDashboard}
|
||||
/>
|
||||
{/* Quality Metrics */}
|
||||
<QualityMetricsPanel
|
||||
metrics={dashboard?.metrics}
|
||||
isLoading={loadingDashboard}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bottom Row: Flagged Items + Reports */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2 gap-6">
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
export { AuditorDashboard } from "./auditor-dashboard";
|
||||
export { LiveFeedsPanel } from "./live-feeds-panel";
|
||||
export { LiveFeedItem } from "./live-feed-item";
|
||||
export { QualityMetricsPanel } from "./quality-metrics-panel";
|
||||
export { FlaggedItemsPanel } from "./flagged-items-panel";
|
||||
export { FlaggedItem } from "./flagged-item";
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ChannelFeed } from "@/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Radio, Clock } from "lucide-react";
|
||||
|
||||
interface LiveFeedItemProps {
|
||||
feed: ChannelFeed;
|
||||
}
|
||||
|
||||
function formatTime(timestamp: string | null): string {
|
||||
if (!timestamp) return "No activity";
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
|
||||
if (diffMins < 1) return "Active now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
export function LiveFeedItem({ feed }: LiveFeedItemProps) {
|
||||
const isActive = feed.status === "active" || feed.message_count_24h > 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 rounded-lg border bg-muted/30 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<Radio
|
||||
className={`h-4 w-4 ${isActive ? "text-green-500 animate-pulse" : "text-gray-400"}`}
|
||||
/>
|
||||
<div>
|
||||
<span className="font-medium text-sm">#{feed.name}</span>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatTime(feed.last_activity)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={isActive ? "default" : "secondary"} className="text-xs">
|
||||
{feed.message_count_24h} msgs
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={isActive ? "text-green-600 border-green-300" : ""}
|
||||
>
|
||||
{isActive ? "Active" : "Idle"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ChannelFeed } from "@/types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Radio } from "lucide-react";
|
||||
import { LiveFeedItem } from "./live-feed-item";
|
||||
|
||||
interface LiveFeedsPanelProps {
|
||||
feeds: ChannelFeed[] | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function LiveFeedsPanel({ feeds, isLoading }: LiveFeedsPanelProps) {
|
||||
const activeCount = (feeds ?? []).filter(
|
||||
(f) => f.status === "active" || f.message_count_24h > 0,
|
||||
).length;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Radio className="h-5 w-5" />
|
||||
Live Feeds
|
||||
</CardTitle>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{activeCount} active
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-14" />
|
||||
))}
|
||||
</div>
|
||||
) : !feeds || feeds.length === 0 ? (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">
|
||||
<Radio className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
No channel feeds available
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{feeds.map((feed) => (
|
||||
<LiveFeedItem key={feed.id} feed={feed} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { MessageComposer } from "./message-composer";
|
||||
export { MessageTypeBadge } from "./message-type-badge";
|
||||
@@ -1,101 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Send } from "lucide-react";
|
||||
|
||||
interface MessageComposerProps {
|
||||
channelId: string;
|
||||
onSend: (message: { content: string; type: string }) => void;
|
||||
isSending?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const MESSAGE_TYPES = [
|
||||
{ value: "dialogue", label: "Dialogue" },
|
||||
{ value: "reasoning", label: "Reasoning" },
|
||||
{ value: "decision", label: "Decision" },
|
||||
{ value: "action", label: "Action" },
|
||||
{ value: "blocker", label: "Blocker" },
|
||||
{ value: "technical", label: "Technical" },
|
||||
];
|
||||
|
||||
export function MessageComposer({
|
||||
onSend,
|
||||
isSending,
|
||||
disabled,
|
||||
}: MessageComposerProps) {
|
||||
const [content, setContent] = useState("");
|
||||
const [type, setType] = useState("dialogue");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!content.trim()) return;
|
||||
|
||||
onSend({ content: content.trim(), type });
|
||||
setContent("");
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
// paddingBottom includes the safe-area inset so the composer clears the
|
||||
// home indicator on notched phones instead of sitting flush under it.
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="border-t p-4"
|
||||
style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message... (Shift+Enter for new line)"
|
||||
className="min-h-[60px] resize-none"
|
||||
disabled={disabled || isSending}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select value={type} onValueChange={setType}>
|
||||
<SelectTrigger className="w-auto min-w-24 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MESSAGE_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={!content.trim() || disabled || isSending}
|
||||
>
|
||||
<Send className="h-4 w-4 mr-1" />
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Markdown supported. Use @agent to mention.
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface MessageTypeBadgeProps {
|
||||
type: string;
|
||||
}
|
||||
|
||||
const typeConfig: Record<string, { label: string; color: string }> = {
|
||||
reasoning: { label: "reasoning", color: "bg-blue-100 text-blue-700" },
|
||||
dialogue: { label: "dialogue", color: "bg-green-100 text-green-700" },
|
||||
decision: { label: "decision", color: "bg-purple-100 text-purple-700" },
|
||||
action: { label: "action", color: "bg-orange-100 text-orange-700" },
|
||||
blocker: { label: "blocker", color: "bg-red-100 text-red-700" },
|
||||
technical: { label: "technical", color: "bg-gray-100 text-gray-700" },
|
||||
general: { label: "general", color: "bg-gray-100 text-gray-700" },
|
||||
};
|
||||
|
||||
export function MessageTypeBadge({ type }: MessageTypeBadgeProps) {
|
||||
const config = typeConfig[type] ?? typeConfig.general;
|
||||
return <Badge className={config.color + " text-xs"}>{config.label}</Badge>;
|
||||
}
|
||||
@@ -2,14 +2,7 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CreateTaskDialog } from "@/components/tasks/create-task-dialog";
|
||||
import {
|
||||
Users,
|
||||
Megaphone,
|
||||
BookOpen,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Bot,
|
||||
} from "lucide-react";
|
||||
import { Users, BookOpen, Shield, Sparkles, Bot } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export function QuickActionsBar() {
|
||||
@@ -38,13 +31,6 @@ export function QuickActionsBar() {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/communications" prefetch={false}>
|
||||
<Button variant="outline">
|
||||
<Megaphone className="h-4 w-4 mr-2" />
|
||||
Broadcast Message
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/journals" prefetch={false}>
|
||||
<Button variant="outline">
|
||||
<BookOpen className="h-4 w-4 mr-2" />
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
UserPlus,
|
||||
MessageSquare,
|
||||
Clock,
|
||||
Hash,
|
||||
} from "lucide-react";
|
||||
@@ -54,8 +53,6 @@ export function KanbanCard({
|
||||
const isBacklog = task.status === TaskStatus.BACKLOG;
|
||||
const [assignOpen, setAssignOpen] = useState(false);
|
||||
const updateTask = useUpdateTask();
|
||||
const hasSessions = task.sessions && task.sessions.length > 0;
|
||||
const primarySession = task.sessions?.find((s) => s.is_primary);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
@@ -169,32 +166,6 @@ export function KanbanCard({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{hasSessions && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`text-xs gap-1 ${primarySession ? "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300" : ""}`}
|
||||
>
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{task.sessions.length}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{task.sessions.length} linked session
|
||||
{task.sessions.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
{primarySession && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Primary: #{primarySession.channel_slug}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
<AssigneeAvatar agentId={task.assigned_to} />
|
||||
</div>
|
||||
|
||||
@@ -456,13 +456,22 @@ function KnowledgeBaseBrowserContent() {
|
||||
<div>LLM: {health.llm_status}</div>
|
||||
<div>Vector: {health.vector_store_status}</div>
|
||||
</div>
|
||||
{(["llm_error", "embedding_error", "vector_store_error"] as const)
|
||||
.filter((k) => typeof health.details?.[k] === "string")
|
||||
.map((k) => (
|
||||
{(
|
||||
[
|
||||
["llm_error", "LLM"],
|
||||
["embedding_error", "Embedding"],
|
||||
["vector_store_error", "Vector store"],
|
||||
] as const
|
||||
)
|
||||
.filter(
|
||||
([k]) => typeof health.details?.[k] === "string",
|
||||
)
|
||||
.map(([k, label]) => (
|
||||
<p
|
||||
key={k}
|
||||
className="text-xs text-red-600 dark:text-red-400 break-words"
|
||||
>
|
||||
<span className="font-medium">{label}:</span>{" "}
|
||||
{health.details[k] as string}
|
||||
</p>
|
||||
))}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
LayoutDashboard,
|
||||
ListTodo,
|
||||
Kanban,
|
||||
MessageSquare,
|
||||
Bell,
|
||||
Activity,
|
||||
ChevronLeft,
|
||||
@@ -50,7 +49,6 @@ export const navItems = [
|
||||
{ title: "Auditor", href: "/auditor", icon: Shield },
|
||||
|
||||
// History
|
||||
{ title: "Communications", href: "/communications", icon: MessageSquare },
|
||||
{ title: "A2A Live", href: "/a2a", icon: Radio },
|
||||
{ title: "Journals", href: "/journals", icon: BookOpen },
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
Pencil,
|
||||
Trash2,
|
||||
Clock,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { EditTaskDialog } from "./edit-task-dialog";
|
||||
@@ -138,7 +137,6 @@ export function TaskActions({
|
||||
|
||||
// Check if task is in backlog (needs PM activation)
|
||||
const isBacklog = task.status === TaskStatus.BACKLOG;
|
||||
const hasSessions = task.sessions && task.sessions.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -164,15 +162,6 @@ export function TaskActions({
|
||||
<Clock className="h-4 w-4 mr-2" />
|
||||
Awaiting PM Activation
|
||||
</DropdownMenuItem>
|
||||
{!hasSessions && (
|
||||
<DropdownMenuItem
|
||||
disabled
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4 mr-2" />
|
||||
Needs session created
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -12,5 +12,4 @@ export { AcceptanceCriteria } from "./acceptance-criteria";
|
||||
export { ProgressTimeline } from "./progress-timeline";
|
||||
export { CheckpointCard } from "./checkpoint-card";
|
||||
export { CommitCard } from "./commit-card";
|
||||
export { TabSessions } from "./tab-sessions";
|
||||
export { WorkSessionCard } from "./work-session-card";
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Task, TaskSessionLink, SessionScope } from "@/types";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MessageSquare, ExternalLink, Star, Hash } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface TabSessionsProps {
|
||||
task: Task;
|
||||
}
|
||||
|
||||
const scopeColors: Record<SessionScope, string> = {
|
||||
[SessionScope.INITIATIVE]:
|
||||
"bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300",
|
||||
[SessionScope.CELL]:
|
||||
"bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
|
||||
[SessionScope.TASK]:
|
||||
"bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
|
||||
};
|
||||
|
||||
const scopeLabels: Record<SessionScope, string> = {
|
||||
[SessionScope.INITIATIVE]: "Initiative",
|
||||
[SessionScope.CELL]: "Cell",
|
||||
[SessionScope.TASK]: "Task",
|
||||
};
|
||||
|
||||
const relationshipLabels: Record<string, string> = {
|
||||
discussion: "Discussion",
|
||||
planning: "Planning",
|
||||
review: "Review",
|
||||
retrospective: "Retrospective",
|
||||
};
|
||||
|
||||
function SessionCard({ session }: { session: TaskSessionLink }) {
|
||||
const shortSessionId = session.session_id.slice(0, 8);
|
||||
|
||||
return (
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-base font-medium">
|
||||
Session {shortSessionId}
|
||||
</CardTitle>
|
||||
{session.is_primary && (
|
||||
<Badge variant="default" className="gap-1">
|
||||
<Star className="h-3 w-3" />
|
||||
Primary
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Badge className={scopeColors[session.scope]}>
|
||||
{scopeLabels[session.scope]}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription className="mt-1 flex items-center gap-2">
|
||||
<span>
|
||||
{relationshipLabels[session.relationship_type] ||
|
||||
session.relationship_type}
|
||||
</span>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Hash className="h-3 w-3" />
|
||||
{session.channel_slug}
|
||||
</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex justify-end">
|
||||
<Link href={`/communications/${session.session_id}`} prefetch={false}>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
View Session
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabSessions({ task }: TabSessionsProps) {
|
||||
const sessions = task.sessions || [];
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8">
|
||||
<MessageSquare className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
|
||||
<h3 className="text-lg font-medium mb-2">No Linked Sessions</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
||||
This task does not have any linked discussion sessions yet. A PM
|
||||
will create a session when work begins.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Sort: primary first, then by scope
|
||||
const sortedSessions = [...sessions].sort((a, b) => {
|
||||
if (a.is_primary !== b.is_primary) return a.is_primary ? -1 : 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Linked Sessions</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Discussion sessions related to this task
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
{sessions.length} session{sessions.length !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{sortedSessions.map((session) => (
|
||||
<SessionCard key={session.session_id} session={session} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { TabProgress } from "./tab-progress";
|
||||
import { TabCommits } from "./tab-commits";
|
||||
import { TabNotes } from "./tab-notes";
|
||||
import { TabDependencies } from "./tab-dependencies";
|
||||
import { TabSessions } from "./tab-sessions";
|
||||
import {
|
||||
FileText,
|
||||
Layout,
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
GitCommit,
|
||||
StickyNote,
|
||||
Link2,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
|
||||
interface TaskTabsProps {
|
||||
@@ -34,11 +32,10 @@ export function TaskTabs({ task }: TaskTabsProps) {
|
||||
(task.auditor_notes ? 1 : 0) +
|
||||
(task.quick_context ? 1 : 0);
|
||||
const depsCount = task.dependency_ids.length + task.blocker_ids.length;
|
||||
const sessionsCount = task.sessions?.length || 0;
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="overview" className="mt-6">
|
||||
<TabsList className="grid w-full grid-cols-7 lg:w-auto lg:inline-grid">
|
||||
<TabsList className="grid w-full grid-cols-6 lg:w-auto lg:inline-grid">
|
||||
<TabsTrigger value="overview" className="gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Overview</span>
|
||||
@@ -61,15 +58,6 @@ export function TaskTabs({ task }: TaskTabsProps) {
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sessions" className="gap-2">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Sessions</span>
|
||||
{sessionsCount > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 h-5 px-1.5">
|
||||
{sessionsCount}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="commits" className="gap-2">
|
||||
<GitCommit className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Commits</span>
|
||||
@@ -109,9 +97,6 @@ export function TaskTabs({ task }: TaskTabsProps) {
|
||||
<TabsContent value="progress">
|
||||
<TabProgress task={task} />
|
||||
</TabsContent>
|
||||
<TabsContent value="sessions">
|
||||
<TabSessions task={task} />
|
||||
</TabsContent>
|
||||
<TabsContent value="commits">
|
||||
<TabCommits task={task} />
|
||||
</TabsContent>
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, act } from "@testing-library/react";
|
||||
import { useEffect } from "react";
|
||||
import type {
|
||||
ConnectionState,
|
||||
WebSocketOptions,
|
||||
} from "@/lib/websocket/connection";
|
||||
|
||||
// Bundle A: the session detail view had no live subscription — send_message now
|
||||
// publishes MESSAGE_SENT → bridge → /ws/sessions/{id}, and useSessionStream is
|
||||
// the panel half that subscribes so an open transcript updates without a manual
|
||||
// Refresh. Mock the connection so the test can drive onMessage frames.
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const instances: MockConnection[] = [];
|
||||
class MockConnection {
|
||||
url: string;
|
||||
onMessage?: (data: unknown) => void;
|
||||
onStateChange?: (state: ConnectionState) => void;
|
||||
didConnect = false;
|
||||
didDisconnect = false;
|
||||
constructor(opts: WebSocketOptions) {
|
||||
this.url = opts.url;
|
||||
this.onMessage = opts.onMessage;
|
||||
this.onStateChange = opts.onStateChange;
|
||||
instances.push(this);
|
||||
}
|
||||
connect() {
|
||||
this.didConnect = true;
|
||||
this.onStateChange?.("connecting");
|
||||
this.onStateChange?.("connected");
|
||||
}
|
||||
disconnect() {
|
||||
this.didDisconnect = true;
|
||||
this.onStateChange?.("disconnected");
|
||||
}
|
||||
}
|
||||
return { instances, MockConnection };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/websocket/connection", () => ({
|
||||
getWebSocketUrl: () => "ws://test/ws",
|
||||
WebSocketConnection: hoisted.MockConnection,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
CEO_AGENT_ID: "00000000-0000-0000-0000-000000000001",
|
||||
STREAM_MAX_MESSAGES: 100,
|
||||
}));
|
||||
|
||||
import { useSessionStream } from "../use-websocket";
|
||||
|
||||
const resultRef: {
|
||||
current: ReturnType<typeof useSessionStream> | null;
|
||||
} = { current: null };
|
||||
|
||||
function Harness({ sessionId }: { sessionId: string | null }) {
|
||||
const ws = useSessionStream(sessionId);
|
||||
useEffect(() => {
|
||||
resultRef.current = ws;
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("useSessionStream", () => {
|
||||
beforeEach(() => {
|
||||
hoisted.instances.length = 0;
|
||||
resultRef.current = null;
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("connects to the session endpoint with the CEO agent_id", () => {
|
||||
render(<Harness sessionId="sess-1" />);
|
||||
expect(hoisted.instances).toHaveLength(1);
|
||||
expect(hoisted.instances[0].url).toContain("/sessions/sess-1");
|
||||
expect(hoisted.instances[0].url).toContain(
|
||||
"agent_id=00000000-0000-0000-0000-000000000001",
|
||||
);
|
||||
expect(resultRef.current?.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it("does not connect when sessionId is null", () => {
|
||||
render(<Harness sessionId={null} />);
|
||||
expect(hoisted.instances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("surfaces a message.new frame in sessionMessages and lastMessage", () => {
|
||||
render(<Harness sessionId="sess-1" />);
|
||||
const conn = hoisted.instances[0];
|
||||
act(() => {
|
||||
conn.onMessage?.({
|
||||
type: "message.new",
|
||||
message_id: "m1",
|
||||
session_id: "sess-1",
|
||||
agent_id: "a1",
|
||||
content: "hello",
|
||||
message_type: "dialogue",
|
||||
});
|
||||
});
|
||||
expect(resultRef.current?.sessionMessages).toHaveLength(1);
|
||||
expect(resultRef.current?.sessionMessages[0].message_id).toBe("m1");
|
||||
expect(resultRef.current?.lastMessage?.type).toBe("message.new");
|
||||
});
|
||||
|
||||
it("ignores the initial connected frame (not a real message)", () => {
|
||||
render(<Harness sessionId="sess-1" />);
|
||||
const conn = hoisted.instances[0];
|
||||
act(() => {
|
||||
conn.onMessage?.({ type: "connected", session_id: "sess-1" });
|
||||
});
|
||||
expect(resultRef.current?.sessionMessages).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { createElement } from "react";
|
||||
|
||||
// Bundle C: GET /sessions/{id} now returns task_links (with titles) in one shot.
|
||||
// useSession must rely on that single read and stop the N+1 re-fetch
|
||||
// (getTasksForSession → tasksApi.get per link) it previously did.
|
||||
const sessionGet = vi.fn();
|
||||
const getTasksForSession = vi.fn();
|
||||
const taskGet = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api/sessions", () => ({
|
||||
sessionsApi: {
|
||||
get: (...args: unknown[]) => sessionGet(...args),
|
||||
getTasksForSession: (...args: unknown[]) => getTasksForSession(...args),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/api/tasks", () => ({
|
||||
tasksApi: { get: (...args: unknown[]) => taskGet(...args) },
|
||||
}));
|
||||
vi.mock("@/lib/api/channels", () => ({ channelsApi: {} }));
|
||||
vi.mock("@/lib/api/messages", () => ({ messagesApi: {} }));
|
||||
|
||||
import { useSession } from "../use-channels";
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return createElement(QueryClientProvider, { client }, children);
|
||||
}
|
||||
|
||||
describe("useSession", () => {
|
||||
beforeEach(() => {
|
||||
sessionGet.mockReset();
|
||||
getTasksForSession.mockReset();
|
||||
taskGet.mockReset();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("returns task_links from the single get() call without per-task fetches", async () => {
|
||||
sessionGet.mockResolvedValue({
|
||||
id: "s1",
|
||||
group_id: "g1",
|
||||
status: "active",
|
||||
scope: "cell",
|
||||
message_count: 0,
|
||||
total_content_length: 0,
|
||||
started_at: "2026-06-30T00:00:00Z",
|
||||
last_activity_at: "2026-06-30T00:00:00Z",
|
||||
closed_at: null,
|
||||
task_links: [
|
||||
{
|
||||
task_id: "t1",
|
||||
task_title: "Build it",
|
||||
is_primary: true,
|
||||
relationship_type: "discussion",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSession("s1"), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data?.task_links).toHaveLength(1);
|
||||
expect(result.current.data?.task_links?.[0].task_title).toBe("Build it");
|
||||
// The redundant N+1 path must be gone.
|
||||
expect(getTasksForSession).not.toHaveBeenCalled();
|
||||
expect(taskGet).not.toHaveBeenCalled();
|
||||
expect(sessionGet).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ export * from "./use-tasks";
|
||||
export * from "./use-rate-limit-websocket";
|
||||
export * from "./use-rate-limit-sync";
|
||||
export * from "./use-agents";
|
||||
export * from "./use-channels";
|
||||
export * from "./use-notifications";
|
||||
// Re-export dashboard hooks excluding duplicates from use-agents
|
||||
export {
|
||||
|
||||
@@ -241,7 +241,7 @@ export function useAgentDefinitions() {
|
||||
/**
|
||||
* Register the live `/api/agents` roster into the display-name resolver
|
||||
* (agent-utils). Mount once near the app root so every surface that resolves an
|
||||
* assignee (task table, task detail, journals, communications, commits) shows
|
||||
* assignee (task table, task detail, journals, commits) shows
|
||||
* the real agent name instead of a raw UUID, and never drifts as agents are
|
||||
* added backend-side. Returns nothing — it's a side-effecting sync.
|
||||
*/
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { isAxiosError } from "axios";
|
||||
import { channelsApi, type ChannelFilters } from "@/lib/api/channels";
|
||||
import { sessionsApi } from "@/lib/api/sessions";
|
||||
import { messagesApi } from "@/lib/api/messages";
|
||||
|
||||
// A 404 on a session/message read is terminal: the session has closed and been
|
||||
// reaped server-side, so it will never come back. Retrying (or continuing to
|
||||
// poll) a 404'd session is exactly what produces the "404 storm" as dead
|
||||
// session-ids accumulate. Treat 404 as final — fail fast, never retry.
|
||||
function isNotFound(error: unknown): boolean {
|
||||
return isAxiosError(error) && error.response?.status === 404;
|
||||
}
|
||||
|
||||
function retryUnlessNotFound(failureCount: number, error: unknown): boolean {
|
||||
if (isNotFound(error)) return false;
|
||||
return failureCount < 1;
|
||||
}
|
||||
|
||||
export const channelKeys = {
|
||||
all: ["channels"] as const,
|
||||
list: (filters?: ChannelFilters) =>
|
||||
[...channelKeys.all, "list", filters] as const,
|
||||
detail: (id: string) => [...channelKeys.all, "detail", id] as const,
|
||||
groups: (channelId: string) =>
|
||||
[...channelKeys.all, "groups", channelId] as const,
|
||||
};
|
||||
|
||||
export const sessionKeys = {
|
||||
all: ["sessions"] as const,
|
||||
list: (groupId: string) => [...sessionKeys.all, "list", groupId] as const,
|
||||
detail: (id: string) => [...sessionKeys.all, "detail", id] as const,
|
||||
};
|
||||
|
||||
export const messageKeys = {
|
||||
all: ["messages"] as const,
|
||||
list: (sessionId: string) => [...messageKeys.all, "list", sessionId] as const,
|
||||
};
|
||||
|
||||
// Fetch channel list once - manual refresh available
|
||||
export function useChannels(filters?: ChannelFilters) {
|
||||
return useQuery({
|
||||
queryKey: channelKeys.list(filters),
|
||||
queryFn: () => channelsApi.list(filters),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
export function useChannel(channelId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: channelKeys.detail(channelId || ""),
|
||||
queryFn: () => channelsApi.get(channelId!),
|
||||
enabled: !!channelId,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch groups for a channel
|
||||
export function useChannelGroups(channelId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: channelKeys.groups(channelId || ""),
|
||||
queryFn: () => channelsApi.getGroups(channelId!),
|
||||
enabled: !!channelId,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch sessions for a group
|
||||
export function useGroupSessions(groupId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: sessionKeys.list(groupId || ""),
|
||||
queryFn: () => sessionsApi.listByGroup(groupId!),
|
||||
enabled: !!groupId,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch a single session by ID. GET /sessions/{id} returns task_links (with
|
||||
// titles) in one shot, so no separate per-link task fetch is needed.
|
||||
export function useSession(sessionId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: sessionKeys.detail(sessionId || ""),
|
||||
queryFn: () => sessionsApi.get(sessionId!),
|
||||
enabled: !!sessionId,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
// A reaped (404) session must not be retried — that is the storm source.
|
||||
retry: retryUnlessNotFound,
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch messages for a session - WebSocket handles new messages.
|
||||
//
|
||||
// The transcript is read once and held (staleTime Infinity, no focus/reconnect
|
||||
// refetch in the global defaults), so an open session is fetched a single time
|
||||
// and a closed session's immutable transcript stays loaded for review. A 404
|
||||
// means the session was reaped server-side; that is terminal, so we never retry
|
||||
// it — retrying reaped sessions is what produced the growing 404 storm as dead
|
||||
// session-ids accumulated. When the consumer unmounts, React Query deactivates
|
||||
// the query (no background refetch loop survives) and GCs it after gcTime.
|
||||
export function useSessionMessages(sessionId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: messageKeys.list(sessionId || ""),
|
||||
queryFn: () => messagesApi.listBySession(sessionId!),
|
||||
enabled: !!sessionId,
|
||||
staleTime: Infinity,
|
||||
retry: retryUnlessNotFound,
|
||||
});
|
||||
}
|
||||
@@ -62,17 +62,14 @@ export function useMetrics() {
|
||||
queryKey: dashboardKeys.metrics(),
|
||||
queryFn: async (): Promise<MetricsSummary> => {
|
||||
// Fetch all metrics in parallel, including real agent status
|
||||
const [velocity, blockers, communication, agentStatus] =
|
||||
await Promise.all([
|
||||
const [velocity, blockers, agentStatus] = await Promise.all([
|
||||
dashboardApi.getVelocityMetrics(),
|
||||
dashboardApi.getBlockerMetrics(),
|
||||
dashboardApi.getCommunicationMetrics(),
|
||||
dashboardApi.getAgentStatus(),
|
||||
]);
|
||||
return {
|
||||
velocity,
|
||||
blockers,
|
||||
communication,
|
||||
agents: {
|
||||
total_agents: agentStatus?.total_agents ?? 0,
|
||||
running: agentStatus?.by_state?.running ?? 0,
|
||||
|
||||
@@ -23,17 +23,6 @@ export interface AgentStreamMessage {
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface ChannelMessage {
|
||||
type: "connected" | "message.new" | "session.closed";
|
||||
channel_id?: string;
|
||||
message_id?: string;
|
||||
agent_id?: string;
|
||||
content?: string;
|
||||
message_type?: string;
|
||||
subscriber_count?: number;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface NotificationMessage {
|
||||
type: "connected" | "notification";
|
||||
agent_id?: string;
|
||||
@@ -56,19 +45,6 @@ export interface A2ASystemMessage {
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface SessionMessage {
|
||||
type: "connected" | "message.new";
|
||||
message_id?: string;
|
||||
session_id?: string;
|
||||
channel_id?: string;
|
||||
agent_id?: string;
|
||||
content?: string;
|
||||
message_type?: string;
|
||||
is_reply?: boolean;
|
||||
reply_to?: string | null;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Generic WebSocket Hook
|
||||
// =============================================================================
|
||||
@@ -191,73 +167,6 @@ export function useAgentStream(agentId: string | null) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a channel's message stream
|
||||
*/
|
||||
export function useChannelStream(channelId: string | null) {
|
||||
const {
|
||||
state,
|
||||
lastMessage,
|
||||
messages,
|
||||
clearMessages,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
} = useWebSocket<ChannelMessage>(
|
||||
channelId ? "/channels/" + channelId : "",
|
||||
{ agent_id: CEO_AGENT_ID },
|
||||
!!channelId,
|
||||
);
|
||||
|
||||
// Filter to only actual messages
|
||||
const channelMessages = messages.filter((m) => m.type === "message.new");
|
||||
|
||||
return {
|
||||
state,
|
||||
lastMessage,
|
||||
channelMessages,
|
||||
allMessages: messages,
|
||||
clearMessages,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a session's live message stream (`/ws/sessions/{id}`).
|
||||
*
|
||||
* The backend publishes MESSAGE_SENT on every persisted send; the websocket
|
||||
* bridge fans it to this stream as a `message.new` frame. The session detail
|
||||
* view consumes `lastMessage` to refresh its transcript live instead of
|
||||
* relying on the manual Refresh button.
|
||||
*/
|
||||
export function useSessionStream(sessionId: string | null) {
|
||||
const {
|
||||
state,
|
||||
lastMessage,
|
||||
messages,
|
||||
clearMessages,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
} = useWebSocket<SessionMessage>(
|
||||
sessionId ? "/sessions/" + sessionId : "",
|
||||
{ agent_id: CEO_AGENT_ID },
|
||||
!!sessionId,
|
||||
);
|
||||
|
||||
// Filter to only actual messages (drop the initial `connected` frame).
|
||||
const sessionMessages = messages.filter((m) => m.type === "message.new");
|
||||
|
||||
return {
|
||||
state,
|
||||
lastMessage,
|
||||
sessionMessages,
|
||||
allMessages: messages,
|
||||
clearMessages,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to notifications for the CEO
|
||||
*/
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import api from "./client";
|
||||
import type { Channel, PaginatedResponse, Group } from "@/types";
|
||||
import { ChannelType } from "@/types";
|
||||
import { isMockMode, mockChannels, mockGroups } from "@/lib/mock-data";
|
||||
|
||||
export interface ChannelFilters {
|
||||
type?: string;
|
||||
is_private?: boolean;
|
||||
}
|
||||
|
||||
export interface ChannelCreate {
|
||||
name: string;
|
||||
slug: string;
|
||||
type?: string;
|
||||
description?: string;
|
||||
topic?: string;
|
||||
is_private?: boolean;
|
||||
}
|
||||
|
||||
export interface ChannelUpdate {
|
||||
name?: string;
|
||||
description?: string;
|
||||
topic?: string;
|
||||
is_archived?: boolean;
|
||||
}
|
||||
|
||||
export const channelsApi = {
|
||||
// List all channels
|
||||
list: async (filters?: ChannelFilters): Promise<Channel[]> => {
|
||||
if (isMockMode()) {
|
||||
let channels = [...mockChannels] as Channel[];
|
||||
if (filters?.type) {
|
||||
channels = channels.filter((c) => c.type === filters.type);
|
||||
}
|
||||
if (filters?.is_private !== undefined) {
|
||||
channels = channels.filter((c) => c.is_private === filters.is_private);
|
||||
}
|
||||
return channels;
|
||||
}
|
||||
const { data } = await api.get<PaginatedResponse<Channel>>("/channels", {
|
||||
params: filters,
|
||||
});
|
||||
return data.items;
|
||||
},
|
||||
|
||||
// Get channel by ID
|
||||
get: async (channelId: string): Promise<Channel> => {
|
||||
if (isMockMode()) {
|
||||
const channel = mockChannels.find((c) => c.id === channelId);
|
||||
if (channel) return channel as Channel;
|
||||
throw new Error("Channel not found");
|
||||
}
|
||||
const { data } = await api.get<Channel>("/channels/" + channelId);
|
||||
return data;
|
||||
},
|
||||
|
||||
// Get channel by slug
|
||||
getBySlug: async (slug: string): Promise<Channel> => {
|
||||
if (isMockMode()) {
|
||||
const channel = mockChannels.find((c) => c.slug === slug);
|
||||
if (channel) return channel as Channel;
|
||||
throw new Error("Channel not found");
|
||||
}
|
||||
// Backend uses query param filter, not path segment
|
||||
const { data } = await api.get<PaginatedResponse<Channel>>("/channels", {
|
||||
params: { slug },
|
||||
});
|
||||
if (!data.items.length) {
|
||||
throw new Error("Channel not found");
|
||||
}
|
||||
return data.items[0];
|
||||
},
|
||||
|
||||
// Get groups for a channel
|
||||
getGroups: async (channelId: string): Promise<Group[]> => {
|
||||
if (isMockMode()) {
|
||||
return mockGroups as Group[];
|
||||
}
|
||||
const { data } = await api.get<Group[]>(
|
||||
"/channels/" + channelId + "/groups",
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
// Create a new channel (PM/CEO only)
|
||||
create: async (channel: ChannelCreate): Promise<Channel> => {
|
||||
if (isMockMode()) {
|
||||
const newChannel: Channel = {
|
||||
id: `channel-${Date.now()}`,
|
||||
name: channel.name,
|
||||
slug: channel.slug,
|
||||
type: (channel.type as ChannelType) || ChannelType.CELL,
|
||||
description: channel.description || null,
|
||||
topic: channel.topic || null,
|
||||
is_private: channel.is_private || false,
|
||||
is_archived: false,
|
||||
member_count: 0,
|
||||
message_count: 0,
|
||||
group_count: 0,
|
||||
can_write: true,
|
||||
};
|
||||
(mockChannels as Channel[]).push(newChannel);
|
||||
return newChannel;
|
||||
}
|
||||
const { data } = await api.post<Channel>("/channels", channel);
|
||||
return data;
|
||||
},
|
||||
|
||||
// Update a channel (PM/CEO only)
|
||||
update: async (
|
||||
channelId: string,
|
||||
updates: ChannelUpdate,
|
||||
): Promise<Channel> => {
|
||||
if (isMockMode()) {
|
||||
const idx = mockChannels.findIndex((c) => c.id === channelId);
|
||||
if (idx === -1) throw new Error("Channel not found");
|
||||
const updated = { ...mockChannels[idx], ...updates } as Channel;
|
||||
(mockChannels as Channel[])[idx] = updated;
|
||||
return updated;
|
||||
}
|
||||
const { data } = await api.patch<Channel>(
|
||||
"/channels/" + channelId,
|
||||
updates,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
// Add a member to a channel (PM/CEO only)
|
||||
addMember: async (channelId: string, agentId: string): Promise<void> => {
|
||||
if (isMockMode()) {
|
||||
return;
|
||||
}
|
||||
await api.post("/channels/" + channelId + "/add-member", {
|
||||
agent_id: agentId,
|
||||
});
|
||||
},
|
||||
|
||||
// Remove a member from a channel (PM/CEO only)
|
||||
removeMember: async (channelId: string, agentId: string): Promise<void> => {
|
||||
if (isMockMode()) {
|
||||
return;
|
||||
}
|
||||
await api.delete("/channels/" + channelId + "/remove-member", {
|
||||
data: { agent_id: agentId },
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -35,7 +35,6 @@ export interface TeamHealth {
|
||||
export interface MetricsSummary {
|
||||
velocity: VelocityMetric;
|
||||
blockers: BlockerMetric;
|
||||
communication: CommunicationMetric;
|
||||
agents: AgentMetric;
|
||||
}
|
||||
|
||||
@@ -51,12 +50,6 @@ export interface BlockerMetric {
|
||||
longest_blocked_hours: number;
|
||||
}
|
||||
|
||||
export interface CommunicationMetric {
|
||||
messages_today: number;
|
||||
active_channels: number;
|
||||
notifications_pending: number;
|
||||
}
|
||||
|
||||
export interface AgentMetric {
|
||||
total_agents: number;
|
||||
running: number;
|
||||
@@ -209,20 +202,6 @@ export const dashboardApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
getCommunicationMetrics: async (): Promise<CommunicationMetric> => {
|
||||
if (isMockMode()) {
|
||||
return {
|
||||
messages_today: 45,
|
||||
active_channels: 5,
|
||||
notifications_pending: 3,
|
||||
};
|
||||
}
|
||||
const { data } = await api.get<CommunicationMetric>(
|
||||
"/dashboard/metrics/communication",
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
getHealthMetrics: async () => {
|
||||
if (isMockMode()) {
|
||||
return mockTeamHealth;
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* Groups API Client
|
||||
*
|
||||
* API functions for group management within channels.
|
||||
*/
|
||||
|
||||
import api from "./client";
|
||||
import { isMockMode } from "@/lib/mock-data";
|
||||
import type { Group } from "@/types";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface GroupCreate {
|
||||
channel_id: string;
|
||||
name: string;
|
||||
hierarchy_level?: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API Client
|
||||
// =============================================================================
|
||||
|
||||
export const groupsApi = {
|
||||
/**
|
||||
* Create a new group within a channel
|
||||
*/
|
||||
create: async (group: GroupCreate): Promise<Group> => {
|
||||
if (isMockMode()) {
|
||||
return {
|
||||
id: `group-${Date.now()}`,
|
||||
name: group.name,
|
||||
hierarchy_level: group.hierarchy_level ?? 0,
|
||||
is_active: true,
|
||||
total_messages: 0,
|
||||
active_session_id: null,
|
||||
};
|
||||
}
|
||||
const { data } = await api.post<Group>("/groups", group);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a group by ID
|
||||
*/
|
||||
get: async (groupId: string): Promise<Group> => {
|
||||
if (isMockMode()) {
|
||||
return {
|
||||
id: groupId,
|
||||
name: "Mock Group",
|
||||
hierarchy_level: 0,
|
||||
is_active: true,
|
||||
total_messages: 0,
|
||||
active_session_id: null,
|
||||
};
|
||||
}
|
||||
const { data } = await api.get<Group>(`/groups/${groupId}`);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -2,7 +2,6 @@ export { api, API_URL } from "./client";
|
||||
export { usageApi } from "./usage";
|
||||
export { tasksApi } from "./tasks";
|
||||
export { orchestratorApi } from "./orchestrator";
|
||||
export { channelsApi } from "./channels";
|
||||
export { notificationsApi } from "./notifications";
|
||||
export { dashboardApi } from "./dashboard";
|
||||
export { knowledgeBaseApi } from "./knowledge-base";
|
||||
@@ -12,7 +11,6 @@ export { workSessionsApi } from "./work-sessions";
|
||||
export { gitApi } from "./git";
|
||||
export { a2aApi } from "./a2a";
|
||||
export { streamApi } from "./stream";
|
||||
export { groupsApi } from "./groups";
|
||||
export { settingsApi } from "./settings";
|
||||
export { companyGoalsApi } from "./company-goals";
|
||||
export { releaseApi } from "./release";
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import api from "./client";
|
||||
import type { Message, MessageType } from "@/types";
|
||||
import {
|
||||
isMockMode,
|
||||
getMockMessages,
|
||||
AGENT_IDS,
|
||||
CHANNEL_IDS,
|
||||
} from "@/lib/mock-data";
|
||||
|
||||
// Store for mock messages (persists during session)
|
||||
let mockMessagesStore: Message[] | null = null;
|
||||
const getMessages = (): Message[] => {
|
||||
if (!mockMessagesStore) {
|
||||
mockMessagesStore = getMockMessages() as Message[];
|
||||
}
|
||||
return mockMessagesStore;
|
||||
};
|
||||
|
||||
export const messagesApi = {
|
||||
// List messages for a session
|
||||
listBySession: async (
|
||||
sessionId: string,
|
||||
limit: number = 50,
|
||||
before?: string,
|
||||
after?: string,
|
||||
): Promise<{ items: Message[]; has_more: boolean }> => {
|
||||
if (isMockMode()) {
|
||||
let messages = getMessages().filter((m) => m.session_id === sessionId);
|
||||
if (before) {
|
||||
messages = messages.filter(
|
||||
(m) => new Date(m.timestamp) < new Date(before),
|
||||
);
|
||||
}
|
||||
if (after) {
|
||||
messages = messages.filter(
|
||||
(m) => new Date(m.timestamp) > new Date(after),
|
||||
);
|
||||
}
|
||||
return {
|
||||
items: messages.slice(0, limit),
|
||||
has_more: messages.length > limit,
|
||||
};
|
||||
}
|
||||
const { data } = await api.get<{ items: Message[]; has_more: boolean }>(
|
||||
"/messages",
|
||||
{
|
||||
params: { session_id: sessionId, limit, before, after },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
// Get message by ID
|
||||
get: async (messageId: string): Promise<Message> => {
|
||||
if (isMockMode()) {
|
||||
const message = getMessages().find((m) => m.id === messageId);
|
||||
if (message) return message;
|
||||
throw new Error("Message not found");
|
||||
}
|
||||
const { data } = await api.get<Message>("/messages/" + messageId);
|
||||
return data;
|
||||
},
|
||||
|
||||
// Send a message
|
||||
send: async (
|
||||
sessionId: string,
|
||||
content: string,
|
||||
type: string = "dialogue",
|
||||
): Promise<Message> => {
|
||||
if (isMockMode()) {
|
||||
const newMessage: Message = {
|
||||
id: `msg-${Date.now()}`,
|
||||
agent_id: AGENT_IDS.ceo,
|
||||
channel_id: CHANNEL_IDS.backendCell,
|
||||
group_id: `msg-${Date.now()}`,
|
||||
session_id: sessionId,
|
||||
type: type as MessageType,
|
||||
content,
|
||||
content_length: content.length,
|
||||
is_reply: false,
|
||||
reply_to: null,
|
||||
mentions: [],
|
||||
task_id: null,
|
||||
commit_ref: null,
|
||||
timestamp: new Date().toISOString(),
|
||||
edited_at: null,
|
||||
was_edited: false,
|
||||
};
|
||||
getMessages().push(newMessage);
|
||||
return newMessage;
|
||||
}
|
||||
const { data } = await api.post<Message>("/messages", {
|
||||
session_id: sessionId,
|
||||
content,
|
||||
type,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
// Edit a message
|
||||
edit: async (messageId: string, content: string): Promise<Message> => {
|
||||
if (isMockMode()) {
|
||||
const messages = getMessages();
|
||||
const idx = messages.findIndex((m) => m.id === messageId);
|
||||
if (idx !== -1) {
|
||||
const message = messages[idx];
|
||||
const editedMessage: Message = {
|
||||
...message,
|
||||
content,
|
||||
content_length: content.length,
|
||||
edited_at: new Date().toISOString(),
|
||||
was_edited: true,
|
||||
};
|
||||
messages[idx] = editedMessage;
|
||||
return editedMessage;
|
||||
}
|
||||
throw new Error("Message not found");
|
||||
}
|
||||
const { data } = await api.patch<Message>("/messages/" + messageId, {
|
||||
content,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
// Delete a message
|
||||
delete: async (messageId: string): Promise<void> => {
|
||||
if (isMockMode()) {
|
||||
const messages = getMessages();
|
||||
const idx = messages.findIndex((m) => m.id === messageId);
|
||||
if (idx !== -1) messages.splice(idx, 1);
|
||||
return;
|
||||
}
|
||||
await api.delete("/messages/" + messageId);
|
||||
},
|
||||
};
|
||||
@@ -1,146 +0,0 @@
|
||||
import api from "./client";
|
||||
import type { Session } from "@/types";
|
||||
import { SessionStatus, SessionScope } from "@/types";
|
||||
import { isMockMode, mockSessions } from "@/lib/mock-data";
|
||||
|
||||
// Session-Task link response from API
|
||||
export interface SessionTaskLinkResponse {
|
||||
id: string;
|
||||
session_id: string;
|
||||
task_id: string;
|
||||
is_primary: boolean;
|
||||
relationship_type: string;
|
||||
added_at: string;
|
||||
added_by: string | null;
|
||||
}
|
||||
|
||||
export interface SessionCreate {
|
||||
group_id: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export const sessionsApi = {
|
||||
// List sessions for a group
|
||||
listByGroup: async (
|
||||
groupId: string,
|
||||
limit: number = 50,
|
||||
): Promise<Session[]> => {
|
||||
if (isMockMode()) {
|
||||
return (mockSessions as Session[]).slice(0, limit);
|
||||
}
|
||||
const { data } = await api.get<{ items: Session[]; total: number }>(
|
||||
"/sessions",
|
||||
{
|
||||
params: { group_id: groupId, limit },
|
||||
},
|
||||
);
|
||||
return data.items;
|
||||
},
|
||||
|
||||
// Get session by ID
|
||||
get: async (sessionId: string): Promise<Session> => {
|
||||
if (isMockMode()) {
|
||||
const session = mockSessions.find((s) => s.id === sessionId);
|
||||
if (session) return session as Session;
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
const { data } = await api.get<Session>("/sessions/" + sessionId);
|
||||
return data;
|
||||
},
|
||||
|
||||
// Get sessions linked to a task
|
||||
getForTask: async (taskId: string): Promise<SessionTaskLinkResponse[]> => {
|
||||
if (isMockMode()) {
|
||||
return []; // No mock session-task links
|
||||
}
|
||||
const { data } = await api.get<SessionTaskLinkResponse[]>(
|
||||
"/sessions/for-task/" + taskId,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
// Close a session
|
||||
close: async (sessionId: string): Promise<Session> => {
|
||||
if (isMockMode()) {
|
||||
const idx = mockSessions.findIndex((s) => s.id === sessionId);
|
||||
if (idx !== -1) {
|
||||
const session = mockSessions[idx] as Session;
|
||||
const closedSession: Session = {
|
||||
...session,
|
||||
status: "closed" as SessionStatus,
|
||||
closed_at: new Date().toISOString(),
|
||||
};
|
||||
(mockSessions as Session[])[idx] = closedSession;
|
||||
return closedSession;
|
||||
}
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
const { data } = await api.post<Session>(
|
||||
"/sessions/" + sessionId + "/close",
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
// Link a task to a session (PM only)
|
||||
linkTask: async (
|
||||
sessionId: string,
|
||||
taskId: string,
|
||||
isPrimary: boolean = false,
|
||||
relationshipType: string = "discussion",
|
||||
): Promise<SessionTaskLinkResponse> => {
|
||||
const { data } = await api.post<SessionTaskLinkResponse>(
|
||||
"/sessions/" + sessionId + "/tasks",
|
||||
{
|
||||
task_id: taskId,
|
||||
is_primary: isPrimary,
|
||||
relationship_type: relationshipType,
|
||||
},
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
// Unlink a task from a session (PM only)
|
||||
unlinkTask: async (sessionId: string, taskId: string): Promise<void> => {
|
||||
await api.delete("/sessions/" + sessionId + "/tasks/" + taskId);
|
||||
},
|
||||
|
||||
// Create a session for tasks (PM only)
|
||||
createForTasks: async (
|
||||
taskIds: string[],
|
||||
channelSlug: string,
|
||||
relationshipType: string = "discussion",
|
||||
): Promise<{ session: Session; links: SessionTaskLinkResponse[] }> => {
|
||||
const { data } = await api.post<{
|
||||
session: Session;
|
||||
links: SessionTaskLinkResponse[];
|
||||
}>("/sessions/for-tasks", {
|
||||
task_ids: taskIds,
|
||||
channel_slug: channelSlug,
|
||||
relationship_type: relationshipType,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
// Create a new session directly
|
||||
create: async (session: SessionCreate): Promise<Session> => {
|
||||
if (isMockMode()) {
|
||||
const now = new Date().toISOString();
|
||||
const newSession: Session = {
|
||||
id: `session-${Date.now()}`,
|
||||
group_id: session.group_id,
|
||||
status: SessionStatus.ACTIVE,
|
||||
scope: (session.scope as SessionScope) || SessionScope.CELL,
|
||||
message_count: 0,
|
||||
total_content_length: 0,
|
||||
started_at: now,
|
||||
last_activity_at: now,
|
||||
closed_at: null,
|
||||
task_links: [],
|
||||
};
|
||||
(mockSessions as Session[]).push(newSession);
|
||||
return newSession;
|
||||
}
|
||||
const { data } = await api.post<Session>("/sessions", session);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -87,7 +87,6 @@ const summaryToTask = (s: TaskSummaryWire): Task => ({
|
||||
quick_context: null,
|
||||
self_verified: false,
|
||||
qa_verified: null,
|
||||
sessions: [],
|
||||
});
|
||||
|
||||
export const tasksApi = {
|
||||
@@ -202,7 +201,6 @@ export const tasksApi = {
|
||||
qa_notes: null,
|
||||
auditor_notes: null,
|
||||
quick_context: null,
|
||||
sessions: [],
|
||||
branch_name: null,
|
||||
pr_number: null,
|
||||
pr_url: null,
|
||||
|
||||
@@ -7,10 +7,6 @@ import {
|
||||
NotificationType,
|
||||
NotificationPriority,
|
||||
JournalEntryType,
|
||||
ChannelType,
|
||||
SessionStatus,
|
||||
SessionScope,
|
||||
MessageType,
|
||||
FlagSeverity,
|
||||
TaskNature,
|
||||
TaskType,
|
||||
@@ -64,17 +60,6 @@ export const PROJECT_IDS = {
|
||||
robocoPanel: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
};
|
||||
|
||||
export const CHANNEL_IDS = {
|
||||
backendCell: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
frontendCell: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
uxuiCell: "cccccccc-cccc-cccc-cccc-cccccccccccc",
|
||||
devAll: "dddddddd-dddd-dddd-dddd-dddddddddddd",
|
||||
qaAll: "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee",
|
||||
pmAll: "ffffffff-ffff-ffff-ffff-ffffffffffff",
|
||||
announcements: "00000000-0000-0000-0000-000000000100",
|
||||
allHands: "00000000-0000-0000-0000-000000000101",
|
||||
};
|
||||
|
||||
// Timestamps - computed dynamically to stay relative
|
||||
const getNow = () => new Date();
|
||||
const getMinutesAgo = (mins: number) => new Date(Date.now() - mins * 60 * 1000);
|
||||
@@ -299,7 +284,6 @@ Implement a complete user authentication system including:
|
||||
completed_at: null,
|
||||
self_verified: false,
|
||||
qa_verified: null,
|
||||
sessions: [],
|
||||
branch_name: "feature/TASK-001-user-auth",
|
||||
pr_number: null,
|
||||
pr_url: null,
|
||||
@@ -442,7 +426,6 @@ Implement a complete user authentication system including:
|
||||
completed_at: null,
|
||||
self_verified: false,
|
||||
qa_verified: null,
|
||||
sessions: [],
|
||||
branch_name: null,
|
||||
pr_number: null,
|
||||
pr_url: null,
|
||||
@@ -497,7 +480,6 @@ Pagination should maintain filter state.`,
|
||||
completed_at: null,
|
||||
self_verified: false,
|
||||
qa_verified: null,
|
||||
sessions: [],
|
||||
branch_name: "fix/TASK-003-pagination-bug",
|
||||
pr_number: null,
|
||||
pr_url: null,
|
||||
@@ -548,7 +530,6 @@ The flow should guide new users through:
|
||||
completed_at: hourAgo.toISOString(),
|
||||
self_verified: true,
|
||||
qa_verified: null,
|
||||
sessions: [],
|
||||
branch_name: "feature/TASK-004-onboarding-design",
|
||||
pr_number: 42,
|
||||
pr_url: "https://github.com/roboco/roboco/pull/42",
|
||||
@@ -659,7 +640,6 @@ Reduce response time to < 100ms for all endpoints.`,
|
||||
completed_at: null,
|
||||
self_verified: false,
|
||||
qa_verified: null,
|
||||
sessions: [],
|
||||
branch_name: "perf/TASK-005-db-optimization",
|
||||
pr_number: null,
|
||||
pr_url: null,
|
||||
@@ -718,7 +698,6 @@ Include:
|
||||
completed_at: hourAgo.toISOString(),
|
||||
self_verified: true,
|
||||
qa_verified: true,
|
||||
sessions: [],
|
||||
branch_name: "docs/TASK-006-api-documentation",
|
||||
pr_number: 38,
|
||||
pr_url: "https://github.com/roboco/roboco/pull/38",
|
||||
@@ -930,83 +909,6 @@ export const mockWaitingAgents = [
|
||||
},
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// MOCK CHANNELS - Matching backend ChannelResponse schema
|
||||
// =============================================================================
|
||||
|
||||
export const mockChannels = [
|
||||
{
|
||||
id: CHANNEL_IDS.backendCell,
|
||||
name: "Backend Cell",
|
||||
slug: "backend-cell",
|
||||
type: ChannelType.CELL,
|
||||
description: "Backend development team channel",
|
||||
topic: null,
|
||||
member_count: 6,
|
||||
message_count: 150,
|
||||
group_count: 3,
|
||||
is_archived: false,
|
||||
is_private: false,
|
||||
can_write: true,
|
||||
},
|
||||
{
|
||||
id: CHANNEL_IDS.frontendCell,
|
||||
name: "Frontend Cell",
|
||||
slug: "frontend-cell",
|
||||
type: ChannelType.CELL,
|
||||
description: "Frontend development team channel",
|
||||
topic: null,
|
||||
member_count: 6,
|
||||
message_count: 120,
|
||||
group_count: 2,
|
||||
is_archived: false,
|
||||
is_private: false,
|
||||
can_write: true,
|
||||
},
|
||||
{
|
||||
id: CHANNEL_IDS.uxuiCell,
|
||||
name: "UX/UI Cell",
|
||||
slug: "uxui-cell",
|
||||
type: ChannelType.CELL,
|
||||
description: "UX/UI design team channel",
|
||||
topic: null,
|
||||
member_count: 5,
|
||||
message_count: 80,
|
||||
group_count: 2,
|
||||
is_archived: false,
|
||||
is_private: false,
|
||||
can_write: true,
|
||||
},
|
||||
{
|
||||
id: CHANNEL_IDS.devAll,
|
||||
name: "All Developers",
|
||||
slug: "dev-all",
|
||||
type: ChannelType.CROSS_CELL,
|
||||
description: "Cross-cell developer discussion",
|
||||
topic: null,
|
||||
member_count: 10,
|
||||
message_count: 200,
|
||||
group_count: 5,
|
||||
is_archived: false,
|
||||
is_private: false,
|
||||
can_write: true,
|
||||
},
|
||||
{
|
||||
id: CHANNEL_IDS.announcements,
|
||||
name: "Announcements",
|
||||
slug: "announcements",
|
||||
type: ChannelType.SPECIAL,
|
||||
description: "Company-wide announcements",
|
||||
topic: null,
|
||||
member_count: 19,
|
||||
message_count: 25,
|
||||
group_count: 1,
|
||||
is_archived: false,
|
||||
is_private: false,
|
||||
can_write: false,
|
||||
},
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// MOCK NOTIFICATIONS - Matching backend NotificationResponse schema
|
||||
// =============================================================================
|
||||
@@ -1215,146 +1117,6 @@ export const mockKanbanDevBoard = {
|
||||
blocked_count: 1,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// MOCK SESSIONS - Matching backend SessionResponse schema
|
||||
// =============================================================================
|
||||
|
||||
export const mockSessions = [
|
||||
{
|
||||
id: mockId(),
|
||||
group_id: mockId(),
|
||||
status: SessionStatus.ACTIVE,
|
||||
scope: SessionScope.TASK,
|
||||
message_count: 25,
|
||||
total_content_length: 5000,
|
||||
started_at: hourAgo.toISOString(),
|
||||
last_activity_at: now.toISOString(),
|
||||
closed_at: null,
|
||||
},
|
||||
{
|
||||
id: mockId(),
|
||||
group_id: mockId(),
|
||||
status: SessionStatus.CLOSED,
|
||||
scope: SessionScope.CELL,
|
||||
message_count: 50,
|
||||
total_content_length: 12000,
|
||||
started_at: dayAgo.toISOString(),
|
||||
last_activity_at: new Date(
|
||||
now.getTime() - 2 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
closed_at: new Date(now.getTime() - 2 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// MOCK MESSAGES - Matching backend MessageResponse schema
|
||||
// =============================================================================
|
||||
|
||||
const messageGroupId = mockId();
|
||||
|
||||
// Use getter for fresh timestamps
|
||||
export const getMockMessages = () => [
|
||||
{
|
||||
id: mockId(),
|
||||
agent_id: AGENT_IDS.beDev1,
|
||||
channel_id: CHANNEL_IDS.backendCell,
|
||||
group_id: messageGroupId,
|
||||
session_id: mockSessions[0].id,
|
||||
type: MessageType.DIALOGUE,
|
||||
content:
|
||||
"Just finished the auth service implementation. Ready to start on the API endpoints.",
|
||||
content_length: 78,
|
||||
is_reply: false,
|
||||
reply_to: null,
|
||||
mentions: [],
|
||||
task_id: TASK_IDS.task1,
|
||||
commit_ref: null,
|
||||
timestamp: getMinutesAgo(60).toISOString(),
|
||||
edited_at: null,
|
||||
was_edited: false,
|
||||
},
|
||||
{
|
||||
id: mockId(),
|
||||
agent_id: AGENT_IDS.bePm,
|
||||
channel_id: CHANNEL_IDS.backendCell,
|
||||
group_id: messageGroupId,
|
||||
session_id: mockSessions[0].id,
|
||||
type: MessageType.DECISION,
|
||||
content: "Great progress! Let's prioritize the OAuth integration next.",
|
||||
content_length: 58,
|
||||
is_reply: true,
|
||||
reply_to: null,
|
||||
mentions: [AGENT_IDS.beDev1],
|
||||
task_id: TASK_IDS.task1,
|
||||
commit_ref: null,
|
||||
timestamp: getMinutesAgo(55).toISOString(),
|
||||
edited_at: null,
|
||||
was_edited: false,
|
||||
},
|
||||
{
|
||||
id: mockId(),
|
||||
agent_id: AGENT_IDS.beDev2,
|
||||
channel_id: CHANNEL_IDS.backendCell,
|
||||
group_id: messageGroupId,
|
||||
session_id: mockSessions[0].id,
|
||||
type: MessageType.BLOCKER,
|
||||
content:
|
||||
"I'm blocked on the database optimization task. Need the auth changes to merge first.",
|
||||
content_length: 82,
|
||||
is_reply: false,
|
||||
reply_to: null,
|
||||
mentions: [AGENT_IDS.beDev1],
|
||||
task_id: TASK_IDS.task5,
|
||||
commit_ref: null,
|
||||
timestamp: getMinutesAgo(50).toISOString(),
|
||||
edited_at: null,
|
||||
was_edited: false,
|
||||
},
|
||||
{
|
||||
id: mockId(),
|
||||
agent_id: AGENT_IDS.beDev1,
|
||||
channel_id: CHANNEL_IDS.backendCell,
|
||||
group_id: messageGroupId,
|
||||
session_id: mockSessions[0].id,
|
||||
type: MessageType.TECHNICAL,
|
||||
content: "Commit pushed: feat(auth): add user authentication schema",
|
||||
content_length: 55,
|
||||
is_reply: false,
|
||||
reply_to: null,
|
||||
mentions: [],
|
||||
task_id: TASK_IDS.task1,
|
||||
commit_ref: "abc123def456",
|
||||
timestamp: getMinutesAgo(45).toISOString(),
|
||||
edited_at: null,
|
||||
was_edited: false,
|
||||
},
|
||||
];
|
||||
// Legacy export for backwards compatibility
|
||||
export const mockMessages = getMockMessages();
|
||||
|
||||
// =============================================================================
|
||||
// MOCK GROUPS - Matching backend GroupResponse schema
|
||||
// =============================================================================
|
||||
|
||||
export const mockGroups = [
|
||||
{
|
||||
id: mockId(),
|
||||
name: "General Discussion",
|
||||
hierarchy_level: 0,
|
||||
is_active: true,
|
||||
total_messages: 150,
|
||||
active_session_id: mockSessions[0].id,
|
||||
},
|
||||
{
|
||||
id: mockId(),
|
||||
name: "Tech Talk",
|
||||
hierarchy_level: 1,
|
||||
is_active: true,
|
||||
total_messages: 80,
|
||||
active_session_id: null,
|
||||
},
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// MOCK AUDITOR DATA - Matching backend dashboard.py schemas
|
||||
// =============================================================================
|
||||
@@ -1409,29 +1171,6 @@ export const mockAuditorReports = [
|
||||
];
|
||||
|
||||
export const mockAuditorDashboard = {
|
||||
live_feeds: [
|
||||
{
|
||||
id: CHANNEL_IDS.backendCell,
|
||||
name: "Backend Cell",
|
||||
status: "streaming",
|
||||
last_activity: now.toISOString(),
|
||||
message_count_24h: 25,
|
||||
},
|
||||
{
|
||||
id: CHANNEL_IDS.frontendCell,
|
||||
name: "Frontend Cell",
|
||||
status: "idle",
|
||||
last_activity: hourAgo.toISOString(),
|
||||
message_count_24h: 15,
|
||||
},
|
||||
{
|
||||
id: CHANNEL_IDS.uxuiCell,
|
||||
name: "UX/UI Cell",
|
||||
status: "idle",
|
||||
last_activity: dayAgo.toISOString(),
|
||||
message_count_24h: 5,
|
||||
},
|
||||
],
|
||||
flagged_items: mockAuditorFlags,
|
||||
metrics: {
|
||||
total_flags: 2,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user