mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(infra): release builds all 17 registry images; pg_dump backup sidecar (#461)
* fix(infra): release builds all 17 registry images; pg_dump backup sidecar release.yml was missing roboco-agent-grok-prompter and roboco-agent-grok-secretary (both FROM the bare local roboco-agent-grok tag, so agent-grok now builds explicitly ahead of the loop, mirroring the agent-base special case) — a fresh registry pull could never succeed. Both compose files gain a backup sidecar on the data network: pg_dump -Fc on start and every 24h, crash-safe tmp+rename, newest-14 rotation, restore walkthrough in docs/backend/ops/database-backups.md. * chore(docs): reflow hard-wrapped prose inherited from the six-PR merge train * chore(foundation): regenerate lifecycle artifacts; reflow inherited prose --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -85,6 +85,16 @@ jobs:
|
||||
-t roboco-agent-base $(regtags roboco-agent-base) .
|
||||
echo "::endgroup::"
|
||||
|
||||
# roboco-agent-grok MUST build next, ahead of the loop: the two
|
||||
# interactive Grok roles (prompter, secretary) build `FROM
|
||||
# roboco-agent-grok` — a local tag that has to exist in the daemon
|
||||
# before they build, and bash associative-array iteration order is
|
||||
# unspecified, so it can't just be another entry in IMAGES below.
|
||||
echo "::group::build roboco-agent-grok"
|
||||
docker build -f docker/agent-grok.Dockerfile \
|
||||
-t roboco-agent-grok $(regtags roboco-agent-grok) .
|
||||
echo "::endgroup::"
|
||||
|
||||
# Every other image → its Dockerfile. Names mirror docker-compose's
|
||||
# `image:` values exactly, so compose can later pull instead of build.
|
||||
declare -A IMAGES=(
|
||||
@@ -101,7 +111,8 @@ jobs:
|
||||
[roboco-agent-prompter]=docker/agent-prompter.Dockerfile
|
||||
[roboco-agent-secretary]=docker/agent-secretary.Dockerfile
|
||||
[roboco-agent-pr-reviewer]=docker/agent-pr-reviewer.Dockerfile
|
||||
[roboco-agent-grok]=docker/agent-grok.Dockerfile
|
||||
[roboco-agent-grok-prompter]=docker/agent-grok-prompter.Dockerfile
|
||||
[roboco-agent-grok-secretary]=docker/agent-grok-secretary.Dockerfile
|
||||
)
|
||||
for name in "${!IMAGES[@]}"; do
|
||||
echo "::group::build ${name}"
|
||||
@@ -114,10 +125,13 @@ jobs:
|
||||
echo "::group::push roboco-agent-base"
|
||||
pushall roboco-agent-base
|
||||
echo "::endgroup::"
|
||||
echo "::group::push roboco-agent-grok"
|
||||
pushall roboco-agent-grok
|
||||
echo "::endgroup::"
|
||||
for name in "${!IMAGES[@]}"; do
|
||||
echo "::group::push ${name}"
|
||||
pushall "${name}"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
|
||||
echo "Published roboco-agent-base + ${#IMAGES[@]} more images to GHCR + Docker Hub at :${VERSION} and :latest"
|
||||
echo "Published roboco-agent-base + roboco-agent-grok + ${#IMAGES[@]} more images to GHCR + Docker Hub at :${VERSION} and :latest"
|
||||
|
||||
@@ -66,6 +66,31 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Backup - periodic pg_dump of the roboco DB (interim; no PITR/WAL yet)
|
||||
# --------------------------------------------------------------------------
|
||||
backup:
|
||||
image: pgvector/pgvector:pg16
|
||||
container_name: roboco-backup
|
||||
restart: unless-stopped
|
||||
# data-only network — pg_dump reaches postgres by container name, same
|
||||
# as every other data-network consumer; never exposed to the agent mesh.
|
||||
networks:
|
||||
- data
|
||||
environment:
|
||||
POSTGRES_HOST: roboco-postgres
|
||||
POSTGRES_PORT: 5432
|
||||
POSTGRES_USER: roboco
|
||||
POSTGRES_PASSWORD: roboco
|
||||
POSTGRES_DB: roboco
|
||||
entrypoint: ["/bin/bash", "/scripts/backup-entrypoint.sh"]
|
||||
volumes:
|
||||
- ./docker/scripts/backup-entrypoint.sh:/scripts/backup-entrypoint.sh:ro
|
||||
- ${ROBOCO_DATA_DIR:-./data}/backups:/backups
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
container_name: roboco-ollama
|
||||
|
||||
@@ -47,6 +47,31 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ==========================================================================
|
||||
# Backup - periodic pg_dump of the roboco DB (interim; no PITR/WAL yet)
|
||||
# ==========================================================================
|
||||
backup:
|
||||
image: pgvector/pgvector:pg16
|
||||
container_name: roboco-backup
|
||||
restart: unless-stopped
|
||||
# data-only network — pg_dump reaches postgres by container name, same
|
||||
# as every other data-network consumer; never exposed to the agent mesh.
|
||||
networks:
|
||||
- data
|
||||
environment:
|
||||
POSTGRES_HOST: roboco-postgres
|
||||
POSTGRES_PORT: 5432
|
||||
POSTGRES_USER: roboco
|
||||
POSTGRES_PASSWORD: roboco
|
||||
POSTGRES_DB: roboco
|
||||
entrypoint: ["/bin/bash", "/scripts/backup-entrypoint.sh"]
|
||||
volumes:
|
||||
- ./docker/scripts/backup-entrypoint.sh:/scripts/backup-entrypoint.sh:ro
|
||||
- ${ROBOCO_DATA_DIR:-./data}/backups:/backups
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
# ==========================================================================
|
||||
# MinIO - Object storage for rendered videos (NAS default-on; registry OFF)
|
||||
# ==========================================================================
|
||||
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Periodic pg_dump of the roboco DB (interim backup — no PITR/WAL archiving).
|
||||
# Runs once on start, then every BACKUP_INTERVAL_SECONDS (default 24h). A
|
||||
# failed dump logs and is retried next cycle; the loop itself never exits.
|
||||
# ponytail: a plain sleep loop, not a cron daemon — good enough for a fixed
|
||||
# 24h cadence, swap for a scheduler only if finer granularity is ever needed.
|
||||
set -u
|
||||
|
||||
BACKUP_DIR="${BACKUP_DIR:-/backups}"
|
||||
KEEP="${BACKUP_KEEP:-14}"
|
||||
INTERVAL_SECONDS="${BACKUP_INTERVAL_SECONDS:-86400}"
|
||||
|
||||
# pg_dump reads these natively — no need to pass -h/-p/-U/-d by hand.
|
||||
export PGHOST="${POSTGRES_HOST:-roboco-postgres}"
|
||||
export PGPORT="${POSTGRES_PORT:-5432}"
|
||||
export PGUSER="${POSTGRES_USER:-roboco}"
|
||||
export PGPASSWORD="${POSTGRES_PASSWORD:-roboco}"
|
||||
export PGDATABASE="${POSTGRES_DB:-roboco}"
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
run_backup() {
|
||||
local ts dest
|
||||
ts="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
dest="${BACKUP_DIR}/roboco-${ts}.dump"
|
||||
echo "[backup] $(date -u -Iseconds) starting pg_dump -> ${dest}"
|
||||
if pg_dump -Fc -f "${dest}.tmp"; then
|
||||
mv "${dest}.tmp" "${dest}"
|
||||
echo "[backup] $(date -u -Iseconds) OK: ${dest}"
|
||||
else
|
||||
rm -f "${dest}.tmp"
|
||||
echo "[backup] $(date -u -Iseconds) FAILED — will retry next cycle" >&2
|
||||
fi
|
||||
|
||||
# Prune to the newest $KEEP dumps. Filenames are our own fixed timestamp
|
||||
# format (no spaces/globs to worry about), so plain `ls -t` is enough.
|
||||
# shellcheck disable=SC2012
|
||||
ls -1t "${BACKUP_DIR}"/roboco-*.dump 2>/dev/null | tail -n "+$((KEEP + 1))" | while IFS= read -r old; do
|
||||
rm -f "$old"
|
||||
done
|
||||
}
|
||||
|
||||
while true; do
|
||||
run_backup
|
||||
sleep "$INTERVAL_SECONDS"
|
||||
done
|
||||
@@ -0,0 +1,23 @@
|
||||
# Database Backups
|
||||
|
||||
An interim `backup` sidecar service ships in both `docker-compose.yml` and `docker-compose.registry.yml`: a plain `pgvector/pgvector:pg16` container (the same image the `postgres` service pins) running `docker/scripts/backup-entrypoint.sh` on the `data` network alongside `postgres`, with no feature flag — it is always on, since a default-off backup is pointless.
|
||||
|
||||
## What it does
|
||||
|
||||
On container start, and then every `BACKUP_INTERVAL_SECONDS` (default `86400`, i.e. 24h), the script runs `pg_dump -Fc` against the `roboco` database over the network (via `PGHOST=roboco-postgres`, the same container-name resolution every other data-network consumer uses) and writes a timestamped custom-format dump to `${ROBOCO_DATA_DIR:-./data}/backups/roboco-<UTC-timestamp>.dump` on the host. The dump is written to a `.tmp` suffix first and only renamed into place on success, so a crash mid-dump never leaves a half-written file that looks complete.
|
||||
|
||||
## Retention
|
||||
|
||||
After each attempt the script prunes `${ROBOCO_DATA_DIR:-./data}/backups` down to the newest `BACKUP_KEEP` dumps (default `14`, i.e. roughly two weeks at the default 24h cadence) by mtime, deleting the rest. There is no offsite copy and no WAL/PITR archiving — this is a point-in-time `pg_dump` snapshot only, taken once a day.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
A failed `pg_dump` (network hiccup, postgres briefly unhealthy, disk full) logs to `docker logs roboco-backup` and is retried on the next cycle; the loop itself never exits, so the container never crash-loops on a transient failure. `restart: unless-stopped` covers the rest.
|
||||
|
||||
## Restoring a dump
|
||||
|
||||
Stop anything writing to the database, then restore into a running (empty or throwaway) `roboco` database with `pg_restore`, for example: `docker exec -i roboco-postgres pg_restore -U roboco -d roboco --clean --if-exists < ./data/backups/roboco-20260711T030000Z.dump` (drop the `.tmp` files if any are present — they are in-progress dumps, not backups). Use `pg_restore -l <dump>` first if you want to inspect or selectively restore a subset of objects rather than the whole database. For a fresh empty database instead of `--clean`, create it first (`createdb -U roboco roboco_restore`) and restore into that.
|
||||
|
||||
## Known ceiling
|
||||
|
||||
This is an interim measure to close the "zero backups" gap, not a full disaster-recovery story: a single daily snapshot on the same host as the database it's backing up is vulnerable to whole-host loss (disk failure, NAS failure). Copying `${ROBOCO_DATA_DIR:-./data}/backups` offsite periodically, or moving to WAL-based continuous archiving, is the natural next step if that risk matters more than the current simplicity.
|
||||
@@ -1,8 +1,6 @@
|
||||
# A2A Conversations Filter Control
|
||||
|
||||
**Location:** `panel/src/components/a2a/`
|
||||
**Design spec:** `docs/ux_ui/design/conversations-filter-control.md`
|
||||
**Related:** A2A page (`panel/src/app/(dashboard)/a2a/page.tsx`)
|
||||
**Location:** `panel/src/components/a2a/` **Design spec:** `docs/ux_ui/design/conversations-filter-control.md` **Related:** A2A page (`panel/src/app/(dashboard)/a2a/page.tsx`)
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -207,17 +205,13 @@ function A2APageContent() {
|
||||
|
||||
### Filter Functions
|
||||
|
||||
**`filterConversations(conversations, filters): AdminConversationSummary[]`**
|
||||
Applies all four dimensions (Agent, Task, Status, Date) to narrow the conversation list.
|
||||
**`filterConversations(conversations, filters): AdminConversationSummary[]`** Applies all four dimensions (Agent, Task, Status, Date) to narrow the conversation list.
|
||||
|
||||
**`filterPairs(pairs, filters): AdminPairSummary[]`**
|
||||
Applies Agent dimension only to narrow switchboard pair cards.
|
||||
**`filterPairs(pairs, filters): AdminPairSummary[]`** Applies Agent dimension only to narrow switchboard pair cards.
|
||||
|
||||
**`distinctA2AAgents(conversations, pairs): string[]`**
|
||||
Derives the checkbox option set by scanning all loaded pairs and conversations, deduping agent slugs, and sorting alphabetically. Call this in a `useMemo` in the parent whenever pairs/conversations change.
|
||||
**`distinctA2AAgents(conversations, pairs): string[]`** Derives the checkbox option set by scanning all loaded pairs and conversations, deduping agent slugs, and sorting alphabetically. Call this in a `useMemo` in the parent whenever pairs/conversations change.
|
||||
|
||||
**`activeA2AFilterCount(filters): number`**
|
||||
Returns the count of active filter values (one per chip). Drives the trigger's count badge. An empty fragment/date counts as 0; a set date counts as 1 per date.
|
||||
**`activeA2AFilterCount(filters): number`** Returns the count of active filter values (one per chip). Drives the trigger's count badge. An empty fragment/date counts as 0; a set date counts as 1 per date.
|
||||
|
||||
## Per-View Rules
|
||||
|
||||
@@ -303,14 +297,10 @@ The component follows the design spec's accessibility contract:
|
||||
|
||||
## Common Questions
|
||||
|
||||
**Q: Why doesn't the fragment search the topic field anymore?**
|
||||
A: The design spec replaced free-text search with four discrete dimensions. Task ID fragment and Agent are the most common filters; if you need to search topics, that would be a fifth dimension—raise it in design review if needed.
|
||||
**Q: Why doesn't the fragment search the topic field anymore?** A: The design spec replaced free-text search with four discrete dimensions. Task ID fragment and Agent are the most common filters; if you need to search topics, that would be a fifth dimension—raise it in design review if needed.
|
||||
|
||||
**Q: Can I make filters persist across page reloads?**
|
||||
A: Not in this version. To add localStorage persistence, wrap `setFilters` in the parent with `useEffect` to sync to localStorage and restore on mount. This would be a follow-up task.
|
||||
**Q: Can I make filters persist across page reloads?** A: Not in this version. To add localStorage persistence, wrap `setFilters` in the parent with `useEffect` to sync to localStorage and restore on mount. This would be a follow-up task.
|
||||
|
||||
**Q: What happens to filtered state when new conversations arrive via WebSocket?**
|
||||
A: Filters remain active. The `filterConversations` function is re-run against the refreshed list on every data update, so incoming messages are immediately re-evaluated against the current filters.
|
||||
**Q: What happens to filtered state when new conversations arrive via WebSocket?** A: Filters remain active. The `filterConversations` function is re-run against the refreshed list on every data update, so incoming messages are immediately re-evaluated against the current filters.
|
||||
|
||||
**Q: Can filters be set via URL query params?**
|
||||
A: Not currently. The state is ephemeral. To support deep-linking (e.g., `?agents=be-dev-1&status=active`), add a URL param sync layer in the parent using `useSearchParams` / `useRouter` from Next.js. This would be a follow-up task.
|
||||
**Q: Can filters be set via URL query params?** A: Not currently. The state is ephemeral. To support deep-linking (e.g., `?agents=be-dev-1&status=active`), add a URL param sync layer in the parent using `useSearchParams` / `useRouter` from Next.js. This would be a follow-up task.
|
||||
|
||||
@@ -163,8 +163,7 @@ Roster (col-span-4) | Stream (col-span-8)
|
||||
|
||||
The connection badge renders in the pane header, and a dismissable banner appears above the message list when reconnecting or disconnected.
|
||||
|
||||
**File:** `panel/src/components/a2a/a2a-connection-badge.tsx`
|
||||
**Utils:** `panel/src/components/a2a/a2a-utils.ts`
|
||||
**File:** `panel/src/components/a2a/a2a-connection-badge.tsx` **Utils:** `panel/src/components/a2a/a2a-utils.ts`
|
||||
|
||||
### `ConnectionState` Type (from `@/lib/websocket/connection`)
|
||||
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
# Conversation-first layout, agent identity, and live-stream affordances
|
||||
|
||||
Interaction spec for the pattern that makes a live conversation the primary
|
||||
surface of a view, rather than a secondary panel bolted onto a data table: a
|
||||
three-region layout, a team-color agent identity scheme that scales to the
|
||||
full 22-agent roster, connection-state visual treatment, a new-message
|
||||
arrival cue, and the loading/empty/error states a conversation panel needs.
|
||||
Written so a frontend developer can implement directly from this document
|
||||
without further design clarification.
|
||||
Interaction spec for the pattern that makes a live conversation the primary surface of a view, rather than a secondary panel bolted onto a data table: a three-region layout, a team-color agent identity scheme that scales to the full 22-agent roster, connection-state visual treatment, a new-message arrival cue, and the loading/empty/error states a conversation panel needs. Written so a frontend developer can implement directly from this document without further design clarification.
|
||||
|
||||
## Scope and where this lives
|
||||
|
||||
This is a **pattern spec**, not a new page proposal. It extends the one
|
||||
conversation surface RoboCo already ships,
|
||||
`panel/src/app/(dashboard)/a2a/page.tsx`, plus its sub-components:
|
||||
This is a **pattern spec**, not a new page proposal. It extends the one conversation surface RoboCo already ships, `panel/src/app/(dashboard)/a2a/page.tsx`, plus its sub-components:
|
||||
|
||||
| Piece | Existing file it extends |
|
||||
|---|---|
|
||||
@@ -22,20 +14,9 @@ conversation surface RoboCo already ships,
|
||||
| Agent identity | `panel/src/lib/agent-utils.ts` (`getAgentInitials`, `getAgentDisplayName`) |
|
||||
| Connection state | `panel/src/hooks/use-websocket.ts` (`ConnectionState` = `"connecting" \| "connected" \| "reconnecting" \| "disconnected"`), `panel/src/components/layout/connection-status.tsx` |
|
||||
|
||||
Nothing here replaces the `/a2a` page's existing behavior (message fetch,
|
||||
reply composer, switchboard/list toggle) — every section below is additive:
|
||||
a third pane, a color layer on an existing avatar, a refined connection
|
||||
badge, an entrance transition for new rows, and the states around the
|
||||
stream when it has nothing (yet) to show. The same three-region composition
|
||||
and identity/connection/arrival treatment apply to any future conversation
|
||||
surface RoboCo adds (e.g. a unified agent-activity inbox) without
|
||||
re-deriving the pattern.
|
||||
Nothing here replaces the `/a2a` page's existing behavior (message fetch, reply composer, switchboard/list toggle) — every section below is additive: a third pane, a color layer on an existing avatar, a refined connection badge, an entrance transition for new rows, and the states around the stream when it has nothing (yet) to show. The same three-region composition and identity/connection/arrival treatment apply to any future conversation surface RoboCo adds (e.g. a unified agent-activity inbox) without re-deriving the pattern.
|
||||
|
||||
**Design bar dial read:** dense product UI (a data-heavy live-ops surface
|
||||
inside existing panel chrome), not a landing page — variance 2, motion 2-3,
|
||||
density 7, the UX/UI cell's dashboard default. No new radius or shadow
|
||||
tokens; color additions are a bounded, named palette (below), not ad hoc
|
||||
hex values.
|
||||
**Design bar dial read:** dense product UI (a data-heavy live-ops surface inside existing panel chrome), not a landing page — variance 2, motion 2-3, density 7, the UX/UI cell's dashboard default. No new radius or shadow tokens; color additions are a bounded, named palette (below), not ad hoc hex values.
|
||||
|
||||
---
|
||||
|
||||
@@ -43,8 +24,7 @@ hex values.
|
||||
|
||||
### The three regions
|
||||
|
||||
A conversation-first surface is composed of three regions, always in this
|
||||
order left-to-right, with the stream always the widest:
|
||||
A conversation-first surface is composed of three regions, always in this order left-to-right, with the stream always the widest:
|
||||
|
||||
```
|
||||
┌─ Roster (list rail) ─┬───── Stream (primary) ─────┬─ Context (collapsible) ─┐
|
||||
@@ -55,20 +35,13 @@ order left-to-right, with the stream always the widest:
|
||||
└────────────────────────┴───────────────────────────────┴─────────────────────┘
|
||||
```
|
||||
|
||||
- **Roster** is navigation: "which conversation am I looking at" — today's
|
||||
`A2ASwitchboard`/`A2AConversationList`.
|
||||
- **Stream** is content: "what was said" — today's `A2ATranscript` plus the
|
||||
reply composer beneath it. This region gets the majority of horizontal
|
||||
space at every breakpoint that shows more than one region, because it is
|
||||
the primary surface, not a byproduct of the roster selection.
|
||||
- **Context** is metadata: participant identity detail, the linked task
|
||||
(title, status, a link into `/tasks/{id}`), and any quick actions — a new
|
||||
region, collapsible, not present in the current implementation.
|
||||
- **Roster** is navigation: "which conversation am I looking at" — today's `A2ASwitchboard`/`A2AConversationList`.
|
||||
- **Stream** is content: "what was said" — today's `A2ATranscript` plus the reply composer beneath it. This region gets the majority of horizontal space at every breakpoint that shows more than one region, because it is the primary surface, not a byproduct of the roster selection.
|
||||
- **Context** is metadata: participant identity detail, the linked task (title, status, a link into `/tasks/{id}`), and any quick actions — a new region, collapsible, not present in the current implementation.
|
||||
|
||||
### Grid and breakpoints
|
||||
|
||||
Extends the existing `grid grid-cols-12 gap-4 lg:gap-6` container
|
||||
(`a2a/page.tsx:260`) with one more breakpoint tier rather than replacing it:
|
||||
Extends the existing `grid grid-cols-12 gap-4 lg:gap-6` container (`a2a/page.tsx:260`) with one more breakpoint tier rather than replacing it:
|
||||
|
||||
| Breakpoint | Regions visible | Column split |
|
||||
|---|---|---|
|
||||
@@ -76,31 +49,17 @@ Extends the existing `grid grid-cols-12 gap-4 lg:gap-6` container
|
||||
| `lg` – `< xl` | Roster + Stream (today's behavior, unchanged) | Roster `col-span-4`, Stream `col-span-8` |
|
||||
| `xl`+ | Roster + Stream + Context | Roster `col-span-3`, Stream `col-span-6`, Context `col-span-3` |
|
||||
|
||||
The context pane is the new addition and is the one that collapses first —
|
||||
it never appears below `xl`, and even at `xl`+ it is dismissible via a
|
||||
header toggle (a `PanelRightClose`/`PanelRightOpen` icon button, `size="sm"
|
||||
variant="ghost"`, matching the existing switchboard/list toggle buttons at
|
||||
`a2a/page.tsx:275-296`) so a user who wants the stream at full width above
|
||||
`xl` can still get it. Collapsed state persists in `localStorage`
|
||||
(`roboco:conversation-context-open`, boolean), read once at mount — the same
|
||||
persistence idiom already used for panel-width/theme preferences (avoids a
|
||||
new state-management dependency).
|
||||
The context pane is the new addition and is the one that collapses first — it never appears below `xl`, and even at `xl`+ it is dismissible via a header toggle (a `PanelRightClose`/`PanelRightOpen` icon button, `size="sm" variant="ghost"`, matching the existing switchboard/list toggle buttons at `a2a/page.tsx:275-296`) so a user who wants the stream at full width above `xl` can still get it. Collapsed state persists in `localStorage` (`roboco:conversation-context-open`, boolean), read once at mount — the same persistence idiom already used for panel-width/theme preferences (avoids a new state-management dependency).
|
||||
|
||||
### Context pane content
|
||||
|
||||
When open, the context pane shows, top to bottom:
|
||||
|
||||
1. Both participants' identity cards (avatar + name + team badge — see
|
||||
§2), each linking to `/agents/{slug}`.
|
||||
2. The linked task, if any: title (truncated to one line), status `Badge`
|
||||
(reusing the same `variant` mapping already used at `a2a/page.tsx:337-344`),
|
||||
and a "View task" link.
|
||||
3. A muted one-line hint when there is no linked task ("This conversation
|
||||
has no linked task"), matching the tone of the existing no-task composer
|
||||
message (`a2a/page.tsx:373-377`).
|
||||
1. Both participants' identity cards (avatar + name + team badge — see §2), each linking to `/agents/{slug}`.
|
||||
2. The linked task, if any: title (truncated to one line), status `Badge` (reusing the same `variant` mapping already used at `a2a/page.tsx:337-344`), and a "View task" link.
|
||||
3. A muted one-line hint when there is no linked task ("This conversation has no linked task"), matching the tone of the existing no-task composer message (`a2a/page.tsx:373-377`).
|
||||
|
||||
The context pane does not duplicate the reply composer or transcript — it
|
||||
is read-only summary, never a second place to act on the conversation.
|
||||
The context pane does not duplicate the reply composer or transcript — it is read-only summary, never a second place to act on the conversation.
|
||||
|
||||
---
|
||||
|
||||
@@ -108,22 +67,11 @@ is read-only summary, never a second place to act on the conversation.
|
||||
|
||||
### Why team color, not per-agent color
|
||||
|
||||
With 22 agents in the roster (`AGENT_UUIDS` in `agent-utils.ts`), a unique
|
||||
hue per agent is not legible — nobody can hold 22 arbitrary colors in
|
||||
working memory, and two similar hues (e.g. two blues for `be-dev-1` and
|
||||
`fe-dev-1`) would read as "the same agent" at a glance. Colour is scoped to
|
||||
the axis that actually matters for fast scanning — **which cell this agent
|
||||
belongs to** — and individual identity within a team is carried by the
|
||||
existing initials/code, not a second hue. This scales cleanly: adding a
|
||||
23rd agent to an existing team changes zero colors; adding a whole new team
|
||||
is the only case that needs a new bucket, and the palette below already has
|
||||
headroom.
|
||||
With 22 agents in the roster (`AGENT_UUIDS` in `agent-utils.ts`), a unique hue per agent is not legible — nobody can hold 22 arbitrary colors in working memory, and two similar hues (e.g. two blues for `be-dev-1` and `fe-dev-1`) would read as "the same agent" at a glance. Colour is scoped to the axis that actually matters for fast scanning — **which cell this agent belongs to** — and individual identity within a team is carried by the existing initials/code, not a second hue. This scales cleanly: adding a 23rd agent to an existing team changes zero colors; adding a whole new team is the only case that needs a new bucket, and the palette below already has headroom.
|
||||
|
||||
### The six buckets
|
||||
|
||||
A new pure function, `getAgentTeamColor(agentId: string | null | undefined):
|
||||
AgentTeamColor`, colocated in `agent-utils.ts` next to `getAgentInitials`
|
||||
(same module — it needs the same slug-resolution logic already there):
|
||||
A new pure function, `getAgentTeamColor(agentId: string | null | undefined): AgentTeamColor`, colocated in `agent-utils.ts` next to `getAgentInitials` (same module — it needs the same slug-resolution logic already there):
|
||||
|
||||
```ts
|
||||
export type AgentTeamColor =
|
||||
@@ -135,11 +83,7 @@ export type AgentTeamColor =
|
||||
| "system";
|
||||
```
|
||||
|
||||
Derived from the slug prefix (`be-*` → `backend`, `fe-*` → `frontend`,
|
||||
`ux-*` → `ux_ui`, `main-pm`/`product-owner`/`head-marketing`/`auditor` →
|
||||
`board`, `ceo`/`CEO` → `ceo`, `intake-*`/`secretary-*`/`pr-reviewer-*` →
|
||||
`system`), with the same UUID-to-slug resolution `getAgentInitials` already
|
||||
does via `resolveToSlug`.
|
||||
Derived from the slug prefix (`be-*` → `backend`, `fe-*` → `frontend`, `ux-*` → `ux_ui`, `main-pm`/`product-owner`/`head-marketing`/`auditor` → `board`, `ceo`/`CEO` → `ceo`, `intake-*`/`secretary-*`/`pr-reviewer-*` → `system`), with the same UUID-to-slug resolution `getAgentInitials` already does via `resolveToSlug`.
|
||||
|
||||
| Bucket | Agents | Token classes (light / dark handled by existing `dark:` pairs already in the codebase's Tailwind v4 setup) |
|
||||
|---|---|---|
|
||||
@@ -150,19 +94,11 @@ does via `resolveToSlug`.
|
||||
| `ceo` | ceo | `bg-primary/15 border-primary/40 text-primary` (the app's own accent — the one human gets the app's own color, not a team bucket) |
|
||||
| `system` | intake-1, secretary-1, pr-reviewer-1 | `bg-slate-500/15 border-slate-500/40 text-slate-700 dark:text-slate-400` |
|
||||
|
||||
Every value here is an existing Tailwind color family already used
|
||||
elsewhere in the codebase for the same semantic weight (`amber` for
|
||||
attention in `release-proposal-card.tsx:181`, `blue`/`violet`/`fuchsia` are
|
||||
Tailwind defaults, no new tokens introduced) at the same `/15` background +
|
||||
`/40` border opacity already established by the pulse-card treatment in
|
||||
`a2a-pair-card.tsx:87`.
|
||||
Every value here is an existing Tailwind color family already used elsewhere in the codebase for the same semantic weight (`amber` for attention in `release-proposal-card.tsx:181`, `blue`/`violet`/`fuchsia` are Tailwind defaults, no new tokens introduced) at the same `/15` background + `/40` border opacity already established by the pulse-card treatment in `a2a-pair-card.tsx:87`.
|
||||
|
||||
### Avatar composition
|
||||
|
||||
Extends the existing avatar circle (`PairAvatar` in `a2a-pair-card.tsx:20-31`,
|
||||
and the inline avatar in `a2a-transcript.tsx:70-74`) with the team color as
|
||||
`border` + `bg`, keeping the initials as the foreground content — the color
|
||||
becomes a ring around identity, not a replacement for it:
|
||||
Extends the existing avatar circle (`PairAvatar` in `a2a-pair-card.tsx:20-31`, and the inline avatar in `a2a-transcript.tsx:70-74`) with the team color as `border` + `bg`, keeping the initials as the foreground content — the color becomes a ring around identity, not a replacement for it:
|
||||
|
||||
```tsx
|
||||
<div
|
||||
@@ -178,25 +114,11 @@ becomes a ring around identity, not a replacement for it:
|
||||
</div>
|
||||
```
|
||||
|
||||
`TEAM_COLOR_CLASSES` is a `Record<AgentTeamColor, string>` map of the class
|
||||
strings from the table above, exported alongside `getAgentTeamColor` so
|
||||
every consumer (transcript rows, pair cards, roster rows, context pane
|
||||
identity cards) applies the identical mapping — one source of truth, no
|
||||
per-component re-derivation.
|
||||
`TEAM_COLOR_CLASSES` is a `Record<AgentTeamColor, string>` map of the class strings from the table above, exported alongside `getAgentTeamColor` so every consumer (transcript rows, pair cards, roster rows, context pane identity cards) applies the identical mapping — one source of truth, no per-component re-derivation.
|
||||
|
||||
### Accessibility
|
||||
|
||||
Color is never the sole differentiator: the `title` attribute always
|
||||
carries the full display name (already the case in `PairAvatar`), the
|
||||
initials/code is always visible text inside the circle, and every place an
|
||||
avatar appears the agent's display name renders as adjacent text (already
|
||||
true in the transcript and pair card). A screen reader user gets the name
|
||||
from the text content regardless of the color layer. All six token pairs
|
||||
above meet WCAG AA (4.5:1) for the `text-*-700`/`text-*-400` foreground
|
||||
against a `bg-*-500/15` fill over the app's `background`/`card` surface —
|
||||
verify against the actual rendered surface at implementation time per the
|
||||
design bar's contrast-audit rule, since a `/15` alpha fill's effective
|
||||
contrast depends on what's behind it.
|
||||
Color is never the sole differentiator: the `title` attribute always carries the full display name (already the case in `PairAvatar`), the initials/code is always visible text inside the circle, and every place an avatar appears the agent's display name renders as adjacent text (already true in the transcript and pair card). A screen reader user gets the name from the text content regardless of the color layer. All six token pairs above meet WCAG AA (4.5:1) for the `text-*-700`/`text-*-400` foreground against a `bg-*-500/15` fill over the app's `background`/`card` surface — verify against the actual rendered surface at implementation time per the design bar's contrast-audit rule, since a `/15` alpha fill's effective contrast depends on what's behind it.
|
||||
|
||||
---
|
||||
|
||||
@@ -204,10 +126,7 @@ contrast depends on what's behind it.
|
||||
|
||||
### States
|
||||
|
||||
`ConnectionState` already has four values (`use-websocket.ts:17-21` via
|
||||
`lib/websocket/connection.ts`); the spec covers all four, since
|
||||
`"connecting"` (initial handshake) and `"reconnecting"` (recovering after a
|
||||
drop) share one visual family with a different label:
|
||||
`ConnectionState` already has four values (`use-websocket.ts:17-21` via `lib/websocket/connection.ts`); the spec covers all four, since `"connecting"` (initial handshake) and `"reconnecting"` (recovering after a drop) share one visual family with a different label:
|
||||
|
||||
| State | Dot | Label | Icon (header, inline) | Placement |
|
||||
|---|---|---|---|---|
|
||||
@@ -216,27 +135,13 @@ drop) share one visual family with a different label:
|
||||
| `reconnecting` | `bg-amber-500`, `animate-pulse` | "Reconnecting…" | `Loader2` with `animate-spin`, `h-3 w-3` | Inline in the pane header, **plus** a thin dismissable strip directly above the stream pane's message list: `bg-amber-500/10 border-b border-amber-500/30 text-amber-700 dark:text-amber-400 text-xs px-3 py-1.5` reading "Reconnecting — messages may be out of date" |
|
||||
| `disconnected` | `bg-muted-foreground/40`, static | "Offline" | `WifiOff`, `h-3 w-3`, `text-muted-foreground` | Inline in the pane header, **plus** the same strip pattern as `reconnecting` but `bg-destructive/10 border-destructive/30 text-destructive`, reading "Disconnected — reconnecting automatically" |
|
||||
|
||||
The `connected`/`connecting`/`reconnecting` distinction matters because a
|
||||
user watching a live conversation needs to know *why* nothing new is
|
||||
arriving: `connected`-but-quiet means the conversation is genuinely idle;
|
||||
`reconnecting`/`disconnected` means the stream itself is the problem, not
|
||||
the conversation. Collapsing all three into one generic "not live" state
|
||||
(as today's binary `isConnected ? "Live" : "Offline"` does) hides that
|
||||
distinction.
|
||||
The `connected`/`connecting`/`reconnecting` distinction matters because a user watching a live conversation needs to know *why* nothing new is arriving: `connected`-but-quiet means the conversation is genuinely idle; `reconnecting`/`disconnected` means the stream itself is the problem, not the conversation. Collapsing all three into one generic "not live" state (as today's binary `isConnected ? "Live" : "Offline"` does) hides that distinction.
|
||||
|
||||
The banner strip is scoped to the stream pane only, not a full-page
|
||||
takeover — this is a live-connection hint, not an application-down state
|
||||
(that's `OfflineState`, reserved for §5's error case where data can't load
|
||||
at all).
|
||||
The banner strip is scoped to the stream pane only, not a full-page takeover — this is a live-connection hint, not an application-down state (that's `OfflineState`, reserved for §5's error case where data can't load at all).
|
||||
|
||||
### Motion note
|
||||
|
||||
The existing `animate-pulse` dot (`a2a/page.tsx:228`) is a Tailwind
|
||||
keyframe that only animates `opacity`, so it already satisfies the "animate
|
||||
transform/opacity only" rule — but it has no `prefers-reduced-motion` guard
|
||||
today. Add one: wrap the pulsing states in `motion-reduce:animate-none`, so
|
||||
a reduced-motion user gets a static dot at full opacity instead of the
|
||||
pulse — the color and label alone still convey the state.
|
||||
The existing `animate-pulse` dot (`a2a/page.tsx:228`) is a Tailwind keyframe that only animates `opacity`, so it already satisfies the "animate transform/opacity only" rule — but it has no `prefers-reduced-motion` guard today. Add one: wrap the pulsing states in `motion-reduce:animate-none`, so a reduced-motion user gets a static dot at full opacity instead of the pulse — the color and label alone still convey the state.
|
||||
|
||||
---
|
||||
|
||||
@@ -244,11 +149,7 @@ pulse — the color and label alone still convey the state.
|
||||
|
||||
### The cue
|
||||
|
||||
When a new message is appended to the stream (a `a2a.message` frame that
|
||||
resolves to a new row after the existing invalidate-on-frame refetch,
|
||||
`a2a/page.tsx:131-140`), the new row enters with a **transform + opacity
|
||||
only** transition — no layout-affecting property, no scroll-listener-driven
|
||||
animation, per the design bar's motion rule:
|
||||
When a new message is appended to the stream (a `a2a.message` frame that resolves to a new row after the existing invalidate-on-frame refetch, `a2a/page.tsx:131-140`), the new row enters with a **transform + opacity only** transition — no layout-affecting property, no scroll-listener-driven animation, per the design bar's motion rule:
|
||||
|
||||
```tsx
|
||||
className={cn(
|
||||
@@ -257,25 +158,13 @@ className={cn(
|
||||
)}
|
||||
```
|
||||
|
||||
`isNew` is derived the same render-phase way `A2APairCard`'s `isPulsing`
|
||||
already is (`a2a-pair-card.tsx:49-60`): compare the incoming message id
|
||||
against a "last seen" set in render, flip to `false` on the next animation
|
||||
frame via `requestAnimationFrame` inside a `useEffect` — no animation
|
||||
library, matching the codebase's existing idiom for this exact kind of
|
||||
one-shot entrance state.
|
||||
`isNew` is derived the same render-phase way `A2APairCard`'s `isPulsing` already is (`a2a-pair-card.tsx:49-60`): compare the incoming message id against a "last seen" set in render, flip to `false` on the next animation frame via `requestAnimationFrame` inside a `useEffect` — no animation library, matching the codebase's existing idiom for this exact kind of one-shot entrance state.
|
||||
|
||||
The starting state (`opacity-0 translate-y-1`, i.e. 4px down) is applied
|
||||
only for rows that mount already-new (a message arriving while the stream
|
||||
is open); rows present at initial transcript load render straight to
|
||||
`opacity-100 translate-y-0` with no transition, so opening a conversation
|
||||
never shows every existing message animating in at once.
|
||||
The starting state (`opacity-0 translate-y-1`, i.e. 4px down) is applied only for rows that mount already-new (a message arriving while the stream is open); rows present at initial transcript load render straight to `opacity-100 translate-y-0` with no transition, so opening a conversation never shows every existing message animating in at once.
|
||||
|
||||
### Off-screen arrival (scrolled up)
|
||||
|
||||
When the user has scrolled up in the stream (not at the bottom) and a new
|
||||
message arrives, do not auto-scroll and do not play the row-entrance
|
||||
transition off-screen. Instead show a small pill anchored to the bottom of
|
||||
the stream pane:
|
||||
When the user has scrolled up in the stream (not at the bottom) and a new message arrives, do not auto-scroll and do not play the row-entrance transition off-screen. Instead show a small pill anchored to the bottom of the stream pane:
|
||||
|
||||
```tsx
|
||||
<button
|
||||
@@ -286,31 +175,17 @@ the stream pane:
|
||||
</button>
|
||||
```
|
||||
|
||||
— same transform/opacity-only constraint, appearing with the same
|
||||
fade-and-rise-in treatment as the row cue. Clicking it scrolls to bottom
|
||||
(smooth, CSS `scroll-behavior: smooth` — a browser-native scroll, not a
|
||||
`scroll` event listener) and dismisses the pill.
|
||||
— same transform/opacity-only constraint, appearing with the same fade-and-rise-in treatment as the row cue. Clicking it scrolls to bottom (smooth, CSS `scroll-behavior: smooth` — a browser-native scroll, not a `scroll` event listener) and dismisses the pill.
|
||||
|
||||
### `prefers-reduced-motion`
|
||||
|
||||
Both cues drop the `translate-y`/`-translate-x` transform and the
|
||||
`duration-200` transition under `motion-reduce:`, leaving only the
|
||||
`opacity` state change applied instantly (`motion-reduce:transition-none
|
||||
motion-reduce:translate-y-0`) — the row/pill still visually distinguishes
|
||||
"just arrived" via a brief `bg-muted/50` background flash (a
|
||||
non-transform, non-motion cue: a background-color change with its own
|
||||
short `transition-colors duration-150`, exempt from the transform/opacity
|
||||
restriction because a plain color transition is not motion) that fades to
|
||||
the row's normal `bg-card` over 150ms, so reduced-motion users still get
|
||||
an arrival signal without any movement.
|
||||
Both cues drop the `translate-y`/`-translate-x` transform and the `duration-200` transition under `motion-reduce:`, leaving only the `opacity` state change applied instantly (`motion-reduce:transition-none motion-reduce:translate-y-0`) — the row/pill still visually distinguishes "just arrived" via a brief `bg-muted/50` background flash (a non-transform, non-motion cue: a background-color change with its own short `transition-colors duration-150`, exempt from the transform/opacity restriction because a plain color transition is not motion) that fades to the row's normal `bg-card` over 150ms, so reduced-motion users still get an arrival signal without any movement.
|
||||
|
||||
---
|
||||
|
||||
## 5. Loading, empty, and error states
|
||||
|
||||
All three states are scoped to the stream pane's content area — the pane
|
||||
chrome (header, region title, connection badge) stays mounted and stable
|
||||
across state changes, only the message-list area swaps.
|
||||
All three states are scoped to the stream pane's content area — the pane chrome (header, region title, connection badge) stays mounted and stable across state changes, only the message-list area swaps.
|
||||
|
||||
| State | Trigger | Treatment |
|
||||
|---|---|---|
|
||||
@@ -318,11 +193,7 @@ across state changes, only the message-list area swaps.
|
||||
| **Empty** | Fetch resolved, zero messages (`A2ATranscript` lines 51-60) | Existing centered icon + one-line text (`MessagesSquare`, opacity-50, `text-sm text-muted-foreground`). Extend the copy to be context-aware: "No messages in this conversation yet" when a conversation is selected (current copy, unchanged) vs. "Select a conversation to view messages" when nothing is selected yet (the roster-selected-nothing case, not currently distinguished) — same icon, same layout, only the string changes based on whether `selectedId`/`peekedPair` is set. |
|
||||
| **Error** | The messages fetch itself errors (distinct from the page-level `isOffline` full-page case at `a2a/page.tsx:239-244`, which covers the *conversations list* failing to load) | A scoped inline state inside the stream pane, not a full-page `OfflineState`: centered `AlertTriangle` icon (`h-8 w-8 opacity-50 text-destructive`), "Couldn't load this conversation" text, and a `Button variant="outline" size="sm"` "Retry" that calls the existing `refetchMessages()`. Reuses the same centered-icon-plus-text layout shell as the empty state (same wrapper `div`, different icon/copy/action) so the three states read as one family, not three unrelated designs. |
|
||||
|
||||
The distinction between the page-level `OfflineState` (backend unreachable
|
||||
entirely, `a2a/page.tsx:239-244`) and this pane-level error state (this one
|
||||
conversation's message fetch failed, everything else on the page still
|
||||
works) matters: a transient 500 on one conversation's messages should never
|
||||
take over the whole page.
|
||||
The distinction between the page-level `OfflineState` (backend unreachable entirely, `a2a/page.tsx:239-244`) and this pane-level error state (this one conversation's message fetch failed, everything else on the page still works) matters: a transient 500 on one conversation's messages should never take over the whole page.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,39 +1,16 @@
|
||||
# Filter control for A2A conversations
|
||||
|
||||
Design spec for a filter control on the panel's `/a2a` page
|
||||
(`panel/src/app/(dashboard)/a2a/page.tsx`), which is the CEO-facing
|
||||
conversation-first view of agent-to-agent chats. Today the page has no
|
||||
filtering at all: Panel 1 shows either the org-chart **Switchboard**
|
||||
(`A2ASwitchboard`) or the classic **Conversation List** (`A2AConversationList`),
|
||||
capped at the last 100 conversations with no way to narrow them.
|
||||
Design spec for a filter control on the panel's `/a2a` page (`panel/src/app/(dashboard)/a2a/page.tsx`), which is the CEO-facing conversation-first view of agent-to-agent chats. Today the page has no filtering at all: Panel 1 shows either the org-chart **Switchboard** (`A2ASwitchboard`) or the classic **Conversation List** (`A2AConversationList`), capped at the last 100 conversations with no way to narrow them.
|
||||
|
||||
This spec covers the filter dimensions, their placement, the active-filter
|
||||
chip representation, and accessibility. It reuses the Popover + Checkbox +
|
||||
Badge-chip pattern already shipped in
|
||||
`panel/src/components/tasks/task-filters.tsx` rather than inventing a new
|
||||
filter idiom, and stays within primitives already in
|
||||
`panel/src/components/ui/` (`popover.tsx`, `checkbox.tsx`, `badge.tsx`,
|
||||
`button.tsx`, `input.tsx`).
|
||||
This spec covers the filter dimensions, their placement, the active-filter chip representation, and accessibility. It reuses the Popover + Checkbox + Badge-chip pattern already shipped in `panel/src/components/tasks/task-filters.tsx` rather than inventing a new filter idiom, and stays within primitives already in `panel/src/components/ui/` (`popover.tsx`, `checkbox.tsx`, `badge.tsx`, `button.tsx`, `input.tsx`).
|
||||
|
||||
## Design-bar dial read
|
||||
|
||||
This is a dense admin surface (a live operations console), not a marketing
|
||||
page, so per the UX/UI team's design bar defaults: **DESIGN_VARIANCE 2**
|
||||
(the control sits in an already-fixed grid, no asymmetry), **MOTION_INTENSITY
|
||||
2** (popover open/close and chip enter/exit only, no scroll choreography),
|
||||
**VISUAL_DENSITY 7** (compact chips and a single icon-button trigger, not an
|
||||
airy multi-field form bar like the full-width `TaskFilters` on `/tasks`,
|
||||
because Panel 1 is only 4/12 columns wide at `lg`+).
|
||||
This is a dense admin surface (a live operations console), not a marketing page, so per the UX/UI team's design bar defaults: **DESIGN_VARIANCE 2** (the control sits in an already-fixed grid, no asymmetry), **MOTION_INTENSITY 2** (popover open/close and chip enter/exit only, no scroll choreography), **VISUAL_DENSITY 7** (compact chips and a single icon-button trigger, not an airy multi-field form bar like the full-width `TaskFilters` on `/tasks`, because Panel 1 is only 4/12 columns wide at `lg`+).
|
||||
|
||||
## 1. Filterable dimensions
|
||||
|
||||
Four dimensions, each backed by a field already on the wire today
|
||||
(`AdminConversationSummary` / `AdminPairSummary` in `panel/src/lib/api/a2a.ts`).
|
||||
None of these are supported as backend query params yet — `a2aApi.listAdminConversations`
|
||||
only accepts `limit`. The spec below filters **client-side over the already-fetched
|
||||
page** (currently capped at `limit=100`); the "Future work" note calls out
|
||||
the backend seam so a later task can add server-side params without changing
|
||||
this UI contract.
|
||||
Four dimensions, each backed by a field already on the wire today (`AdminConversationSummary` / `AdminPairSummary` in `panel/src/lib/api/a2a.ts`). None of these are supported as backend query params yet — `a2aApi.listAdminConversations` only accepts `limit`. The spec below filters **client-side over the already-fetched page** (currently capped at `limit=100`); the "Future work" note calls out the backend seam so a later task can add server-side params without changing this UI contract.
|
||||
|
||||
| # | Dimension | Source field(s) | Value domain | Control widget |
|
||||
|---|---|---|---|---|
|
||||
@@ -42,31 +19,13 @@ this UI contract.
|
||||
| 3 | **Status** | `status` (`"active"` \| `"archived"`, the same two values `A2AConversationList`'s `Badge variant` already branches on) | The 2 known statuses | Two-option toggle group (button pair, `aria-pressed`, same idiom as the page's own Switchboard/List view toggle) — a checkbox list would be overkill for 2 values |
|
||||
| 4 | **Date/time range** | `last_message_at` (falls back to `created_at` when null, matching the list item's own `formatDistanceToNow` fallback) | Any ISO-8601 instant; user picks calendar dates, compared at day granularity in the viewer's local timezone | Two `Input type="date"` fields labeled "From" / "To" — no date-range-picker component exists in `panel/src/components/ui/`, so this stays within already-installed primitives per the ladder (native `<input type="date">` is a browser-native widget, not a new dependency) |
|
||||
|
||||
**Per-view applicability.** The Switchboard is org-chart pair cards
|
||||
(`AdminPairSummary`), not a conversation feed — most pairs have never
|
||||
talked (`conversation_id: null`). Only **Agent** applies there (narrows
|
||||
which pair cards render, same predicate as dimension 1 above); Task/Status/
|
||||
Date filters have no meaning for a pair with no conversation, so selecting
|
||||
them while in Switchboard view shows a one-line inline note ("Task, Status,
|
||||
and Date filters apply to the Conversation List view") rather than silently
|
||||
hiding pairs. Switching to List view via the existing `LayoutGrid`/`ListIcon`
|
||||
toggle applies all four dimensions.
|
||||
**Per-view applicability.** The Switchboard is org-chart pair cards (`AdminPairSummary`), not a conversation feed — most pairs have never talked (`conversation_id: null`). Only **Agent** applies there (narrows which pair cards render, same predicate as dimension 1 above); Task/Status/ Date filters have no meaning for a pair with no conversation, so selecting them while in Switchboard view shows a one-line inline note ("Task, Status, and Date filters apply to the Conversation List view") rather than silently hiding pairs. Switching to List view via the existing `LayoutGrid`/`ListIcon` toggle applies all four dimensions.
|
||||
|
||||
**Future work (not this task):** once conversation volume regularly exceeds
|
||||
the `limit=100` page, promote Task/Status/Date to real backend query params
|
||||
on `GET /a2a/chat/admin/conversations` (`agent`, `task_id`, `status`,
|
||||
`from`, `to`) so filtering isn't limited to whatever page happened to load.
|
||||
Client-side filtering as specified here is correct for the current data
|
||||
volume and ships without a backend task.
|
||||
**Future work (not this task):** once conversation volume regularly exceeds the `limit=100` page, promote Task/Status/Date to real backend query params on `GET /a2a/chat/admin/conversations` (`agent`, `task_id`, `status`, `from`, `to`) so filtering isn't limited to whatever page happened to load. Client-side filtering as specified here is correct for the current data volume and ships without a backend task.
|
||||
|
||||
## 2. Placement
|
||||
|
||||
The control must not crowd the conversation-first message stream: it lives
|
||||
entirely inside **Panel 1** (the list/switchboard card), never inside
|
||||
**Panel 2** (transcript + composer). Concretely, it is added to the existing
|
||||
Panel-1 header row in `A2APageContent` (`page.tsx` lines ~269-298), which
|
||||
today holds a `Radio` icon, a label, and the Switchboard/List view-toggle
|
||||
buttons:
|
||||
The control must not crowd the conversation-first message stream: it lives entirely inside **Panel 1** (the list/switchboard card), never inside **Panel 2** (transcript + composer). Concretely, it is added to the existing Panel-1 header row in `A2APageContent` (`page.tsx` lines ~269-298), which today holds a `Radio` icon, a label, and the Switchboard/List view-toggle buttons:
|
||||
|
||||
```
|
||||
Collapsed (no active filters) — Panel 1 header, unchanged height:
|
||||
@@ -76,20 +35,9 @@ Collapsed (no active filters) — Panel 1 header, unchanged height:
|
||||
│ ...pair cards / conversation list... │
|
||||
```
|
||||
|
||||
The trigger is a single compact `Button variant="outline" size="sm"` reading
|
||||
`Filters` (funnel icon, `lucide-react`'s `SlidersHorizontal`), matching the
|
||||
view-toggle buttons' height (`h-7`) so the header row's height never changes
|
||||
— that's what keeps it from crowding the list below. It shows an active-count
|
||||
badge inline (`Filters · 2`) instead of a separate counter chip when >=1
|
||||
filter is set, same abbreviation `TaskFilters` already uses for its
|
||||
per-dimension triggers (`"${n} statuses"`).
|
||||
The trigger is a single compact `Button variant="outline" size="sm"` reading `Filters` (funnel icon, `lucide-react`'s `SlidersHorizontal`), matching the view-toggle buttons' height (`h-7`) so the header row's height never changes — that's what keeps it from crowding the list below. It shows an active-count badge inline (`Filters · 2`) instead of a separate counter chip when >=1 filter is set, same abbreviation `TaskFilters` already uses for its per-dimension triggers (`"${n} statuses"`).
|
||||
|
||||
Clicking the trigger opens **one** `Popover` (not four separate popovers
|
||||
like the full-width `/tasks` page — Panel 1 is too narrow at 4/12 columns
|
||||
for a row of triggers) containing all four dimension controls stacked
|
||||
vertically, each in its own labeled section with a small `Clear` link when
|
||||
that dimension has a selection — directly modeled on each section inside
|
||||
`TaskFilters`' existing per-dimension `PopoverContent` blocks:
|
||||
Clicking the trigger opens **one** `Popover` (not four separate popovers like the full-width `/tasks` page — Panel 1 is too narrow at 4/12 columns for a row of triggers) containing all four dimension controls stacked vertically, each in its own labeled section with a small `Clear` link when that dimension has a selection — directly modeled on each section inside `TaskFilters`' existing per-dimension `PopoverContent` blocks:
|
||||
|
||||
```
|
||||
Expanded (popover open), anchored bottom-right of the trigger:
|
||||
@@ -113,25 +61,13 @@ Expanded (popover open), anchored bottom-right of the trigger:
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
Filters apply live as each control changes (no separate "Apply" button) —
|
||||
consistent with `TaskFilters`, whose `onStatusChange`/`onTeamChange` etc.
|
||||
fire immediately. The popover's max height is capped (`max-h-[70vh]
|
||||
overflow-y-auto`, same idea as `TaskFilters`' `max-h-64 overflow-y-auto`
|
||||
project/product lists) so it never grows taller than the viewport on small
|
||||
screens.
|
||||
Filters apply live as each control changes (no separate "Apply" button) — consistent with `TaskFilters`, whose `onStatusChange`/`onTeamChange` etc. fire immediately. The popover's max height is capped (`max-h-[70vh] overflow-y-auto`, same idea as `TaskFilters`' `max-h-64 overflow-y-auto` project/product lists) so it never grows taller than the viewport on small screens.
|
||||
|
||||
On mobile (`<lg`, where Panel 1 is the only visible pane per the page's
|
||||
existing `onDetailLevel` show/hide split), the trigger and popover behave
|
||||
identically — the popover already clamps to the viewport, so no separate
|
||||
mobile layout is needed.
|
||||
On mobile (`<lg`, where Panel 1 is the only visible pane per the page's existing `onDetailLevel` show/hide split), the trigger and popover behave identically — the popover already clamps to the viewport, so no separate mobile layout is needed.
|
||||
|
||||
## 3. Active-filter chips + clear-all
|
||||
|
||||
When one or more filters are active, a **chip row** appears directly below
|
||||
the Panel-1 header (above the list/switchboard content), pushing the list
|
||||
down by exactly the chip row's own height — it does not overlay content and
|
||||
it collapses to zero height (not rendered at all) when no filters are set,
|
||||
so the empty/default state is pixel-identical to today's layout:
|
||||
When one or more filters are active, a **chip row** appears directly below the Panel-1 header (above the list/switchboard content), pushing the list down by exactly the chip row's own height — it does not overlay content and it collapses to zero height (not rendered at all) when no filters are set, so the empty/default state is pixel-identical to today's layout:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
@@ -142,29 +78,16 @@ so the empty/default state is pixel-identical to today's layout:
|
||||
│ ...filtered pair cards / conversation list...│
|
||||
```
|
||||
|
||||
Each chip is a `Badge variant="secondary"` with a trailing `X`
|
||||
(`lucide-react`) icon button, exactly `TaskFilters`' existing chip markup
|
||||
(`<Badge variant="secondary" className="gap-1">{label}<X className="h-3 w-3
|
||||
cursor-pointer hover:text-destructive" onClick={...} /></Badge>`). One chip
|
||||
per active value:
|
||||
Each chip is a `Badge variant="secondary"` with a trailing `X` (`lucide-react`) icon button, exactly `TaskFilters`' existing chip markup (`<Badge variant="secondary" className="gap-1">{label}<X className="h-3 w-3 cursor-pointer hover:text-destructive" onClick={...} /></Badge>`). One chip per active value:
|
||||
|
||||
- **Agent**: one chip per selected agent, labeled with `getAgentDisplayName()`.
|
||||
- **Task**: one chip for the id-fragment text (`Task: <fragment>`) if set, one
|
||||
chip labeled `No linked task` if that toggle is on.
|
||||
- **Task**: one chip for the id-fragment text (`Task: <fragment>`) if set, one chip labeled `No linked task` if that toggle is on.
|
||||
- **Status**: one chip per selected status (`Active` / `Archived`).
|
||||
- **Date range**: up to two chips, `From <date>` and `To <date>`, each
|
||||
independently removable.
|
||||
- **Date range**: up to two chips, `From <date>` and `To <date>`, each independently removable.
|
||||
|
||||
Clicking a chip's `X` removes only that value (unchecking the matching
|
||||
control inside the popover, same two-way binding `TaskFilters` uses between
|
||||
its checkbox state and its chip `onClick`). The row wraps (`flex flex-wrap
|
||||
gap-2`) rather than truncating or scrolling horizontally, so every active
|
||||
filter stays visible without an extra interaction.
|
||||
Clicking a chip's `X` removes only that value (unchecking the matching control inside the popover, same two-way binding `TaskFilters` uses between its checkbox state and its chip `onClick`). The row wraps (`flex flex-wrap gap-2`) rather than truncating or scrolling horizontally, so every active filter stays visible without an extra interaction.
|
||||
|
||||
**Clear all** is a `Button variant="ghost" size="sm"` at the end of the chip
|
||||
row, visible only when >=1 filter is active (same condition that gates the
|
||||
whole chip row and mirrors `TaskFilters`' own "Clear all" button), and it
|
||||
resets every one of the four dimensions in a single click.
|
||||
**Clear all** is a `Button variant="ghost" size="sm"` at the end of the chip row, visible only when >=1 filter is active (same condition that gates the whole chip row and mirrors `TaskFilters`' own "Clear all" button), and it resets every one of the four dimensions in a single click.
|
||||
|
||||
## 4. Accessibility
|
||||
|
||||
@@ -182,12 +105,7 @@ resets every one of the four dimensions in a single click.
|
||||
|
||||
### WCAG AA contrast
|
||||
|
||||
The control introduces zero new colors — every state below reuses the
|
||||
app's existing shadcn/ui tokens (`panel/src/app/globals.css`), which are
|
||||
already in production use across the panel, so this control carries no new
|
||||
contrast risk. Stated minimums (per WCAG 2.1 AA): **4.5:1** for body/label
|
||||
text, **3:1** for large text (≥18px/14px-bold) and for non-text UI
|
||||
components (focus rings, icon-only button boundaries).
|
||||
The control introduces zero new colors — every state below reuses the app's existing shadcn/ui tokens (`panel/src/app/globals.css`), which are already in production use across the panel, so this control carries no new contrast risk. Stated minimums (per WCAG 2.1 AA): **4.5:1** for body/label text, **3:1** for large text (≥18px/14px-bold) and for non-text UI components (focus rings, icon-only button boundaries).
|
||||
|
||||
| Element | Tokens | Notes |
|
||||
|---|---|---|
|
||||
@@ -199,32 +117,10 @@ components (focus rings, icon-only button boundaries).
|
||||
| Status toggle button (selected) | `--primary-foreground` on `--primary` (light: `oklch(0.985 0 0)` on `oklch(0.205 0 0)`) | Near-white on near-black — the app's own primary-button pair |
|
||||
| Focus-visible ring (all controls) | `--ring` outline, ≥3:1 against `--background` and `--card` | Radix + Tailwind's default `focus-visible:ring` treatment already applied to `Button`/`Checkbox`/`Input` across the panel |
|
||||
|
||||
Because every pair above is an existing, already-shipped token combination
|
||||
(not a new color introduced by this spec), no new contrast audit tooling is
|
||||
required — QA can spot-check with devtools' contrast inspector against this
|
||||
table rather than measuring from scratch. Dark mode uses the same token
|
||||
names with their dark-mode values (`globals.css` `.dark` block), which
|
||||
preserve the same relative-lightness relationships (e.g. `--secondary` /
|
||||
`--secondary-foreground` stay a light-text-on-darker-chip pair), so no
|
||||
dark-mode-specific override is needed.
|
||||
Because every pair above is an existing, already-shipped token combination (not a new color introduced by this spec), no new contrast audit tooling is required — QA can spot-check with devtools' contrast inspector against this table rather than measuring from scratch. Dark mode uses the same token names with their dark-mode values (`globals.css` `.dark` block), which preserve the same relative-lightness relationships (e.g. `--secondary` / `--secondary-foreground` stay a light-text-on-darker-chip pair), so no dark-mode-specific override is needed.
|
||||
|
||||
## 5. Empty and edge states
|
||||
|
||||
- **Zero results after filtering**: the list/switchboard content area shows
|
||||
the same empty-state idiom the components already use for "no data at all"
|
||||
(`MessagesSquare`/`Radio` icon + one line of `text-muted-foreground`), but
|
||||
with copy that names the cause: `"No conversations match the current
|
||||
filters"` plus an inline `Clear all` link — distinct from today's `"No A2A
|
||||
conversations yet"` (zero data) and `"No allowed A2A pairs configured"`
|
||||
(zero pairs), so the CEO isn't told there's no data when there's just no
|
||||
match.
|
||||
- **Agent list is empty on first load** (conversations/pairs still loading):
|
||||
the Agent checkbox section shows 2 `Skeleton` rows (same `Skeleton`
|
||||
component the list/switchboard already use for their own loading state)
|
||||
instead of an empty list, so the popover doesn't imply there are zero
|
||||
agents.
|
||||
- **Filters persist across live updates**: the page already invalidates and
|
||||
refetches conversations on every `a2a.message` WebSocket frame
|
||||
(`page.tsx` lines 131-140); filter *state* is local component state, not
|
||||
derived from the fetch, so an incoming live message does not reset active
|
||||
filters — it's re-evaluated against the refreshed list.
|
||||
- **Zero results after filtering**: the list/switchboard content area shows the same empty-state idiom the components already use for "no data at all" (`MessagesSquare`/`Radio` icon + one line of `text-muted-foreground`), but with copy that names the cause: `"No conversations match the current filters"` plus an inline `Clear all` link — distinct from today's `"No A2A conversations yet"` (zero data) and `"No allowed A2A pairs configured"` (zero pairs), so the CEO isn't told there's no data when there's just no match.
|
||||
- **Agent list is empty on first load** (conversations/pairs still loading): the Agent checkbox section shows 2 `Skeleton` rows (same `Skeleton` component the list/switchboard already use for their own loading state) instead of an empty list, so the popover doesn't imply there are zero agents.
|
||||
- **Filters persist across live updates**: the page already invalidates and refetches conversations on every `a2a.message` WebSocket frame (`page.tsx` lines 131-140); filter *state* is local component state, not derived from the fetch, so an incoming live message does not reset active filters — it's re-evaluated against the refreshed list.
|
||||
|
||||
+2
-2
@@ -32,7 +32,7 @@ When you bump the `next` package, **always update `eslint-config-next` to match*
|
||||
pnpm install
|
||||
```
|
||||
|
||||
This updates `pnpm-lock.yaml` while preserving `node_modules`, keeping the resolution deterministic.
|
||||
This updates `pnpm-lock.yaml` while preserving `node_modules`, keeping the resolution deterministic.
|
||||
|
||||
3. **Run the quality gate:**
|
||||
|
||||
@@ -44,7 +44,7 @@ When you bump the `next` package, **always update `eslint-config-next` to match*
|
||||
pnpm build
|
||||
```
|
||||
|
||||
All must pass with no errors before committing.
|
||||
All must pass with no errors before committing.
|
||||
|
||||
4. **Commit the changes:**
|
||||
```bash
|
||||
|
||||
+312
-85
@@ -1,19 +1,38 @@
|
||||
{
|
||||
"claim_rules": {
|
||||
"auditor": [],
|
||||
"cell_pm": ["needs_revision", "pending"],
|
||||
"cell_pm": [
|
||||
"needs_revision",
|
||||
"pending"
|
||||
],
|
||||
"ceo": [],
|
||||
"developer": ["needs_revision", "pending"],
|
||||
"documenter": ["awaiting_documentation", "pending"],
|
||||
"developer": [
|
||||
"needs_revision",
|
||||
"pending"
|
||||
],
|
||||
"documenter": [
|
||||
"awaiting_documentation",
|
||||
"pending"
|
||||
],
|
||||
"head_marketing": [],
|
||||
"main_pm": ["needs_revision", "pending"],
|
||||
"pr_reviewer": ["awaiting_pr_review", "pending"],
|
||||
"main_pm": [
|
||||
"needs_revision",
|
||||
"pending"
|
||||
],
|
||||
"pr_reviewer": [
|
||||
"awaiting_pr_review",
|
||||
"pending"
|
||||
],
|
||||
"product_owner": [],
|
||||
"qa": ["awaiting_qa"]
|
||||
"qa": [
|
||||
"awaiting_qa"
|
||||
]
|
||||
},
|
||||
"intents": [
|
||||
{
|
||||
"allowed_roles": ["documenter"],
|
||||
"allowed_roles": [
|
||||
"documenter"
|
||||
],
|
||||
"composes": [],
|
||||
"description": "Claim awaiting_documentation. Returns evidence inline.",
|
||||
"name": "claim_doc_task",
|
||||
@@ -21,7 +40,9 @@
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": ["pr_reviewer"],
|
||||
"allowed_roles": [
|
||||
"pr_reviewer"
|
||||
],
|
||||
"composes": [],
|
||||
"description": "Claim an assembled-PR review task (awaiting_pr_review) WITHOUT transitioning it \u2014 mirrors QA's claim_review. The assembled diff and the parent task's acceptance criteria are returned inline.",
|
||||
"name": "claim_gate_review",
|
||||
@@ -29,15 +50,22 @@
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": ["pr_reviewer"],
|
||||
"composes": ["claim", "start"],
|
||||
"allowed_roles": [
|
||||
"pr_reviewer"
|
||||
],
|
||||
"composes": [
|
||||
"claim",
|
||||
"start"
|
||||
],
|
||||
"description": "Claim an inbound external-PR review task and start work. pending -> claimed -> in_progress.",
|
||||
"name": "claim_pr_review",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": ["qa"],
|
||||
"allowed_roles": [
|
||||
"qa"
|
||||
],
|
||||
"composes": [],
|
||||
"description": "Claim a task in awaiting_qa for review. Returns evidence inline.",
|
||||
"name": "claim_review",
|
||||
@@ -45,15 +73,23 @@
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": ["cell_pm", "main_pm"],
|
||||
"composes": ["complete"],
|
||||
"allowed_roles": [
|
||||
"cell_pm",
|
||||
"main_pm"
|
||||
],
|
||||
"composes": [
|
||||
"complete"
|
||||
],
|
||||
"description": "Cell PM merges the PR (leaf into the cell branch, or the gated cell\u2192root PR into the root branch) + transitions to completed; Main PM escalates the root to the CEO (who merges root\u2192master). The merge runs BEFORE the complete transition: TaskService.complete asserts the PR is already merged, so the choreographer verb body (cell_pm_complete / main_pm_complete) owns the merge-first ordering \u2014 no trailing pr_merge side_effect is declared here.",
|
||||
"name": "complete",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": ["cell_pm", "main_pm"],
|
||||
"allowed_roles": [
|
||||
"cell_pm",
|
||||
"main_pm"
|
||||
],
|
||||
"composes": [],
|
||||
"description": "Stamp parent acceptance criteria onto an existing child's parent_ac_refs after the fact \u2014 for a replacement child whose delegate omitted covers_parent_criteria. Or, targeting your OWN root/coordination task, declare criteria as root-owned (only your own machinery satisfies them \u2014 never push these into a cell). No status change; the verb body owns ownership + criterion validation.",
|
||||
"name": "declare_coverage",
|
||||
@@ -61,23 +97,37 @@
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": ["cell_pm", "main_pm"],
|
||||
"composes": ["create_subtask"],
|
||||
"allowed_roles": [
|
||||
"cell_pm",
|
||||
"main_pm"
|
||||
],
|
||||
"composes": [
|
||||
"create_subtask"
|
||||
],
|
||||
"description": "Create a subtask under the current task. Validates the delegation chain (main_pm->cell_pm; cell_pm->its team's devs) and the assignee-vs-task_type rule (Cell PMs get planning-typed tasks; devs get code/research, UX devs also design). documentation is NOT delegatable \u2014 the lifecycle auto-creates the doc phase after the code subtask passes QA.",
|
||||
"name": "delegate",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": ["head_marketing", "main_pm", "product_owner"],
|
||||
"composes": ["escalate_to_ceo"],
|
||||
"allowed_roles": [
|
||||
"head_marketing",
|
||||
"main_pm",
|
||||
"product_owner"
|
||||
],
|
||||
"composes": [
|
||||
"escalate_to_ceo"
|
||||
],
|
||||
"description": "Escalate to CEO with reason. Transitions to awaiting_ceo_approval.",
|
||||
"name": "escalate_to_ceo",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": ["cell_pm", "main_pm"],
|
||||
"allowed_roles": [
|
||||
"cell_pm",
|
||||
"main_pm"
|
||||
],
|
||||
"composes": [],
|
||||
"description": "Escalate to your role's escalation_target.",
|
||||
"name": "escalate_up",
|
||||
@@ -85,8 +135,12 @@
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": ["qa"],
|
||||
"composes": ["qa_fail"],
|
||||
"allowed_roles": [
|
||||
"qa"
|
||||
],
|
||||
"composes": [
|
||||
"qa_fail"
|
||||
],
|
||||
"description": "Fail QA with concrete issues. Transitions to needs_revision.",
|
||||
"name": "fail_review",
|
||||
"pre_side_effects": [],
|
||||
@@ -108,16 +162,27 @@
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": ["developer", "documenter", "qa"],
|
||||
"composes": ["block"],
|
||||
"allowed_roles": [
|
||||
"developer",
|
||||
"documenter",
|
||||
"qa"
|
||||
],
|
||||
"composes": [
|
||||
"block"
|
||||
],
|
||||
"description": "Escalate to PM. Logs a struggle journal entry.",
|
||||
"name": "i_am_blocked",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": ["developer"],
|
||||
"composes": ["submit_verification", "submit_qa"],
|
||||
"allowed_roles": [
|
||||
"developer"
|
||||
],
|
||||
"composes": [
|
||||
"submit_verification",
|
||||
"submit_qa"
|
||||
],
|
||||
"description": "Submit work for QA. Auto-runs in_progress->verifying then verifying->awaiting_qa. Strict - PR must be open (call open_pr first) and >=1 commit.",
|
||||
"name": "i_am_done",
|
||||
"pre_side_effects": [],
|
||||
@@ -144,71 +209,111 @@
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": ["documenter"],
|
||||
"composes": ["docs_complete"],
|
||||
"allowed_roles": [
|
||||
"documenter"
|
||||
],
|
||||
"composes": [
|
||||
"docs_complete"
|
||||
],
|
||||
"description": "Signal docs complete. Transitions to awaiting_pm_review.",
|
||||
"name": "i_documented",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": ["cell_pm", "main_pm"],
|
||||
"composes": ["claim", "set_plan", "start"],
|
||||
"allowed_roles": [
|
||||
"cell_pm",
|
||||
"main_pm"
|
||||
],
|
||||
"composes": [
|
||||
"claim",
|
||||
"set_plan",
|
||||
"start"
|
||||
],
|
||||
"description": "PM mirror of i_will_work_on for parent tasks. Claim, plan, transition to in_progress; from there delegate subtasks.",
|
||||
"name": "i_will_plan",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": ["developer"],
|
||||
"composes": ["claim", "set_plan", "start"],
|
||||
"allowed_roles": [
|
||||
"developer"
|
||||
],
|
||||
"composes": [
|
||||
"claim",
|
||||
"set_plan",
|
||||
"start"
|
||||
],
|
||||
"description": "Claim a task, set the plan, and transition to in_progress. Atomic - preconditions checked before any state mutation.",
|
||||
"name": "i_will_work_on",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": ["developer"],
|
||||
"allowed_roles": [
|
||||
"developer"
|
||||
],
|
||||
"composes": [],
|
||||
"description": "Push the branch and open a PR. Atomic - preconditions (assignee, >=1 commit, no prior PR) checked BEFORE any git operation. After success, call i_am_done.",
|
||||
"name": "open_pr",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": ["push_branch", "create_pr"]
|
||||
"side_effects": [
|
||||
"push_branch",
|
||||
"create_pr"
|
||||
]
|
||||
},
|
||||
{
|
||||
"allowed_roles": ["qa"],
|
||||
"composes": ["qa_pass"],
|
||||
"allowed_roles": [
|
||||
"qa"
|
||||
],
|
||||
"composes": [
|
||||
"qa_pass"
|
||||
],
|
||||
"description": "Pass QA. Transitions awaiting_qa -> awaiting_documentation.",
|
||||
"name": "pass_review",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": ["pr_reviewer"],
|
||||
"composes": ["pr_review_done"],
|
||||
"allowed_roles": [
|
||||
"pr_reviewer"
|
||||
],
|
||||
"composes": [
|
||||
"pr_review_done"
|
||||
],
|
||||
"description": "Post one complete change-request to the external PR and finish the review task. in_progress -> completed.",
|
||||
"name": "post_pr_review",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": ["pr_reviewer"],
|
||||
"composes": ["pr_fail"],
|
||||
"allowed_roles": [
|
||||
"pr_reviewer"
|
||||
],
|
||||
"composes": [
|
||||
"pr_fail"
|
||||
],
|
||||
"description": "Fail the assembled-PR review with concrete issues. Transitions awaiting_pr_review -> needs_revision, routed back like a QA fail.",
|
||||
"name": "pr_fail",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": ["pr_reviewer"],
|
||||
"composes": ["pr_pass"],
|
||||
"allowed_roles": [
|
||||
"pr_reviewer"
|
||||
],
|
||||
"composes": [
|
||||
"pr_pass"
|
||||
],
|
||||
"description": "Pass the assembled-PR review. Transitions awaiting_pr_review -> awaiting_pm_review so the PM can merge.",
|
||||
"name": "pr_pass",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": ["cell_pm"],
|
||||
"allowed_roles": [
|
||||
"cell_pm"
|
||||
],
|
||||
"composes": [],
|
||||
"description": "Hand a claimed/in_progress task to another developer in your own cell. The branch is keyed to the task (not the agent), so it is preserved \u2014 the new developer continues the work-in-progress. No status change.",
|
||||
"name": "reassign",
|
||||
@@ -216,39 +321,66 @@
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": ["cell_pm", "main_pm"],
|
||||
"composes": ["request_changes"],
|
||||
"allowed_roles": [
|
||||
"cell_pm",
|
||||
"main_pm"
|
||||
],
|
||||
"composes": [
|
||||
"request_changes"
|
||||
],
|
||||
"description": "Reject the merge review with concrete issues. Transitions awaiting_pm_review -> needs_revision, routed back like a QA fail (original developer for a leaf, revision PM for an assembled task). Use this for an AC/scope violation caught at merge review \u2014 never i_am_blocked/escalate, which have no revision routing.",
|
||||
"name": "request_changes",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": ["cell_pm", "developer", "documenter", "main_pm", "qa"],
|
||||
"composes": ["resume"],
|
||||
"allowed_roles": [
|
||||
"cell_pm",
|
||||
"developer",
|
||||
"documenter",
|
||||
"main_pm",
|
||||
"qa"
|
||||
],
|
||||
"composes": [
|
||||
"resume"
|
||||
],
|
||||
"description": "Resume a paused task you own. paused -> in_progress.",
|
||||
"name": "resume",
|
||||
"pre_side_effects": [],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": ["main_pm"],
|
||||
"composes": ["submit_for_review"],
|
||||
"allowed_roles": [
|
||||
"main_pm"
|
||||
],
|
||||
"composes": [
|
||||
"submit_for_review"
|
||||
],
|
||||
"description": "Main PM opens the root\u2192master PR and moves the root task to awaiting_pr_review for the main reviewer (the root analogue of the cell PM's submit_up). After pr_pass, call complete to escalate to the CEO. For branch-bearing roots (a Main-PM root-subtask assembles the cells' merged work); branchless coordination roots skip the gate and complete directly. The gate is branch-keyed, not task_type-keyed \u2014 a Main-PM root is planning-typed, never code.",
|
||||
"name": "submit_root",
|
||||
"pre_side_effects": ["create_root_pr"],
|
||||
"pre_side_effects": [
|
||||
"create_root_pr"
|
||||
],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": ["cell_pm"],
|
||||
"composes": ["submit_for_review"],
|
||||
"allowed_roles": [
|
||||
"cell_pm"
|
||||
],
|
||||
"composes": [
|
||||
"submit_for_review"
|
||||
],
|
||||
"description": "Cell PM opens the cell\u2192root PR and moves the cell task into the PR-review gate (awaiting_pr_review). The cell reviewer reviews the assembled diff; after pr_pass the same Cell PM completes it.",
|
||||
"name": "submit_up",
|
||||
"pre_side_effects": ["create_pr"],
|
||||
"pre_side_effects": [
|
||||
"create_pr"
|
||||
],
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": ["developer"],
|
||||
"allowed_roles": [
|
||||
"developer"
|
||||
],
|
||||
"composes": [],
|
||||
"description": "Rebase your task's branch onto its current base THROUGH the gate (raw git is denied). Use when your branch has fallen behind its base \u2014 e.g. a sibling task's PR merged into the parent branch while you worked. Fetches origin, rebases head onto base, and force-pushes (with-lease). No DB state change. On conflicts the rebase is aborted and the conflicted files are returned \u2014 resolve by hand, commit, then sync_branch again. Pass stash=True to auto-stash uncommitted changes instead of refusing DIRTY_WORKSPACE; they are restored after the rebase.",
|
||||
"name": "sync_branch",
|
||||
@@ -270,7 +402,9 @@
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": ["main_pm"],
|
||||
"allowed_roles": [
|
||||
"main_pm"
|
||||
],
|
||||
"composes": [],
|
||||
"description": "List actionable tasks across all teams (Main PM only).",
|
||||
"name": "triage_all",
|
||||
@@ -278,8 +412,13 @@
|
||||
"side_effects": []
|
||||
},
|
||||
{
|
||||
"allowed_roles": ["cell_pm", "main_pm"],
|
||||
"composes": ["unblock"],
|
||||
"allowed_roles": [
|
||||
"cell_pm",
|
||||
"main_pm"
|
||||
],
|
||||
"composes": [
|
||||
"unblock"
|
||||
],
|
||||
"description": "PM unblocks a blocked task; restores pre-block state.",
|
||||
"name": "unblock",
|
||||
"pre_side_effects": [],
|
||||
@@ -304,121 +443,175 @@
|
||||
"transitions": [
|
||||
{
|
||||
"action": "cancel",
|
||||
"roles": ["ceo"],
|
||||
"roles": [
|
||||
"ceo"
|
||||
],
|
||||
"source": "awaiting_ceo_approval",
|
||||
"target": "cancelled"
|
||||
},
|
||||
{
|
||||
"action": "ceo_approve",
|
||||
"roles": ["ceo"],
|
||||
"roles": [
|
||||
"ceo"
|
||||
],
|
||||
"source": "awaiting_ceo_approval",
|
||||
"target": "completed"
|
||||
},
|
||||
{
|
||||
"action": "ceo_reject",
|
||||
"roles": ["ceo"],
|
||||
"roles": [
|
||||
"ceo"
|
||||
],
|
||||
"source": "awaiting_ceo_approval",
|
||||
"target": "needs_revision"
|
||||
},
|
||||
{
|
||||
"action": "ceo_reject_to_pool",
|
||||
"roles": ["ceo"],
|
||||
"roles": [
|
||||
"ceo"
|
||||
],
|
||||
"source": "awaiting_ceo_approval",
|
||||
"target": "pending"
|
||||
},
|
||||
{
|
||||
"action": "docs_complete",
|
||||
"roles": ["documenter"],
|
||||
"roles": [
|
||||
"documenter"
|
||||
],
|
||||
"source": "awaiting_documentation",
|
||||
"target": "awaiting_pm_review"
|
||||
},
|
||||
{
|
||||
"action": "cancel",
|
||||
"roles": ["cell_pm", "ceo", "main_pm"],
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"ceo",
|
||||
"main_pm"
|
||||
],
|
||||
"source": "awaiting_documentation",
|
||||
"target": "cancelled"
|
||||
},
|
||||
{
|
||||
"action": "claim",
|
||||
"roles": ["documenter"],
|
||||
"roles": [
|
||||
"documenter"
|
||||
],
|
||||
"source": "awaiting_documentation",
|
||||
"target": "claimed"
|
||||
},
|
||||
{
|
||||
"action": "escalate_to_ceo",
|
||||
"roles": ["head_marketing", "main_pm", "product_owner"],
|
||||
"roles": [
|
||||
"head_marketing",
|
||||
"main_pm",
|
||||
"product_owner"
|
||||
],
|
||||
"source": "awaiting_pm_review",
|
||||
"target": "awaiting_ceo_approval"
|
||||
},
|
||||
{
|
||||
"action": "cancel",
|
||||
"roles": ["cell_pm", "ceo", "main_pm"],
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"ceo",
|
||||
"main_pm"
|
||||
],
|
||||
"source": "awaiting_pm_review",
|
||||
"target": "cancelled"
|
||||
},
|
||||
{
|
||||
"action": "complete",
|
||||
"roles": ["cell_pm", "main_pm"],
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"main_pm"
|
||||
],
|
||||
"source": "awaiting_pm_review",
|
||||
"target": "completed"
|
||||
},
|
||||
{
|
||||
"action": "request_changes",
|
||||
"roles": ["cell_pm", "main_pm"],
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"main_pm"
|
||||
],
|
||||
"source": "awaiting_pm_review",
|
||||
"target": "needs_revision"
|
||||
},
|
||||
{
|
||||
"action": "pr_pass",
|
||||
"roles": ["pr_reviewer"],
|
||||
"roles": [
|
||||
"pr_reviewer"
|
||||
],
|
||||
"source": "awaiting_pr_review",
|
||||
"target": "awaiting_pm_review"
|
||||
},
|
||||
{
|
||||
"action": "cancel",
|
||||
"roles": ["cell_pm", "ceo", "main_pm"],
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"ceo",
|
||||
"main_pm"
|
||||
],
|
||||
"source": "awaiting_pr_review",
|
||||
"target": "cancelled"
|
||||
},
|
||||
{
|
||||
"action": "claim",
|
||||
"roles": ["pr_reviewer"],
|
||||
"roles": [
|
||||
"pr_reviewer"
|
||||
],
|
||||
"source": "awaiting_pr_review",
|
||||
"target": "claimed"
|
||||
},
|
||||
{
|
||||
"action": "pr_fail",
|
||||
"roles": ["pr_reviewer"],
|
||||
"roles": [
|
||||
"pr_reviewer"
|
||||
],
|
||||
"source": "awaiting_pr_review",
|
||||
"target": "needs_revision"
|
||||
},
|
||||
{
|
||||
"action": "qa_pass",
|
||||
"roles": ["qa"],
|
||||
"roles": [
|
||||
"qa"
|
||||
],
|
||||
"source": "awaiting_qa",
|
||||
"target": "awaiting_documentation"
|
||||
},
|
||||
{
|
||||
"action": "cancel",
|
||||
"roles": ["cell_pm", "ceo", "main_pm"],
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"ceo",
|
||||
"main_pm"
|
||||
],
|
||||
"source": "awaiting_qa",
|
||||
"target": "cancelled"
|
||||
},
|
||||
{
|
||||
"action": "claim",
|
||||
"roles": ["qa"],
|
||||
"roles": [
|
||||
"qa"
|
||||
],
|
||||
"source": "awaiting_qa",
|
||||
"target": "claimed"
|
||||
},
|
||||
{
|
||||
"action": "qa_fail",
|
||||
"roles": ["qa"],
|
||||
"roles": [
|
||||
"qa"
|
||||
],
|
||||
"source": "awaiting_qa",
|
||||
"target": "needs_revision"
|
||||
},
|
||||
{
|
||||
"action": "cancel",
|
||||
"roles": ["cell_pm", "ceo", "main_pm"],
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"ceo",
|
||||
"main_pm"
|
||||
],
|
||||
"source": "backlog",
|
||||
"target": "cancelled"
|
||||
},
|
||||
@@ -430,13 +623,21 @@
|
||||
},
|
||||
{
|
||||
"action": "escalate_to_ceo",
|
||||
"roles": ["head_marketing", "main_pm", "product_owner"],
|
||||
"roles": [
|
||||
"head_marketing",
|
||||
"main_pm",
|
||||
"product_owner"
|
||||
],
|
||||
"source": "blocked",
|
||||
"target": "awaiting_ceo_approval"
|
||||
},
|
||||
{
|
||||
"action": "cancel",
|
||||
"roles": ["cell_pm", "ceo", "main_pm"],
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"ceo",
|
||||
"main_pm"
|
||||
],
|
||||
"source": "blocked",
|
||||
"target": "cancelled"
|
||||
},
|
||||
@@ -454,7 +655,11 @@
|
||||
},
|
||||
{
|
||||
"action": "cancel",
|
||||
"roles": ["cell_pm", "ceo", "main_pm"],
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"ceo",
|
||||
"main_pm"
|
||||
],
|
||||
"source": "claimed",
|
||||
"target": "cancelled"
|
||||
},
|
||||
@@ -484,13 +689,19 @@
|
||||
},
|
||||
{
|
||||
"action": "cancel",
|
||||
"roles": ["cell_pm", "ceo", "main_pm"],
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"ceo",
|
||||
"main_pm"
|
||||
],
|
||||
"source": "in_progress",
|
||||
"target": "cancelled"
|
||||
},
|
||||
{
|
||||
"action": "pr_review_done",
|
||||
"roles": ["pr_reviewer"],
|
||||
"roles": [
|
||||
"pr_reviewer"
|
||||
],
|
||||
"source": "in_progress",
|
||||
"target": "completed"
|
||||
},
|
||||
@@ -508,7 +719,11 @@
|
||||
},
|
||||
{
|
||||
"action": "cancel",
|
||||
"roles": ["cell_pm", "ceo", "main_pm"],
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"ceo",
|
||||
"main_pm"
|
||||
],
|
||||
"source": "needs_revision",
|
||||
"target": "cancelled"
|
||||
},
|
||||
@@ -520,7 +735,11 @@
|
||||
},
|
||||
{
|
||||
"action": "cancel",
|
||||
"roles": ["cell_pm", "ceo", "main_pm"],
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"ceo",
|
||||
"main_pm"
|
||||
],
|
||||
"source": "paused",
|
||||
"target": "cancelled"
|
||||
},
|
||||
@@ -532,7 +751,11 @@
|
||||
},
|
||||
{
|
||||
"action": "cancel",
|
||||
"roles": ["cell_pm", "ceo", "main_pm"],
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"ceo",
|
||||
"main_pm"
|
||||
],
|
||||
"source": "pending",
|
||||
"target": "cancelled"
|
||||
},
|
||||
@@ -550,7 +773,11 @@
|
||||
},
|
||||
{
|
||||
"action": "cancel",
|
||||
"roles": ["cell_pm", "ceo", "main_pm"],
|
||||
"roles": [
|
||||
"cell_pm",
|
||||
"ceo",
|
||||
"main_pm"
|
||||
],
|
||||
"source": "verifying",
|
||||
"target": "cancelled"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user