refactor: extract agent bounded context + fix SSE comments + cleanup unused
- Extract src/lib/agent/ bounded context with types, registry, messaging - Add comments_count to BeadIssue for SSE comment detection - Create batch endpoints for mail/reservations APIs - Add memory validation to session-preflight - Remove unused empty dirs (mockup, sessions, timeline) - Move stashes to docs/references, gitignore them
This commit is contained in:
parent
6f41c4af31
commit
18fbafdce4
34 changed files with 62714 additions and 1970 deletions
|
|
@ -1 +1 @@
|
|||
1910
|
||||
44920
|
||||
1
.beads/dolt-server.activity
Normal file
1
.beads/dolt-server.activity
Normal file
|
|
@ -0,0 +1 @@
|
|||
1772664767
|
||||
60827
.beads/dolt-server.log
60827
.beads/dolt-server.log
File diff suppressed because it is too large
Load diff
1
.beads/dolt-server.pid
Normal file
1
.beads/dolt-server.pid
Normal file
|
|
@ -0,0 +1 @@
|
|||
45716
|
||||
1
.beads/dolt-server.port
Normal file
1
.beads/dolt-server.port
Normal file
|
|
@ -0,0 +1 @@
|
|||
3307
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -19,3 +19,6 @@ artifacts/
|
|||
|
||||
# beadboard runtime artifacts
|
||||
.beadboard/
|
||||
|
||||
# Archived stashes for reference
|
||||
docs/references/stashes/
|
||||
|
|
|
|||
|
|
@ -1,166 +0,0 @@
|
|||
diff --git a/.gitignore b/.gitignore
|
||||
index eb35607..596f42a 100644
|
||||
--- a/.gitignore
|
||||
+++ b/.gitignore
|
||||
@@ -3,3 +3,6 @@ node_modules/
|
||||
*.tsbuildinfo
|
||||
.worktrees/
|
||||
worktrees/
|
||||
+
|
||||
+# bv (beads viewer) local config and caches
|
||||
+.bv/
|
||||
diff --git a/src/app/globals.css b/src/app/globals.css
|
||||
index d17e938..a474080 100644
|
||||
--- a/src/app/globals.css
|
||||
+++ b/src/app/globals.css
|
||||
@@ -3,15 +3,15 @@
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
- --color-bg: #090c14;
|
||||
- --color-surface: #101827;
|
||||
- --color-surface-muted: #192336;
|
||||
- --color-surface-raised: #22314a;
|
||||
- --color-text-strong: #f6f8ff;
|
||||
- --color-text-body: #d8e0f1;
|
||||
- --color-text-muted: #9caccc;
|
||||
- --color-border-soft: rgba(145, 166, 204, 0.3);
|
||||
- --color-border-strong: rgba(187, 209, 246, 0.62);
|
||||
+ --color-bg: #090909;
|
||||
+ --color-surface: #161616;
|
||||
+ --color-surface-muted: #212121;
|
||||
+ --color-surface-raised: #2a2a2a;
|
||||
+ --color-text-strong: #f5f5f5;
|
||||
+ --color-text-body: #d0d0d0;
|
||||
+ --color-text-muted: #9a9a9a;
|
||||
+ --color-border-soft: rgba(255, 255, 255, 0.15);
|
||||
+ --color-border-strong: rgba(255, 255, 255, 0.3);
|
||||
|
||||
--status-open: #60a5fa;
|
||||
--status-progress: #fbbf24;
|
||||
@@ -38,10 +38,9 @@ body {
|
||||
|
||||
body {
|
||||
background:
|
||||
- radial-gradient(circle at 10% 12%, rgba(12, 138, 215, 0.34), transparent 36%),
|
||||
- radial-gradient(circle at 84% 20%, rgba(250, 122, 91, 0.18), transparent 30%),
|
||||
- radial-gradient(circle at 68% 88%, rgba(57, 189, 154, 0.14), transparent 36%),
|
||||
- linear-gradient(155deg, #05070d 0%, #0b1322 42%, #121e34 100%);
|
||||
+ radial-gradient(circle at 14% 12%, rgba(255, 255, 255, 0.05), transparent 36%),
|
||||
+ radial-gradient(circle at 84% 18%, rgba(255, 180, 80, 0.06), transparent 32%),
|
||||
+ linear-gradient(160deg, #070707 0%, #101010 48%, #161616 100%);
|
||||
color: var(--color-text-body);
|
||||
- font-family: 'Segoe UI', 'Aptos', Inter, system-ui, sans-serif;
|
||||
+ font-family: 'DM Sans', 'Segoe UI', Inter, system-ui, sans-serif;
|
||||
}
|
||||
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
|
||||
index ff1ad90..1417e77 100644
|
||||
--- a/src/app/layout.tsx
|
||||
+++ b/src/app/layout.tsx
|
||||
@@ -1,7 +1,18 @@
|
||||
import type { Metadata } from 'next';
|
||||
+import { DM_Sans, JetBrains_Mono } from 'next/font/google';
|
||||
import type { ReactNode } from 'react';
|
||||
import './globals.css';
|
||||
|
||||
+const dmSans = DM_Sans({
|
||||
+ subsets: ['latin'],
|
||||
+ variable: '--font-ui',
|
||||
+});
|
||||
+
|
||||
+const jetbrainsMono = JetBrains_Mono({
|
||||
+ subsets: ['latin'],
|
||||
+ variable: '--font-mono',
|
||||
+});
|
||||
+
|
||||
export const metadata: Metadata = {
|
||||
title: 'BeadBoard',
|
||||
description: 'Windows-native Beads dashboard',
|
||||
@@ -10,7 +21,7 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
- <body>{children}</body>
|
||||
+ <body className={`${dmSans.variable} ${jetbrainsMono.variable}`}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
diff --git a/src/components/kanban/kanban-controls.tsx b/src/components/kanban/kanban-controls.tsx
|
||||
index 78b09f4..e1e04f9 100644
|
||||
--- a/src/components/kanban/kanban-controls.tsx
|
||||
+++ b/src/components/kanban/kanban-controls.tsx
|
||||
@@ -14,7 +14,7 @@ interface KanbanControlsProps {
|
||||
|
||||
export function KanbanControls({ filters, stats, onFiltersChange }: KanbanControlsProps) {
|
||||
const inputClass =
|
||||
- 'rounded-xl border border-border-soft bg-surface-muted/78 px-3 py-2.5 text-sm text-text-strong outline-none transition placeholder:text-text-muted focus:border-cyan-300/70 focus:ring-2 focus:ring-cyan-300/20';
|
||||
+ 'rounded-xl border border-border-soft bg-surface-muted/78 px-3 py-2.5 text-sm text-text-strong outline-none transition placeholder:text-text-muted focus:border-border-strong focus:ring-2 focus:ring-white/10';
|
||||
|
||||
return (
|
||||
<section className="grid gap-3">
|
||||
@@ -57,7 +57,7 @@ export function KanbanControls({ filters, stats, onFiltersChange }: KanbanContro
|
||||
type="checkbox"
|
||||
checked={filters.showClosed ?? false}
|
||||
onChange={(event) => onFiltersChange({ ...filters, showClosed: event.target.checked })}
|
||||
- className="h-4 w-4 accent-cyan-400"
|
||||
+ className="h-4 w-4 accent-amber-400"
|
||||
/>
|
||||
Show closed
|
||||
</label>
|
||||
diff --git a/src/components/shared/chip.tsx b/src/components/shared/chip.tsx
|
||||
index c1637e6..e29d49d 100644
|
||||
--- a/src/components/shared/chip.tsx
|
||||
+++ b/src/components/shared/chip.tsx
|
||||
@@ -7,7 +7,7 @@ interface ChipProps {
|
||||
|
||||
const CHIP_TONE_CLASS: Record<NonNullable<ChipProps['tone']>, string> = {
|
||||
default: 'border-border-soft bg-surface-muted/75 text-text-body',
|
||||
- status: 'border-cyan-300/30 bg-cyan-500/20 text-cyan-50',
|
||||
+ status: 'border-zinc-300/30 bg-zinc-500/20 text-zinc-100',
|
||||
priority: 'border-amber-300/30 bg-amber-500/20 text-amber-50',
|
||||
};
|
||||
|
||||
diff --git a/tailwind.config.ts b/tailwind.config.ts
|
||||
index 5ad9067..953965c 100644
|
||||
--- a/tailwind.config.ts
|
||||
+++ b/tailwind.config.ts
|
||||
@@ -5,8 +5,8 @@ const config: Config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
- ui: ['Segoe UI', 'Inter', 'system-ui', 'sans-serif'],
|
||||
- mono: ['JetBrains Mono', 'Consolas', 'monospace'],
|
||||
+ ui: ['var(--font-ui)', 'Segoe UI', 'Inter', 'system-ui', 'sans-serif'],
|
||||
+ mono: ['var(--font-mono)', 'Consolas', 'monospace'],
|
||||
},
|
||||
colors: {
|
||||
bg: 'var(--color-bg)',
|
||||
diff --git a/tests/guards/kanban-responsive-contract.test.mjs b/tests/guards/kanban-responsive-contract.test.mjs
|
||||
index 4e02f28..3efabf4 100644
|
||||
--- a/tests/guards/kanban-responsive-contract.test.mjs
|
||||
+++ b/tests/guards/kanban-responsive-contract.test.mjs
|
||||
@@ -9,11 +9,12 @@ async function read(relativePath) {
|
||||
return fs.readFile(path.join(ROOT, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
-test('kanban board uses intentional horizontal scroll affordances', async () => {
|
||||
+test('kanban board uses expandable vertical swimlanes', async () => {
|
||||
const board = await read('src/components/kanban/kanban-board.tsx');
|
||||
|
||||
- assert.match(board, /snap-x/);
|
||||
- assert.match(board, /overflow-x-auto/);
|
||||
+ assert.match(board, /aria-expanded/);
|
||||
+ assert.match(board, /onActivateStatus/);
|
||||
+ assert.match(board, /max-h-\[50vh\]/);
|
||||
});
|
||||
|
||||
test('kanban page defines mobile detail drawer behavior', async () => {
|
||||
@@ -21,6 +22,8 @@ test('kanban page defines mobile detail drawer behavior', async () => {
|
||||
|
||||
assert.match(page, /fixed inset-0/);
|
||||
assert.match(page, /lg:hidden/);
|
||||
+ assert.match(page, /lg:grid-cols-\[minmax\(0,1fr\)_minmax\(22rem,26rem\)\]/);
|
||||
+ assert.match(page, /lg:border-l/);
|
||||
});
|
||||
|
||||
test('kanban controls use fluid full-width sizing on small viewports', async () => {
|
||||
File diff suppressed because it is too large
Load diff
80
project.md
Normal file
80
project.md
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# project.md — BeadBoard Driver Session Cache
|
||||
|
||||
This file is maintained by agents. A new agent reads this first.
|
||||
If the Environment Status table shows all `pass`, skip straight to Step 2 of the runbook.
|
||||
Only re-run a check if its row says `fail` or `unknown`, or if you hit an actual error.
|
||||
|
||||
---
|
||||
|
||||
## Environment Status Cache
|
||||
|
||||
Last updated: YYYY-MM-DD by `<agent-bead-id>`
|
||||
|
||||
| Component | Status | Version / Detail | Verified |
|
||||
|-----------|--------|-----------------|---------|
|
||||
| `bd` on PATH | `unknown` | | |
|
||||
| `bb` on PATH | `unknown` | | |
|
||||
| `.beads` db exists | `unknown` | | |
|
||||
| `mail.delegate` configured | `unknown` | | |
|
||||
| `session-preflight` | `unknown` | | |
|
||||
| `bb agent` registered | `unknown` | `BB_AGENT=` | |
|
||||
| Tests last run | `unknown` | | |
|
||||
|
||||
**Status values:** `pass` · `fail` · `unknown` · `skip` (not applicable to this project)
|
||||
|
||||
**Rule:** If every row is `pass` → skip Step 1 entirely and go straight to Step 2.
|
||||
If any row is `fail` or `unknown` → run only that check, update this table, continue.
|
||||
|
||||
---
|
||||
|
||||
## Project Identity
|
||||
|
||||
- Project name:
|
||||
- Repository root:
|
||||
- Primary language/runtime:
|
||||
- Primary package manager:
|
||||
|
||||
## Tooling Baseline
|
||||
|
||||
- `bd` installed and on PATH: yes/no — version:
|
||||
- `bb` installed and on PATH: yes/no — version:
|
||||
- Detection commands used:
|
||||
- Shell/platform: (e.g. WSL2/bash, macOS/zsh, Windows/PowerShell)
|
||||
|
||||
## BeadBoard/Communication Setup
|
||||
|
||||
- `.beads` database: exists/created on YYYY-MM-DD via `bd init`
|
||||
- Mail delegate: `bd config set mail.delegate "node <abs-path>/scripts/bb-mail-shim.mjs"` — configured YYYY-MM-DD
|
||||
- Agent identity policy: `export BB_AGENT=<role-name>` (set fresh each session in Step 2)
|
||||
- `session-preflight` last pass: YYYY-MM-DD
|
||||
|
||||
## Agent State + Heartbeat Policy
|
||||
|
||||
- Agent bead naming: `bb-<role-name>` (e.g. `bb-silver-scribe`)
|
||||
- Required state transitions: `spawning → running → working → stuck/done/stopped`
|
||||
- Heartbeat: LLM agents heartbeat at turn start + before long commands; daemon agents every 5 min
|
||||
|
||||
## Command Baseline
|
||||
|
||||
- Install:
|
||||
- Build:
|
||||
- Typecheck:
|
||||
- Lint:
|
||||
- Test:
|
||||
|
||||
## Known Workarounds
|
||||
|
||||
Document only stable, repeatable workarounds.
|
||||
|
||||
1. Trigger:
|
||||
- Symptom:
|
||||
- Workaround:
|
||||
- Verified:
|
||||
|
||||
## Session Log (append-only)
|
||||
|
||||
Each agent appends one line when they update this file:
|
||||
|
||||
| Date | Agent | What changed |
|
||||
|------|-------|-------------|
|
||||
| YYYY-MM-DD | `<agent-bead-id>` | Initial project.md created |
|
||||
|
|
@ -315,6 +315,8 @@ Stop and correct if you are about to:
|
|||
Swarm composition, molecule operations, worker dispatch patterns.
|
||||
- `references/missions-realtime.md`:
|
||||
Real-time/watcher/event troubleshooting.
|
||||
- `references/creating-beads.md`:
|
||||
Creating epics, tasks, subtasks with proper naming, dependencies, and workflow.
|
||||
|
||||
## Bottom Line
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,31 @@
|
|||
# Project Driver Template
|
||||
# project.md — BeadBoard Driver Session Cache
|
||||
|
||||
Use this template to create `project.md` at the target repository root.
|
||||
The first agent in a repo should create this file; later agents must read and update it before work.
|
||||
This file is maintained by agents. A new agent reads this first.
|
||||
If the Environment Status table shows all `pass`, skip straight to Step 2 of the runbook.
|
||||
Only re-run a check if its row says `fail` or `unknown`, or if you hit an actual error.
|
||||
|
||||
---
|
||||
|
||||
## Environment Status Cache
|
||||
|
||||
Last updated: YYYY-MM-DD by `<agent-bead-id>`
|
||||
|
||||
| Component | Status | Version / Detail | Verified |
|
||||
|-----------|--------|-----------------|---------|
|
||||
| `bd` on PATH | `unknown` | | |
|
||||
| `bb` on PATH | `unknown` | | |
|
||||
| `.beads` db exists | `unknown` | | |
|
||||
| `mail.delegate` configured | `unknown` | | |
|
||||
| `session-preflight` | `unknown` | | |
|
||||
| `bb agent` registered | `unknown` | `BB_AGENT=` | |
|
||||
| Tests last run | `unknown` | | |
|
||||
|
||||
**Status values:** `pass` · `fail` · `unknown` · `skip` (not applicable to this project)
|
||||
|
||||
**Rule:** If every row is `pass` → skip Step 1 entirely and go straight to Step 2.
|
||||
If any row is `fail` or `unknown` → run only that check, update this table, continue.
|
||||
|
||||
---
|
||||
|
||||
## Project Identity
|
||||
|
||||
|
|
@ -10,68 +34,33 @@ The first agent in a repo should create this file; later agents must read and up
|
|||
- Primary language/runtime:
|
||||
- Primary package manager:
|
||||
|
||||
## Tooling Baseline (Global Installs)
|
||||
## Tooling Baseline
|
||||
|
||||
Record what is already installed on this machine so later agents do not re-check unnecessarily.
|
||||
|
||||
- `bd` installed and on PATH: yes/no
|
||||
- `bb` or `beadboard` installed and on PATH: yes/no
|
||||
- Detection commands used (with date):
|
||||
- Notes on shell/platform quirks (WSL/Windows/macOS/Linux):
|
||||
- `bd` installed and on PATH: yes/no — version:
|
||||
- `bb` installed and on PATH: yes/no — version:
|
||||
- Detection commands used:
|
||||
- Shell/platform: (e.g. WSL2/bash, macOS/zsh, Windows/PowerShell)
|
||||
|
||||
## BeadBoard/Communication Setup
|
||||
|
||||
- Mail delegate command configured:
|
||||
- `bd config set mail.delegate "node <abs-path>/skills/beadboard-driver/scripts/bb-mail-shim.mjs"`
|
||||
- Agent identity env var policy:
|
||||
- Preferred: `BB_AGENT=<agent-id>`
|
||||
- Fallback: `BD_ACTOR=<agent-id>`
|
||||
- Delegate validation status:
|
||||
- `node skills/beadboard-driver/scripts/ensure-bb-mail-configured.mjs` pass/fail
|
||||
- Session preflight status:
|
||||
- `node skills/beadboard-driver/scripts/session-preflight.mjs` pass/fail
|
||||
- `.beads` database: exists/created on YYYY-MM-DD via `bd init`
|
||||
- Mail delegate: `bd config set mail.delegate "node <abs-path>/scripts/bb-mail-shim.mjs"` — configured YYYY-MM-DD
|
||||
- Agent identity policy: `export BB_AGENT=<role-name>` (set fresh each session in Step 2)
|
||||
- `session-preflight` last pass: YYYY-MM-DD
|
||||
|
||||
## Agent State + Heartbeat Policy
|
||||
|
||||
- Agent bead naming convention for this repo:
|
||||
- Required state transitions (spawning -> running -> working -> stuck/done/stopped):
|
||||
- Heartbeat cadence during active work (recommended 30-120s):
|
||||
- Stuck escalation timeout before user ping:
|
||||
|
||||
## Swarm / Formula Defaults
|
||||
|
||||
- Primary epic/swarm pattern used by this repo:
|
||||
- Formula/proto id(s) commonly used (if any):
|
||||
- Preferred swarm command flow (`bd swarm validate/create/status` etc.):
|
||||
- Agent bead naming: `bb-<role-name>` (e.g. `bb-silver-scribe`)
|
||||
- Required state transitions: `spawning → running → working → stuck/done/stopped`
|
||||
- Heartbeat: LLM agents heartbeat at turn start + before long commands; daemon agents every 5 min
|
||||
|
||||
## Command Baseline
|
||||
|
||||
- Install command:
|
||||
- Build command:
|
||||
- Typecheck command:
|
||||
- Lint command:
|
||||
- Test command:
|
||||
- Smoke command (optional):
|
||||
|
||||
## Verification Policy Overrides
|
||||
|
||||
- Required gates for this project:
|
||||
- Known slow gates and timeout guidance:
|
||||
- Evidence format expected in bead notes:
|
||||
|
||||
## Scope and Safety
|
||||
|
||||
- Forbidden commands/actions for this repo:
|
||||
- Paths requiring reservation before edits:
|
||||
- External systems requiring human approval:
|
||||
- Secret handling guidance:
|
||||
|
||||
## Coordination Defaults
|
||||
|
||||
- Default handoff style:
|
||||
- Blocker escalation policy:
|
||||
- ACK expectations for `HANDOFF`/`BLOCKED`:
|
||||
- Reservation conflict policy (`--takeover-stale` rules):
|
||||
- Install:
|
||||
- Build:
|
||||
- Typecheck:
|
||||
- Lint:
|
||||
- Test:
|
||||
|
||||
## Known Workarounds
|
||||
|
||||
|
|
@ -80,24 +69,12 @@ Document only stable, repeatable workarounds.
|
|||
1. Trigger:
|
||||
- Symptom:
|
||||
- Workaround:
|
||||
- Verification:
|
||||
- Owner:
|
||||
- Verified:
|
||||
|
||||
2. Trigger:
|
||||
- Symptom:
|
||||
- Workaround:
|
||||
- Verification:
|
||||
- Owner:
|
||||
## Session Log (append-only)
|
||||
|
||||
## Session Closeout Checklist
|
||||
Each agent appends one line when they update this file:
|
||||
|
||||
- [ ] Bead status/assignee updated
|
||||
- [ ] Verification commands executed and recorded
|
||||
- [ ] Artifacts attached/linked
|
||||
- [ ] Memory review performed
|
||||
- [ ] Follow-up beads created (if needed)
|
||||
- [ ] `project.md` updated with any new environment facts
|
||||
|
||||
## Change Log
|
||||
|
||||
- YYYY-MM-DD: Initial `project.md` created from template.
|
||||
| Date | Agent | What changed |
|
||||
|------|-------|-------------|
|
||||
| YYYY-MM-DD | `<agent-bead-id>` | Initial project.md created |
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ Blocked condition:
|
|||
|
||||
```bash
|
||||
bd agent state bb-silver-scribe stuck
|
||||
bb agent send --from silver-scribe --to cobalt-ridge --bead beadboard-123 --category BLOCKED --subject "Waiting on schema" --body "Need migration direction before continuing."
|
||||
bd mail send --to cobalt-ridge --bead beadboard-123 --category BLOCKED --subject "Waiting on schema" --body "Need migration direction before continuing."
|
||||
```
|
||||
|
||||
Work completion:
|
||||
|
|
@ -74,14 +74,16 @@ Use `bd agent heartbeat <agent-bead-id>` to refresh `last_activity` without chan
|
|||
bd agent heartbeat bb-silver-scribe
|
||||
```
|
||||
|
||||
When to heartbeat:
|
||||
- At least once every 5-10 minutes during long-running work
|
||||
**Daemon agents (persistent processes):**
|
||||
- Normal work: every 5 minutes
|
||||
- High-risk long operations: every 2-3 minutes
|
||||
- Immediately before long test/build phases
|
||||
- Immediately after recovering from interruptions
|
||||
|
||||
Recommended cadence:
|
||||
- Normal work: every 5 minutes
|
||||
- High-risk long operations: every 2-3 minutes
|
||||
**LLM agents (Claude Code, turn-based):**
|
||||
- At turn start (when picking up work)
|
||||
- Immediately before long-running commands
|
||||
- Inter-turn silence is expected and not a health signal
|
||||
|
||||
## Witness Death Timeout
|
||||
|
||||
|
|
@ -95,6 +97,8 @@ Operational interpretation:
|
|||
Agent-side rule:
|
||||
- If you are alive and still executing, heartbeat before anyone has to guess.
|
||||
|
||||
> **Current status:** The Witness enforcement layer is not yet running. Heartbeats are recorded in `last_activity` and visible in the BeadBoard dashboard but are not currently auto-enforced. Agents will not be auto-marked `dead`. Daemon implementation is a future epic.
|
||||
|
||||
## Slot Operations (Current Work Attachment)
|
||||
|
||||
The `hook` slot links an agent bead to the active task bead.
|
||||
|
|
@ -125,7 +129,7 @@ Important slot constraints:
|
|||
|
||||
When blocked:
|
||||
1. Set state to stuck (`bd agent state ... stuck`)
|
||||
2. Send explicit BLOCKED coordination event (`bb agent send --category BLOCKED ...`)
|
||||
2. Send explicit BLOCKED coordination event (`bd mail send --category BLOCKED ...`)
|
||||
3. Keep heartbeat active while waiting
|
||||
4. Resume with `running`/`working` once unblocked
|
||||
|
||||
|
|
|
|||
|
|
@ -8,8 +8,12 @@ Day-to-day runbooks use `bd mail` delegation rather than direct low-level agent
|
|||
- `node skills/beadboard-driver/scripts/session-preflight.mjs`
|
||||
- `node skills/beadboard-driver/scripts/ensure-bb-mail-configured.mjs`
|
||||
- `bd create --title="Agent: <role-name>" --description="<agent scope>" --type=task --priority=0 --label="gt:agent,role:<orchestrator|ui|graph|backend|infra>"`
|
||||
- `bd agent state <agent-bead-id> spawning`
|
||||
- `bd agent state <agent-bead-id> running`
|
||||
- `bd agent state <agent-bead-id> spawning` — agent bead created, environment not yet verified
|
||||
- `bd agent state <agent-bead-id> running` — environment verified, ready to claim work
|
||||
- `bd agent state <agent-bead-id> working` — work bead claimed, actively executing
|
||||
- `bd agent state <agent-bead-id> stuck` — blocked, waiting on intervention or response
|
||||
- `bd agent state <agent-bead-id> done` — work bead closed, all deliverables complete
|
||||
- `bd agent state <agent-bead-id> stopped` — session ending cleanly
|
||||
- `bd agent heartbeat <agent-bead-id>`
|
||||
- `bd agent show <agent-bead-id>`
|
||||
|
||||
|
|
@ -75,7 +79,7 @@ Delegate setup and validation:
|
|||
|
||||
## Environment and Repair Helpers
|
||||
|
||||
- `node skills/beadboard-driver/scripts/resolve-bb.mjs`
|
||||
- `node skills/beadboard-driver/scripts/readiness-report.mjs --checks <json> --artifacts <json>`
|
||||
- `node skills/beadboard-driver/scripts/diagnose-env.mjs`
|
||||
- `node skills/beadboard-driver/scripts/heal-common-issues.mjs [--project-root <path>] [--apply] [--fix-git-index-lock]`
|
||||
- `node {baseDir}/scripts/setup-mail-delegate.mjs` — configure mail.delegate (self-resolves shim path)
|
||||
- `node {baseDir}/scripts/readiness-report.mjs --checks <json> --artifacts <json>`
|
||||
- `node {baseDir}/scripts/diagnose-env.mjs`
|
||||
- `node {baseDir}/scripts/heal-common-issues.mjs [--project-root <path>] [--apply] [--fix-git-index-lock]`
|
||||
|
|
|
|||
286
skills/beadboard-driver/references/creating-beads.md
Normal file
286
skills/beadboard-driver/references/creating-beads.md
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
# Creating and Managing Beads
|
||||
|
||||
Complete guide for agents on bead creation, naming, dependencies, and workflow.
|
||||
|
||||
---
|
||||
|
||||
## Bead Naming Format (CRITICAL)
|
||||
|
||||
| Level | Format | Example |
|
||||
|-------|--------|---------|
|
||||
| **Epic** | `beadboard-<id>` | `beadboard-abc` |
|
||||
| **Task** | `beadboard-<epic>.x` | `beadboard-abc.1` |
|
||||
| **Subtask** | `beadboard-<epic>.x.x` | `beadboard-abc.1.2` |
|
||||
|
||||
**Rules:**
|
||||
- `<id>` is auto-generated by `bd create` (alphanumeric, 2-4 chars)
|
||||
- Task numbers increment sequentially under epic
|
||||
- Subtask numbers increment under parent task
|
||||
- **Never skip levels**: epic → task → subtask
|
||||
|
||||
---
|
||||
|
||||
## Creating Epics
|
||||
|
||||
Epics represent high-level features or initiatives.
|
||||
|
||||
```bash
|
||||
bd create \
|
||||
--title="[EPIC] User Authentication System" \
|
||||
--description="Implement secure user login/registration with OAuth support" \
|
||||
--type=epic \
|
||||
--priority=2 \
|
||||
--label="feature,auth"
|
||||
```
|
||||
|
||||
**Output:** `Created beadboard-xyz (epic)`
|
||||
|
||||
---
|
||||
|
||||
## Creating Tasks Under Epics
|
||||
|
||||
Tasks are actionable work units under an epic.
|
||||
|
||||
```bash
|
||||
# Task naming convention: epic.1: Description
|
||||
bd create \
|
||||
--title="xyz.1: Implement login form UI" \
|
||||
--description="Build responsive login form with email/password fields" \
|
||||
--type=task \
|
||||
--priority=2 \
|
||||
--parent=beadboard-xyz
|
||||
```
|
||||
|
||||
**Output:** `Created beadboard-xyz.1 (task)`
|
||||
|
||||
### Creating Subtasks
|
||||
|
||||
```bash
|
||||
# Subtask naming: epic.task.subtask: Description
|
||||
bd create \
|
||||
--title="xyz.1.1: Add form validation" \
|
||||
--description="Implement client-side validation for email format" \
|
||||
--type=task \
|
||||
--priority=1 \
|
||||
--parent=beadboard-xyz.1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Setting Dependencies
|
||||
|
||||
### Blocker Dependencies (Execution Order)
|
||||
|
||||
Use when one bead must complete before another starts:
|
||||
|
||||
```bash
|
||||
# xyz.1 cannot start until xyz is done
|
||||
bd dep add beadboard-xyz.1 beadboard-xyz
|
||||
|
||||
# xyz.1.2 is blocked by xyz.1.1
|
||||
bd dep add beadboard-xyz.1.2 beadboard-xyz.1.1
|
||||
```
|
||||
|
||||
### Parent-Child Relationships
|
||||
|
||||
Use for semantic grouping (non-blocking):
|
||||
|
||||
```bash
|
||||
# Link child to parent epic
|
||||
bd dep relate beadboard-xyz beadboard-xyz.1
|
||||
|
||||
# Link subtask to task
|
||||
bd dep relate beadboard-xyz.1 beadboard-xyz.1.1
|
||||
```
|
||||
|
||||
### Viewing Dependencies
|
||||
|
||||
```bash
|
||||
bd dep list beadboard-xyz # Show all dependencies
|
||||
bd ready # Show unblocked, ready-to-start beads
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Writing Descriptions
|
||||
|
||||
Every bead description MUST include:
|
||||
|
||||
```markdown
|
||||
**Scope:**
|
||||
- Specific thing to implement
|
||||
- Another specific requirement
|
||||
|
||||
**Out of Scope:**
|
||||
- Things explicitly not included
|
||||
- Future work to avoid
|
||||
|
||||
**Success Criteria:**
|
||||
1. Specific measurable outcome
|
||||
2. Test passes
|
||||
3. Evidence of completion
|
||||
```
|
||||
|
||||
### Example Description
|
||||
|
||||
```bash
|
||||
bd create --title="xyz.2: API endpoint" --description="Scope:
|
||||
- POST /api/login endpoint
|
||||
- JWT token generation
|
||||
- Rate limiting (5 req/min)
|
||||
|
||||
Out of Scope:
|
||||
- OAuth providers (in xyz.3)
|
||||
- Password reset flow
|
||||
|
||||
Success Criteria:
|
||||
1. Endpoint returns 200 with valid JWT
|
||||
2. Returns 401 for invalid credentials
|
||||
3. Rate limit enforced" --type=task --priority=2 --parent=beadboard-xyz
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Closing Beads
|
||||
|
||||
### Step 1: Update with Evidence
|
||||
|
||||
```bash
|
||||
bd update beadboard-xyz.1 \
|
||||
--notes "Tests pass: npm test -- --grep 'login'
|
||||
Files changed:
|
||||
- src/components/LoginForm.tsx
|
||||
- src/lib/validation.ts
|
||||
Coverage: 94%"
|
||||
```
|
||||
|
||||
### Step 2: Close
|
||||
|
||||
```bash
|
||||
bd close beadboard-xyz.1 --reason "Login form implemented, tested, merged to main"
|
||||
```
|
||||
|
||||
**Rule:** Update first, then close. `bd close` does not accept `--notes`.
|
||||
|
||||
---
|
||||
|
||||
## Complete Example: End-to-End Workflow
|
||||
|
||||
### 1. Create Epic
|
||||
|
||||
```bash
|
||||
$ bd create --title="[EPIC] Payment Integration" --description="Add Stripe payment processing" --type=epic --priority=1 --label="feature,payments"
|
||||
|
||||
Created beadboard-pmt (epic)
|
||||
```
|
||||
|
||||
### 2. Create Tasks
|
||||
|
||||
```bash
|
||||
$ bd create --title="pmt.1: Stripe account setup" --description="Scope:
|
||||
- Create Stripe dev account
|
||||
- Configure webhook endpoint
|
||||
- Add API keys to vault
|
||||
|
||||
Out of Scope:
|
||||
- Production account (separate epic)
|
||||
|
||||
Success Criteria:
|
||||
1. Test transactions work in sandbox
|
||||
2. Webhook receives events" --type=task --priority=1 --parent=beadboard-pmt
|
||||
|
||||
Created beadboard-pmt.1 (task)
|
||||
|
||||
$ bd create --title="pmt.2: Checkout UI" --description="Scope:
|
||||
- Payment form component
|
||||
- Card element integration
|
||||
- Error handling display
|
||||
|
||||
Success Criteria:
|
||||
1. Form submits to Stripe
|
||||
2. Shows success/failure states" --type=task --priority=2 --parent=beadboard-pmt
|
||||
|
||||
Created beadboard-pmt.2 (task)
|
||||
```
|
||||
|
||||
### 3. Set Dependencies
|
||||
|
||||
```bash
|
||||
# pmt.2 blocked by pmt.1 (need Stripe setup first)
|
||||
$ bd dep add beadboard-pmt.2 beadboard-pmt.1
|
||||
|
||||
Added dependency: beadboard-pmt.2 -> beadboard-pmt.1
|
||||
```
|
||||
|
||||
### 4. Check Ready Status
|
||||
|
||||
```bash
|
||||
$ bd ready
|
||||
|
||||
beadboard-pmt.1 [task] pmt.1: Stripe account setup
|
||||
```
|
||||
|
||||
Only `pmt.1` is ready—`pmt.2` is blocked.
|
||||
|
||||
### 5. Create Subtask
|
||||
|
||||
```bash
|
||||
$ bd create --title="pmt.1.1: Webhook handler" --description="Scope:
|
||||
- POST /webhooks/stripe endpoint
|
||||
- Verify Stripe signature
|
||||
- Update order status
|
||||
|
||||
Success Criteria:
|
||||
1. Signature verification passes
|
||||
2. Order updated correctly" --type=task --priority=1 --parent=beadboard-pmt.1
|
||||
|
||||
Created beadboard-pmt.1.1 (task)
|
||||
```
|
||||
|
||||
### 6. Work and Close
|
||||
|
||||
```bash
|
||||
# After completing pmt.1.1
|
||||
$ bd update beadboard-pmt.1.1 --notes "Webhook handler implemented
|
||||
- src/app/api/webhooks/stripe/route.ts
|
||||
- Tests: npm test webhook.test.ts (3 passing)"
|
||||
|
||||
$ bd close beadboard-pmt.1.1 --reason "Webhook handler complete"
|
||||
|
||||
# After completing pmt.1
|
||||
$ bd update beadboard-pmt.1 --notes "Stripe integration complete
|
||||
- Sandbox account: acct_xxx
|
||||
- Webhook: /api/webhooks/stripe
|
||||
- All tests passing"
|
||||
|
||||
$ bd close beadboard-pmt.1 --reason "Stripe account setup finished"
|
||||
|
||||
# Now pmt.2 becomes unblocked
|
||||
$ bd ready
|
||||
|
||||
beadboard-pmt.2 [task] pmt.2: Checkout UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `bd create --title="..." --type=epic` | Create epic |
|
||||
| `bd create --title="epic.N: ..." --parent=beadboard-xxx` | Create task |
|
||||
| `bd dep add <blocked> <blocker>` | Set blocker dependency |
|
||||
| `bd dep relate <parent> <child>` | Set semantic relationship |
|
||||
| `bd ready` | List unblocked beads |
|
||||
| `bd update <id> --notes "..."` | Add evidence |
|
||||
| `bd close <id> --reason "..."` | Complete bead |
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
1. **Wrong naming**: `task-1` instead of `beadboard-abc.1`
|
||||
2. **Missing parent**: Tasks must have `--parent=beadboard-<epic>`
|
||||
3. **Closing before updating**: Always `update` first, then `close`
|
||||
4. **Wrong dep direction**: `bd dep add <blocked> <blocker>` (blocked first!)
|
||||
5. **Skipping evidence**: Always include command output in `--notes`
|
||||
|
|
@ -13,12 +13,12 @@ This document tracks high-impact coordination and environment failures for the B
|
|||
|
||||
## `BB_NOT_FOUND`
|
||||
|
||||
- Signal: `resolve-bb.mjs` or `bb-mail-shim.mjs` reports bb command missing.
|
||||
- Signal: `session-preflight.mjs` or `bb-mail-shim.mjs` reports bb command missing.
|
||||
- Cause: global BeadBoard CLI not installed, or not discoverable.
|
||||
- Recovery:
|
||||
- Install BeadBoard globally (`bb`/`beadboard` on `PATH`).
|
||||
- Re-run `node skills/beadboard-driver/scripts/resolve-bb.mjs`.
|
||||
- Re-run preflight.
|
||||
- Install BeadBoard globally (`bb`/`beadboard` on `PATH`) — see Bootstrap Step C in SKILL.md.
|
||||
- Run `node {baseDir}/scripts/setup-mail-delegate.mjs` to reconfigure the mail delegate after `bb` is installed.
|
||||
- Re-run preflight: `node {baseDir}/scripts/session-preflight.mjs`.
|
||||
|
||||
## `MAIL_DELEGATE_MISSING` / `BD_MAIL_DELEGATE_NOT_SET`
|
||||
|
||||
|
|
@ -33,10 +33,12 @@ This document tracks high-impact coordination and environment failures for the B
|
|||
|
||||
- Signal: `ensure-bb-mail-configured.mjs` fails contract checks.
|
||||
- Cause: delegate points to wrong command, missing shim path, or invalid `BB_AGENT` context.
|
||||
- Recovery:
|
||||
- Run session preflight to re-apply expected delegate command.
|
||||
- Set `BB_AGENT` explicitly.
|
||||
- Validate with `node skills/beadboard-driver/scripts/ensure-bb-mail-configured.mjs`.
|
||||
- Recovery (in order):
|
||||
1. Check delegate is set: `bd config get mail.delegate`
|
||||
2. Verify shim path: the path shown must be absolute and the `bb-mail-shim.mjs` file must exist on disk
|
||||
3. Reconfigure if wrong/missing: `node {baseDir}/scripts/setup-mail-delegate.mjs`
|
||||
4. Verify `BB_AGENT` is set: `echo $BB_AGENT` (must be non-empty)
|
||||
5. Re-run verification: `node {baseDir}/scripts/ensure-bb-mail-configured.mjs` — expected: `ok: true`
|
||||
|
||||
## `DOLT_NOT_RUNNING`
|
||||
|
||||
|
|
@ -72,4 +74,4 @@ This document tracks high-impact coordination and environment failures for the B
|
|||
|
||||
- Do not write `.beads/issues.jsonl` directly.
|
||||
- Do not close beads without fresh evidence.
|
||||
- Do not bypass invalid `BB_REPO` values; fix configuration first.
|
||||
- Do not bypass a misconfigured mail delegate; fix configuration with `{baseDir}/scripts/setup-mail-delegate.mjs` first.
|
||||
|
|
|
|||
|
|
@ -7,8 +7,7 @@ This runbook is the minimum lifecycle contract for agents using BeadBoard Driver
|
|||
1. Run preflight and discovery checks:
|
||||
|
||||
```bash
|
||||
node skills/beadboard-driver/scripts/session-preflight.mjs
|
||||
node skills/beadboard-driver/scripts/resolve-bb.mjs
|
||||
node {baseDir}/scripts/session-preflight.mjs
|
||||
```
|
||||
|
||||
2. Create or identify your agent bead first (required before claiming work):
|
||||
|
|
@ -27,7 +26,8 @@ bd agent state <agent-bead-id> running
|
|||
4. Query hard memory for your domain before claim:
|
||||
|
||||
```bash
|
||||
bd query "label=memory AND label=mem-canonical AND label=mem-hard AND status=closed"
|
||||
# Select domain: memory-arch | memory-workflow | memory-agent | memory-ux | memory-reliability
|
||||
bd query "label=memory AND label=mem-canonical AND label=<domain> AND status=closed" --sort updated --reverse
|
||||
```
|
||||
|
||||
## 2) Discover Work and Read Epic Context
|
||||
|
|
@ -100,7 +100,7 @@ bd agent state <agent-bead-id> stuck
|
|||
2. Coordination signal:
|
||||
|
||||
```bash
|
||||
bb agent send --from <agent-name> --to <target-agent-or-role> --bead <bead-id> --category BLOCKED --subject "<blocker summary>" --body "<what is needed>"
|
||||
bd mail send --to <target-agent-or-role> --bead <bead-id> --category BLOCKED --subject "<blocker summary>" --body "<what is needed>"
|
||||
```
|
||||
|
||||
3. Keep heartbeat while waiting:
|
||||
|
|
|
|||
|
|
@ -40,6 +40,38 @@ function configureMailDelegate(bdPath, shimPath) {
|
|||
};
|
||||
}
|
||||
|
||||
function validateMemorySystem(bdPath) {
|
||||
try {
|
||||
const result = spawnSync(bdPath, ['query', 'label=mem-canonical,status=closed', '--limit', '5'], {
|
||||
stdio: 'pipe',
|
||||
shell: false,
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
return {
|
||||
validated: false,
|
||||
reason: 'Failed to query memory system',
|
||||
memories_found: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const output = result.stdout?.toString() || '';
|
||||
const memoryCount = (output.match(/beadboard-/g) || []).length;
|
||||
|
||||
return {
|
||||
validated: true,
|
||||
memories_found: memoryCount,
|
||||
note: 'Remember to read memory beads at session start: bd show beadboard-116 beadboard-60a beadboard-zas',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
validated: false,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
memories_found: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const shimPath = join(__dirname, 'bb-mail-shim.mjs');
|
||||
|
||||
|
|
@ -54,13 +86,14 @@ async function main() {
|
|||
reason: 'Could not find bd in PATH.',
|
||||
remediation:
|
||||
process.platform === 'win32'
|
||||
? 'Primary: npm i -g beadboard. Fallback: powershell -ExecutionPolicy Bypass -File .\\install\\install.ps1. Then ensure bd is available in PATH.'
|
||||
? 'Primary: npm i -g beadboard. Fallback: powershell -ExecutionPolicy Bypass -File ./install/install.ps1. Then ensure bd is available in PATH.'
|
||||
: 'Primary: npm i -g beadboard. Fallback: bash ./install/install.sh. Then ensure bd is available in PATH.',
|
||||
tools: {
|
||||
bd: { available: false, path: null },
|
||||
},
|
||||
bb: null,
|
||||
mail: null,
|
||||
memory: null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
|
|
@ -86,6 +119,7 @@ async function main() {
|
|||
configured: false,
|
||||
reason: 'bb not available — mail delegate requires bb agent commands',
|
||||
},
|
||||
memory: null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
|
|
@ -95,6 +129,7 @@ async function main() {
|
|||
}
|
||||
|
||||
const mail = configureMailDelegate(bdPath, shimPath);
|
||||
const memory = validateMemorySystem(bdPath);
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
|
|
@ -106,6 +141,7 @@ async function main() {
|
|||
},
|
||||
bb,
|
||||
mail,
|
||||
memory,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
|
|
@ -124,6 +160,7 @@ async function main() {
|
|||
},
|
||||
bb: null,
|
||||
mail: null,
|
||||
memory: null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
|
|
|
|||
95
skills/beadboard-driver/scripts/setup-mail-delegate.mjs
Normal file
95
skills/beadboard-driver/scripts/setup-mail-delegate.mjs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* setup-mail-delegate.mjs
|
||||
*
|
||||
* Configures bd's mail.delegate to point at the bb-mail-shim.mjs bundled
|
||||
* alongside this script. Uses import.meta.url to resolve the absolute path
|
||||
* so the caller never needs to know where the skill is installed.
|
||||
*
|
||||
* Usage: node {baseDir}/scripts/setup-mail-delegate.mjs
|
||||
* Output: JSON { ok, configured, delegate } or { ok, error_code, reason }
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
import { findCommandInPath } from './lib/driver-lib.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const shimPath = join(__dirname, 'bb-mail-shim.mjs');
|
||||
const delegateCommand = `node ${shimPath}`;
|
||||
|
||||
async function main() {
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
const bdPath = await findCommandInPath('bd');
|
||||
|
||||
if (!bdPath) {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
ok: false,
|
||||
error_code: 'BD_NOT_FOUND',
|
||||
reason: 'Could not find bd in PATH. Install with: npm install -g beads-cli',
|
||||
delegate: null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
dry_run: true,
|
||||
configured: false,
|
||||
delegate: delegateCommand,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = spawnSync(bdPath, ['config', 'set', 'mail.delegate', delegateCommand], {
|
||||
stdio: 'pipe',
|
||||
shell: false,
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr?.toString().trim() || '';
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
ok: false,
|
||||
error_code: 'BD_CONFIG_FAILED',
|
||||
reason: stderr || 'bd config set mail.delegate exited non-zero.',
|
||||
delegate: delegateCommand,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
configured: true,
|
||||
delegate: delegateCommand,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
void main();
|
||||
|
|
@ -21,7 +21,7 @@ test('ensure-project-context creates project.md when missing', async () => {
|
|||
const content = await fs.readFile(path.join(root, 'project.md'), 'utf8');
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.created, true);
|
||||
assert.match(content, /Project Driver Template/);
|
||||
assert.match(content, /Environment Status Cache/);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const tests = [
|
|||
path.join(__dirname, 'diagnose-env.contract.test.mjs'),
|
||||
path.join(__dirname, 'heal-common-issues.contract.test.mjs'),
|
||||
path.join(__dirname, 'ensure-project-context.contract.test.mjs'),
|
||||
path.join(__dirname, 'setup-mail-delegate.contract.test.mjs'),
|
||||
];
|
||||
|
||||
const child = spawn(process.execPath, ['--test', ...tests], {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const scriptPath = path.resolve('skills/beadboard-driver/scripts/setup-mail-delegate.mjs');
|
||||
const expectedShimPath = path.join(__dirname, '..', 'scripts', 'bb-mail-shim.mjs');
|
||||
|
||||
async function withTempDir(run) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bb-skill-setupdelegate-'));
|
||||
try {
|
||||
await run(root);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('setup-mail-delegate contract: dry-run resolves absolute shim path', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const { stdout } = await execFileAsync(process.execPath, [scriptPath, '--dry-run'], {
|
||||
cwd: root,
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
const result = JSON.parse(stdout);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.dry_run, true);
|
||||
assert.ok(
|
||||
result.delegate.includes('bb-mail-shim.mjs'),
|
||||
`delegate should reference bb-mail-shim.mjs, got: ${result.delegate}`,
|
||||
);
|
||||
|
||||
const resolvedPath = result.delegate.replace(/^node\s+/, '');
|
||||
assert.ok(
|
||||
path.isAbsolute(resolvedPath),
|
||||
`delegate path should be absolute, got: ${resolvedPath}`,
|
||||
);
|
||||
assert.equal(
|
||||
resolvedPath,
|
||||
expectedShimPath,
|
||||
`delegate should point to the bundled shim`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('setup-mail-delegate contract: BD_NOT_FOUND when bd missing', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const { stdout } = await execFileAsync(process.execPath, [scriptPath], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: '/nonexistent',
|
||||
},
|
||||
});
|
||||
|
||||
const result = JSON.parse(stdout);
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error_code, 'BD_NOT_FOUND');
|
||||
});
|
||||
});
|
||||
29
src/app/api/agents/mail/batch/route.ts
Normal file
29
src/app/api/agents/mail/batch/route.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { inboxAgentMessages } from '../../../../../lib/agent-mail';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const agentsParam = searchParams.get('agents') ?? '';
|
||||
const limitParam = searchParams.get('limit');
|
||||
const limit = limitParam ? Number.parseInt(limitParam, 10) : 25;
|
||||
|
||||
if (!agentsParam) {
|
||||
return NextResponse.json({ ok: true, data: [] }, { status: 200 });
|
||||
}
|
||||
|
||||
const agentNames = agentsParam.split(',').map(a => a.trim()).filter(Boolean);
|
||||
|
||||
const results = await Promise.all(
|
||||
agentNames.map(async (agent) => {
|
||||
const result = await inboxAgentMessages({ agent, limit });
|
||||
if (!result.ok) {
|
||||
return { agent, messages: [] };
|
||||
}
|
||||
return { agent, messages: result.data ?? [] };
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json({ ok: true, data: results }, { status: 200 });
|
||||
}
|
||||
33
src/app/api/agents/reservations/batch/route.ts
Normal file
33
src/app/api/agents/reservations/batch/route.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { statusAgentReservations } from '../../../../../lib/agent-reservations';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const agentsParam = searchParams.get('agents') ?? '';
|
||||
|
||||
if (!agentsParam) {
|
||||
return NextResponse.json({ ok: true, data: [] }, { status: 200 });
|
||||
}
|
||||
|
||||
const agentNames = agentsParam.split(',').map(a => a.trim()).filter(Boolean);
|
||||
|
||||
const results = await Promise.all(
|
||||
agentNames.map(async (agent) => {
|
||||
const result = await statusAgentReservations({ agent });
|
||||
if (!result.ok || !result.data) {
|
||||
return { agent, scope: undefined, reservations: [] };
|
||||
}
|
||||
const reservations = result.data.reservations ?? [];
|
||||
const first = reservations[0];
|
||||
return {
|
||||
agent,
|
||||
scope: first?.scope as string | undefined,
|
||||
reservations
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json({ ok: true, data: results }, { status: 200 });
|
||||
}
|
||||
|
|
@ -305,29 +305,24 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
|
|||
return;
|
||||
}
|
||||
|
||||
const mailResponses = await Promise.all(
|
||||
agentRoster.map(async (agent) => {
|
||||
const response = await fetch(`/api/agents/mail?agent=${encodeURIComponent(agent.name)}&limit=15`);
|
||||
const payload = await response.json().catch(() => ({ ok: false }));
|
||||
return [agent.name, response.ok && payload.ok ? (payload.data as CoordMessage[]) : []] as const;
|
||||
}),
|
||||
);
|
||||
// Use batch endpoints to reduce API calls from 2N to 2
|
||||
const agentNames = agentRoster.map(a => a.name).join(',');
|
||||
|
||||
const reservationResponses = await Promise.all(
|
||||
agentRoster.map(async (agent) => {
|
||||
const response = await fetch(`/api/agents/reservations?agent=${encodeURIComponent(agent.name)}`);
|
||||
const payload = await response.json().catch(() => ({ ok: false }));
|
||||
if (!response.ok || !payload.ok) {
|
||||
return [agent.name, undefined] as const;
|
||||
}
|
||||
return [agent.name, payload.data?.reservations?.[0]?.scope as string | undefined] as const;
|
||||
}),
|
||||
);
|
||||
const [mailResponse, reservationsResponse] = await Promise.all([
|
||||
fetch(`/api/agents/mail/batch?agents=${encodeURIComponent(agentNames)}&limit=15`),
|
||||
fetch(`/api/agents/reservations/batch?agents=${encodeURIComponent(agentNames)}`),
|
||||
]);
|
||||
|
||||
const mailPayload = await mailResponse.json().catch(() => ({ ok: false, data: [] }));
|
||||
const reservationsPayload = await reservationsResponse.json().catch(() => ({ ok: false, data: [] }));
|
||||
|
||||
// Collect all messages from all agents
|
||||
const uniqueMessages = new Map<string, CoordMessage>();
|
||||
for (const [, messages] of mailResponses) {
|
||||
for (const message of messages) {
|
||||
uniqueMessages.set(message.message_id, message);
|
||||
if (mailPayload.ok && mailPayload.data) {
|
||||
for (const entry of mailPayload.data) {
|
||||
for (const message of (entry.messages ?? [])) {
|
||||
uniqueMessages.set(message.message_id, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -346,8 +341,16 @@ export function ActivityPanel({ issues, collapsed = false, projectRoot }: Activi
|
|||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||
.slice(0, 25);
|
||||
|
||||
// Build reservation map
|
||||
const reservationMap: Record<string, string | undefined> = {};
|
||||
if (reservationsPayload.ok && reservationsPayload.data) {
|
||||
for (const entry of reservationsPayload.data) {
|
||||
reservationMap[entry.agent] = entry.scope;
|
||||
}
|
||||
}
|
||||
|
||||
setCoordActivities(mapped);
|
||||
setReservationByAgent(Object.fromEntries(reservationResponses));
|
||||
setReservationByAgent(reservationMap);
|
||||
};
|
||||
|
||||
void fetchCoordination();
|
||||
|
|
|
|||
|
|
@ -184,38 +184,39 @@ export function SocialPage({
|
|||
return;
|
||||
}
|
||||
|
||||
const mailPairs = await Promise.all(
|
||||
agentNames.map(async (agent) => {
|
||||
const response = await fetch(`/api/agents/mail?agent=${encodeURIComponent(agent)}&limit=25`);
|
||||
const payload = await response.json().catch(() => ({ ok: false }));
|
||||
if (!response.ok || !payload.ok) {
|
||||
return [agent, [] as CoordMessage[]] as const;
|
||||
}
|
||||
return [agent, (payload.data ?? []) as CoordMessage[]] as const;
|
||||
}),
|
||||
);
|
||||
// Use batch endpoints to reduce API calls from 2N to 2
|
||||
const agentsParam = agentNames.join(',');
|
||||
|
||||
const reservationsPairs = await Promise.all(
|
||||
agentNames.map(async (agent) => {
|
||||
const response = await fetch(`/api/agents/reservations?agent=${encodeURIComponent(agent)}`);
|
||||
const payload = await response.json().catch(() => ({ ok: false }));
|
||||
if (!response.ok || !payload.ok) {
|
||||
return [agent, undefined] as const;
|
||||
}
|
||||
const first = (payload.data?.reservations ?? [])[0];
|
||||
return [agent, first?.scope as string | undefined] as const;
|
||||
}),
|
||||
);
|
||||
const [mailResponse, reservationsResponse] = await Promise.all([
|
||||
fetch(`/api/agents/mail/batch?agents=${encodeURIComponent(agentsParam)}&limit=25`),
|
||||
fetch(`/api/agents/reservations/batch?agents=${encodeURIComponent(agentsParam)}`),
|
||||
]);
|
||||
|
||||
const mailPayload = await mailResponse.json().catch(() => ({ ok: false, data: [] }));
|
||||
const reservationsPayload = await reservationsResponse.json().catch(() => ({ ok: false, data: [] }));
|
||||
|
||||
const nextMessages: Record<string, CoordMessage[]> = {};
|
||||
const nextUnread: Record<string, number> = {};
|
||||
for (const [agent, messages] of mailPairs) {
|
||||
nextMessages[agent] = messages;
|
||||
nextUnread[agent] = messages.filter((m) => m.state === 'unread').length;
|
||||
const nextReservations: Record<string, string | undefined> = {};
|
||||
|
||||
// Process mail results
|
||||
if (mailPayload.ok && mailPayload.data) {
|
||||
for (const entry of mailPayload.data) {
|
||||
nextMessages[entry.agent] = entry.messages ?? [];
|
||||
nextUnread[entry.agent] = (entry.messages ?? []).filter((m: CoordMessage) => m.state === 'unread').length;
|
||||
}
|
||||
}
|
||||
|
||||
// Process reservations results
|
||||
if (reservationsPayload.ok && reservationsPayload.data) {
|
||||
for (const entry of reservationsPayload.data) {
|
||||
nextReservations[entry.agent] = entry.scope;
|
||||
}
|
||||
}
|
||||
|
||||
setAgentMessagesByName(nextMessages);
|
||||
setAgentUnreadByName(nextUnread);
|
||||
setAgentReservationsByName(Object.fromEntries(reservationsPairs));
|
||||
setAgentReservationsByName(nextReservations);
|
||||
}, [agentNames]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -1,52 +1,3 @@
|
|||
export type ProtocolEventType = 'HANDOFF' | 'BLOCKED' | 'INCURSION' | 'RESUME' | 'INFO';
|
||||
|
||||
export interface ProtocolEventEnvelope<T = any> {
|
||||
id: string;
|
||||
version: 'v1';
|
||||
event_type: ProtocolEventType;
|
||||
project_root: string;
|
||||
bead_id: string;
|
||||
from_agent: string | null;
|
||||
to_agent: string | null;
|
||||
scope: string | null;
|
||||
created_at: string;
|
||||
payload: T;
|
||||
}
|
||||
|
||||
export type ProtocolEvent = ProtocolEventEnvelope;
|
||||
|
||||
export interface CreateProtocolEventInput {
|
||||
event_type: ProtocolEventType;
|
||||
project_root: string;
|
||||
bead_id: string;
|
||||
from_agent?: string;
|
||||
to_agent?: string;
|
||||
scope?: string;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
export interface ProtocolDeps {
|
||||
now: () => string;
|
||||
idGenerator: () => string;
|
||||
}
|
||||
|
||||
export function createProtocolEvent(
|
||||
input: CreateProtocolEventInput,
|
||||
deps: Partial<ProtocolDeps> = {}
|
||||
): ProtocolEvent {
|
||||
const now = deps.now ? deps.now() : new Date().toISOString();
|
||||
const generateId = deps.idGenerator ?? (() => `proto_${Date.now()}_${Math.random().toString(16).slice(2, 6)}`);
|
||||
|
||||
return {
|
||||
id: generateId(),
|
||||
version: 'v1',
|
||||
event_type: input.event_type,
|
||||
project_root: input.project_root,
|
||||
bead_id: input.bead_id,
|
||||
from_agent: input.from_agent ?? null,
|
||||
to_agent: input.to_agent ?? null,
|
||||
scope: input.scope ?? null,
|
||||
created_at: now,
|
||||
payload: input.payload,
|
||||
};
|
||||
}
|
||||
// Re-export from new bounded context
|
||||
// This file is deprecated - import from ./agent/types instead
|
||||
export * from './agent/types';
|
||||
|
|
|
|||
|
|
@ -1,503 +1,10 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { runBdCommand } from './bridge';
|
||||
import { activityEventBus } from './realtime';
|
||||
|
||||
const AGENT_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
export type AgentCommandName = 'agent register' | 'agent list' | 'agent show' | 'agent activity-lease' | 'agent state';
|
||||
|
||||
export type AgentZfcState = 'idle' | 'spawning' | 'running' | 'working' | 'stuck' | 'done' | 'stopped' | 'dead';
|
||||
|
||||
export interface AgentCommandError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface AgentCommandResponse<T> {
|
||||
ok: boolean;
|
||||
command: AgentCommandName;
|
||||
data: T | null;
|
||||
error: AgentCommandError | null;
|
||||
}
|
||||
|
||||
export interface AgentRecord {
|
||||
agent_id: string;
|
||||
display_name: string;
|
||||
role: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
last_seen_at: string;
|
||||
version: number;
|
||||
rig?: string;
|
||||
role_type?: string;
|
||||
swarm_id?: string;
|
||||
current_task?: string;
|
||||
}
|
||||
|
||||
export interface RegisterAgentInput {
|
||||
name: string;
|
||||
display?: string;
|
||||
role: string;
|
||||
forceUpdate?: boolean;
|
||||
rig?: string;
|
||||
}
|
||||
|
||||
export interface RegisterAgentDeps {
|
||||
now: () => string;
|
||||
projectRoot: string;
|
||||
}
|
||||
|
||||
export interface ListAgentsInput {
|
||||
role?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface ShowAgentInput {
|
||||
agent: string;
|
||||
}
|
||||
|
||||
export interface ActivityLeaseInput {
|
||||
agent: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes agent name to bead ID with prefix.
|
||||
* e.g. "silver-castle" -> "bb-silver-castle"
|
||||
*/
|
||||
function toBeadId(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed.startsWith('bb-')) return trimmed;
|
||||
return `bb-${trimmed}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips prefix from bead ID for display/internal logic.
|
||||
* e.g. "bb-silver-castle" -> "silver-castle"
|
||||
*/
|
||||
function fromBeadId(id: string): string {
|
||||
if (id.startsWith('bb-')) return id.slice(3);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Robustly extracts the first JSON block from a potentially noisy string.
|
||||
* Handles cases where 'bd' outputs warnings or daemon logs before the JSON.
|
||||
*/
|
||||
function extractJson(text: string): any {
|
||||
const start = text.indexOf('{');
|
||||
const end = text.lastIndexOf('}');
|
||||
if (start === -1 || end === -1) {
|
||||
throw new Error('No JSON block found in output');
|
||||
}
|
||||
const jsonPart = text.slice(start, end + 1);
|
||||
return JSON.parse(jsonPart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Robustly extracts the first JSON array from a potentially noisy string.
|
||||
*/
|
||||
function extractJsonArray(text: string): any[] {
|
||||
const start = text.indexOf('[');
|
||||
const end = text.lastIndexOf(']');
|
||||
if (start === -1 || end === -1) {
|
||||
// Check if it's a single object instead
|
||||
try {
|
||||
const single = extractJson(text);
|
||||
return [single];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const jsonPart = text.slice(start, end + 1);
|
||||
return JSON.parse(jsonPart);
|
||||
}
|
||||
|
||||
function trimOrEmpty(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper to fetch and parse agent details robustly.
|
||||
*/
|
||||
async function callBdAgentShow(beadId: string, projectRoot: string): Promise<AgentRecord | null> {
|
||||
const showResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: ['agent', 'show', beadId, '--json'],
|
||||
});
|
||||
|
||||
if (!showResult.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const bdAgent = extractJson(showResult.stdout);
|
||||
return mapBdAgentToRecord(bdAgent);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(command: AgentCommandName, code: string, message: string): AgentCommandResponse<never> {
|
||||
return {
|
||||
ok: false,
|
||||
command,
|
||||
data: null,
|
||||
error: { code, message },
|
||||
};
|
||||
}
|
||||
|
||||
function success<T>(command: AgentCommandName, data: T): AgentCommandResponse<T> {
|
||||
return {
|
||||
ok: true,
|
||||
command,
|
||||
data,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
function validateAgentId(value: string): AgentCommandError | null {
|
||||
if (!AGENT_ID_PATTERN.test(value) || value.length < 3 || value.length > 48) {
|
||||
return {
|
||||
code: 'INVALID_AGENT_ID',
|
||||
message: 'Agent id must match ^[a-z0-9]+(?:-[a-z0-9]+)*$ and be 3..48 characters.',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateRole(value: string): AgentCommandError | null {
|
||||
if (!value) {
|
||||
return {
|
||||
code: 'INVALID_ROLE',
|
||||
message: 'Role is required.',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function mapBdAgentToRecord(bdAgent: any): AgentRecord {
|
||||
// Extract role from labels if role_type is not set
|
||||
let role = bdAgent.role_type || 'agent';
|
||||
let swarmId: string | undefined;
|
||||
let currentTask: string | undefined;
|
||||
|
||||
if (Array.isArray(bdAgent.labels)) {
|
||||
const roleLabel = bdAgent.labels.find((l: string) => l.startsWith('role:'));
|
||||
if (roleLabel) {
|
||||
role = roleLabel.split(':')[1];
|
||||
}
|
||||
const swarmLabel = bdAgent.labels.find((l: string) => l.startsWith('swarm:'));
|
||||
if (swarmLabel) {
|
||||
swarmId = swarmLabel.split(':')[1];
|
||||
}
|
||||
const workingLabel = bdAgent.labels.find((l: string) => l.startsWith('working:'));
|
||||
if (workingLabel) {
|
||||
currentTask = workingLabel.split(':')[1];
|
||||
}
|
||||
}
|
||||
|
||||
let rig = bdAgent.rig;
|
||||
if (!rig && Array.isArray(bdAgent.labels)) {
|
||||
const rigLabel = bdAgent.labels.find((l: string) => l.startsWith('rig:'));
|
||||
if (rigLabel) {
|
||||
rig = rigLabel.split(':')[1];
|
||||
}
|
||||
}
|
||||
|
||||
const record: AgentRecord = {
|
||||
agent_id: fromBeadId(bdAgent.id),
|
||||
display_name: bdAgent.title?.replace(/^Agent: /, '') || fromBeadId(bdAgent.id),
|
||||
role,
|
||||
status: bdAgent.agent_state || 'idle',
|
||||
created_at: bdAgent.created_at || bdAgent.last_activity || new Date().toISOString(),
|
||||
last_seen_at: bdAgent.last_activity || new Date().toISOString(),
|
||||
version: 1,
|
||||
rig,
|
||||
role_type: bdAgent.role_type,
|
||||
swarm_id: swarmId,
|
||||
current_task: currentTask,
|
||||
};
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function registerAgent(
|
||||
input: RegisterAgentInput,
|
||||
deps: Partial<RegisterAgentDeps> = {},
|
||||
): Promise<AgentCommandResponse<AgentRecord>> {
|
||||
const command: AgentCommandName = 'agent register';
|
||||
const name = trimOrEmpty(input.name);
|
||||
const role = trimOrEmpty(input.role);
|
||||
const display = trimOrEmpty(input.display) || name;
|
||||
const projectRoot = deps.projectRoot || process.cwd();
|
||||
|
||||
const agentIdError = validateAgentId(name);
|
||||
if (agentIdError) {
|
||||
return invalid(command, agentIdError.code, agentIdError.message);
|
||||
}
|
||||
|
||||
const roleError = validateRole(role);
|
||||
if (roleError) {
|
||||
return invalid(command, roleError.code, roleError.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const beadId = toBeadId(name);
|
||||
|
||||
// 1. Check if agent exists
|
||||
const showResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: ['agent', 'show', beadId, '--json'],
|
||||
});
|
||||
|
||||
if (showResult.success && !input.forceUpdate) {
|
||||
return invalid(command, 'DUPLICATE_AGENT_ID', 'Agent is already registered. Use --force-update to change display/role.');
|
||||
}
|
||||
|
||||
// 2. Set state (auto-creates if missing)
|
||||
const stateResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: ['agent', 'state', beadId, 'idle', '--json'],
|
||||
});
|
||||
|
||||
if (!stateResult.success) {
|
||||
return invalid(command, 'INTERNAL_ERROR', `Failed to set agent state: ${stateResult.error}`);
|
||||
}
|
||||
|
||||
// 3. Update title, role, and rig via labels
|
||||
const labels = ['gt:agent'];
|
||||
if (role) {
|
||||
labels.push(`role:${role}`);
|
||||
}
|
||||
if (input.rig) {
|
||||
labels.push(`rig:${input.rig}`);
|
||||
}
|
||||
|
||||
const updateArgs = ['update', beadId, '--title', `Agent: ${display}`, '--add-label', labels.join(',')];
|
||||
|
||||
const updateResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: [...updateArgs, '--json'],
|
||||
});
|
||||
|
||||
if (!updateResult.success) {
|
||||
console.error('Update failed:', updateResult.error, updateResult.stdout, updateResult.stderr);
|
||||
return invalid(command, 'INTERNAL_ERROR', `Failed to update agent details: ${updateResult.error}`);
|
||||
}
|
||||
|
||||
// 4. Force flush to ensure issues.jsonl is updated (critical for tests and sync)
|
||||
const flushResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: ['admin', 'flush'],
|
||||
});
|
||||
if (!flushResult.success) {
|
||||
console.error('Flush failed:', flushResult.error, flushResult.stdout, flushResult.stderr);
|
||||
}
|
||||
|
||||
// 5. Return the new record
|
||||
const record = await callBdAgentShow(beadId, projectRoot);
|
||||
if (!record) {
|
||||
return invalid(command, 'INTERNAL_ERROR', 'Failed to retrieve final agent state.');
|
||||
}
|
||||
|
||||
return success(command, record);
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to register agent.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAgents(
|
||||
input: ListAgentsInput,
|
||||
deps: Partial<RegisterAgentDeps> = {},
|
||||
): Promise<AgentCommandResponse<AgentRecord[]>> {
|
||||
const command: AgentCommandName = 'agent list';
|
||||
const role = trimOrEmpty(input.role);
|
||||
const status = trimOrEmpty(input.status);
|
||||
const projectRoot = deps.projectRoot || process.cwd();
|
||||
|
||||
try {
|
||||
const listResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: ['list', '--label', 'gt:agent', '--json'],
|
||||
});
|
||||
|
||||
if (!listResult.success) {
|
||||
return invalid(command, 'INTERNAL_ERROR', `Failed to list agents from bd: ${listResult.error}`);
|
||||
}
|
||||
|
||||
const rawList = extractJsonArray(listResult.stdout);
|
||||
if (rawList.length === 0) {
|
||||
return success(command, []);
|
||||
}
|
||||
|
||||
const agents: AgentRecord[] = [];
|
||||
for (const item of rawList) {
|
||||
// Get detailed agent state for each bead found using show
|
||||
const record = await callBdAgentShow(item.id, projectRoot);
|
||||
if (record) {
|
||||
if (role && record.role !== role) continue;
|
||||
if (status && record.status !== status) continue;
|
||||
|
||||
agents.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
return success(command, agents.sort((a, b) => a.agent_id.localeCompare(b.agent_id)));
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to list agents.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function showAgent(
|
||||
input: ShowAgentInput,
|
||||
deps: Partial<RegisterAgentDeps> = {},
|
||||
): Promise<AgentCommandResponse<AgentRecord>> {
|
||||
const command: AgentCommandName = 'agent show';
|
||||
const name = trimOrEmpty(input.agent);
|
||||
const projectRoot = deps.projectRoot || process.cwd();
|
||||
|
||||
const agentIdError = validateAgentId(name);
|
||||
if (agentIdError) {
|
||||
return invalid(command, agentIdError.code, agentIdError.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const beadId = toBeadId(name);
|
||||
const record = await callBdAgentShow(beadId, projectRoot);
|
||||
|
||||
if (!record) {
|
||||
return invalid(command, 'AGENT_NOT_FOUND', 'Agent is not registered.');
|
||||
}
|
||||
|
||||
return success(command, record);
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to load agent.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the ZFC state of an agent bead.
|
||||
*/
|
||||
export async function setAgentState(
|
||||
input: { agent: string; state: AgentZfcState },
|
||||
deps: Partial<RegisterAgentDeps> = {},
|
||||
): Promise<AgentCommandResponse<AgentRecord>> {
|
||||
const command: AgentCommandName = 'agent state';
|
||||
const name = trimOrEmpty(input.agent);
|
||||
const state = input.state;
|
||||
const projectRoot = deps.projectRoot || process.cwd();
|
||||
|
||||
const agentIdError = validateAgentId(name);
|
||||
if (agentIdError) {
|
||||
return invalid(command, agentIdError.code, agentIdError.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const beadId = toBeadId(name);
|
||||
|
||||
const stateResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: ['agent', 'state', beadId, state, '--json'],
|
||||
});
|
||||
|
||||
if (!stateResult.success) {
|
||||
return invalid(command, 'AGENT_NOT_FOUND', 'Agent is not registered.');
|
||||
}
|
||||
|
||||
const record = await callBdAgentShow(beadId, projectRoot);
|
||||
if (!record) {
|
||||
return invalid(command, 'INTERNAL_ERROR', 'Failed to retrieve agent state after update.');
|
||||
}
|
||||
|
||||
return success(command, record);
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to set agent state.');
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentLiveness = 'active' | 'stale' | 'evicted' | 'idle';
|
||||
|
||||
/**
|
||||
* Derives the liveness state of an agent based on its last seen timestamp.
|
||||
* active: < 15m
|
||||
* stale: 15m - 30m
|
||||
* evicted: 30m - 60m
|
||||
* idle: >= 60m
|
||||
*/
|
||||
export function deriveLiveness(lastSeenAt: string, now: Date = new Date(), staleMinutes: number = 15): AgentLiveness {
|
||||
const lastSeen = new Date(lastSeenAt).getTime();
|
||||
const diffMs = now.getTime() - lastSeen;
|
||||
const diffMin = diffMs / (1000 * 60);
|
||||
|
||||
if (diffMin >= 60) {
|
||||
return 'idle';
|
||||
}
|
||||
if (diffMin >= 2 * staleMinutes) {
|
||||
return 'evicted';
|
||||
}
|
||||
if (diffMin >= staleMinutes) {
|
||||
return 'stale';
|
||||
}
|
||||
return 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extends the activity lease for a registered agent by emitting a native bd wisp.
|
||||
* This provides silent observability WITHOUT persistent git churn.
|
||||
*/
|
||||
export async function extendActivityLease(
|
||||
input: ActivityLeaseInput,
|
||||
deps: Partial<RegisterAgentDeps> = {},
|
||||
): Promise<AgentCommandResponse<AgentRecord | null>> {
|
||||
const command: AgentCommandName = 'agent activity-lease';
|
||||
const name = trimOrEmpty(input.agent);
|
||||
const projectRoot = deps.projectRoot || process.cwd();
|
||||
|
||||
const agentIdError = validateAgentId(name);
|
||||
if (agentIdError) {
|
||||
return invalid(command, agentIdError.code, agentIdError.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const beadId = toBeadId(name);
|
||||
|
||||
// We create an ephemeral wisp of type 'heartbeat' tied to the agent bead.
|
||||
// This refreshes the 'last_activity' in the bd system without mutating issues.jsonl.
|
||||
const wispResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: [
|
||||
'create',
|
||||
`pulse:${name}:${Date.now()}`,
|
||||
'--type', 'event',
|
||||
'--wisp-type', 'heartbeat',
|
||||
'--ephemeral',
|
||||
'--event-actor', beadId,
|
||||
'--json'
|
||||
],
|
||||
});
|
||||
|
||||
if (!wispResult.success) {
|
||||
return invalid(command, 'INTERNAL_ERROR', `Failed to emit heartbeat wisp: ${wispResult.error}`);
|
||||
}
|
||||
|
||||
// Emit heartbeat to activity bus for real-time aggregation
|
||||
activityEventBus.emit({
|
||||
id: randomUUID(),
|
||||
kind: 'heartbeat',
|
||||
beadId: beadId,
|
||||
beadTitle: `Agent: ${name}`,
|
||||
projectId: projectRoot,
|
||||
projectName: path.basename(projectRoot),
|
||||
timestamp: new Date().toISOString(),
|
||||
actor: name,
|
||||
payload: { message: 'running' }
|
||||
});
|
||||
|
||||
// We return ok: true. The actual lease state will be aggregated from wisps.
|
||||
return success(command, null);
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to extend activity lease.');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @deprecated Import from './agent/registry' or './agent/types' instead
|
||||
*
|
||||
* This file is kept for backward compatibility.
|
||||
* All implementations have been moved to src/lib/agent/
|
||||
*/
|
||||
|
||||
// Re-export everything from the new bounded context
|
||||
export * from './agent/registry';
|
||||
export * from './agent/types';
|
||||
|
|
|
|||
15
src/lib/agent/index.ts
Normal file
15
src/lib/agent/index.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* Agent Bounded Context
|
||||
*
|
||||
* This module provides the public API for agent coordination:
|
||||
* - Registry: agent registration, listing, state management
|
||||
* - Messaging: mail, inbox, handoffs
|
||||
* - Reservations: scope locking, resource allocation
|
||||
* - Sessions: lifecycle, liveness, coordination
|
||||
*
|
||||
* Current state: Migration in progress.
|
||||
* Import from individual modules for now.
|
||||
*/
|
||||
|
||||
// Re-export from types (already migrated)
|
||||
export * from './types';
|
||||
437
src/lib/agent/messaging.ts
Normal file
437
src/lib/agent/messaging.ts
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
/**
|
||||
* Agent Messaging / Mail System
|
||||
*
|
||||
* This module handles agent-to-agent coordination messages:
|
||||
* - sendAgentMessage: Send a message to another agent
|
||||
* - inboxAgentMessages: Retrieve messages for an agent
|
||||
* - readAgentMessage: Mark a message as read
|
||||
* - ackAgentMessage: Acknowledge a message (for HANDOFF/BLOCKED)
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { runBdCommand } from '../bridge';
|
||||
import {
|
||||
type SendAgentMessageInput,
|
||||
type SendAgentMessageDeps,
|
||||
type InboxAgentMessagesInput,
|
||||
type MessageActionInput,
|
||||
type MessageMutationDeps,
|
||||
type AgentMessage,
|
||||
type MailCommandName,
|
||||
type MailCommandError,
|
||||
type MailCommandResponse,
|
||||
type MessageCategory,
|
||||
type MessageState,
|
||||
} from './types';
|
||||
|
||||
const MESSAGE_ID_PATTERN = /^msg_/;
|
||||
const AGENT_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
function invalid<T>(command: MailCommandName, code: string, message: string): MailCommandResponse<T> {
|
||||
return { ok: false, command, data: null, error: { code, message } };
|
||||
}
|
||||
|
||||
function success<T>(command: MailCommandName, data: T): MailCommandResponse<T> {
|
||||
return { ok: true, command, data, error: null };
|
||||
}
|
||||
|
||||
function trimOrEmpty(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function extractJson(text: string): any {
|
||||
const start = text.indexOf('{');
|
||||
const end = text.lastIndexOf('}');
|
||||
if (start === -1 || end === -1) {
|
||||
throw new Error('No JSON block found in output');
|
||||
}
|
||||
const jsonPart = text.slice(start, end + 1);
|
||||
return JSON.parse(jsonPart);
|
||||
}
|
||||
|
||||
function extractJsonArray(text: string): any[] {
|
||||
const start = text.indexOf('[');
|
||||
const end = text.lastIndexOf(']');
|
||||
if (start === -1 || end === -1) {
|
||||
try {
|
||||
const single = extractJson(text);
|
||||
return [single];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const jsonPart = text.slice(start, end + 1);
|
||||
return JSON.parse(jsonPart);
|
||||
}
|
||||
|
||||
function toBeadId(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed.startsWith('bb-')) return trimmed;
|
||||
return `bb-${trimmed}`;
|
||||
}
|
||||
|
||||
async function getProjectRoot(deps: { projectRoot?: string }): Promise<string> {
|
||||
return deps.projectRoot || process.cwd();
|
||||
}
|
||||
|
||||
async function verifyAgentExists(agent: string, projectRoot: string): Promise<boolean> {
|
||||
const beadId = toBeadId(agent);
|
||||
const result = await runBdCommand({
|
||||
projectRoot,
|
||||
args: ['show', beadId, '--json'],
|
||||
});
|
||||
return result.success;
|
||||
}
|
||||
|
||||
function mapRawToAgentMessage(raw: any): AgentMessage {
|
||||
return {
|
||||
message_id: raw.message_id || raw.id || '',
|
||||
thread_id: raw.thread_id || raw.thread || '',
|
||||
bead_id: raw.bead_id || raw.bead || '',
|
||||
from_agent: raw.from_agent || raw.from || '',
|
||||
to_agent: raw.to_agent || raw.to || '',
|
||||
category: (raw.category as MessageCategory) || 'INFO',
|
||||
subject: raw.subject || '',
|
||||
body: raw.body || '',
|
||||
state: (raw.state as MessageState) || 'unread',
|
||||
requires_ack: raw.requires_ack ?? (raw.category === 'HANDOFF' || raw.category === 'BLOCKED'),
|
||||
created_at: raw.created_at || raw.created_at || '',
|
||||
read_at: raw.read_at || null,
|
||||
acked_at: raw.acked_at || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendAgentMessage(
|
||||
input: SendAgentMessageInput,
|
||||
deps?: Partial<SendAgentMessageDeps> & { projectRoot?: string },
|
||||
): Promise<MailCommandResponse<AgentMessage>> {
|
||||
const command: MailCommandName = 'agent send';
|
||||
const projectRoot = await getProjectRoot(deps || {});
|
||||
const now = deps?.now || (() => new Date().toISOString());
|
||||
const idGenerator = deps?.idGenerator || (() => `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`);
|
||||
|
||||
const from = trimOrEmpty(input.from);
|
||||
const to = trimOrEmpty(input.to);
|
||||
const bead = trimOrEmpty(input.bead);
|
||||
const category = input.category;
|
||||
const subject = trimOrEmpty(input.subject);
|
||||
const body = trimOrEmpty(input.body);
|
||||
const thread = trimOrEmpty(input.thread);
|
||||
|
||||
if (!from) {
|
||||
return invalid(command, 'INVALID_INPUT', 'Sender agent is required.');
|
||||
}
|
||||
if (!to) {
|
||||
return invalid(command, 'INVALID_INPUT', 'Recipient agent is required.');
|
||||
}
|
||||
if (!bead) {
|
||||
return invalid(command, 'INVALID_INPUT', 'Bead ID is required.');
|
||||
}
|
||||
if (!category) {
|
||||
return invalid(command, 'INVALID_INPUT', 'Category is required.');
|
||||
}
|
||||
if (!subject) {
|
||||
return invalid(command, 'INVALID_INPUT', 'Subject is required.');
|
||||
}
|
||||
if (!body) {
|
||||
return invalid(command, 'INVALID_INPUT', 'Body is required.');
|
||||
}
|
||||
|
||||
const fromExists = await verifyAgentExists(from, projectRoot);
|
||||
if (!fromExists) {
|
||||
return invalid(command, 'UNKNOWN_SENDER', `Sender agent '${from}' is not registered.`);
|
||||
}
|
||||
|
||||
const toExists = await verifyAgentExists(to, projectRoot);
|
||||
if (!toExists) {
|
||||
return invalid(command, 'UNKNOWN_RECIPIENT', `Recipient agent '${to}' is not registered.`);
|
||||
}
|
||||
|
||||
const validCategories = ['HANDOFF', 'BLOCKED', 'DECISION', 'INFO'];
|
||||
if (!validCategories.includes(category)) {
|
||||
return invalid(command, 'INVALID_CATEGORY', `Category must be one of: ${validCategories.join(', ')}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const messageId = idGenerator();
|
||||
const threadId = thread || `thread_${now().replace(/[-:]/g, '').replace('T', '_').split('.')[0]}`;
|
||||
const requiresAck = category === 'HANDOFF' || category === 'BLOCKED';
|
||||
|
||||
const commentArgs = [
|
||||
'comment',
|
||||
bead,
|
||||
'--author', from,
|
||||
'--body', JSON.stringify({ message_id: messageId, thread_id: threadId, from_agent: from, to_agent: to, category, subject, body, requires_ack: requiresAck, created_at: now() }),
|
||||
];
|
||||
|
||||
const result = await runBdCommand({
|
||||
projectRoot,
|
||||
args: [...commentArgs, '--json'],
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return invalid(command, 'INTERNAL_ERROR', `Failed to send message: ${result.error}`);
|
||||
}
|
||||
|
||||
const message: AgentMessage = {
|
||||
message_id: messageId,
|
||||
thread_id: threadId,
|
||||
bead_id: bead,
|
||||
from_agent: from,
|
||||
to_agent: to,
|
||||
category,
|
||||
subject,
|
||||
body,
|
||||
state: 'unread',
|
||||
requires_ack: requiresAck,
|
||||
created_at: now(),
|
||||
read_at: null,
|
||||
acked_at: null,
|
||||
};
|
||||
|
||||
return success(command, message);
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to send message.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function inboxAgentMessages(
|
||||
input: InboxAgentMessagesInput,
|
||||
deps: { projectRoot?: string } = {},
|
||||
): Promise<MailCommandResponse<AgentMessage[]>> {
|
||||
const command: MailCommandName = 'agent inbox';
|
||||
const projectRoot = await getProjectRoot(deps);
|
||||
|
||||
const agent = trimOrEmpty(input.agent);
|
||||
if (!agent) {
|
||||
return invalid(command, 'INVALID_INPUT', 'Agent name is required.');
|
||||
}
|
||||
|
||||
const agentExists = await verifyAgentExists(agent, projectRoot);
|
||||
if (!agentExists) {
|
||||
return invalid(command, 'UNKNOWN_AGENT', `Agent '${agent}' is not registered.`);
|
||||
}
|
||||
|
||||
try {
|
||||
const listResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: ['list', '--author', toBeadId(agent), '--json'],
|
||||
});
|
||||
|
||||
if (!listResult.success) {
|
||||
return invalid(command, 'INTERNAL_ERROR', `Failed to list messages: ${listResult.error}`);
|
||||
}
|
||||
|
||||
const rawList = extractJsonArray(listResult.stdout);
|
||||
const messages: AgentMessage[] = [];
|
||||
|
||||
for (const item of rawList) {
|
||||
try {
|
||||
const commentBody = JSON.parse(item.body || '{}');
|
||||
if (commentBody.to_agent === agent || commentBody.from_agent === agent) {
|
||||
if (input.state && commentBody.state !== input.state) continue;
|
||||
if (input.bead && commentBody.bead_id !== input.bead) continue;
|
||||
|
||||
const msg = mapRawToAgentMessage(commentBody);
|
||||
if (msg.to_agent === agent) {
|
||||
messages.push(msg);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip non-message comments
|
||||
}
|
||||
}
|
||||
|
||||
if (input.limit && messages.length > input.limit) {
|
||||
messages.length = input.limit;
|
||||
}
|
||||
|
||||
return success(command, messages);
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to retrieve inbox.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function readAgentMessage(
|
||||
input: MessageActionInput,
|
||||
deps?: Partial<MessageMutationDeps> & { projectRoot?: string },
|
||||
): Promise<MailCommandResponse<AgentMessage>> {
|
||||
const command: MailCommandName = 'agent read';
|
||||
const projectRoot = await getProjectRoot(deps || {});
|
||||
const now = deps?.now || (() => new Date().toISOString());
|
||||
|
||||
const agent = trimOrEmpty(input.agent);
|
||||
const messageId = trimOrEmpty(input.message);
|
||||
|
||||
if (!agent) {
|
||||
return invalid(command, 'INVALID_INPUT', 'Agent name is required.');
|
||||
}
|
||||
if (!messageId) {
|
||||
return invalid(command, 'INVALID_INPUT', 'Message ID is required.');
|
||||
}
|
||||
|
||||
const agentExists = await verifyAgentExists(agent, projectRoot);
|
||||
if (!agentExists) {
|
||||
return invalid(command, 'UNKNOWN_AGENT', `Agent '${agent}' is not registered.`);
|
||||
}
|
||||
|
||||
try {
|
||||
const listResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: ['list', '--author', toBeadId(agent), '--json'],
|
||||
});
|
||||
|
||||
if (!listResult.success) {
|
||||
return invalid(command, 'INTERNAL_ERROR', `Failed to find message: ${listResult.error}`);
|
||||
}
|
||||
|
||||
const rawList = extractJsonArray(listResult.stdout);
|
||||
let foundMessage: any = null;
|
||||
let foundBead = '';
|
||||
|
||||
for (const item of rawList) {
|
||||
try {
|
||||
const commentBody = JSON.parse(item.body || '{}');
|
||||
if (commentBody.message_id === messageId && commentBody.to_agent === agent) {
|
||||
foundMessage = commentBody;
|
||||
foundBead = item.id;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Skip
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundMessage) {
|
||||
return invalid(command, 'MESSAGE_NOT_FOUND', `Message '${messageId}' not found for agent '${agent}'.`);
|
||||
}
|
||||
|
||||
if (foundMessage.state === 'read' || foundMessage.state === 'acked') {
|
||||
return invalid(command, 'ALREADY_READ', 'Message is already read or acknowledged.');
|
||||
}
|
||||
|
||||
const updatedMessage = {
|
||||
...foundMessage,
|
||||
state: 'read' as MessageState,
|
||||
read_at: now(),
|
||||
};
|
||||
|
||||
const commentArgs = [
|
||||
'comment',
|
||||
foundBead,
|
||||
'--author', agent,
|
||||
'--body', JSON.stringify(updatedMessage),
|
||||
];
|
||||
|
||||
const updateResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: [...commentArgs, '--json'],
|
||||
});
|
||||
|
||||
if (!updateResult.success) {
|
||||
return invalid(command, 'INTERNAL_ERROR', `Failed to mark message as read: ${updateResult.error}`);
|
||||
}
|
||||
|
||||
const message = mapRawToAgentMessage(updatedMessage);
|
||||
|
||||
return success(command, message);
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to read message.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function ackAgentMessage(
|
||||
input: MessageActionInput,
|
||||
deps?: Partial<MessageMutationDeps> & { projectRoot?: string },
|
||||
): Promise<MailCommandResponse<AgentMessage>> {
|
||||
const command: MailCommandName = 'agent ack';
|
||||
const projectRoot = await getProjectRoot(deps || {});
|
||||
const now = deps?.now || (() => new Date().toISOString());
|
||||
|
||||
const agent = trimOrEmpty(input.agent);
|
||||
const messageId = trimOrEmpty(input.message);
|
||||
|
||||
if (!agent) {
|
||||
return invalid(command, 'INVALID_INPUT', 'Agent name is required.');
|
||||
}
|
||||
if (!messageId) {
|
||||
return invalid(command, 'INVALID_INPUT', 'Message ID is required.');
|
||||
}
|
||||
|
||||
const agentExists = await verifyAgentExists(agent, projectRoot);
|
||||
if (!agentExists) {
|
||||
return invalid(command, 'UNKNOWN_AGENT', `Agent '${agent}' is not registered.`);
|
||||
}
|
||||
|
||||
try {
|
||||
const listResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: ['list', '--author', toBeadId(agent), '--json'],
|
||||
});
|
||||
|
||||
if (!listResult.success) {
|
||||
return invalid(command, 'INTERNAL_ERROR', `Failed to find message: ${listResult.error}`);
|
||||
}
|
||||
|
||||
const rawList = extractJsonArray(listResult.stdout);
|
||||
let foundMessage: any = null;
|
||||
let foundBead = '';
|
||||
|
||||
for (const item of rawList) {
|
||||
try {
|
||||
const commentBody = JSON.parse(item.body || '{}');
|
||||
if (commentBody.message_id === messageId && commentBody.to_agent === agent) {
|
||||
foundMessage = commentBody;
|
||||
foundBead = item.id;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Skip
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundMessage) {
|
||||
return invalid(command, 'MESSAGE_NOT_FOUND', `Message '${messageId}' not found for agent '${agent}'.`);
|
||||
}
|
||||
|
||||
if (foundMessage.to_agent !== agent) {
|
||||
return invalid(command, 'ACK_FORBIDDEN', 'Only the recipient can acknowledge this message.');
|
||||
}
|
||||
|
||||
if (!foundMessage.requires_ack) {
|
||||
return invalid(command, 'ACK_NOT_REQUIRED', 'This message does not require acknowledgment.');
|
||||
}
|
||||
|
||||
if (foundMessage.state === 'acked') {
|
||||
return invalid(command, 'ALREADY_ACKED', 'Message is already acknowledged.');
|
||||
}
|
||||
|
||||
const updatedMessage = {
|
||||
...foundMessage,
|
||||
state: 'acked' as MessageState,
|
||||
acked_at: now(),
|
||||
};
|
||||
|
||||
const commentArgs = [
|
||||
'comment',
|
||||
foundBead,
|
||||
'--author', agent,
|
||||
'--body', JSON.stringify(updatedMessage),
|
||||
];
|
||||
|
||||
const updateResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: [...commentArgs, '--json'],
|
||||
});
|
||||
|
||||
if (!updateResult.success) {
|
||||
return invalid(command, 'INTERNAL_ERROR', `Failed to acknowledge message: ${updateResult.error}`);
|
||||
}
|
||||
|
||||
const message = mapRawToAgentMessage(updatedMessage);
|
||||
|
||||
return success(command, message);
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to acknowledge message.');
|
||||
}
|
||||
}
|
||||
442
src/lib/agent/registry.ts
Normal file
442
src/lib/agent/registry.ts
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { runBdCommand } from '../bridge';
|
||||
import { activityEventBus } from '../realtime';
|
||||
import type {
|
||||
AgentCommandName,
|
||||
AgentZfcState,
|
||||
AgentCommandError,
|
||||
AgentCommandResponse,
|
||||
AgentRecord,
|
||||
RegisterAgentInput,
|
||||
RegisterAgentDeps,
|
||||
ListAgentsInput,
|
||||
ShowAgentInput,
|
||||
ActivityLeaseInput,
|
||||
AgentLiveness,
|
||||
} from './types';
|
||||
|
||||
const AGENT_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const agentCache = new Map<string, CacheEntry<AgentRecord | null>>();
|
||||
const CACHE_TTL_MS = 30_000;
|
||||
|
||||
function getCachedAgent(beadId: string): AgentRecord | null {
|
||||
const entry = agentCache.get(beadId);
|
||||
if (entry && entry.expiresAt > Date.now()) {
|
||||
return entry.data;
|
||||
}
|
||||
agentCache.delete(beadId);
|
||||
return null;
|
||||
}
|
||||
|
||||
function setCachedAgent(beadId: string, data: AgentRecord | null): void {
|
||||
agentCache.set(beadId, { data, expiresAt: Date.now() + CACHE_TTL_MS });
|
||||
}
|
||||
|
||||
function toBeadId(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed.startsWith('bb-')) return trimmed;
|
||||
return `bb-${trimmed}`;
|
||||
}
|
||||
|
||||
function fromBeadId(id: string): string {
|
||||
if (id.startsWith('bb-')) return id.slice(3);
|
||||
return id;
|
||||
}
|
||||
|
||||
function extractJson(text: string): any {
|
||||
const start = text.indexOf('{');
|
||||
const end = text.lastIndexOf('}');
|
||||
if (start === -1 || end === -1) {
|
||||
throw new Error('No JSON block found in output');
|
||||
}
|
||||
const jsonPart = text.slice(start, end + 1);
|
||||
return JSON.parse(jsonPart);
|
||||
}
|
||||
|
||||
function extractJsonArray(text: string): any[] {
|
||||
const start = text.indexOf('[');
|
||||
const end = text.lastIndexOf(']');
|
||||
if (start === -1 || end === -1) {
|
||||
try {
|
||||
const single = extractJson(text);
|
||||
return [single];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const jsonPart = text.slice(start, end + 1);
|
||||
return JSON.parse(jsonPart);
|
||||
}
|
||||
|
||||
function trimOrEmpty(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
async function callBdAgentShow(beadId: string, projectRoot: string): Promise<AgentRecord | null> {
|
||||
const cached = getCachedAgent(beadId);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const showResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: ['agent', 'show', beadId, '--json'],
|
||||
});
|
||||
|
||||
let record: AgentRecord | null = null;
|
||||
if (showResult.success) {
|
||||
try {
|
||||
const bdAgent = extractJson(showResult.stdout);
|
||||
record = mapBdAgentToRecord(bdAgent);
|
||||
} catch {
|
||||
record = null;
|
||||
}
|
||||
}
|
||||
|
||||
setCachedAgent(beadId, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
function invalid(command: AgentCommandName, code: string, message: string): AgentCommandResponse<never> {
|
||||
return {
|
||||
ok: false,
|
||||
command,
|
||||
data: null,
|
||||
error: { code, message },
|
||||
};
|
||||
}
|
||||
|
||||
function success<T>(command: AgentCommandName, data: T): AgentCommandResponse<T> {
|
||||
return {
|
||||
ok: true,
|
||||
command,
|
||||
data,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
function validateAgentId(value: string): AgentCommandError | null {
|
||||
if (!AGENT_ID_PATTERN.test(value) || value.length < 3 || value.length > 48) {
|
||||
return {
|
||||
code: 'INVALID_AGENT_ID',
|
||||
message: 'Agent id must match ^[a-z0-9]+(?:-[a-z0-9]+)*$ and be 3..48 characters.',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateRole(value: string): AgentCommandError | null {
|
||||
if (!value) {
|
||||
return {
|
||||
code: 'INVALID_ROLE',
|
||||
message: 'Role is required.',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function mapBdAgentToRecord(bdAgent: any): AgentRecord {
|
||||
let role = bdAgent.role_type || 'agent';
|
||||
let swarmId: string | undefined;
|
||||
let currentTask: string | undefined;
|
||||
|
||||
if (Array.isArray(bdAgent.labels)) {
|
||||
const roleLabel = bdAgent.labels.find((l: string) => l.startsWith('role:'));
|
||||
if (roleLabel) {
|
||||
role = roleLabel.split(':')[1];
|
||||
}
|
||||
const swarmLabel = bdAgent.labels.find((l: string) => l.startsWith('swarm:'));
|
||||
if (swarmLabel) {
|
||||
swarmId = swarmLabel.split(':')[1];
|
||||
}
|
||||
const workingLabel = bdAgent.labels.find((l: string) => l.startsWith('working:'));
|
||||
if (workingLabel) {
|
||||
currentTask = workingLabel.split(':')[1];
|
||||
}
|
||||
}
|
||||
|
||||
let rig = bdAgent.rig;
|
||||
if (!rig && Array.isArray(bdAgent.labels)) {
|
||||
const rigLabel = bdAgent.labels.find((l: string) => l.startsWith('rig:'));
|
||||
if (rigLabel) {
|
||||
rig = rigLabel.split(':')[1];
|
||||
}
|
||||
}
|
||||
|
||||
const record: AgentRecord = {
|
||||
agent_id: fromBeadId(bdAgent.id),
|
||||
display_name: bdAgent.title?.replace(/^Agent: /, '') || fromBeadId(bdAgent.id),
|
||||
role,
|
||||
status: bdAgent.agent_state || 'idle',
|
||||
created_at: bdAgent.created_at || bdAgent.last_activity || new Date().toISOString(),
|
||||
last_seen_at: bdAgent.last_activity || new Date().toISOString(),
|
||||
version: 1,
|
||||
rig,
|
||||
role_type: bdAgent.role_type,
|
||||
swarm_id: swarmId,
|
||||
current_task: currentTask,
|
||||
};
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function registerAgent(
|
||||
input: RegisterAgentInput,
|
||||
deps: Partial<RegisterAgentDeps> = {},
|
||||
): Promise<AgentCommandResponse<AgentRecord>> {
|
||||
const command: AgentCommandName = 'agent register';
|
||||
const name = trimOrEmpty(input.name);
|
||||
const role = trimOrEmpty(input.role);
|
||||
const display = trimOrEmpty(input.display) || name;
|
||||
const projectRoot = deps.projectRoot || process.cwd();
|
||||
|
||||
const agentIdError = validateAgentId(name);
|
||||
if (agentIdError) {
|
||||
return invalid(command, agentIdError.code, agentIdError.message);
|
||||
}
|
||||
|
||||
const roleError = validateRole(role);
|
||||
if (roleError) {
|
||||
return invalid(command, roleError.code, roleError.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const beadId = toBeadId(name);
|
||||
|
||||
const showResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: ['agent', 'show', beadId, '--json'],
|
||||
});
|
||||
|
||||
if (showResult.success && !input.forceUpdate) {
|
||||
return invalid(command, 'DUPLICATE_AGENT_ID', 'Agent is already registered. Use --force-update to change display/role.');
|
||||
}
|
||||
|
||||
const stateResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: ['agent', 'state', beadId, 'idle', '--json'],
|
||||
});
|
||||
|
||||
if (!stateResult.success) {
|
||||
return invalid(command, 'INTERNAL_ERROR', `Failed to set agent state: ${stateResult.error}`);
|
||||
}
|
||||
|
||||
const labels = ['gt:agent'];
|
||||
if (role) {
|
||||
labels.push(`role:${role}`);
|
||||
}
|
||||
if (input.rig) {
|
||||
labels.push(`rig:${input.rig}`);
|
||||
}
|
||||
|
||||
const updateArgs = ['update', beadId, '--title', `Agent: ${display}`, '--add-label', labels.join(',')];
|
||||
|
||||
const updateResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: [...updateArgs, '--json'],
|
||||
});
|
||||
|
||||
if (!updateResult.success) {
|
||||
console.error('Update failed:', updateResult.error, updateResult.stdout, updateResult.stderr);
|
||||
return invalid(command, 'INTERNAL_ERROR', `Failed to update agent details: ${updateResult.error}`);
|
||||
}
|
||||
|
||||
const flushResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: ['admin', 'flush'],
|
||||
});
|
||||
if (!flushResult.success) {
|
||||
console.error('Flush failed:', flushResult.error, flushResult.stdout, flushResult.stderr);
|
||||
}
|
||||
|
||||
const record = await callBdAgentShow(beadId, projectRoot);
|
||||
if (!record) {
|
||||
return invalid(command, 'INTERNAL_ERROR', 'Failed to retrieve final agent state.');
|
||||
}
|
||||
|
||||
return success(command, record);
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to register agent.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAgents(
|
||||
input: ListAgentsInput,
|
||||
deps: Partial<RegisterAgentDeps> = {},
|
||||
): Promise<AgentCommandResponse<AgentRecord[]>> {
|
||||
const command: AgentCommandName = 'agent list';
|
||||
const role = trimOrEmpty(input.role);
|
||||
const status = trimOrEmpty(input.status);
|
||||
const projectRoot = deps.projectRoot || process.cwd();
|
||||
|
||||
try {
|
||||
const listResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: ['list', '--label', 'gt:agent', '--json'],
|
||||
});
|
||||
|
||||
if (!listResult.success) {
|
||||
return invalid(command, 'INTERNAL_ERROR', `Failed to list agents from bd: ${listResult.error}`);
|
||||
}
|
||||
|
||||
const rawList = extractJsonArray(listResult.stdout);
|
||||
if (rawList.length === 0) {
|
||||
return success(command, []);
|
||||
}
|
||||
|
||||
const agents: AgentRecord[] = [];
|
||||
for (const item of rawList) {
|
||||
const record = await callBdAgentShow(item.id, projectRoot);
|
||||
if (record) {
|
||||
if (role && record.role !== role) continue;
|
||||
if (status && record.status !== status) continue;
|
||||
|
||||
agents.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
return success(command, agents.sort((a, b) => a.agent_id.localeCompare(b.agent_id)));
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to list agents.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function showAgent(
|
||||
input: ShowAgentInput,
|
||||
deps: Partial<RegisterAgentDeps> = {},
|
||||
): Promise<AgentCommandResponse<AgentRecord>> {
|
||||
const command: AgentCommandName = 'agent show';
|
||||
const name = trimOrEmpty(input.agent);
|
||||
const projectRoot = deps.projectRoot || process.cwd();
|
||||
|
||||
const agentIdError = validateAgentId(name);
|
||||
if (agentIdError) {
|
||||
return invalid(command, agentIdError.code, agentIdError.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const beadId = toBeadId(name);
|
||||
const record = await callBdAgentShow(beadId, projectRoot);
|
||||
|
||||
if (!record) {
|
||||
return invalid(command, 'AGENT_NOT_FOUND', 'Agent is not registered.');
|
||||
}
|
||||
|
||||
return success(command, record);
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to load agent.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function setAgentState(
|
||||
input: { agent: string; state: AgentZfcState },
|
||||
deps: Partial<RegisterAgentDeps> = {},
|
||||
): Promise<AgentCommandResponse<AgentRecord>> {
|
||||
const command: AgentCommandName = 'agent state';
|
||||
const name = trimOrEmpty(input.agent);
|
||||
const state = input.state;
|
||||
const projectRoot = deps.projectRoot || process.cwd();
|
||||
|
||||
const agentIdError = validateAgentId(name);
|
||||
if (agentIdError) {
|
||||
return invalid(command, agentIdError.code, agentIdError.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const beadId = toBeadId(name);
|
||||
|
||||
const stateResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: ['agent', 'state', beadId, state, '--json'],
|
||||
});
|
||||
|
||||
if (!stateResult.success) {
|
||||
return invalid(command, 'AGENT_NOT_FOUND', 'Agent is not registered.');
|
||||
}
|
||||
|
||||
const record = await callBdAgentShow(beadId, projectRoot);
|
||||
if (!record) {
|
||||
return invalid(command, 'INTERNAL_ERROR', 'Failed to retrieve agent state after update.');
|
||||
}
|
||||
|
||||
return success(command, record);
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to set agent state.');
|
||||
}
|
||||
}
|
||||
|
||||
export function deriveLiveness(lastSeenAt: string, now: Date = new Date(), staleMinutes: number = 15): AgentLiveness {
|
||||
const lastSeen = new Date(lastSeenAt).getTime();
|
||||
const diffMs = now.getTime() - lastSeen;
|
||||
const diffMin = diffMs / (1000 * 60);
|
||||
|
||||
if (diffMin >= 60) {
|
||||
return 'idle';
|
||||
}
|
||||
if (diffMin >= 2 * staleMinutes) {
|
||||
return 'evicted';
|
||||
}
|
||||
if (diffMin >= staleMinutes) {
|
||||
return 'stale';
|
||||
}
|
||||
return 'active';
|
||||
}
|
||||
|
||||
export async function extendActivityLease(
|
||||
input: ActivityLeaseInput,
|
||||
deps: Partial<RegisterAgentDeps> = {},
|
||||
): Promise<AgentCommandResponse<AgentRecord | null>> {
|
||||
const command: AgentCommandName = 'agent activity-lease';
|
||||
const name = trimOrEmpty(input.agent);
|
||||
const projectRoot = deps.projectRoot || process.cwd();
|
||||
|
||||
const agentIdError = validateAgentId(name);
|
||||
if (agentIdError) {
|
||||
return invalid(command, agentIdError.code, agentIdError.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const beadId = toBeadId(name);
|
||||
|
||||
const wispResult = await runBdCommand({
|
||||
projectRoot,
|
||||
args: [
|
||||
'create',
|
||||
`pulse:${name}:${Date.now()}`,
|
||||
'--type', 'event',
|
||||
'--wisp-type', 'heartbeat',
|
||||
'--ephemeral',
|
||||
'--event-actor', beadId,
|
||||
'--json'
|
||||
],
|
||||
});
|
||||
|
||||
if (!wispResult.success) {
|
||||
return invalid(command, 'INTERNAL_ERROR', `Failed to emit heartbeat wisp: ${wispResult.error}`);
|
||||
}
|
||||
|
||||
activityEventBus.emit({
|
||||
id: randomUUID(),
|
||||
kind: 'heartbeat',
|
||||
beadId: beadId,
|
||||
beadTitle: `Agent: ${name}`,
|
||||
projectId: projectRoot,
|
||||
projectName: path.basename(projectRoot),
|
||||
timestamp: new Date().toISOString(),
|
||||
actor: name,
|
||||
payload: { message: 'running' }
|
||||
});
|
||||
|
||||
return success(command, null);
|
||||
} catch (error) {
|
||||
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to extend activity lease.');
|
||||
}
|
||||
}
|
||||
177
src/lib/agent/types.ts
Normal file
177
src/lib/agent/types.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
export type ProtocolEventType = 'HANDOFF' | 'BLOCKED' | 'INCURSION' | 'RESUME' | 'INFO';
|
||||
|
||||
export interface ProtocolEventEnvelope<T = any> {
|
||||
id: string;
|
||||
version: 'v1';
|
||||
event_type: ProtocolEventType;
|
||||
project_root: string;
|
||||
bead_id: string;
|
||||
from_agent: string | null;
|
||||
to_agent: string | null;
|
||||
scope: string | null;
|
||||
created_at: string;
|
||||
payload: T;
|
||||
}
|
||||
|
||||
export type ProtocolEvent = ProtocolEventEnvelope;
|
||||
|
||||
export interface CreateProtocolEventInput {
|
||||
event_type: ProtocolEventType;
|
||||
project_root: string;
|
||||
bead_id: string;
|
||||
from_agent?: string;
|
||||
to_agent?: string;
|
||||
scope?: string;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
export interface ProtocolDeps {
|
||||
now: () => string;
|
||||
idGenerator: () => string;
|
||||
}
|
||||
|
||||
export function createProtocolEvent(
|
||||
input: CreateProtocolEventInput,
|
||||
deps: Partial<ProtocolDeps> = {}
|
||||
): ProtocolEvent {
|
||||
const now = deps.now ? deps.now() : new Date().toISOString();
|
||||
const generateId = deps.idGenerator ?? (() => `proto_${Date.now()}_${Math.random().toString(16).slice(2, 6)}`);
|
||||
|
||||
return {
|
||||
id: generateId(),
|
||||
version: 'v1',
|
||||
event_type: input.event_type,
|
||||
project_root: input.project_root,
|
||||
bead_id: input.bead_id,
|
||||
from_agent: input.from_agent ?? null,
|
||||
to_agent: input.to_agent ?? null,
|
||||
scope: input.scope ?? null,
|
||||
created_at: now,
|
||||
payload: input.payload,
|
||||
};
|
||||
}
|
||||
|
||||
export type AgentCommandName = 'agent register' | 'agent list' | 'agent show' | 'agent activity-lease' | 'agent state';
|
||||
|
||||
export type AgentZfcState = 'idle' | 'spawning' | 'running' | 'working' | 'stuck' | 'done' | 'stopped' | 'dead';
|
||||
|
||||
export interface AgentCommandError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface AgentCommandResponse<T> {
|
||||
ok: boolean;
|
||||
command: AgentCommandName;
|
||||
data: T | null;
|
||||
error: AgentCommandError | null;
|
||||
}
|
||||
|
||||
export interface AgentRecord {
|
||||
agent_id: string;
|
||||
display_name: string;
|
||||
role: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
last_seen_at: string;
|
||||
version: number;
|
||||
rig?: string;
|
||||
role_type?: string;
|
||||
swarm_id?: string;
|
||||
current_task?: string;
|
||||
}
|
||||
|
||||
export interface RegisterAgentInput {
|
||||
name: string;
|
||||
display?: string;
|
||||
role: string;
|
||||
forceUpdate?: boolean;
|
||||
rig?: string;
|
||||
}
|
||||
|
||||
export interface RegisterAgentDeps {
|
||||
now: () => string;
|
||||
projectRoot: string;
|
||||
}
|
||||
|
||||
export interface ListAgentsInput {
|
||||
role?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface ShowAgentInput {
|
||||
agent: string;
|
||||
}
|
||||
|
||||
export interface ActivityLeaseInput {
|
||||
agent: string;
|
||||
}
|
||||
|
||||
export type AgentLiveness = 'active' | 'stale' | 'evicted' | 'idle';
|
||||
|
||||
// Mail/Messaging types
|
||||
export const MESSAGE_CATEGORIES = ['HANDOFF', 'BLOCKED', 'DECISION', 'INFO'] as const;
|
||||
export const MESSAGE_STATES = ['unread', 'read', 'acked'] as const;
|
||||
|
||||
export type MessageCategory = typeof MESSAGE_CATEGORIES[number];
|
||||
export type MessageState = typeof MESSAGE_STATES[number];
|
||||
export type MailCommandName = 'agent send' | 'agent inbox' | 'agent read' | 'agent ack';
|
||||
|
||||
export interface MailCommandError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface MailCommandResponse<T> {
|
||||
ok: boolean;
|
||||
command: MailCommandName;
|
||||
data: T | null;
|
||||
error: MailCommandError | null;
|
||||
}
|
||||
|
||||
export interface AgentMessage {
|
||||
message_id: string;
|
||||
thread_id: string;
|
||||
bead_id: string;
|
||||
from_agent: string;
|
||||
to_agent: string;
|
||||
category: MessageCategory;
|
||||
subject: string;
|
||||
body: string;
|
||||
state: MessageState;
|
||||
requires_ack: boolean;
|
||||
created_at: string;
|
||||
read_at: string | null;
|
||||
acked_at: string | null;
|
||||
}
|
||||
|
||||
export interface SendAgentMessageInput {
|
||||
from: string;
|
||||
to: string;
|
||||
bead: string;
|
||||
category: MessageCategory;
|
||||
subject: string;
|
||||
body: string;
|
||||
thread?: string;
|
||||
}
|
||||
|
||||
export interface SendAgentMessageDeps {
|
||||
now: () => string;
|
||||
idGenerator: () => string;
|
||||
}
|
||||
|
||||
export interface InboxAgentMessagesInput {
|
||||
agent: string;
|
||||
state?: MessageState;
|
||||
bead?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface MessageActionInput {
|
||||
agent: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface MessageMutationDeps {
|
||||
now: () => string;
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ interface IssueRow extends RowDataPacket {
|
|||
closed_at: Date | string | null;
|
||||
due_at: Date | string | null;
|
||||
labels_concat: string | null;
|
||||
comments_count: number | null;
|
||||
}
|
||||
|
||||
interface DepRow extends RowDataPacket {
|
||||
|
|
@ -60,6 +61,7 @@ function normalizeRow(row: IssueRow, deps: BeadDependency[]): BeadIssue {
|
|||
due_at: toIsoString(row.due_at),
|
||||
estimated_minutes: typeof row.estimated_minutes === 'number' ? row.estimated_minutes : null,
|
||||
external_ref: row.external_ref ?? null,
|
||||
comments_count: (row.comments_count ?? 0) as number,
|
||||
metadata: row.metadata ?? {},
|
||||
};
|
||||
}
|
||||
|
|
@ -86,7 +88,9 @@ export async function readIssuesViaDolt(
|
|||
try {
|
||||
// Query 1: All issues with comma-separated labels (GROUP_CONCAT avoids N+1)
|
||||
const [issueRows] = await pool.execute<IssueRow[]>(
|
||||
`SELECT i.*, GROUP_CONCAT(l.label SEPARATOR ',') AS labels_concat
|
||||
`SELECT i.*,
|
||||
GROUP_CONCAT(l.label SEPARATOR ',') AS labels_concat,
|
||||
(SELECT COUNT(*) FROM comments c WHERE c.issue_id = i.id) AS comments_count
|
||||
FROM issues i
|
||||
LEFT JOIN labels l ON l.issue_id = i.id
|
||||
GROUP BY i.id`
|
||||
|
|
|
|||
|
|
@ -67,22 +67,31 @@ export function diffSnapshots(
|
|||
events.push(createEvent('estimate_changed', curr, now, { field: 'estimated_minutes', from: prev.estimated_minutes, to: curr.estimated_minutes }));
|
||||
}
|
||||
|
||||
// 4. Collection Changes (Labels)
|
||||
if (!areArraysEqual(prev.labels, curr.labels)) {
|
||||
events.push(createEvent('labels_changed', curr, now, {
|
||||
field: 'labels',
|
||||
from: prev.labels.join(','),
|
||||
to: curr.labels.join(',')
|
||||
}));
|
||||
}
|
||||
|
||||
// 5. Collection Changes (Dependencies)
|
||||
// 4. Collection Changes (Labels)
|
||||
if (!areArraysEqual(prev.labels, curr.labels)) {
|
||||
events.push(createEvent('labels_changed', curr, now, {
|
||||
field: 'labels',
|
||||
from: prev.labels.join(','),
|
||||
to: curr.labels.join(',')
|
||||
}));
|
||||
}
|
||||
|
||||
// 5. Collection Changes (Comments) - detect comment count changes
|
||||
if (prev.comments_count !== curr.comments_count) {
|
||||
events.push(createEvent('comment_added', curr, now, {
|
||||
field: 'comments_count',
|
||||
from: String(prev.comments_count),
|
||||
to: String(curr.comments_count)
|
||||
}));
|
||||
}
|
||||
|
||||
// 6. Collection Changes (Dependencies)
|
||||
diffDependencies(prev.dependencies, curr.dependencies).forEach(kindAndTarget => {
|
||||
events.push(createEvent(kindAndTarget.kind, curr, now, { to: kindAndTarget.target, field: kindAndTarget.type }));
|
||||
});
|
||||
});
|
||||
|
||||
// 6. Detect Deleted Issues
|
||||
// 7. Detect Deleted Issues
|
||||
if (previous) {
|
||||
const currMap = new Set(current.map(c => c.id));
|
||||
previous.forEach(prev => {
|
||||
|
|
|
|||
|
|
@ -52,8 +52,9 @@ export interface BeadIssue {
|
|||
created_by: string | null;
|
||||
due_at: string | null;
|
||||
estimated_minutes: number | null;
|
||||
external_ref: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
external_ref: string | null;
|
||||
comments_count?: number;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ParseableBeadIssue extends Partial<BeadIssue> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue