Skip to content

Commit 1e1b3d2

Browse files
itaywolclaude
andauthored
feat: add team expertise exchange billboard (#20)
Add `adept exchange`: a minimal self-hosted HTTP billboard where a developer (or their agent) posts a request for a teammate's expertise and teammates stack responses on it. The server is passive storage + bearer auth — it never runs an agent; agents participate by calling the CLI with --json. - internal/exchange: Store interface + pluggable DriverRegistry (memory, fs with atomic JSON write-through), crypto/rand tokens hashed sha256 and constant-time compared, stdlib net/http server (Go 1.25 method routing) with author-only status changes and first-response auto-flip, HTTP client, and a per-user credential/dismissal store under ~/.adeptability/exchange (0600). - CLI: serve, register, submit, list, show, respond, close/reopen, token rotate, status, and recommendation dismiss|undismiss. - Bundle an expertise-exchange default skill (with a references sidecar) that checks setup state and prompts the user to register, host, or dismiss; extend the seed mechanism to carry sidecar files. Plain HTTP by design — front with a TLS reverse proxy when exposed. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 39b7f19 commit 1e1b3d2

25 files changed

Lines changed: 2539 additions & 28 deletions

cmd/adept/e2e_exchange_test.go

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
package main_test
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
"encoding/json"
7+
"errors"
8+
"os"
9+
"os/exec"
10+
"path/filepath"
11+
"strconv"
12+
"strings"
13+
"testing"
14+
"time"
15+
16+
"github.com/stretchr/testify/require"
17+
)
18+
19+
// TestE2EExchange drives the full billboard loop through the real binary:
20+
// serve (fs driver) → register → submit → list --mine → respond → show → close.
21+
func TestE2EExchange(t *testing.T) {
22+
if testing.Short() {
23+
t.Skip("skipping e2e under -short")
24+
}
25+
26+
repoRoot := findRepoRoot(t)
27+
binPath := adeptBin(t.TempDir())
28+
buildBinary(t, repoRoot, binPath)
29+
30+
home := t.TempDir()
31+
libRoot := filepath.Join(t.TempDir(), "lib")
32+
dataDir := filepath.Join(t.TempDir(), "board")
33+
baseEnv := []string{
34+
"PATH=" + os.Getenv("PATH"),
35+
"HOME=" + home,
36+
"ADEPT_LIBRARY=" + libRoot,
37+
}
38+
39+
// Start the server on an ephemeral port and capture its bootstrap token
40+
// and bound address from stdout.
41+
serve := exec.Command(binPath, "exchange", "serve", "--addr", "127.0.0.1:0", "--db", "fs", "--data", dataDir)
42+
serve.Env = baseEnv
43+
stdout, err := serve.StdoutPipe()
44+
require.NoError(t, err)
45+
serve.Stderr = serve.Stdout
46+
require.NoError(t, serve.Start())
47+
t.Cleanup(func() { _ = serve.Process.Kill(); _ = serve.Wait() })
48+
49+
boot, addr := scanServeStartup(t, stdout)
50+
server := "http://" + addr
51+
env := append([]string{"ADEPT_EXCHANGE_SERVER=" + server}, baseEnv...)
52+
53+
run := func(t *testing.T, args ...string) (string, int) {
54+
t.Helper()
55+
cmd := exec.Command(binPath, args...)
56+
cmd.Env = env
57+
var out, errBuf bytes.Buffer
58+
cmd.Stdout = &out
59+
cmd.Stderr = &errBuf
60+
err := cmd.Run()
61+
code := 0
62+
var exitErr *exec.ExitError
63+
if errors.As(err, &exitErr) {
64+
code = exitErr.ExitCode()
65+
} else if err != nil {
66+
t.Fatalf("run adept %v: %v\nstderr: %s", args, err, errBuf.String())
67+
}
68+
return out.String() + errBuf.String(), code
69+
}
70+
71+
t.Run("register stores a token", func(t *testing.T) {
72+
out, code := run(t, "exchange", "register", "--bootstrap", boot, "--handle", "alice")
73+
require.Equal(t, 0, code, out)
74+
require.FileExists(t, filepath.Join(libRoot, "exchange", "127.0.0.1_"+portOf(addr)+".json"))
75+
})
76+
77+
var itemID int
78+
t.Run("submit returns an item", func(t *testing.T) {
79+
out, code := run(t, "--json", "exchange", "submit", "--title", "how does sync work", "--body", "asking", "--assignee", "alice")
80+
require.Equal(t, 0, code, out)
81+
var item struct {
82+
ID int `json:"id"`
83+
Status string `json:"status"`
84+
}
85+
require.NoError(t, json.Unmarshal([]byte(out), &item))
86+
require.Equal(t, "attention-required", item.Status)
87+
itemID = item.ID
88+
})
89+
90+
t.Run("list --mine shows the request", func(t *testing.T) {
91+
out, code := run(t, "--json", "exchange", "list", "--mine")
92+
require.Equal(t, 0, code, out)
93+
var items []struct {
94+
ID int `json:"id"`
95+
}
96+
require.NoError(t, json.Unmarshal([]byte(out), &items))
97+
require.Len(t, items, 1)
98+
require.Equal(t, itemID, items[0].ID)
99+
})
100+
101+
t.Run("respond auto-flips to in-progress", func(t *testing.T) {
102+
out, code := run(t, "--json", "exchange", "respond", strconv.Itoa(itemID), "--body", "read the orchestrator")
103+
require.Equal(t, 0, code, out)
104+
var item struct {
105+
Status string `json:"status"`
106+
Comments []struct {
107+
Body string `json:"body"`
108+
} `json:"comments"`
109+
}
110+
require.NoError(t, json.Unmarshal([]byte(out), &item))
111+
require.Equal(t, "in-progress", item.Status)
112+
require.Len(t, item.Comments, 1)
113+
})
114+
115+
t.Run("close moves it to closed", func(t *testing.T) {
116+
out, code := run(t, "--json", "exchange", "close", strconv.Itoa(itemID))
117+
require.Equal(t, 0, code, out)
118+
var item struct {
119+
Status string `json:"status"`
120+
}
121+
require.NoError(t, json.Unmarshal([]byte(out), &item))
122+
require.Equal(t, "closed", item.Status)
123+
})
124+
}
125+
126+
// scanServeStartup reads serve's stdout until it has both the bootstrap token
127+
// and the bound address, or times out.
128+
func scanServeStartup(t *testing.T, r interface{ Read([]byte) (int, error) }) (boot, addr string) {
129+
t.Helper()
130+
type res struct{ boot, addr string }
131+
done := make(chan res, 1)
132+
go func() {
133+
sc := bufio.NewScanner(r)
134+
var b, a string
135+
grabNext := false
136+
for sc.Scan() {
137+
line := strings.TrimSpace(sc.Text())
138+
switch {
139+
case grabNext:
140+
b = line
141+
grabNext = false
142+
case strings.Contains(line, "bootstrap token"):
143+
grabNext = true
144+
case strings.Contains(line, "billboard serving on "):
145+
rest := strings.TrimPrefix(line, "billboard serving on ")
146+
a, _, _ = strings.Cut(rest, " ")
147+
}
148+
if b != "" && a != "" {
149+
done <- res{b, a}
150+
return
151+
}
152+
}
153+
if err := sc.Err(); err != nil {
154+
return // EOF/read error: let the select time out with a clear failure
155+
}
156+
}()
157+
select {
158+
case r := <-done:
159+
return r.boot, r.addr
160+
case <-time.After(10 * time.Second):
161+
t.Fatal("timed out waiting for exchange serve startup")
162+
return "", ""
163+
}
164+
}
165+
166+
func portOf(addr string) string {
167+
_, port, _ := strings.Cut(addr, ":")
168+
return port
169+
}

0 commit comments

Comments
 (0)