Add M0 server skeleton with SQLite store, SSE events, worktrees, and tailscale service

This commit is contained in:
Ian Gulliver
2026-10-13 22:19:20 -07:00
parent 555a55efc8
commit 2328904131
26 changed files with 3030 additions and 194 deletions
+8
View File
@@ -0,0 +1,8 @@
package: api
output: api.gen.go
generate:
std-http-server: true
models: true
embedded-spec: true
output-options:
skip-prune: true
+246
View File
@@ -0,0 +1,246 @@
openapi: 3.0.3
info:
title: byre
version: 0.0.0
paths:
/api/me:
get:
operationId: getMe
responses:
"200":
description: The calling user.
content:
application/json:
schema: { $ref: "#/components/schemas/User" }
put:
operationId: updateMe
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/UserUpdate" }
responses:
"200":
description: The updated user.
content:
application/json:
schema: { $ref: "#/components/schemas/User" }
"400": { $ref: "#/components/responses/Error" }
/api/projects:
get:
operationId: listProjects
responses:
"200":
description: All projects.
content:
application/json:
schema:
type: array
items: { $ref: "#/components/schemas/Project" }
post:
operationId: createProject
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ProjectCreate" }
responses:
"201":
description: The new project.
content:
application/json:
schema: { $ref: "#/components/schemas/Project" }
"400": { $ref: "#/components/responses/Error" }
/api/projects/{projectId}:
parameters:
- { name: projectId, in: path, required: true, schema: { type: string } }
get:
operationId: getProject
responses:
"200":
description: The project.
content:
application/json:
schema: { $ref: "#/components/schemas/Project" }
"404": { $ref: "#/components/responses/Error" }
/api/projects/{projectId}/tasks:
parameters:
- { name: projectId, in: path, required: true, schema: { type: string } }
get:
operationId: listTasks
responses:
"200":
description: The project's tasks, newest first.
content:
application/json:
schema:
type: array
items: { $ref: "#/components/schemas/Task" }
"404": { $ref: "#/components/responses/Error" }
post:
operationId: createTask
description: Creates the task's branch and worktree from the project's default branch.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/TaskCreate" }
responses:
"201":
description: The new task.
content:
application/json:
schema: { $ref: "#/components/schemas/Task" }
"400": { $ref: "#/components/responses/Error" }
"404": { $ref: "#/components/responses/Error" }
/api/tasks/{taskId}:
parameters:
- { name: taskId, in: path, required: true, schema: { type: string } }
get:
operationId: getTask
responses:
"200":
description: The task.
content:
application/json:
schema: { $ref: "#/components/schemas/Task" }
"404": { $ref: "#/components/responses/Error" }
/api/tasks/{taskId}/complete:
parameters:
- { name: taskId, in: path, required: true, schema: { type: string } }
post:
operationId: completeTask
description: Removes the task's worktree and branch. The task's record and events remain.
responses:
"200":
description: The completed task.
content:
application/json:
schema: { $ref: "#/components/schemas/Task" }
"404": { $ref: "#/components/responses/Error" }
"409": { $ref: "#/components/responses/Error" }
/api/events:
get:
operationId: streamEvents
description: >
Server-sent events. Each event's id is its seq; data is the Event as JSON.
Events after Last-Event-ID (or the after query parameter) are replayed before live events.
parameters:
- { name: Last-Event-ID, in: header, required: false, schema: { type: integer, format: int64 } }
- { name: after, in: query, required: false, schema: { type: integer, format: int64 } }
responses:
"200":
description: Event stream.
content:
text/event-stream:
schema: { type: string }
/api/admin/status:
get:
operationId: getStatus
responses:
"200":
description: Server health and usage.
content:
application/json:
schema: { $ref: "#/components/schemas/Status" }
components:
responses:
Error:
description: Error.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
schemas:
Error:
type: object
required: [message]
properties:
message: { type: string }
User:
type: object
required: [login, display_name, git_name, git_email]
properties:
login: { type: string }
display_name: { type: string }
git_name: { type: string }
git_email: { type: string }
UserUpdate:
type: object
required: [git_name, git_email]
properties:
git_name: { type: string }
git_email: { type: string }
Project:
type: object
required: [id, name, repo_path, default_branch, remote, created_by, created_at]
properties:
id: { type: string }
name: { type: string }
repo_path: { type: string }
default_branch: { type: string }
remote: { type: string }
created_by: { type: string }
created_at: { type: string, format: date-time }
ProjectCreate:
type: object
required: [name, repo_path]
properties:
name: { type: string }
repo_path: { type: string }
Task:
type: object
required: [id, project_id, title, branch, worktree_path, base_ref, status, created_by, created_at]
properties:
id: { type: string }
project_id: { type: string }
title: { type: string }
branch: { type: string }
worktree_path: { type: string }
base_ref: { type: string }
status: { type: string, enum: [active, completed] }
created_by: { type: string }
created_at: { type: string, format: date-time }
completed_at: { type: string, format: date-time }
TaskCreate:
type: object
required: [title]
properties:
title: { type: string }
Event:
type: object
required: [seq, kind, actor, payload, created_at]
properties:
seq: { type: integer, format: int64 }
kind: { type: string }
project_id: { type: string }
task_id: { type: string }
actor: { type: string }
payload: { type: object }
created_at: { type: string, format: date-time }
Status:
type: object
required: [started_at, db_bytes, last_event_seq, projects, active_tasks, sse_clients]
properties:
started_at: { type: string, format: date-time }
db_bytes: { type: integer, format: int64 }
last_event_seq: { type: integer, format: int64 }
projects: { type: integer }
active_tasks: { type: integer }
sse_clients: { type: integer }
+12 -189
View File
@@ -1,206 +1,29 @@
package main
import (
"bufio"
"context"
"encoding/json"
"flag"
"fmt"
"log/slog"
"os"
"os/exec"
"os/signal"
"strings"
"g.fc.run/misc/byre/internal/claude"
"g.fc.run/misc/byre/internal/mcp"
"g.fc.run/misc/byre/internal/report"
)
func main() {
resume := flag.String("resume", "", "claude session id to resume")
flag.Parse()
dir, err := os.Getwd()
if err != nil {
fatal("getwd", "err", err)
if len(os.Args) < 2 {
usage()
}
gate := &report.Gate{OnReport: printReport}
ctx := context.Background()
s, err := claude.Start(ctx, claude.Options{
Cwd: dir,
Resume: *resume,
GitName: gitConfig(dir, "user.name"),
GitEmail: gitConfig(dir, "user.email"),
MCPServers: []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: []mcp.Tool{gate.Tool()}}},
Hooks: map[string][]claude.HookMatcher{
"Stop": {{Func: gate.StopHook}},
},
})
if err != nil {
fatal("start claude", "err", err)
}
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, os.Interrupt)
lines := make(chan string)
go readLines(lines)
for {
fmt.Print("> ")
select {
case line, ok := <-lines:
if !ok {
shutdown(s)
return
}
if strings.TrimSpace(line) == "" {
continue
}
gate.Reset()
err := s.Send(line)
if err != nil {
fatal("send", "err", err)
}
runTurn(ctx, s, sigs)
case <-sigs:
fmt.Println()
shutdown(s)
return
}
switch os.Args[1] {
case "serve":
serveCmd(os.Args[2:])
case "run":
runCmd(os.Args[2:])
default:
usage()
}
}
func runTurn(ctx context.Context, s *claude.Session, sigs <-chan os.Signal) {
for {
select {
case ev, ok := <-s.Events():
if !ok {
fatal("claude exited")
}
if printEvent(ev) {
return
}
case <-sigs:
fmt.Println("\n[interrupting]")
err := s.Interrupt(ctx)
if err != nil {
slog.Error("interrupt", "err", err)
}
}
}
}
func printEvent(ev claude.Event) bool {
switch {
case ev.Type == "system" && ev.Subtype == "init":
info := struct {
SessionID string `json:"session_id"`
Model string `json:"model"`
}{}
err := json.Unmarshal(ev.Raw, &info)
if err != nil {
fatal("parse init", "err", err)
}
fmt.Printf("[session %s, %s]\n", info.SessionID, info.Model)
case ev.Type == "assistant":
blocks, err := ev.Blocks()
if err != nil {
fatal("parse assistant", "err", err)
}
for _, b := range blocks {
switch b.Type {
case "text":
fmt.Println(b.Text)
case "tool_use":
fmt.Printf("→ %s %s\n", b.Name, oneLine(string(b.Input), 160))
}
}
case ev.Type == "user":
blocks, err := ev.Blocks()
if err != nil {
fatal("parse user", "err", err)
}
for _, b := range blocks {
if b.Type == "tool_result" && b.IsError {
fmt.Printf("← error %s\n", oneLine(string(b.Content), 160))
}
}
case ev.Type == "result":
res, err := ev.Result()
if err != nil {
fatal("parse result", "err", err)
}
fmt.Printf("[%s, %d turns, %.1fs, $%.4f]\n", res.Subtype, res.NumTurns, float64(res.DurationMS)/1000, res.TotalCostUSD)
return true
}
return false
}
func printReport(r report.Report) {
fmt.Println("── report")
sections := []struct {
name string
items []string
}{
{"done", r.Done},
{"open questions", r.OpenQuestions},
{"next steps", r.NextSteps},
{"risks", r.Risks},
{"deviations", r.Deviations},
}
for _, sec := range sections {
if len(sec.items) == 0 {
continue
}
fmt.Printf(" %s:\n", sec.name)
for _, item := range sec.items {
fmt.Printf(" • %s\n", item)
}
}
}
func readLines(out chan<- string) {
defer close(out)
sc := bufio.NewScanner(os.Stdin)
for sc.Scan() {
out <- sc.Text()
}
}
func shutdown(s *claude.Session) {
err := s.Close()
if err != nil {
slog.Warn("claude exit", "err", err)
}
}
func gitConfig(dir, key string) string {
cmd := exec.Command("git", "config", key)
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
fatal("git config", "key", key, "err", err)
}
return strings.TrimSpace(string(out))
}
func oneLine(s string, max int) string {
s = strings.Join(strings.Fields(s), " ")
if len(s) > max {
return s[:max] + "…"
}
return s
func usage() {
fmt.Fprintln(os.Stderr, "usage: byre serve [--dev] | byre run [--resume ID]")
os.Exit(2)
}
func fatal(msg string, args ...any) {
+205
View File
@@ -0,0 +1,205 @@
package main
import (
"bufio"
"context"
"encoding/json"
"flag"
"fmt"
"log/slog"
"os"
"os/exec"
"os/signal"
"strings"
"g.fc.run/misc/byre/internal/claude"
"g.fc.run/misc/byre/internal/mcp"
"g.fc.run/misc/byre/internal/report"
)
func runCmd(args []string) {
fs := flag.NewFlagSet("run", flag.ExitOnError)
resume := fs.String("resume", "", "claude session id to resume")
fs.Parse(args)
dir, err := os.Getwd()
if err != nil {
fatal("getwd", "err", err)
}
gate := &report.Gate{OnReport: printReport}
ctx := context.Background()
s, err := claude.Start(ctx, claude.Options{
Cwd: dir,
Resume: *resume,
GitName: gitConfig(dir, "user.name"),
GitEmail: gitConfig(dir, "user.email"),
MCPServers: []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: []mcp.Tool{gate.Tool()}}},
Hooks: map[string][]claude.HookMatcher{
"Stop": {{Func: gate.StopHook}},
},
})
if err != nil {
fatal("start claude", "err", err)
}
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, os.Interrupt)
lines := make(chan string)
go readLines(lines)
for {
fmt.Print("> ")
select {
case line, ok := <-lines:
if !ok {
shutdown(s)
return
}
if strings.TrimSpace(line) == "" {
continue
}
gate.Reset()
err := s.Send(line)
if err != nil {
fatal("send", "err", err)
}
runTurn(ctx, s, sigs)
case <-sigs:
fmt.Println()
shutdown(s)
return
}
}
}
func runTurn(ctx context.Context, s *claude.Session, sigs <-chan os.Signal) {
for {
select {
case ev, ok := <-s.Events():
if !ok {
fatal("claude exited")
}
if printEvent(ev) {
return
}
case <-sigs:
fmt.Println("\n[interrupting]")
err := s.Interrupt(ctx)
if err != nil {
slog.Error("interrupt", "err", err)
}
}
}
}
func printEvent(ev claude.Event) bool {
switch {
case ev.Type == "system" && ev.Subtype == "init":
info := struct {
SessionID string `json:"session_id"`
Model string `json:"model"`
}{}
err := json.Unmarshal(ev.Raw, &info)
if err != nil {
fatal("parse init", "err", err)
}
fmt.Printf("[session %s, %s]\n", info.SessionID, info.Model)
case ev.Type == "assistant":
blocks, err := ev.Blocks()
if err != nil {
fatal("parse assistant", "err", err)
}
for _, b := range blocks {
switch b.Type {
case "text":
fmt.Println(b.Text)
case "tool_use":
fmt.Printf("→ %s %s\n", b.Name, oneLine(string(b.Input), 160))
}
}
case ev.Type == "user":
blocks, err := ev.Blocks()
if err != nil {
fatal("parse user", "err", err)
}
for _, b := range blocks {
if b.Type == "tool_result" && b.IsError {
fmt.Printf("← error %s\n", oneLine(string(b.Content), 160))
}
}
case ev.Type == "result":
res, err := ev.Result()
if err != nil {
fatal("parse result", "err", err)
}
fmt.Printf("[%s, %d turns, %.1fs, $%.4f]\n", res.Subtype, res.NumTurns, float64(res.DurationMS)/1000, res.TotalCostUSD)
return true
}
return false
}
func printReport(r report.Report) {
fmt.Println("── report")
sections := []struct {
name string
items []string
}{
{"done", r.Done},
{"open questions", r.OpenQuestions},
{"next steps", r.NextSteps},
{"risks", r.Risks},
{"deviations", r.Deviations},
}
for _, sec := range sections {
if len(sec.items) == 0 {
continue
}
fmt.Printf(" %s:\n", sec.name)
for _, item := range sec.items {
fmt.Printf(" • %s\n", item)
}
}
}
func readLines(out chan<- string) {
defer close(out)
sc := bufio.NewScanner(os.Stdin)
for sc.Scan() {
out <- sc.Text()
}
}
func shutdown(s *claude.Session) {
err := s.Close()
if err != nil {
slog.Warn("claude exit", "err", err)
}
}
func gitConfig(dir, key string) string {
cmd := exec.Command("git", "config", key)
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
fatal("git config", "key", key, "err", err)
}
return strings.TrimSpace(string(out))
}
func oneLine(s string, max int) string {
s = strings.Join(strings.Fields(s), " ")
if len(s) > max {
return s[:max] + "…"
}
return s
}
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"context"
"errors"
"flag"
"io/fs"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"g.fc.run/misc/byre/internal/server"
"g.fc.run/misc/byre/web"
)
const listenAddr = "127.0.0.1:8270"
func serveCmd(args []string) {
flags := flag.NewFlagSet("serve", flag.ExitOnError)
dev := flags.Bool("dev", false, "serve the SPA from ./web on disk instead of the embedded copy")
flags.Parse(args)
configDir, err := os.UserConfigDir()
if err != nil {
fatal("user config dir", "err", err)
}
spa := fs.FS(web.FS)
if *dev {
spa = os.DirFS("web")
}
srv, err := server.New(filepath.Join(configDir, "byre"), spa)
if err != nil {
fatal("start server", "err", err)
}
defer srv.Close()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
hs := &http.Server{
Addr: listenAddr,
Handler: srv.Handler(),
BaseContext: func(net.Listener) context.Context { return ctx },
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
hs.Shutdown(shutdownCtx)
}()
slog.Info("listening", "addr", listenAddr, "dev", *dev)
err = hs.ListenAndServe()
if !errors.Is(err, http.ErrServerClosed) {
fatal("serve", "err", err)
}
}
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>run.fc.byre</string>
<key>ProgramArguments</key>
<array>
<string>/Users/flamingcow/bin/byre</string>
<string>serve</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/Users/flamingcow/bin:/Users/flamingcow/.local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/flamingcow/Library/Logs/byre.log</string>
<key>StandardErrorPath</key>
<string>/Users/flamingcow/Library/Logs/byre.log</string>
</dict>
</plist>
+2
View File
@@ -2,6 +2,8 @@
How `internal/claude` drives the `claude` CLI. Worked out from the Agent SDKs (`@anthropic-ai/claude-agent-sdk` on npm, `claude-agent-sdk` on PyPI); the Python SDK's `_internal/query.py` and `_internal/transport/subprocess_cli.py` are the most readable references. The CLI version the tests last passed against is in the `system`/`init` event of any transcript under `internal/claude/testdata/transcripts/` (`claude_code_version`).
Transcripts (`Options.Transcript`) record every line in both directions as `{"dir":"send"|"recv","msg":...}`. The account email from the `initialize` response is replaced with `[redacted]` everywhere it appears.
## Launch
```
+45
View File
@@ -0,0 +1,45 @@
# Operations
## Layout
- `cmd/byre`: `byre serve` runs the server; `byre run` drives one Claude session in the current directory from the terminal.
- `api/openapi.yaml` is the API source of truth. `go generate ./internal/api` regenerates `internal/api/api.gen.go`, whose types are also the store's model types.
- `internal/store`: SQLite (WAL). Every mutation and its event commit in one transaction; events are published to SSE subscribers in `seq` order.
- `internal/server`: HTTP handlers, identity, SSE.
- `internal/git`: worktree create and remove.
- `web/`: the SPA, embedded into the binary. `byre serve --dev` serves `./web` from disk instead (run from the repo root).
## Data
State lives in the `byre` directory under Go's `os.UserConfigDir()` (on macOS, `~/Library/Application Support/byre`): `byre.db` and `worktrees/<project-id>/<task-id>`. Task branches are `byre/<task-id>` in the project's repo.
## Running as a service
The listen address is `listenAddr` in `cmd/byre/serve.go`; it binds loopback only.
```
go install ./cmd/byre
cp deploy/run.fc.byre.plist ~/Library/LaunchAgents/
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/run.fc.byre.plist
tailscale serve --bg --https=<port from listenAddr> http://<listenAddr>
```
byre gets its own HTTPS port on the machine's `ts.net` name rather than a path under another service: the SPA and API use absolute paths, which break under a path prefix. `tailscale serve status` shows the URL and what else the machine serves.
After rebuilding: `go install ./cmd/byre && launchctl kickstart -k gui/$(id -u)/run.fc.byre`. Logs go to `~/Library/Logs/byre.log`.
Clients reach `tailscale serve` over HTTP/2; it proxies to byre over HTTP/1.1 and passes SSE through unbuffered.
## Identity
`tailscale serve` adds `Tailscale-User-Login` and `Tailscale-User-Name` to proxied requests. The server accepts API requests only from loopback and only with both headers; anything else gets 401 or 403. The SPA's static files need no identity. Users are created on first request; each sets their commit identity with `PUT /api/me`.
To call the API locally without Tailscale, send the headers yourself:
```
curl -H 'Tailscale-User-Login: you@example.com' -H 'Tailscale-User-Name: You' http://<listenAddr>/api/me
```
## Events
`GET /api/events` is server-sent events: `id` is the event's `seq`, `data` the Event JSON. Reconnecting with `Last-Event-ID` (EventSource does this itself) or `?after=` replays everything after that seq before going live. A client that falls further behind than its subscriber buffer (`subscribe` in `internal/server/hub.go`) is disconnected and catches up by reconnecting.
+35 -1
View File
@@ -1,3 +1,37 @@
module g.fc.run/misc/byre
go 1.26
go 1.26.0
require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/getkin/kin-openapi v0.142.0 // indirect
github.com/go-openapi/jsonpointer v0.23.1 // indirect
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 // indirect
github.com/oapi-codegen/runtime v1.7.0 // indirect
github.com/oasdiff/yaml v0.1.1 // indirect
github.com/oasdiff/yaml3 v0.0.14 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/speakeasy-api/jsonpath v0.6.3 // indirect
github.com/speakeasy-api/openapi v1.24.0 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.50.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.77.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
modernc.org/sqlite v1.60.1 // indirect
)
tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
+169
View File
@@ -0,0 +1,169 @@
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/getkin/kin-openapi v0.142.0 h1:izj0vBdFprMhitfzaX8sTqztsEQyvwhssBoB6n8NO7w=
github.com/getkin/kin-openapi v0.142.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY=
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 h1:s4hxMxuqtR8jPzXkBTtFwY/SBuj3gEAYikmbBSdtLMM=
github.com/oapi-codegen/oapi-codegen/v2 v2.8.0/go.mod h1:yae2TI9IYB5vxQ35gFrpXh9L5H1eJv4MAUK1jumGMTo=
github.com/oapi-codegen/runtime v1.7.0 h1:t7358VYPvNbWJ9gdAkIK/smVeHpBf6yp8VTsaZsb/7k=
github.com/oapi-codegen/runtime v1.7.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU=
github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw=
github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU=
github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI=
github.com/speakeasy-api/openapi v1.24.0 h1:opoD27rupX7zBVPq1HkIGLeMOzNNA7JalhYP8q34i04=
github.com/speakeasy-api/openapi v1.24.0/go.mod h1:g3+dIMe0AYgbbGvnlQZqesmjAVWSm9BmsjLevnefQrg=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
+688
View File
@@ -0,0 +1,688 @@
//go:build go1.22
// Package api provides primitives to interact with the openapi HTTP API.
//
// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.8.0 DO NOT EDIT.
package api
import (
"bytes"
"compress/flate"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/url"
"path"
"strings"
"time"
"github.com/getkin/kin-openapi/openapi3"
"github.com/oapi-codegen/runtime"
)
// Defines values for TaskStatus.
const (
Active TaskStatus = "active"
Completed TaskStatus = "completed"
)
// Valid indicates whether the value is a known member of the TaskStatus enum.
func (e TaskStatus) Valid() bool {
switch e {
case Active:
return true
case Completed:
return true
default:
return false
}
}
// Error defines model for Error.
type Error struct {
Message string `json:"message"`
}
// Event defines model for Event.
type Event struct {
Actor string `json:"actor"`
CreatedAt time.Time `json:"created_at"`
Kind string `json:"kind"`
Payload map[string]interface{} `json:"payload"`
ProjectId *string `json:"project_id,omitempty"`
Seq int64 `json:"seq"`
TaskId *string `json:"task_id,omitempty"`
}
// Project defines model for Project.
type Project struct {
CreatedAt time.Time `json:"created_at"`
CreatedBy string `json:"created_by"`
DefaultBranch string `json:"default_branch"`
Id string `json:"id"`
Name string `json:"name"`
Remote string `json:"remote"`
RepoPath string `json:"repo_path"`
}
// ProjectCreate defines model for ProjectCreate.
type ProjectCreate struct {
Name string `json:"name"`
RepoPath string `json:"repo_path"`
}
// Status defines model for Status.
type Status struct {
ActiveTasks int `json:"active_tasks"`
DbBytes int64 `json:"db_bytes"`
LastEventSeq int64 `json:"last_event_seq"`
Projects int `json:"projects"`
SseClients int `json:"sse_clients"`
StartedAt time.Time `json:"started_at"`
}
// Task defines model for Task.
type Task struct {
BaseRef string `json:"base_ref"`
Branch string `json:"branch"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
CreatedBy string `json:"created_by"`
Id string `json:"id"`
ProjectId string `json:"project_id"`
Status TaskStatus `json:"status"`
Title string `json:"title"`
WorktreePath string `json:"worktree_path"`
}
// TaskStatus defines model for Task.Status.
type TaskStatus string
// TaskCreate defines model for TaskCreate.
type TaskCreate struct {
Title string `json:"title"`
}
// User defines model for User.
type User struct {
DisplayName string `json:"display_name"`
GitEmail string `json:"git_email"`
GitName string `json:"git_name"`
Login string `json:"login"`
}
// UserUpdate defines model for UserUpdate.
type UserUpdate struct {
GitEmail string `json:"git_email"`
GitName string `json:"git_name"`
}
// StreamEventsParams defines parameters for StreamEvents.
type StreamEventsParams struct {
After *int64 `form:"after,omitempty" json:"after,omitempty"`
LastEventID *int64 `json:"Last-Event-ID,omitempty"`
}
// UpdateMeJSONRequestBody defines body for UpdateMe for application/json ContentType.
type UpdateMeJSONRequestBody = UserUpdate
// CreateProjectJSONRequestBody defines body for CreateProject for application/json ContentType.
type CreateProjectJSONRequestBody = ProjectCreate
// CreateTaskJSONRequestBody defines body for CreateTask for application/json ContentType.
type CreateTaskJSONRequestBody = TaskCreate
// ServerInterface represents all server handlers.
type ServerInterface interface {
// (GET /api/admin/status)
GetStatus(w http.ResponseWriter, r *http.Request)
// (GET /api/events)
StreamEvents(w http.ResponseWriter, r *http.Request, params StreamEventsParams)
// (GET /api/me)
GetMe(w http.ResponseWriter, r *http.Request)
// (PUT /api/me)
UpdateMe(w http.ResponseWriter, r *http.Request)
// (GET /api/projects)
ListProjects(w http.ResponseWriter, r *http.Request)
// (POST /api/projects)
CreateProject(w http.ResponseWriter, r *http.Request)
// (GET /api/projects/{projectId})
GetProject(w http.ResponseWriter, r *http.Request, projectId string)
// (GET /api/projects/{projectId}/tasks)
ListTasks(w http.ResponseWriter, r *http.Request, projectId string)
// (POST /api/projects/{projectId}/tasks)
CreateTask(w http.ResponseWriter, r *http.Request, projectId string)
// (GET /api/tasks/{taskId})
GetTask(w http.ResponseWriter, r *http.Request, taskId string)
// (POST /api/tasks/{taskId}/complete)
CompleteTask(w http.ResponseWriter, r *http.Request, taskId string)
}
// ServerInterfaceWrapper converts contexts to parameters.
type ServerInterfaceWrapper struct {
Handler ServerInterface
HandlerMiddlewares []MiddlewareFunc
ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error)
}
type MiddlewareFunc func(http.Handler) http.Handler
// GetStatus operation middleware
func (siw *ServerInterfaceWrapper) GetStatus(w http.ResponseWriter, r *http.Request) {
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetStatus(w, r)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// StreamEvents operation middleware
func (siw *ServerInterfaceWrapper) StreamEvents(w http.ResponseWriter, r *http.Request) {
var err error
_ = err
// Parameter object where we will unmarshal all parameters from the context
var params StreamEventsParams
// ------------- Optional query parameter "after" -------------
err = runtime.BindQueryParameterWithOptions("form", true, false, "after", r.URL.Query(), &params.After, runtime.BindQueryParameterOptions{Type: "integer", Format: "int64"})
if err != nil {
var requiredError *runtime.RequiredParameterError
if errors.As(err, &requiredError) {
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "after"})
} else {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "after", Err: err})
}
return
}
headers := r.Header
// ------------- Optional header parameter "Last-Event-ID" -------------
if valueList, found := headers[http.CanonicalHeaderKey("Last-Event-ID")]; found {
var LastEventID int64
n := len(valueList)
if n != 1 {
siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "Last-Event-ID", Count: n})
return
}
err = runtime.BindStyledParameterWithOptions("simple", "Last-Event-ID", valueList[0], &LastEventID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "integer", Format: "int64"})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "Last-Event-ID", Err: err})
return
}
params.LastEventID = &LastEventID
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.StreamEvents(w, r, params)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// GetMe operation middleware
func (siw *ServerInterfaceWrapper) GetMe(w http.ResponseWriter, r *http.Request) {
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetMe(w, r)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// UpdateMe operation middleware
func (siw *ServerInterfaceWrapper) UpdateMe(w http.ResponseWriter, r *http.Request) {
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.UpdateMe(w, r)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// ListProjects operation middleware
func (siw *ServerInterfaceWrapper) ListProjects(w http.ResponseWriter, r *http.Request) {
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.ListProjects(w, r)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// CreateProject operation middleware
func (siw *ServerInterfaceWrapper) CreateProject(w http.ResponseWriter, r *http.Request) {
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.CreateProject(w, r)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// GetProject operation middleware
func (siw *ServerInterfaceWrapper) GetProject(w http.ResponseWriter, r *http.Request) {
var err error
_ = err
// ------------- Path parameter "projectId" -------------
var projectId string
err = runtime.BindStyledParameterWithOptions("simple", "projectId", r.PathValue("projectId"), &projectId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "projectId", Err: err})
return
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetProject(w, r, projectId)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// ListTasks operation middleware
func (siw *ServerInterfaceWrapper) ListTasks(w http.ResponseWriter, r *http.Request) {
var err error
_ = err
// ------------- Path parameter "projectId" -------------
var projectId string
err = runtime.BindStyledParameterWithOptions("simple", "projectId", r.PathValue("projectId"), &projectId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "projectId", Err: err})
return
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.ListTasks(w, r, projectId)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// CreateTask operation middleware
func (siw *ServerInterfaceWrapper) CreateTask(w http.ResponseWriter, r *http.Request) {
var err error
_ = err
// ------------- Path parameter "projectId" -------------
var projectId string
err = runtime.BindStyledParameterWithOptions("simple", "projectId", r.PathValue("projectId"), &projectId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "projectId", Err: err})
return
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.CreateTask(w, r, projectId)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// GetTask operation middleware
func (siw *ServerInterfaceWrapper) GetTask(w http.ResponseWriter, r *http.Request) {
var err error
_ = err
// ------------- Path parameter "taskId" -------------
var taskId string
err = runtime.BindStyledParameterWithOptions("simple", "taskId", r.PathValue("taskId"), &taskId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "taskId", Err: err})
return
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetTask(w, r, taskId)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// CompleteTask operation middleware
func (siw *ServerInterfaceWrapper) CompleteTask(w http.ResponseWriter, r *http.Request) {
var err error
_ = err
// ------------- Path parameter "taskId" -------------
var taskId string
err = runtime.BindStyledParameterWithOptions("simple", "taskId", r.PathValue("taskId"), &taskId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "taskId", Err: err})
return
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.CompleteTask(w, r, taskId)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
type UnescapedCookieParamError struct {
ParamName string
Err error
}
func (e *UnescapedCookieParamError) Error() string {
return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName)
}
func (e *UnescapedCookieParamError) Unwrap() error {
return e.Err
}
type UnmarshalingParamError struct {
ParamName string
Err error
}
func (e *UnmarshalingParamError) Error() string {
return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error())
}
func (e *UnmarshalingParamError) Unwrap() error {
return e.Err
}
type RequiredParamError struct {
ParamName string
}
func (e *RequiredParamError) Error() string {
return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName)
}
type RequiredHeaderError struct {
ParamName string
Err error
}
func (e *RequiredHeaderError) Error() string {
return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName)
}
func (e *RequiredHeaderError) Unwrap() error {
return e.Err
}
type InvalidParamFormatError struct {
ParamName string
Err error
}
func (e *InvalidParamFormatError) Error() string {
return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error())
}
func (e *InvalidParamFormatError) Unwrap() error {
return e.Err
}
type TooManyValuesForParamError struct {
ParamName string
Count int
}
func (e *TooManyValuesForParamError) Error() string {
return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count)
}
// Handler creates http.Handler with routing matching OpenAPI spec.
func Handler(si ServerInterface) http.Handler {
return HandlerWithOptions(si, StdHTTPServerOptions{})
}
// ServeMux is an abstraction of [http.ServeMux].
type ServeMux interface {
HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request))
http.Handler
}
type StdHTTPServerOptions struct {
BaseURL string
BaseRouter ServeMux
Middlewares []MiddlewareFunc
ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error)
}
// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux.
func HandlerFromMux(si ServerInterface, m ServeMux) http.Handler {
return HandlerWithOptions(si, StdHTTPServerOptions{
BaseRouter: m,
})
}
func HandlerFromMuxWithBaseURL(si ServerInterface, m ServeMux, baseURL string) http.Handler {
return HandlerWithOptions(si, StdHTTPServerOptions{
BaseURL: baseURL,
BaseRouter: m,
})
}
// HandlerWithOptions creates http.Handler with additional options
func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.Handler {
m := options.BaseRouter
if m == nil {
m = http.NewServeMux()
}
if options.ErrorHandlerFunc == nil {
options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) {
http.Error(w, err.Error(), http.StatusBadRequest)
}
}
wrapper := ServerInterfaceWrapper{
Handler: si,
HandlerMiddlewares: options.Middlewares,
ErrorHandlerFunc: options.ErrorHandlerFunc,
}
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/me", wrapper.GetMe)
m.HandleFunc(http.MethodPut+" "+options.BaseURL+"/api/me", wrapper.UpdateMe)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/projects", wrapper.ListProjects)
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/projects", wrapper.CreateProject)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/projects/{projectId}", wrapper.GetProject)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/projects/{projectId}/tasks", wrapper.ListTasks)
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/projects/{projectId}/tasks", wrapper.CreateTask)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}", wrapper.GetTask)
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/tasks/{taskId}/complete", wrapper.CompleteTask)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/events", wrapper.StreamEvents)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/admin/status", wrapper.GetStatus)
return m
}
// Base64 encoded, compressed with deflate, json marshaled OpenAPI spec.
// Stored as a slice of fixed-width chunks rather than one concatenated
// const string: with thousands of chunks the chained `+` fold is several
// times slower for the Go compiler than parsing a slice literal.
var swaggerSpec = []string{
"zFhbb+M2E/0rBL8PSAsottsNCtR96iUoUmzbRZN92gYCLY1tbiRSGY69NQL994KkrjblS2p3+xRZJIdn",
"Ds8ZDfPCE50XWoEiw6cvHMEUWhlwP24RNdqHRCsCRfZRFEUmE0FSq/FHo5V9Z5Il5MI+/R9hzqf8f+M2",
"6tiPmrGPVpZlxFMwCcrCBuFTv82I24Fqbm/zAnUBSNJjysEYsQD7SJsC+JQbQqkWbjnC80oipHz6oZn4",
"GNUT9ewjJMTLiN+uq2z6sUVCfsutyBFPEARBGgu3aq4xt088FQTXJHPg0e6aJ6nSYLBCbDItumMtsgK1",
"fYxleKmB5x4Aqeibm3ZzqQgWgHYmCfMUjrLFkw1ZgY0qBlqIvcxDTL7zeHe5fA1j9ZrZJph8CnOxyiie",
"oVDJMjhlgDYlcggOIOSahoYKHReClocplJYot0d32Q7gZrtepsdS/KObtUv0ntyOTmAHewjHPQlamaBr",
"5BpiKzjT2aqjxXQWzzbkZx8h3UwYisFaND5e75VxBhAYA3GSybrKBSaQwNPkum2jNkAn4Z1kOkCjPnN9",
"kKEDeBDmaZf+mTAQu7Ib0MAeq9gKncFrLXpGWw949lAlbNQIapXbE/Bs8k5mHRbbhSQpCxvmk8YnQoBT",
"XN9BWYduWN+OGLVn1eA/rRRYBQzVgaG8tlD7aaHg7w0EvrepNEUmNvFgmVlIiiEXMhscHVya6YVUhyH7",
"aVEfSid0F8NQYu+LNMjaq9FvYTwSjF0m1Vx3DozPNmiXrQGN74cmo8loYrfXBShRSD7lb0aT0Rv3Xaal",
"wz0WhRyLNJdq3DphAc6VNj/XoN2lfMp/Brqvtdbr7r6eTM7W21U7BJq7e8A1IFuCyGjJhErZyrZltt2z",
"k10erjx2MwiFuDagiPmpI3YrkqX/cWWYTJk0TJJhBp6/Y6kgYV/QEpjr9Jgw7Jf7338b+Z+GiTkBsrfC",
"0LV7c333E/tCo1vhx55XgBtWCBQ5EOCXTCAwBKs/SNkM5hqBZXINNaI/rUL7zN8Tgsj9lu7sqmCGTz+8",
"cKt8vgSRAtbdw5T3IPGow/7BT2AZVTEd9Daky+fEUI8HpULwF/lzuzYuzb5Wts2y2/O7c/FLe1rwZhtS",
"8q9wSRW7ChgA+7AElogsk2rBVgbcXcV+oVYBkL7QVDifV2DoB51uzgqxqmVlvwYRrqD8TOSsHKK0Jifi",
"N37jULwGYHshrM++28MFFfBWGnrX9k//KFdJkJtDSdfXm7IxiUAUmxAP32cZqxOoBaJNIAn/Aa8jX0Ym",
"/TvDUUr56tybD4lFwaeaqDNoZfxSPd2l5b7K0eX7Qg45kHc/55sTcg5/O6qesirzDQt8+6ijPXX5cR+d",
"4+ZKN2jGh+rqcnknusvPETbsMH1lmMsgspIDQ2wu0fzH+G+LRD8N71zfw9gkrgzztwrXQtX3CjZHnbs5",
"bcrVPx2q6aOdrsRHdnRepvZ0Lin/cuHxGhmuOpbIk0vOqWKpHeWkN36xfw6UpuYsLlSX9tHSoeTcjvCZ",
"v64c9ckb1zd6d3+7yMZDPvwDcr3u+7Axn3Vi5TL20I4jJBpTN+rvBgztdVAFrFil9TkF0Pyz5HVSsLO/",
"PcUdZfl3AAAA//8=",
}
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
// after base64-decoding and flate-decompressing the embedded blob.
func decodeSpec() ([]byte, error) {
encoded := strings.Join(swaggerSpec, "")
compressed, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("error base64 decoding spec: %w", err)
}
zr := flate.NewReader(bytes.NewReader(compressed))
var buf bytes.Buffer
if _, err := buf.ReadFrom(zr); err != nil {
return nil, fmt.Errorf("read flate: %w", err)
}
if err := zr.Close(); err != nil {
return nil, fmt.Errorf("close flate reader: %w", err)
}
return buf.Bytes(), nil
}
var rawSpec = decodeSpecCached()
// a naive cache of the decoded OpenAPI spec
func decodeSpecCached() func() ([]byte, error) {
data, err := decodeSpec()
return func() ([]byte, error) {
return data, err
}
}
// Constructs a synthetic filesystem for resolving external references when loading openapi specifications.
func PathToRawSpec(pathToFile string) map[string]func() ([]byte, error) {
res := make(map[string]func() ([]byte, error))
if len(pathToFile) > 0 {
res[pathToFile] = rawSpec
}
return res
}
// GetSpec returns the OpenAPI specification corresponding to the generated
// code in this file. External references in the spec are resolved through
// PathToRawSpec; externally-referenced files must be embedded in their
// corresponding Go packages (via the import-mapping feature). URL-based
// external refs are not supported.
func GetSpec() (swagger *openapi3.T, err error) {
resolvePath := PathToRawSpec("")
loader := openapi3.NewLoader()
loader.IsExternalRefsAllowed = true
loader.ReadFromURIFunc = func(loader *openapi3.Loader, url *url.URL) ([]byte, error) {
pathToFile := url.String()
pathToFile = path.Clean(pathToFile)
getSpec, ok := resolvePath[pathToFile]
if !ok {
err1 := fmt.Errorf("path not found: %s", pathToFile)
return nil, err1
}
return getSpec()
}
var specData []byte
specData, err = rawSpec()
if err != nil {
return
}
swagger, err = loader.LoadFromData(specData)
if err != nil {
return
}
return
}
// GetSpecJSON returns the raw JSON bytes of the embedded OpenAPI
// specification: decompressed but not unmarshaled. External references
// are not resolved here; the bytes are the spec exactly as embedded by
// codegen. The result is cached at package init time, so repeated calls
// are cheap.
func GetSpecJSON() ([]byte, error) {
return rawSpec()
}
// GetSwagger returns the OpenAPI specification corresponding to the
// generated code in this file.
//
// Deprecated: GetSwagger predates kin-openapi renaming openapi3.Swagger
// to openapi3.T. Use [GetSpec] instead. This wrapper is retained for
// backwards compatibility.
func GetSwagger() (*openapi3.T, error) {
return GetSpec()
}
+3
View File
@@ -0,0 +1,3 @@
package api
//go:generate go tool oapi-codegen -config ../../api/oapi-codegen.yaml ../../api/openapi.yaml
+28 -4
View File
@@ -2,6 +2,7 @@ package claude
import (
"bufio"
"bytes"
"context"
"crypto/rand"
"encoding/hex"
@@ -103,6 +104,7 @@ type Session struct {
writeMu sync.Mutex
transcript io.Writer
scrub []byte
pendingMu sync.Mutex
pending map[string]chan controlResult
@@ -360,6 +362,10 @@ func (s *Session) record(dir string, line []byte) {
return
}
if len(s.scrub) > 0 {
line = bytes.ReplaceAll(line, s.scrub, []byte("[redacted]"))
}
b, err := json.Marshal(map[string]any{"dir": dir, "msg": json.RawMessage(line)})
if err != nil {
slog.Error("record transcript", "err", err)
@@ -389,10 +395,6 @@ func (s *Session) readLoop(stdout io.Reader) {
}
func (s *Session) handleLine(line []byte) {
s.writeMu.Lock()
s.record("recv", line)
s.writeMu.Unlock()
env := envelope{}
err := json.Unmarshal(line, &env)
if err != nil {
@@ -400,6 +402,13 @@ func (s *Session) handleLine(line []byte) {
return
}
s.writeMu.Lock()
if env.Type == "control_response" {
s.learnScrub(env.Response)
}
s.record("recv", line)
s.writeMu.Unlock()
switch env.Type {
case "control_response":
s.handleControlResponse(env.Response)
@@ -422,6 +431,21 @@ func (s *Session) handleLine(line []byte) {
s.events <- Event{Type: env.Type, Subtype: env.Subtype, Raw: append(json.RawMessage{}, line...)}
}
func (s *Session) learnScrub(raw json.RawMessage) {
resp := struct {
Response struct {
Account struct {
Email string `json:"email"`
} `json:"account"`
} `json:"response"`
}{}
err := json.Unmarshal(raw, &resp)
if err != nil || resp.Response.Account.Email == "" {
return
}
s.scrub = []byte(resp.Response.Account.Email)
}
func (s *Session) handleControlResponse(raw json.RawMessage) {
resp := struct {
Subtype string `json:"subtype"`
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+86
View File
@@ -0,0 +1,86 @@
package git
import (
"fmt"
"os/exec"
"path/filepath"
"strings"
)
type RepoInfo struct {
Root string
DefaultBranch string
Remote string
}
func Inspect(path string) (RepoInfo, error) {
abs, err := filepath.Abs(path)
if err != nil {
return RepoInfo{}, err
}
root, err := run(abs, "rev-parse", "--show-toplevel")
if err != nil {
return RepoInfo{}, err
}
root, err = filepath.EvalSymlinks(root)
if err != nil {
return RepoInfo{}, err
}
abs, err = filepath.EvalSymlinks(abs)
if err != nil {
return RepoInfo{}, err
}
if root != abs {
return RepoInfo{}, fmt.Errorf("%s is inside the repo at %s; register the repo root", abs, root)
}
branch, err := run(root, "symbolic-ref", "--short", "HEAD")
if err != nil {
return RepoInfo{}, err
}
remote, err := run(root, "remote", "get-url", "origin")
if err != nil {
return RepoInfo{}, err
}
return RepoInfo{Root: root, DefaultBranch: branch, Remote: remote}, nil
}
func AddWorktree(repo, path, branch, base string) (string, error) {
sha, err := run(repo, "rev-parse", "--verify", base+"^{commit}")
if err != nil {
return "", err
}
_, err = run(repo, "worktree", "add", "-b", branch, path, sha)
if err != nil {
return "", err
}
return sha, nil
}
func RemoveWorktree(repo, path, branch string) error {
_, err := run(repo, "worktree", "remove", "--force", path)
if err != nil {
return err
}
_, err = run(repo, "branch", "-D", branch)
return err
}
func run(dir string, args ...string) (string, error) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out)))
}
return strings.TrimSpace(string(out)), nil
}
+53
View File
@@ -0,0 +1,53 @@
package server
import (
"sync"
"g.fc.run/misc/byre/internal/api"
)
type hub struct {
mu sync.Mutex
subs map[chan api.Event]bool
}
func newHub() *hub {
return &hub{subs: map[chan api.Event]bool{}}
}
func (h *hub) subscribe() chan api.Event {
h.mu.Lock()
defer h.mu.Unlock()
ch := make(chan api.Event, 256)
h.subs[ch] = true
return ch
}
func (h *hub) unsubscribe(ch chan api.Event) {
h.mu.Lock()
defer h.mu.Unlock()
if !h.subs[ch] {
return
}
delete(h.subs, ch)
close(ch)
}
func (h *hub) publish(ev api.Event) {
h.mu.Lock()
defer h.mu.Unlock()
for ch := range h.subs {
select {
case ch <- ev:
default:
delete(h.subs, ch)
close(ch)
}
}
}
func (h *hub) count() int {
h.mu.Lock()
defer h.mu.Unlock()
return len(h.subs)
}
+360
View File
@@ -0,0 +1,360 @@
package server
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/git"
"g.fc.run/misc/byre/internal/store"
)
type Server struct {
store *store.Store
hub *hub
worktreeRoot string
startedAt time.Time
spa fs.FS
usersMu sync.Mutex
users map[string]string
}
type ctxKey struct{}
func New(dataDir string, spa fs.FS) (*Server, error) {
err := os.MkdirAll(dataDir, 0o755)
if err != nil {
return nil, err
}
s := &Server{
hub: newHub(),
worktreeRoot: filepath.Join(dataDir, "worktrees"),
startedAt: time.Now().UTC(),
spa: spa,
users: map[string]string{},
}
s.store, err = store.Open(filepath.Join(dataDir, "byre.db"), s.hub.publish)
if err != nil {
return nil, err
}
return s, nil
}
func (s *Server) Close() error {
return s.store.Close()
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
api.HandlerWithOptions(s, api.StdHTTPServerOptions{
BaseRouter: mux,
Middlewares: []api.MiddlewareFunc{s.identify},
ErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) {
writeError(w, http.StatusBadRequest, err)
},
})
mux.Handle("/", http.FileServerFS(s.spa))
return mux
}
func (s *Server) identify(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
writeError(w, http.StatusForbidden, err)
return
}
ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() {
writeError(w, http.StatusForbidden, fmt.Errorf("request from %s did not arrive via tailscale serve", host))
return
}
login := r.Header.Get("Tailscale-User-Login")
name := r.Header.Get("Tailscale-User-Name")
if login == "" || name == "" {
writeError(w, http.StatusUnauthorized, errors.New("missing Tailscale-User-Login or Tailscale-User-Name"))
return
}
err = s.ensureUser(r.Context(), login, name)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, login)))
})
}
func (s *Server) ensureUser(ctx context.Context, login, name string) error {
s.usersMu.Lock()
defer s.usersMu.Unlock()
if s.users[login] == name {
return nil
}
_, err := s.store.EnsureUser(ctx, login, name)
if err != nil {
return err
}
s.users[login] = name
return nil
}
func actor(r *http.Request) string {
return r.Context().Value(ctxKey{}).(string)
}
func (s *Server) GetMe(w http.ResponseWriter, r *http.Request) {
u, err := s.store.GetUser(r.Context(), actor(r))
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, u)
}
func (s *Server) UpdateMe(w http.ResponseWriter, r *http.Request) {
req := api.UserUpdate{}
if !readJSON(w, r, &req) {
return
}
if strings.TrimSpace(req.GitName) == "" || !strings.Contains(req.GitEmail, "@") {
writeError(w, http.StatusBadRequest, errors.New("git_name and a valid git_email are required"))
return
}
u, err := s.store.UpdateUserGit(r.Context(), actor(r), req.GitName, req.GitEmail)
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, u)
}
func (s *Server) ListProjects(w http.ResponseWriter, r *http.Request) {
projects, err := s.store.ListProjects(r.Context())
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, projects)
}
func (s *Server) CreateProject(w http.ResponseWriter, r *http.Request) {
req := api.ProjectCreate{}
if !readJSON(w, r, &req) {
return
}
if strings.TrimSpace(req.Name) == "" {
writeError(w, http.StatusBadRequest, errors.New("name is required"))
return
}
info, err := git.Inspect(req.RepoPath)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
p := api.Project{
Id: store.NewID(),
Name: req.Name,
RepoPath: info.Root,
DefaultBranch: info.DefaultBranch,
Remote: info.Remote,
CreatedBy: actor(r),
CreatedAt: time.Now().UTC(),
}
err = s.store.CreateProject(r.Context(), p)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusCreated, p)
}
func (s *Server) GetProject(w http.ResponseWriter, r *http.Request, projectID string) {
p, err := s.store.GetProject(r.Context(), projectID)
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, p)
}
func (s *Server) ListTasks(w http.ResponseWriter, r *http.Request, projectID string) {
_, err := s.store.GetProject(r.Context(), projectID)
if err != nil {
writeStoreError(w, err)
return
}
tasks, err := s.store.ListTasks(r.Context(), projectID)
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, tasks)
}
func (s *Server) CreateTask(w http.ResponseWriter, r *http.Request, projectID string) {
req := api.TaskCreate{}
if !readJSON(w, r, &req) {
return
}
if strings.TrimSpace(req.Title) == "" {
writeError(w, http.StatusBadRequest, errors.New("title is required"))
return
}
p, err := s.store.GetProject(r.Context(), projectID)
if err != nil {
writeStoreError(w, err)
return
}
id := store.NewID()
t := api.Task{
Id: id,
ProjectId: p.Id,
Title: req.Title,
Branch: "byre/" + id,
WorktreePath: filepath.Join(s.worktreeRoot, p.Id, id),
Status: api.Active,
CreatedBy: actor(r),
CreatedAt: time.Now().UTC(),
}
t.BaseRef, err = git.AddWorktree(p.RepoPath, t.WorktreePath, t.Branch, p.DefaultBranch)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
err = s.store.CreateTask(r.Context(), t)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusCreated, t)
}
func (s *Server) GetTask(w http.ResponseWriter, r *http.Request, taskID string) {
t, err := s.store.GetTask(r.Context(), taskID)
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, t)
}
func (s *Server) CompleteTask(w http.ResponseWriter, r *http.Request, taskID string) {
t, err := s.store.GetTask(r.Context(), taskID)
if err != nil {
writeStoreError(w, err)
return
}
if t.Status != api.Active {
writeError(w, http.StatusConflict, fmt.Errorf("task %s is %s", t.Id, t.Status))
return
}
p, err := s.store.GetProject(r.Context(), t.ProjectId)
if err != nil {
writeStoreError(w, err)
return
}
err = git.RemoveWorktree(p.RepoPath, t.WorktreePath, t.Branch)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
t, err = s.store.CompleteTask(r.Context(), taskID, actor(r))
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, t)
}
func (s *Server) GetStatus(w http.ResponseWriter, r *http.Request) {
c, err := s.store.Counts(r.Context())
if err != nil {
writeStoreError(w, err)
return
}
dbBytes := int64(0)
for _, suffix := range []string{"", "-wal", "-shm"} {
fi, err := os.Stat(s.store.Path() + suffix)
if err == nil {
dbBytes += fi.Size()
}
}
writeJSON(w, http.StatusOK, api.Status{
StartedAt: s.startedAt,
DbBytes: dbBytes,
LastEventSeq: c.LastEventSeq,
Projects: c.Projects,
ActiveTasks: c.ActiveTasks,
SseClients: s.hub.count(),
})
}
func readJSON(w http.ResponseWriter, r *http.Request, v any) bool {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
err := dec.Decode(v)
if err != nil {
writeError(w, http.StatusBadRequest, fmt.Errorf("decode request: %w", err))
return false
}
return true
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
err := json.NewEncoder(w).Encode(v)
if err != nil {
slog.Warn("write response", "err", err)
}
}
func writeError(w http.ResponseWriter, status int, err error) {
writeJSON(w, status, api.Error{Message: err.Error()})
}
func writeStoreError(w http.ResponseWriter, err error) {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, err)
return
}
writeError(w, http.StatusInternalServerError, err)
}
+352
View File
@@ -0,0 +1,352 @@
package server_test
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/server"
)
const login = "alice@example.com"
type harness struct {
t *testing.T
url string
}
func newHarness(t *testing.T) *harness {
srv, err := server.New(t.TempDir(), fstest.MapFS{"index.html": {Data: []byte("shell")}})
if err != nil {
t.Fatalf("new server: %v", err)
}
t.Cleanup(func() { srv.Close() })
hs := httptest.NewServer(srv.Handler())
t.Cleanup(hs.Close)
return &harness{t: t, url: hs.URL}
}
func (h *harness) do(method, path string, body any, out any) int {
h.t.Helper()
buf := &bytes.Buffer{}
if body != nil {
err := json.NewEncoder(buf).Encode(body)
if err != nil {
h.t.Fatalf("encode: %v", err)
}
}
req, err := http.NewRequest(method, h.url+path, buf)
if err != nil {
h.t.Fatalf("new request: %v", err)
}
req.Header.Set("Tailscale-User-Login", login)
req.Header.Set("Tailscale-User-Name", "Alice")
resp, err := http.DefaultClient.Do(req)
if err != nil {
h.t.Fatalf("%s %s: %v", method, path, err)
}
defer resp.Body.Close()
if out != nil && resp.StatusCode < 300 {
err = json.NewDecoder(resp.Body).Decode(out)
if err != nil {
h.t.Fatalf("decode %s %s: %v", method, path, err)
}
}
return resp.StatusCode
}
func (h *harness) createProject(name string) api.Project {
h.t.Helper()
p := api.Project{}
code := h.do("POST", "/api/projects", api.ProjectCreate{Name: name, RepoPath: repoWithOrigin(h.t)}, &p)
if code != http.StatusCreated {
h.t.Fatalf("create project: %d", code)
}
return p
}
func TestIdentityRequired(t *testing.T) {
h := newHarness(t)
resp, err := http.Get(h.url + "/api/me")
if err != nil {
t.Fatalf("get: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("want 401 without tailscale headers, got %d", resp.StatusCode)
}
resp, err = http.Get(h.url + "/")
if err != nil {
t.Fatalf("get spa: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("want spa shell without auth, got %d", resp.StatusCode)
}
}
func TestMe(t *testing.T) {
h := newHarness(t)
me := api.User{}
h.do("GET", "/api/me", nil, &me)
if me.Login != login || me.DisplayName != "Alice" || me.GitEmail != "" {
t.Fatalf("unexpected user %+v", me)
}
code := h.do("PUT", "/api/me", api.UserUpdate{GitName: "Alice", GitEmail: "nope"}, nil)
if code != http.StatusBadRequest {
t.Fatalf("want 400 for bad email, got %d", code)
}
code = h.do("PUT", "/api/me", api.UserUpdate{GitName: "Alice A", GitEmail: "alice@example.org"}, &me)
if code != http.StatusOK || me.GitName != "Alice A" || me.GitEmail != "alice@example.org" {
t.Fatalf("update failed: %d %+v", code, me)
}
}
func TestProjectAndTaskLifecycle(t *testing.T) {
h := newHarness(t)
p := h.createProject("demo")
if p.DefaultBranch != "main" || !strings.HasSuffix(p.Remote, "origin.git") {
t.Fatalf("unexpected project %+v", p)
}
code := h.do("POST", "/api/projects", api.ProjectCreate{Name: "demo", RepoPath: p.RepoPath}, nil)
if code != http.StatusBadRequest {
t.Fatalf("want 400 for duplicate project, got %d", code)
}
task := api.Task{}
code = h.do("POST", "/api/projects/"+p.Id+"/tasks", api.TaskCreate{Title: "first"}, &task)
if code != http.StatusCreated {
t.Fatalf("create task: %d", code)
}
_, err := os.Stat(filepath.Join(task.WorktreePath, "README"))
if err != nil {
t.Fatalf("worktree not checked out: %v", err)
}
head := gitOut(t, task.WorktreePath, "rev-parse", "--abbrev-ref", "HEAD")
if head != task.Branch {
t.Fatalf("worktree on %s, want %s", head, task.Branch)
}
if task.BaseRef != gitOut(t, p.RepoPath, "rev-parse", "main") {
t.Fatalf("base ref %s is not main's head", task.BaseRef)
}
tasks := []api.Task{}
h.do("GET", "/api/projects/"+p.Id+"/tasks", nil, &tasks)
if len(tasks) != 1 || tasks[0].Id != task.Id {
t.Fatalf("unexpected task list %+v", tasks)
}
err = os.WriteFile(filepath.Join(task.WorktreePath, "dirty"), []byte("x"), 0o644)
if err != nil {
t.Fatalf("write: %v", err)
}
done := api.Task{}
code = h.do("POST", "/api/tasks/"+task.Id+"/complete", nil, &done)
if code != http.StatusOK || done.Status != api.Completed || done.CompletedAt == nil {
t.Fatalf("complete: %d %+v", code, done)
}
_, err = os.Stat(task.WorktreePath)
if !os.IsNotExist(err) {
t.Fatalf("worktree still exists: %v", err)
}
branches := gitOut(t, p.RepoPath, "branch", "--list", task.Branch)
if branches != "" {
t.Fatalf("branch still exists: %s", branches)
}
code = h.do("POST", "/api/tasks/"+task.Id+"/complete", nil, nil)
if code != http.StatusConflict {
t.Fatalf("want 409 completing twice, got %d", code)
}
code = h.do("GET", "/api/tasks/nope", nil, nil)
if code != http.StatusNotFound {
t.Fatalf("want 404 for unknown task, got %d", code)
}
}
func TestEventStreamResume(t *testing.T) {
h := newHarness(t)
stream := h.openStream(nil)
p := h.createProject("demo")
first := stream.next()
if first.Kind != "project.created" || *first.ProjectId != p.Id {
t.Fatalf("unexpected first event %+v", first)
}
stream.close()
task := api.Task{}
h.do("POST", "/api/projects/"+p.Id+"/tasks", api.TaskCreate{Title: "while away"}, &task)
h.do("PUT", "/api/me", api.UserUpdate{GitName: "Alice", GitEmail: "alice@example.org"}, nil)
resumed := h.openStream(&first.Seq)
missed := resumed.next()
if missed.Kind != "task.created" || *missed.TaskId != task.Id || missed.Seq != first.Seq+1 {
t.Fatalf("unexpected replayed event %+v", missed)
}
if next := resumed.next(); next.Kind != "user.updated" {
t.Fatalf("unexpected second replayed event %+v", next)
}
h.do("POST", "/api/tasks/"+task.Id+"/complete", nil, nil)
if live := resumed.next(); live.Kind != "task.completed" {
t.Fatalf("unexpected live event %+v", live)
}
status := api.Status{}
h.do("GET", "/api/admin/status", nil, &status)
if status.LastEventSeq != 4 || status.Projects != 1 || status.ActiveTasks != 0 || status.SseClients < 1 {
t.Fatalf("unexpected status %+v", status)
}
}
type stream struct {
t *testing.T
cancel context.CancelFunc
resp *http.Response
r *bufio.Reader
}
func (h *harness) openStream(lastEventID *int64) *stream {
h.t.Helper()
ctx, cancel := context.WithCancel(context.Background())
req, err := http.NewRequestWithContext(ctx, "GET", h.url+"/api/events", nil)
if err != nil {
h.t.Fatalf("new request: %v", err)
}
req.Header.Set("Tailscale-User-Login", login)
req.Header.Set("Tailscale-User-Name", "Alice")
if lastEventID != nil {
req.Header.Set("Last-Event-ID", fmt.Sprint(*lastEventID))
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
h.t.Fatalf("open stream: %v", err)
}
if resp.StatusCode != http.StatusOK {
h.t.Fatalf("open stream: %d", resp.StatusCode)
}
s := &stream{t: h.t, cancel: cancel, resp: resp, r: bufio.NewReader(resp.Body)}
h.t.Cleanup(s.close)
return s
}
func (s *stream) next() api.Event {
s.t.Helper()
type result struct {
ev api.Event
err error
}
ch := make(chan result, 1)
go func() {
id := ""
data := ""
for {
line, err := s.r.ReadString('\n')
if err != nil {
ch <- result{err: err}
return
}
line = strings.TrimRight(line, "\n")
switch {
case strings.HasPrefix(line, "id: "):
id = strings.TrimPrefix(line, "id: ")
case strings.HasPrefix(line, "data: "):
data = strings.TrimPrefix(line, "data: ")
case line == "" && data != "":
ev := api.Event{}
err := json.Unmarshal([]byte(data), &ev)
if err == nil && fmt.Sprint(ev.Seq) != id {
err = fmt.Errorf("sse id %s does not match seq %d", id, ev.Seq)
}
ch <- result{ev: ev, err: err}
return
}
}
}()
select {
case res := <-ch:
if res.err != nil {
s.t.Fatalf("read event: %v", res.err)
}
return res.ev
case <-time.After(5 * time.Second):
s.t.Fatalf("timed out waiting for event")
return api.Event{}
}
}
func (s *stream) close() {
s.cancel()
s.resp.Body.Close()
}
func repoWithOrigin(t *testing.T) string {
dir := t.TempDir()
origin := filepath.Join(dir, "origin.git")
repo := filepath.Join(dir, "repo")
gitOut(t, dir, "init", "--bare", "--initial-branch=main", origin)
gitOut(t, dir, "init", "--initial-branch=main", repo)
gitOut(t, repo, "remote", "add", "origin", origin)
err := os.WriteFile(filepath.Join(repo, "README"), []byte("hi\n"), 0o644)
if err != nil {
t.Fatalf("write readme: %v", err)
}
gitOut(t, repo, "add", "README")
gitOut(t, repo, "commit", "--message", "root")
gitOut(t, repo, "push", "origin", "main")
return repo
}
func gitOut(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=Test",
"GIT_AUTHOR_EMAIL=test@example.com",
"GIT_COMMITTER_NAME=Test",
"GIT_COMMITTER_EMAIL=test@example.com",
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
return strings.TrimSpace(string(out))
}
+91
View File
@@ -0,0 +1,91 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"time"
"g.fc.run/misc/byre/internal/api"
)
const backfillPage = 500
func (s *Server) StreamEvents(w http.ResponseWriter, r *http.Request, params api.StreamEventsParams) {
last := int64(0)
if params.After != nil {
last = *params.After
}
if params.LastEventID != nil {
last = *params.LastEventID
}
rc := http.NewResponseController(w)
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK)
live := s.hub.subscribe()
defer s.hub.unsubscribe(live)
for {
events, err := s.store.EventsAfter(r.Context(), last, backfillPage)
if err != nil {
return
}
for _, ev := range events {
if !writeEvent(w, ev) {
return
}
last = ev.Seq
}
if len(events) < backfillPage {
break
}
}
if rc.Flush() != nil {
return
}
ping := time.NewTicker(30 * time.Second)
defer ping.Stop()
for {
select {
case ev, ok := <-live:
if !ok {
return
}
if ev.Seq <= last {
continue
}
if !writeEvent(w, ev) {
return
}
last = ev.Seq
case <-ping.C:
_, err := fmt.Fprint(w, ": ping\n\n")
if err != nil {
return
}
case <-r.Context().Done():
return
}
if rc.Flush() != nil {
return
}
}
}
func writeEvent(w http.ResponseWriter, ev api.Event) bool {
data, err := json.Marshal(ev)
if err != nil {
return false
}
_, err = fmt.Fprintf(w, "id: %d\ndata: %s\n\n", ev.Seq, data)
return err == nil
}
+405
View File
@@ -0,0 +1,405 @@
package store
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
_ "modernc.org/sqlite"
"g.fc.run/misc/byre/internal/api"
)
var ErrNotFound = errors.New("not found")
const schema = `
CREATE TABLE IF NOT EXISTS users (
login TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
git_name TEXT NOT NULL DEFAULT '',
git_email TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
repo_path TEXT NOT NULL UNIQUE,
default_branch TEXT NOT NULL,
remote TEXT NOT NULL,
created_by TEXT NOT NULL REFERENCES users(login),
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
title TEXT NOT NULL,
branch TEXT NOT NULL,
worktree_path TEXT NOT NULL,
base_ref TEXT NOT NULL,
status TEXT NOT NULL,
created_by TEXT NOT NULL REFERENCES users(login),
created_at TEXT NOT NULL,
completed_at TEXT
);
CREATE INDEX IF NOT EXISTS tasks_project ON tasks(project_id, created_at);
CREATE TABLE IF NOT EXISTS events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
project_id TEXT,
task_id TEXT,
actor TEXT NOT NULL,
payload TEXT NOT NULL,
created_at TEXT NOT NULL
);
`
type Store struct {
db *sql.DB
path string
writeMu sync.Mutex
onEvent func(api.Event)
}
func Open(path string, onEvent func(api.Event)) (*Store, error) {
dsn := "file:" + path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_txlock=immediate"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
_, err = db.Exec(schema)
if err != nil {
db.Close()
return nil, fmt.Errorf("apply schema: %w", err)
}
return &Store{db: db, path: path, onEvent: onEvent}, nil
}
func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) Path() string {
return s.path
}
func NewID() string {
b := make([]byte, 6)
rand.Read(b)
return hex.EncodeToString(b)
}
func (s *Store) EnsureUser(ctx context.Context, login, displayName string) (api.User, error) {
_, err := s.db.ExecContext(ctx, `
INSERT INTO users (login, display_name, created_at) VALUES (?, ?, ?)
ON CONFLICT (login) DO UPDATE SET display_name = excluded.display_name
`, login, displayName, now())
if err != nil {
return api.User{}, err
}
return s.GetUser(ctx, login)
}
func (s *Store) GetUser(ctx context.Context, login string) (api.User, error) {
u := api.User{}
err := s.db.QueryRowContext(ctx, `SELECT login, display_name, git_name, git_email FROM users WHERE login = ?`, login).
Scan(&u.Login, &u.DisplayName, &u.GitName, &u.GitEmail)
if errors.Is(err, sql.ErrNoRows) {
return u, ErrNotFound
}
return u, err
}
func (s *Store) UpdateUserGit(ctx context.Context, login, gitName, gitEmail string) (api.User, error) {
err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
_, err := tx.ExecContext(ctx, `UPDATE users SET git_name = ?, git_email = ? WHERE login = ?`, gitName, gitEmail, login)
if err != nil {
return err
}
return emit(api.Event{Kind: "user.updated", Actor: login, Payload: map[string]any{"login": login}})
})
if err != nil {
return api.User{}, err
}
return s.GetUser(ctx, login)
}
func (s *Store) CreateProject(ctx context.Context, p api.Project) error {
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO projects (id, name, repo_path, default_branch, remote, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, p.Id, p.Name, p.RepoPath, p.DefaultBranch, p.Remote, p.CreatedBy, formatTime(p.CreatedAt))
if err != nil {
return err
}
return emit(api.Event{Kind: "project.created", ProjectId: &p.Id, Actor: p.CreatedBy, Payload: toPayload(p)})
})
}
func (s *Store) ListProjects(ctx context.Context) ([]api.Project, error) {
rows, err := s.db.QueryContext(ctx, `SELECT `+projectColumns+` FROM projects ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
projects := []api.Project{}
for rows.Next() {
p, err := scanProject(rows)
if err != nil {
return nil, err
}
projects = append(projects, p)
}
return projects, rows.Err()
}
func (s *Store) GetProject(ctx context.Context, id string) (api.Project, error) {
p, err := scanProject(s.db.QueryRowContext(ctx, `SELECT `+projectColumns+` FROM projects WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return p, ErrNotFound
}
return p, err
}
func (s *Store) CreateTask(ctx context.Context, t api.Task) error {
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO tasks (id, project_id, title, branch, worktree_path, base_ref, status, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, t.Id, t.ProjectId, t.Title, t.Branch, t.WorktreePath, t.BaseRef, string(t.Status), t.CreatedBy, formatTime(t.CreatedAt))
if err != nil {
return err
}
return emit(api.Event{Kind: "task.created", ProjectId: &t.ProjectId, TaskId: &t.Id, Actor: t.CreatedBy, Payload: toPayload(t)})
})
}
func (s *Store) ListTasks(ctx context.Context, projectID string) ([]api.Task, error) {
rows, err := s.db.QueryContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE project_id = ? ORDER BY created_at DESC`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
tasks := []api.Task{}
for rows.Next() {
t, err := scanTask(rows)
if err != nil {
return nil, err
}
tasks = append(tasks, t)
}
return tasks, rows.Err()
}
func (s *Store) GetTask(ctx context.Context, id string) (api.Task, error) {
t, err := scanTask(s.db.QueryRowContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return t, ErrNotFound
}
return t, err
}
func (s *Store) CompleteTask(ctx context.Context, id, actor string) (api.Task, error) {
err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
res, err := tx.ExecContext(ctx, `UPDATE tasks SET status = ?, completed_at = ? WHERE id = ? AND status = ?`,
string(api.Completed), now(), id, string(api.Active))
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
projectID := ""
err = tx.QueryRowContext(ctx, `SELECT project_id FROM tasks WHERE id = ?`, id).Scan(&projectID)
if err != nil {
return err
}
return emit(api.Event{Kind: "task.completed", ProjectId: &projectID, TaskId: &id, Actor: actor, Payload: map[string]any{"id": id}})
})
if err != nil {
return api.Task{}, err
}
return s.GetTask(ctx, id)
}
func (s *Store) EventsAfter(ctx context.Context, after int64, limit int) ([]api.Event, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT seq, kind, project_id, task_id, actor, payload, created_at
FROM events WHERE seq > ? ORDER BY seq LIMIT ?
`, after, limit)
if err != nil {
return nil, err
}
defer rows.Close()
events := []api.Event{}
for rows.Next() {
ev := api.Event{}
payload := ""
createdAt := ""
err := rows.Scan(&ev.Seq, &ev.Kind, &ev.ProjectId, &ev.TaskId, &ev.Actor, &payload, &createdAt)
if err != nil {
return nil, err
}
err = json.Unmarshal([]byte(payload), &ev.Payload)
if err != nil {
return nil, err
}
ev.CreatedAt, err = parseTime(createdAt)
if err != nil {
return nil, err
}
events = append(events, ev)
}
return events, rows.Err()
}
type Counts struct {
LastEventSeq int64
Projects int
ActiveTasks int
}
func (s *Store) Counts(ctx context.Context) (Counts, error) {
c := Counts{}
err := s.db.QueryRowContext(ctx, `
SELECT
(SELECT COALESCE(MAX(seq), 0) FROM events),
(SELECT COUNT(*) FROM projects),
(SELECT COUNT(*) FROM tasks WHERE status = ?)
`, string(api.Active)).Scan(&c.LastEventSeq, &c.Projects, &c.ActiveTasks)
return c, err
}
type emitFunc func(api.Event) error
func (s *Store) write(ctx context.Context, fn func(tx *sql.Tx, emit emitFunc) error) error {
s.writeMu.Lock()
defer s.writeMu.Unlock()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
events := []api.Event{}
emit := func(ev api.Event) error {
ev.CreatedAt = time.Now().UTC()
payload, err := json.Marshal(ev.Payload)
if err != nil {
return err
}
err = tx.QueryRowContext(ctx, `
INSERT INTO events (kind, project_id, task_id, actor, payload, created_at)
VALUES (?, ?, ?, ?, ?, ?) RETURNING seq
`, ev.Kind, ev.ProjectId, ev.TaskId, ev.Actor, string(payload), formatTime(ev.CreatedAt)).Scan(&ev.Seq)
if err != nil {
return err
}
events = append(events, ev)
return nil
}
err = fn(tx, emit)
if err != nil {
return err
}
err = tx.Commit()
if err != nil {
return err
}
for _, ev := range events {
s.onEvent(ev)
}
return nil
}
const projectColumns = `id, name, repo_path, default_branch, remote, created_by, created_at`
const taskColumns = `id, project_id, title, branch, worktree_path, base_ref, status, created_by, created_at, completed_at`
type scanner interface {
Scan(dest ...any) error
}
func scanProject(row scanner) (api.Project, error) {
p := api.Project{}
createdAt := ""
err := row.Scan(&p.Id, &p.Name, &p.RepoPath, &p.DefaultBranch, &p.Remote, &p.CreatedBy, &createdAt)
if err != nil {
return p, err
}
p.CreatedAt, err = parseTime(createdAt)
return p, err
}
func scanTask(row scanner) (api.Task, error) {
t := api.Task{}
status := ""
createdAt := ""
completedAt := sql.NullString{}
err := row.Scan(&t.Id, &t.ProjectId, &t.Title, &t.Branch, &t.WorktreePath, &t.BaseRef, &status, &t.CreatedBy, &createdAt, &completedAt)
if err != nil {
return t, err
}
t.Status = api.TaskStatus(status)
t.CreatedAt, err = parseTime(createdAt)
if err != nil {
return t, err
}
if completedAt.Valid {
ct, err := parseTime(completedAt.String)
if err != nil {
return t, err
}
t.CompletedAt = &ct
}
return t, nil
}
func toPayload(v any) map[string]any {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
m := map[string]any{}
err = json.Unmarshal(b, &m)
if err != nil {
panic(err)
}
return m
}
func now() string {
return formatTime(time.Now())
}
func formatTime(t time.Time) string {
return t.UTC().Format(time.RFC3339Nano)
}
func parseTime(s string) (time.Time, error) {
return time.Parse(time.RFC3339Nano, s)
}
+6
View File
@@ -0,0 +1,6 @@
package web
import "embed"
//go:embed index.html
var FS embed.FS
+42
View File
@@ -0,0 +1,42 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>byre</title>
<style>
body { font: 14px/1.4 -apple-system, system-ui, sans-serif; margin: 2rem; color: #222; }
h1 { margin: 0 0 1rem; }
section { margin-bottom: 1.5rem; }
pre { background: #f4f4f4; padding: .5rem; overflow-x: auto; }
#events div { font-family: ui-monospace, monospace; font-size: 12px; }
</style>
</head>
<body>
<h1>byre</h1>
<section><h2>Me</h2><pre id="me"></pre></section>
<section><h2>Projects</h2><pre id="projects"></pre></section>
<section><h2>Events</h2><div id="events"></div></section>
<script>
async function load(id, path) {
const res = await fetch(path);
document.getElementById(id).textContent = JSON.stringify(await res.json(), null, 2);
}
load("me", "/api/me");
load("projects", "/api/projects");
const events = document.getElementById("events");
const source = new EventSource("/api/events");
source.onmessage = (msg) => {
const ev = JSON.parse(msg.data);
const line = document.createElement("div");
line.textContent = `${ev.seq} ${ev.created_at} ${ev.actor} ${ev.kind} ${JSON.stringify(ev.payload)}`;
events.prepend(line);
if (ev.kind.startsWith("project.")) {
load("projects", "/api/projects");
}
};
</script>
</body>
</html>