diff --git a/.gitignore b/.gitignore index 17bb164..6e5e8d2 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,8 @@ squad-export.json .env .env.* !.env.example +# Local-only Langfuse init keys (not production); needed for live-traces.ps1 +!scripts/langfuse/.env *.pem *.key diff --git a/AGENTS.md b/AGENTS.md index e1805e7..d452065 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,6 +157,8 @@ go test ./... go build -o squad-oc.exe ./cmd/squad-oc ``` +Prefer Task when it is on PATH (`task --list`): `task test`, `task build`, `task ci`, `task release` (snapshot only), `task bump TAG=vX.Y.Z`, `task langfuse:up` / `task langfuse:down`, `task live:e2e` / `task live:traces`. Real publish is `task release:tag TAG=vX.Y.Z` on main then `task release:push`. Do not edit `internal/version/version.go`. Do not use this git clone as the live serve cwd. + Live OpenCode HTTP checks need a running `opencode serve` and use the `live` build tag. ## Architecture diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7350142..a819b2e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,13 +19,36 @@ go test ./... go build -o squad-oc ./cmd/squad-oc ``` -Requires **Go 1.26.6+**. Optional live check (dummy project only, never this clone as the serve cwd unless you mean to dogfood): +Or, with [Task](https://taskfile.dev) (`go install github.com/go-task/task/v3/cmd/task@latest`): + +```bash +task # list +task test # go test ./... +task build # go build -o squad-oc[.exe] +task ci # local CI gate (fmt/vet/lint/race/build/vuln/actionlint) +task tools # install lint/vuln/actionlint/goreleaser +task release:check # validate .goreleaser.yaml +task release # GoReleaser snapshot into dist/ (no tag, no publish) +task bump TAG=vX.Y.Z # pin Homebrew/Scoop/winget from dist/ (no PR) +task release:tag TAG=vX.Y.Z # annotated tag on main only (no push) +task release:push TAG=vX.Y.Z # push tag; GitHub release workflow publishes +task langfuse:up +task langfuse:down +task live:e2e +task live:traces +``` + +Do not edit `internal/version/version.go` to bump. Do not tag a feature branch. + +Requires **Go 1.26.6+**. Optional live checks (dummy project only, never this clone as the serve cwd unless you mean to dogfood): ```powershell ./scripts/live-e2e.ps1 +./scripts/live-traces.ps1 +# same as: task live:e2e / task live:traces ``` -That talks to `opencode serve` on `127.0.0.1:4096`. The TUI (`opencode`) is not the API. +`live-e2e.ps1` talks to `opencode serve` on `127.0.0.1:4096` (doctor + PONG). `live-traces.ps1` exercises JSONL spans and optional local Langfuse OTLP ingest. The TUI (`opencode`) is not the API. CI stays `go test ./...` without live env. `task ci` is the local mirror of `.github/workflows/ci.yml`. ## Branch and PR diff --git a/README.md b/README.md index 5c0b06b..cdf3ec0 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ OpenCode creates `.opencode/package.json` (`@opencode-ai/plugin`) and runs an in | `pack ` | One-shot pull of extra agents/skills | | `link ` / `link --sync` / `link --off` | Share one team directory across several repos (git URL clones into `~/.squad-oc/links/`) | | `update-check [--json] [--refresh]` | Prints `up to date` or `update available` vs GitHub latest tag | -| `traces [--last N] [--json] [--export file]` | Local `run` / `watch` spans; `--export` writes OTLP JSON | +| `traces [--last N] [--json] [--export file]` | Local `run` / `watch` spans (`.squad/traces/spans.jsonl`); `--export` writes OTLP JSON; optional live push via `OTEL_EXPORTER_OTLP_*` | | `mcp apply` / `list` / `init` | Merge org `.squad/mcp-config.json` into `opencode.json` | | `marketplace add` / `list` / `remove` / `browse` / `install` | Register a skills pack and copy a plugin into `.opencode/skills/` | | `plugin install @` / `list` / `uninstall ` | Named skill install; uninstall removes only `.opencode/skills//` | @@ -133,7 +133,7 @@ squad-oc (Go) `upgrade --self` downloads the latest GitHub Release for this OS/arch and replaces the running binary. On Windows, if the exe is locked, it writes `squad-oc.exe.new` beside it (`replaced on next start`). -`traces` lists local spans from `run` and `watch --execute`. `--export file` writes OTLP JSON any collector can ingest. +`traces` lists local spans from `run` and `watch --execute`. Default storage is `.squad/traces/spans.jsonl`. `--export file` writes OTLP JSON any collector can ingest. Set `OTEL_EXPORTER_OTLP_ENDPOINT` (or `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`) to push live during `run` / `watch --execute`. Protocols: `http/protobuf` (default) and `grpc` via `OTEL_EXPORTER_OTLP_PROTOCOL`. Prompt/completion bodies always land in local JSONL; OTel message attributes only when `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` is on (default off). Langfuse local example: endpoint `http://127.0.0.1:3000/api/public/otel`, Basic Auth in `OTEL_EXPORTER_OTLP_HEADERS`, plus header `x-langfuse-ingestion-version=4`. Aspire Dashboard remains a valid OTLP **consumer**, not a shipped `squad-oc` command. ### Share extra agents (`upstream` / `pack`) @@ -218,7 +218,7 @@ Original-Squad / Copilot-host pieces we are not building: - GitHub Copilot CLI / Copilot SDK - Interactive Ink/`squad` shell (use the OpenCode TUI) -- Aspire / .NET dashboard (traces are local OTLP JSON, not Aspire) +- Aspire / .NET dashboard (won’t ship; point any OTLP consumer at optional push or `traces --export`) ## Develop diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..9095a7a --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,191 @@ +# https://taskfile.dev — local CI + Langfuse/live helpers. +# Install: go install github.com/go-task/task/v3/cmd/task@latest +version: "3" + +vars: + BINARY: '{{if eq OS "windows"}}squad-oc.exe{{else}}squad-oc{{end}}' + COMPOSE: scripts/langfuse/docker-compose.yml + PWSH: '{{if eq OS "windows"}}powershell{{else}}pwsh{{end}}' + +tasks: + default: + desc: List tasks + silent: true + cmds: + - task --list + + tools: + desc: Install golangci-lint, govulncheck, actionlint, goreleaser (versions match CI) + cmds: + - go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 + - go install golang.org/x/vuln/cmd/govulncheck@v1.7.0 + - go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 + - go install github.com/goreleaser/goreleaser/v2@v2.17.1 + + tidy: + desc: go mod tidy + cmds: + - go mod tidy + + cover: + desc: Unit tests with coverage profile + cmds: + - go test -coverprofile=coverage.out ./... + - go tool cover -func=coverage.out + + test: + desc: Unit tests (no race, no live OpenCode) + cmds: + - go test ./... + + test:race: + desc: Unit tests with -race (needs a C toolchain on Windows) + cmds: + - go test -race -count=1 ./... + + vet: + desc: go vet + cmds: + - go vet ./... + + lint: + desc: golangci-lint (same timeout as CI) + cmds: + - golangci-lint run --timeout=4m + + fmt: + desc: Apply gofmt to all .go files + cmds: + - gofmt -w . + + fmt:check: + desc: Fail if gofmt would change files (Linux/macOS; CI). Skipped on Windows (CRLF false positive). + cmds: + - task: fmt:check:windows + - task: fmt:check:unix + + fmt:check:windows: + internal: true + platforms: [windows] + cmds: + - cmd /c echo skip gofmt -l on Windows CI still checks + + fmt:check:unix: + internal: true + platforms: [linux, darwin] + cmds: + - | + files=$(gofmt -l .) + if [ -n "$files" ]; then + echo unformatted: + echo "$files" + exit 1 + fi + + build: + desc: Build squad-oc + cmds: + - go build -o {{.BINARY}} ./cmd/squad-oc + + install: + desc: go install ./cmd/squad-oc onto GOPATH/bin + cmds: + - go install ./cmd/squad-oc + + clean: + desc: Remove local binary, dist/, and coverage.out + cmds: + - '{{if eq OS "windows"}}cmd /c if exist {{.BINARY}} del /q {{.BINARY}} & if exist dist rmdir /s /q dist & if exist coverage.out del /q coverage.out{{else}}rm -rf {{.BINARY}} dist coverage.out{{end}}' + + vuln: + desc: govulncheck + cmds: + - govulncheck ./... + + actionlint: + desc: Lint GitHub workflow YAML + cmds: + - actionlint + + ci: + desc: Local CI gate matching .github/workflows/ci.yml + deps: [fmt:check, vet, lint, test:race, build, vuln, actionlint] + + langfuse:up: + desc: Start local Langfuse v4 on 127.0.0.1:3000 + cmds: + - docker compose -f {{.COMPOSE}} up -d + + langfuse:down: + desc: Stop local Langfuse (keeps volumes) + cmds: + - docker compose -f {{.COMPOSE}} down + + langfuse:down:volumes: + desc: Stop local Langfuse and remove squad-oc-langfuse-* volumes + cmds: + - docker compose -f {{.COMPOSE}} down -v + + live:e2e: + desc: Dummy PONG check (D:\xAI\squad-oc-dummy only) + cmds: + - "{{.PWSH}} -File scripts/live-e2e.ps1" + + live:traces: + desc: Dummy traces + optional local Langfuse OTLP + cmds: + - "{{.PWSH}} -File scripts/live-traces.ps1" + + live:traces:skip-langfuse: + desc: Dummy traces only (no Langfuse) + cmds: + - "{{.PWSH}} -File scripts/live-traces.ps1 -SkipLangfuse" + + live:models: + desc: Dummy per-agent model pin check + cmds: + - "{{.PWSH}} -File scripts/live-models.ps1" + + release:check: + desc: Validate .goreleaser.yaml + cmds: + - goreleaser check + + release: + desc: Local GoReleaser snapshot into dist/ (no tag, no GitHub publish) + cmds: + - goreleaser release --snapshot --clean + + release:tag: + desc: Annotated tag TAG=vX.Y.Z on main HEAD only (does not push) + requires: + vars: [TAG] + cmds: + - task: release:tag:windows + - task: release:tag:unix + + release:tag:windows: + internal: true + platforms: [windows] + cmds: + - powershell -NoProfile -File scripts/release-tag.ps1 -Tag {{.TAG}} + + release:tag:unix: + internal: true + platforms: [linux, darwin] + cmds: + - bash scripts/release-tag.sh {{.TAG}} + + release:push: + desc: Push existing tag TAG=vX.Y.Z to origin (starts GitHub release workflow) + requires: + vars: [TAG] + cmds: + - git push origin {{.TAG}} + + bump: + desc: Pin Homebrew/Scoop/winget from dist/ (TAG=vX.Y.Z). Does not open a PR. + requires: + vars: [TAG] + cmds: + - bash -c "TAG='{{.TAG}}' REPO='{{.REPO | default "xeaser/squad-opencode"}}' DIST='{{.DIST | default "dist"}}' scripts/copy-packaging-from-dist.sh" diff --git a/docs/get-started.md b/docs/get-started.md index 54aad3b..1e82c3e 100644 --- a/docs/get-started.md +++ b/docs/get-started.md @@ -53,7 +53,7 @@ Inside OpenCode: 3. Paste your API key 4. Quit when done -Optional: pin a seat to an OpenCode id (`provider/model-id`) with `squad-oc cast --model squad xai/grok-3` or `cast --add NAME --model anthropic/claude-sonnet-4-5`. Empty cells inherit the Squad model, then the session default. This does not write `opencode.json`. +Optional: pin a seat to an OpenCode id (`provider/model-id`) with `squad-oc cast --model squad opencode/big-pickle` or `cast --add NAME --model opencode/hy3-free`. Use an id from `opencode models` on this machine — `xai/…` and `anthropic/…` only work after `/connect` for that provider. Empty cells inherit the Squad model, then the session default. This does not write `opencode.json`. --- @@ -186,7 +186,7 @@ The script builds `squad-oc.exe`, inits `D:\xAI\squad-oc-dummy` if needed, start - `squad-oc watch --health` prints the last Ralph snapshot - `squad-oc brief` prints the morning listing (PRs, tickets, last done, next). Soft if `gh` is missing. - `squad-oc watch --execute --overnight-start 18:00 --overnight-end 08:00` -- `squad-oc traces` lists local run/watch spans (`--export` writes OTLP JSON) +- `squad-oc traces` lists local run/watch spans (default: `.squad/traces/spans.jsonl`; `--export` writes OTLP JSON). File is the default; set `OTEL_EXPORTER_OTLP_ENDPOINT` (or traces-specific) to push during `run` / `watch --execute`. Protocols: `http/protobuf` (default) and `grpc` via `OTEL_EXPORTER_OTLP_PROTOCOL`. Bodies always in local JSONL; OTel message content only if `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` is on (default off). Langfuse local: endpoint `http://127.0.0.1:3000/api/public/otel`, Basic Auth in `OTEL_EXPORTER_OTLP_HEADERS`, header `x-langfuse-ingestion-version=4`. Aspire standalone can consume OTLP; it is not a shipped command. - `squad-oc run -p "…"` starts `opencode serve` on `127.0.0.1:4096` if nothing is there; a custom `--url` never auto-starts - `squad-oc pack ` or `squad-oc upstream add ` to pull extra agents/skills (see README) - `squad-oc mcp init` / `apply` / `list` — org `.squad/mcp-config.json` into `opencode.json` (workshop §8) diff --git a/docs/use-cases.md b/docs/use-cases.md index c9e3aae..623124b 100644 --- a/docs/use-cases.md +++ b/docs/use-cases.md @@ -65,7 +65,7 @@ Matches README **Non-goals** (plus the memory MCP the roadmap refuses): |------------------------------|---------| | GitHub Copilot CLI / Copilot SDK | Different host. OpenCode TUI + `opencode serve`. | | Interactive Ink / `squad` shell | Use the OpenCode TUI. | -| Aspire / .NET dashboard | `squad-oc traces` is local OTLP JSON, not Aspire. | +| Aspire / .NET dashboard | Won’t ship a dashboard. Local JSONL + optional OTLP push; Aspire standalone can still consume OTLP. | | `squad_state` memory MCP | OpenCode + `.squad/` files remain memory. | Also not product features (workshop may mention them as **example** org MCP servers after P1): WorkIQ, Outlook COM, Teams Adaptive Cards. @@ -101,7 +101,7 @@ Each original-Squad ease row is a **squad-oc command** you already ran, a **late | Snapshot team files | `export` / `import` | | Move team out of the worktree | `externalize` / `internalize` | | Context / PII hygiene | `nap` / `scrub-emails` | -| Local spans | `squad-oc traces` | +| Local spans / observability | `squad-oc traces` (`.squad/traces/spans.jsonl`) + optional OTLP push via `OTEL_EXPORTER_OTLP_ENDPOINT` | | Drop-in `mcp-config.json` | `squad-oc mcp init` / `apply` / `list` | | Marketplace browse + install | `squad-oc marketplace browse` / `install` | | Office / themed names | `squad-oc init --theme office` (native `@michael`) or later `cast --theme office` (mention map; `@lead` gone) | diff --git a/go.mod b/go.mod index 89c5228..6692d6d 100644 --- a/go.mod +++ b/go.mod @@ -2,11 +2,35 @@ module github.com/xeaser/squad-opencode go 1.26.6 -require github.com/sst/opencode-sdk-go v0.19.2 +require ( + github.com/sst/opencode-sdk-go v0.19.2 + go.opentelemetry.io/otel v1.46.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 + go.opentelemetry.io/otel/sdk v1.46.0 + go.opentelemetry.io/otel/trace v1.46.0 +) require ( + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect github.com/tidwall/gjson v1.14.4 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect + go.opentelemetry.io/otel/metric v1.46.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect + google.golang.org/grpc v1.83.1 // indirect + google.golang.org/protobuf v1.36.12 // indirect ) diff --git a/go.sum b/go.sum index 44b3211..40467e3 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,24 @@ +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= github.com/sst/opencode-sdk-go v0.19.2 h1:ffgQpE+ms4F0Wop/tT4tqTvFAbocyWYM8iy543b3Ous= github.com/sst/opencode-sdk-go v0.19.2/go.mod h1:rrpo5n0Be43y6tJ29TeMxH1/zeoDcB0D43nJh6gnL34= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -10,3 +29,43 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 h1:w53CDeOA/Kurp7yRsegSr6pbbr759dOvJ+yNmWM6Hxs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0/go.mod h1:BOmGMCbAtvcJiSJ+hLuhgPLdDbimnraSl8irz3iY8sY= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 h1:KrC1YrQeSt46ITMWAbgQx1M1eV1/1TKzttrBzymPmss= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0/go.mod h1:zDSEzoEqsOrgBeGvH66KRgxh90VonFyJqBHA0Pk3+rM= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= +go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= +go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI= +google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688/go.mod h1:1RJ9BQGyNdZwkGc1eTqkErfRZ6RJyYPHZo73BZ1vQqI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= +google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= +google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 5a7a78a..8d962af 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -132,7 +132,7 @@ Commands: link --sync link --off update-check [--json] [--refresh] - traces [--last N] [--json] [--export file] + traces [--last N] [--json] [--export file] # local spans.jsonl; OTEL_EXPORTER_OTLP_* optional push mcp apply | list | init marketplace add | list | remove | browse [name] | install [--from ] plugin install @ | list | uninstall @@ -593,6 +593,10 @@ func cmdRun(args []string) int { if code != 0 { return code } + if _, err := traces.ResolveSettings(squad.Detect(root).Config, os.Getenv); err != nil { + fmt.Fprintln(os.Stderr, err) + return 2 + } ensured, code := ensureAPI(apiURL, root) if code != 0 { return code @@ -671,6 +675,10 @@ func cmdWatch(args []string) int { if code != 0 { return code } + if _, err := traces.ResolveSettings(squad.Detect(root).Config, os.Getenv); err != nil { + fmt.Fprintln(os.Stderr, err) + return 2 + } backend, err := watch.ParseStateBackend(stateBackend, root) if err != nil { fmt.Fprintln(os.Stderr, err) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index c0de1b5..75d2304 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -123,6 +123,22 @@ func TestRunRequiresPrompt(t *testing.T) { } } +func TestRunWatchBadOTLPProtocolExit2(t *testing.T) { + root := t.TempDir() + prev, _ := os.Getwd() + if err := os.Chdir(root); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(prev) }) + t.Setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/json") + if Execute([]string{"run", "-p", "hi"}) != 2 { + t.Fatal("run bad protocol should be 2 before work") + } + if Execute([]string{"watch", "--once"}) != 2 { + t.Fatal("watch bad protocol should be 2 before work") + } +} + func TestTracesCLI(t *testing.T) { root := t.TempDir() prev, _ := os.Getwd() diff --git a/internal/opencodeclient/run.go b/internal/opencodeclient/run.go index 2b054df..1c77333 100644 --- a/internal/opencodeclient/run.go +++ b/internal/opencodeclient/run.go @@ -3,28 +3,39 @@ package opencodeclient import ( "context" "fmt" + "math" + "os" "strconv" "strings" "time" "github.com/sst/opencode-sdk-go" + "github.com/xeaser/squad-opencode/internal/squad" "github.com/xeaser/squad-opencode/internal/traces" ) // RunRequest is a non-interactive prompt. type RunRequest struct { - Directory string - Agent string - Prompt string - Title string + Directory string + Agent string + Prompt string + Title string + SkipRecord bool } // RunResult is the assistant text from a session. type RunResult struct { - SessionID string - Text string + SessionID string + Text string + HasGeneration bool + Provider, Model string + InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheWriteTokens int + Cost float64 } +// pushOTLP is the OTel export hook. TestMain no-ops it; collector-down tests replace it. +var pushOTLP = traces.Push + // Runner creates a session and sends a prompt. type Runner interface { Run(ctx context.Context, req RunRequest) (RunResult, error) @@ -39,7 +50,9 @@ type SDKRunner struct { func (r SDKRunner) Run(ctx context.Context, req RunRequest) (RunResult, error) { start := time.Now() res, err := r.run(ctx, req) - recordRun(req, start, err) + if !req.SkipRecord { + recordRun(req, start, err, res) + } return res, err } @@ -92,31 +105,50 @@ func (r SDKRunner) run(ctx context.Context, req RunRequest) (RunResult, error) { } } } - return RunResult{SessionID: sess.ID, Text: b.String()}, nil + res := RunResult{SessionID: sess.ID, Text: b.String(), HasGeneration: true} + if resp != nil { + res.Provider = resp.Info.ProviderID + res.Model = resp.Info.ModelID + res.Cost = resp.Info.Cost + res.InputTokens = int(math.Round(resp.Info.Tokens.Input)) + res.OutputTokens = int(math.Round(resp.Info.Tokens.Output)) + res.ReasoningTokens = int(math.Round(resp.Info.Tokens.Reasoning)) + res.CacheReadTokens = int(math.Round(resp.Info.Tokens.Cache.Read)) + res.CacheWriteTokens = int(math.Round(resp.Info.Tokens.Cache.Write)) + if res.SessionID == "" { + res.SessionID = resp.Info.SessionID + } + } + return res, nil } -func recordRun(req RunRequest, start time.Time, runErr error) { - if req.Directory == "" { - return - } - agent := req.Agent - if agent == "" { - agent = "squad" - } - status := "OK" - if runErr != nil { - status = "ERROR" - } - _ = traces.Append(req.Directory, traces.Span{ - Name: "squad-oc.run", - Start: start, - End: time.Now(), - Status: status, - Attributes: map[string]string{ - "agent": agent, - "prompt_bytes": strconv.Itoa(len(req.Prompt)), - }, - }) +func recordRun(req RunRequest, start time.Time, runErr error, res RunResult) { + s, err := traces.ResolveSettings(squad.Detect(req.Directory).Config, os.Getenv) + if err != nil { + s = traces.Settings{} + } + if err := traces.Write(req.Directory, traces.RecordInput{ + ParentName: "squad-oc.run", + Start: start, + End: time.Now(), + Err: runErr, + Agent: req.Agent, + Prompt: req.Prompt, + Completion: res.Text, + SessionID: res.SessionID, + Attrs: map[string]string{"prompt_bytes": strconv.Itoa(len(req.Prompt))}, + HasGeneration: res.HasGeneration, + Provider: res.Provider, + Model: res.Model, + InputTokens: res.InputTokens, + OutputTokens: res.OutputTokens, + ReasoningTokens: res.ReasoningTokens, + CacheReadTokens: res.CacheReadTokens, + CacheWriteTokens: res.CacheWriteTokens, + Cost: res.Cost, + }, s, pushOTLP); err != nil { + fmt.Fprintln(os.Stderr, "traces:", err) + } } // FakeRunner records calls for tests. @@ -136,5 +168,5 @@ func (f *FakeRunner) Run(_ context.Context, req RunRequest) (RunResult, error) { if text == "" { text = "ok: " + req.Prompt } - return RunResult{SessionID: "fake-session", Text: text}, nil + return RunResult{SessionID: "fake-session", Text: text, HasGeneration: true}, nil } diff --git a/internal/opencodeclient/run_test.go b/internal/opencodeclient/run_test.go index 0997d81..59e6df6 100644 --- a/internal/opencodeclient/run_test.go +++ b/internal/opencodeclient/run_test.go @@ -3,42 +3,79 @@ package opencodeclient import ( "context" "errors" + "os" "testing" "time" "github.com/xeaser/squad-opencode/internal/traces" ) +func TestMain(m *testing.M) { + pushOTLP = func(context.Context, traces.Settings, traces.Span, *traces.Span) error { + return nil + } + _ = os.Unsetenv("OTEL_EXPORTER_OTLP_ENDPOINT") + _ = os.Unsetenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") + os.Exit(m.Run()) +} + func TestFakeRunner(t *testing.T) { f := &FakeRunner{Text: "hello"} res, err := f.Run(context.Background(), RunRequest{Prompt: "p", Agent: "squad"}) if err != nil || res.Text != "hello" || len(f.Calls) != 1 { t.Fatalf("%+v %v", res, err) } + if !res.HasGeneration || res.SessionID != "fake-session" { + t.Fatalf("generation %+v", res) + } } func TestRecordRunSpan(t *testing.T) { root := t.TempDir() start := time.Now().Add(-20 * time.Millisecond) - recordRun(RunRequest{Directory: root, Agent: "lead", Prompt: "abcd"}, start, nil) + recordRun(RunRequest{Directory: root, Agent: "lead", Prompt: "abcd"}, start, nil, RunResult{ + SessionID: "ses_1", Text: "ok", + HasGeneration: true, Provider: "xai", Model: "grok-4", + InputTokens: 1, OutputTokens: 2, Cost: 0.1, + }) spans, err := traces.List(root, 10) - if err != nil || len(spans) != 1 { + if err != nil || len(spans) != 2 { t.Fatalf("%+v %v", spans, err) } - s := spans[0] - if s.Name != "squad-oc.run" || s.Status != "OK" { - t.Fatalf("%+v", s) + if spans[0].Name != "squad-oc.run" || spans[1].Name != traces.NameChat { + t.Fatalf("%+v", spans) } - if s.Attributes["agent"] != "lead" || s.Attributes["prompt_bytes"] != "4" { - t.Fatalf("attrs %+v", s.Attributes) + if spans[1].Model != "grok-4" || spans[1].Prompt != "abcd" || spans[1].Completion != "ok" { + t.Fatalf("child %+v", spans[1]) } - recordRun(RunRequest{Directory: root, Prompt: "xy"}, start, errors.New("boom")) + recordRun(RunRequest{Directory: root, Prompt: "xy"}, start, errors.New("boom"), RunResult{}) spans, err = traces.List(root, 10) - if err != nil || len(spans) != 2 { + if err != nil || len(spans) != 3 { t.Fatalf("%+v %v", spans, err) } - if spans[1].Status != "ERROR" || spans[1].Attributes["agent"] != "squad" { - t.Fatalf("%+v", spans[1]) + if spans[2].Name != "squad-oc.run" || spans[2].Status != "ERROR" { + t.Fatalf("%+v", spans[2]) + } +} + +func TestRecordRunCollectorFailureDoesNotFail(t *testing.T) { + root := t.TempDir() + t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:1") + prev := pushOTLP + t.Cleanup(func() { pushOTLP = prev }) + pushOTLP = func(context.Context, traces.Settings, traces.Span, *traces.Span) error { + return errors.New("collector down") + } + start := time.Now() + recordRun(RunRequest{Directory: root, Prompt: "p"}, start, nil, RunResult{ + SessionID: "s", Text: "ok", HasGeneration: true, + }) + spans, err := traces.List(root, 10) + if err != nil || len(spans) != 2 { + t.Fatalf("JSONL must still be written: %+v %v", spans, err) + } + if spans[0].Name != "squad-oc.run" || spans[1].Name != traces.NameChat { + t.Fatalf("%+v", spans) } } diff --git a/internal/squad/types.go b/internal/squad/types.go index ab1fb6f..bd88a9e 100644 --- a/internal/squad/types.go +++ b/internal/squad/types.go @@ -1,5 +1,12 @@ package squad +// OTLP is optional live-export knobs. Missing key = unset. Never store API keys here. +type OTLPConfig struct { + Endpoint string `json:"endpoint,omitempty"` + Protocol string `json:"protocol,omitempty"` + CaptureContent *bool `json:"capture_content,omitempty"` +} + // Config is stored at .squad/config.json and marks initialization. type Config struct { Version int `json:"version"` @@ -20,6 +27,8 @@ type Config struct { Theme string `json:"theme,omitempty"` // ThemeOrigin is how the theme was set: "init" or "applied". ThemeOrigin string `json:"themeOrigin,omitempty"` + // OTLP is optional live OTLP export settings (env wins at resolve time). + OTLP *OTLPConfig `json:"otlp,omitempty"` } // MentionRow is one row in .squad/mentions.md (slugs without @). diff --git a/internal/traces/otel.go b/internal/traces/otel.go new file mode 100644 index 0000000..b115539 --- /dev/null +++ b/internal/traces/otel.go @@ -0,0 +1,210 @@ +package traces + +import ( + "context" + "encoding/json" + "fmt" + "net" + "strings" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/trace" + + "github.com/xeaser/squad-opencode/internal/version" +) + +// NewExporterKind returns the protocol kind without starting an exporter. +func NewExporterKind(protocol string) (string, error) { + switch protocol { + case ProtocolGRPC: + return ProtocolGRPC, nil + case ProtocolHTTP, "": + return ProtocolHTTP, nil + default: + return "", fmt.Errorf("unknown OTLP protocol %q (want %s or %s)", protocol, ProtocolHTTP, ProtocolGRPC) + } +} + +// Push exports parent/child via the official OTel SDK. Empty endpoint is a no-op. +func Push(ctx context.Context, s Settings, parent Span, child *Span) error { + if s.Endpoint == "" { + return nil + } + exp, err := newExporter(ctx, s) + if err != nil { + return err + } + res := resource.NewSchemaless( + attribute.String("service.name", "squad-oc"), + attribute.String("service.version", version.Version), + ) + tp := sdktrace.NewTracerProvider( + sdktrace.WithSpanProcessor(sdktrace.NewSimpleSpanProcessor(exp)), + sdktrace.WithResource(res), + ) + recErr := recordWithTracer(ctx, tp.Tracer("squad-oc"), parent, child, s.Capture) + shutErr := tp.Shutdown(ctx) + if recErr != nil { + return recErr + } + return shutErr +} + +func newExporter(ctx context.Context, s Settings) (sdktrace.SpanExporter, error) { + kind, err := NewExporterKind(s.Protocol) + if err != nil { + return nil, err + } + if kind == ProtocolGRPC { + return newGRPCExporter(ctx, s.Endpoint) + } + return newHTTPExporter(ctx, s.Endpoint) +} + +func httpTracesURL(endpoint string) string { + if strings.HasSuffix(endpoint, "/v1/traces") { + return endpoint + } + return strings.TrimRight(endpoint, "/") + "/v1/traces" +} + +func newHTTPExporter(ctx context.Context, endpoint string) (sdktrace.SpanExporter, error) { + return otlptracehttp.New(ctx, otlptracehttp.WithEndpointURL(httpTracesURL(endpoint))) +} + +func newGRPCExporter(ctx context.Context, endpoint string) (sdktrace.SpanExporter, error) { + hostport, insecure := grpcTarget(endpoint) + opts := []otlptracegrpc.Option{otlptracegrpc.WithEndpoint(hostport)} + if insecure { + opts = append(opts, otlptracegrpc.WithInsecure()) + } + return otlptracegrpc.New(ctx, opts...) +} + +func grpcTarget(endpoint string) (hostport string, insecure bool) { + u := strings.TrimSpace(endpoint) + lower := strings.ToLower(u) + switch { + case strings.HasPrefix(lower, "http://"): + insecure = true + u = u[len("http://"):] + case strings.HasPrefix(lower, "https://"): + u = u[len("https://"):] + } + if i := strings.Index(u, "/"); i >= 0 { + u = u[:i] + } + host := u + if h, _, err := net.SplitHostPort(u); err == nil { + host = h + } + host = strings.Trim(host, "[]") + if strings.EqualFold(host, "localhost") || host == "127.0.0.1" || host == "::1" { + insecure = true + } + return u, insecure +} + +func recordWithTracer(ctx context.Context, tr trace.Tracer, parent Span, child *Span, capture bool) error { + pctx, pspan := tr.Start(ctx, parent.Name, startOpts(parent.Start, parentOTelAttrs(parent))...) + if strings.EqualFold(parent.Status, "ERROR") { + pspan.SetStatus(codes.Error, "") + } + if child != nil { + _, cspan := tr.Start(pctx, child.Name, startOpts(child.Start, childOTelAttrs(*child, capture))...) + if strings.EqualFold(child.Status, "ERROR") { + cspan.SetStatus(codes.Error, "") + } + endSpan(cspan, child.End) + } + endSpan(pspan, parent.End) + return nil +} + +func startOpts(start time.Time, attrs []attribute.KeyValue) []trace.SpanStartOption { + opts := make([]trace.SpanStartOption, 0, 2) + if !start.IsZero() { + opts = append(opts, trace.WithTimestamp(start)) + } + if len(attrs) > 0 { + opts = append(opts, trace.WithAttributes(attrs...)) + } + return opts +} + +func endSpan(sp trace.Span, end time.Time) { + if !end.IsZero() { + sp.End(trace.WithTimestamp(end)) + } else { + sp.End() + } +} + +func parentOTelAttrs(s Span) []attribute.KeyValue { + var attrs []attribute.KeyValue + if s.Agent != "" { + attrs = append(attrs, attribute.String("gen_ai.agent.name", s.Agent)) + } + if s.SessionID != "" { + attrs = append(attrs, attribute.String("gen_ai.conversation.id", s.SessionID)) + } + if v := s.Attributes["issues"]; v != "" { + attrs = append(attrs, attribute.String("issues", v)) + } + return attrs +} + +func childOTelAttrs(s Span, capture bool) []attribute.KeyValue { + attrs := []attribute.KeyValue{ + attribute.String("gen_ai.operation.name", "chat"), + attribute.Int64("gen_ai.usage.input_tokens", int64(s.InputTokens)), + attribute.Int64("gen_ai.usage.output_tokens", int64(s.OutputTokens)), + attribute.Float64("gen_ai.usage.cost", s.Cost), + } + if s.Provider != "" { + attrs = append(attrs, attribute.String("gen_ai.provider.name", s.Provider)) + } + if s.Model != "" { + attrs = append(attrs, attribute.String("gen_ai.request.model", s.Model)) + } + if s.ReasoningTokens != 0 { + attrs = append(attrs, attribute.Int64("gen_ai.usage.reasoning.output_tokens", int64(s.ReasoningTokens))) + } + if s.CacheReadTokens != 0 { + attrs = append(attrs, attribute.Int64("gen_ai.usage.cache_read.input_tokens", int64(s.CacheReadTokens))) + } + if s.CacheWriteTokens != 0 { + attrs = append(attrs, attribute.Int64("gen_ai.usage.cache_write.input_tokens", int64(s.CacheWriteTokens))) + } + if s.Agent != "" { + attrs = append(attrs, attribute.String("gen_ai.agent.name", s.Agent)) + } + if s.SessionID != "" { + attrs = append(attrs, attribute.String("gen_ai.conversation.id", s.SessionID)) + } + if capture { + attrs = append(attrs, + attribute.String("gen_ai.input.messages", messagesJSON("user", s.Prompt)), + attribute.String("gen_ai.output.messages", messagesJSON("assistant", s.Completion)), + ) + } + return attrs +} + +func messagesJSON(role, content string) string { + type msg struct { + Role string `json:"role"` + Content string `json:"content"` + } + b, err := json.Marshal([]msg{{Role: role, Content: content}}) + if err != nil { + return "" + } + return string(b) +} diff --git a/internal/traces/otel_test.go b/internal/traces/otel_test.go new file mode 100644 index 0000000..43296ad --- /dev/null +++ b/internal/traces/otel_test.go @@ -0,0 +1,225 @@ +package traces + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "go.opentelemetry.io/otel/attribute" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" +) + +func TestBuildParentChildAndParentOnly(t *testing.T) { + start := time.Date(2026, 8, 27, 15, 0, 0, 0, time.UTC) + parent, child := Build(RecordInput{ + ParentName: "squad-oc.run", + Start: start, + End: start.Add(time.Second), + Agent: "lead", + Prompt: "hi", + Completion: "yo", + SessionID: "ses_1", + Attrs: map[string]string{"agent": "lead", "prompt_bytes": "2"}, + HasGeneration: true, + Provider: "xai", + Model: "grok-4", + InputTokens: 1, + OutputTokens: 2, + Cost: 0, + }) + if parent.Name != "squad-oc.run" || child == nil || child.Name != NameChat { + t.Fatalf("%+v %+v", parent, child) + } + if child.TraceID != parent.TraceID || child.ParentID != parent.SpanID { + t.Fatal("tree") + } + if child.Prompt != "hi" || child.Cost != 0 || child.Model != "grok-4" { + t.Fatalf("child %+v", child) + } + + parent, child = Build(RecordInput{ParentName: "squad-oc.run", Err: errors.New("boom"), Agent: "squad"}) + if parent.Status != "ERROR" || child != nil { + t.Fatalf("fail %+v %v", parent, child) + } +} + +func TestPushEmptyEndpointNoop(t *testing.T) { + if err := Push(context.Background(), Settings{}, Span{Name: "squad-oc.run"}, nil); err != nil { + t.Fatal(err) + } +} + +func TestWriteAppendsAndReturnsPushError(t *testing.T) { + root := t.TempDir() + start := time.Date(2026, 8, 27, 16, 0, 0, 0, time.UTC) + pushErr := errors.New("collector down") + err := Write(root, RecordInput{ + ParentName: "squad-oc.run", + Start: start, + End: start.Add(time.Millisecond), + Agent: "lead", + Prompt: "hi", + Completion: "yo", + SessionID: "ses_1", + HasGeneration: true, + Model: "grok-4", + }, Settings{Endpoint: "http://127.0.0.1:1"}, func(context.Context, Settings, Span, *Span) error { + return pushErr + }) + if !errors.Is(err, pushErr) { + t.Fatalf("want push error, got %v", err) + } + if !strings.Contains(err.Error(), "otlp push:") { + t.Fatalf("want otlp push wrap, got %v", err) + } + spans, err := List(root, 10) + if err != nil || len(spans) != 2 { + t.Fatalf("JSONL %+v %v", spans, err) + } + if spans[0].Name != "squad-oc.run" || spans[1].Name != NameChat { + t.Fatalf("%+v", spans) + } + + empty := t.TempDir() + if err := Write(empty, RecordInput{ParentName: "squad-oc.run"}, Settings{}, nil); err != nil { + t.Fatal(err) + } + spans, err = List(empty, 10) + if err != nil || len(spans) != 1 { + t.Fatalf("no-endpoint still JSONL: %+v %v", spans, err) + } +} + +func TestWriteWrapsAppendError(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, ".squad"), []byte("not-a-dir"), 0o644); err != nil { + t.Fatal(err) + } + err := Write(root, RecordInput{ParentName: "squad-oc.run"}, Settings{}, nil) + if err == nil || !strings.Contains(err.Error(), "append:") { + t.Fatalf("want append wrap, got %v", err) + } +} + +func TestRecordToSpanRecorderTypedAttrs(t *testing.T) { + rec := tracetest.NewSpanRecorder() + tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(rec)) + t.Cleanup(func() { _ = tp.Shutdown(context.Background()) }) + parent, child := Build(RecordInput{ + ParentName: "squad-oc.run", Agent: "squad", SessionID: "ses_1", + HasGeneration: true, Provider: "xai", Model: "grok-4", + InputTokens: 3, OutputTokens: 4, Cost: 0.5, + Prompt: "P", Completion: "C", + }) + if err := recordWithTracer(context.Background(), tp.Tracer("squad-oc"), parent, child, false); err != nil { + t.Fatal(err) + } + spans := rec.Ended() + if len(spans) != 2 { + t.Fatalf("len=%d", len(spans)) + } + // order: child ends first if started as child; accept either order, find by name + var gen sdktrace.ReadOnlySpan + for _, sp := range spans { + if sp.Name() == NameChat { + gen = sp + } + } + if gen == nil { + t.Fatal("missing gen_ai.chat") + } + attrs := attrMap(gen.Attributes()) + if attrs["gen_ai.request.model"] != "grok-4" { + t.Fatalf("%v", attrs) + } + if _, ok := attrs["gen_ai.input.messages"]; ok { + t.Fatal("capture off") + } + // tokens must be int64-typed on the span (check via attribute.KeyValue) + if !hasInt(gen.Attributes(), "gen_ai.usage.input_tokens", 3) { + t.Fatalf("tokens %+v", gen.Attributes()) + } +} + +func TestRecordToSpanRecorderCaptureOn(t *testing.T) { + rec := tracetest.NewSpanRecorder() + tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(rec)) + t.Cleanup(func() { _ = tp.Shutdown(context.Background()) }) + parent, child := Build(RecordInput{ + ParentName: "squad-oc.run", Agent: "squad", SessionID: "ses_1", + HasGeneration: true, Provider: "xai", Model: "grok-4", + InputTokens: 3, OutputTokens: 4, Cost: 0.5, + Prompt: "P", Completion: "C", + }) + if err := recordWithTracer(context.Background(), tp.Tracer("squad-oc"), parent, child, true); err != nil { + t.Fatal(err) + } + var gen sdktrace.ReadOnlySpan + for _, sp := range rec.Ended() { + if sp.Name() == NameChat { + gen = sp + } + } + if gen == nil { + t.Fatal("missing gen_ai.chat") + } + attrs := attrMap(gen.Attributes()) + inMsg, ok := attrs["gen_ai.input.messages"] + if !ok || !strings.Contains(inMsg, "P") { + t.Fatalf("input messages %v", attrs) + } + outMsg, ok := attrs["gen_ai.output.messages"] + if !ok || !strings.Contains(outMsg, "C") { + t.Fatalf("output messages %v", attrs) + } +} + +func TestHTTPExporterHitsTestServer(t *testing.T) { + var got []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + got = b + w.WriteHeader(200) + })) + t.Cleanup(srv.Close) + parent, child := Build(RecordInput{ParentName: "squad-oc.run", HasGeneration: true, Model: "m"}) + err := Push(context.Background(), Settings{Endpoint: srv.URL, Protocol: ProtocolHTTP}, parent, child) + if err != nil { + t.Fatal(err) + } + if len(got) == 0 { + t.Fatal("no protobuf posted") + } +} + +func TestExporterKindGRPC(t *testing.T) { + k, err := NewExporterKind(ProtocolGRPC) + if err != nil || k != ProtocolGRPC { + t.Fatalf("%s %v", k, err) + } +} + +func attrMap(kvs []attribute.KeyValue) map[string]string { + m := make(map[string]string, len(kvs)) + for _, kv := range kvs { + m[string(kv.Key)] = kv.Value.String() + } + return m +} + +func hasInt(kvs []attribute.KeyValue, key string, want int64) bool { + for _, kv := range kvs { + if string(kv.Key) == key && kv.Value.Type() == attribute.INT64 && kv.Value.AsInt64() == want { + return true + } + } + return false +} diff --git a/internal/traces/record.go b/internal/traces/record.go new file mode 100644 index 0000000..42c2024 --- /dev/null +++ b/internal/traces/record.go @@ -0,0 +1,117 @@ +package traces + +import ( + "context" + "fmt" + "time" +) + +// RecordInput is the shared parent+child builder used by run and watch. +type RecordInput struct { + ParentName string + Start, End time.Time + Err error + Agent string + Prompt string + Completion string + SessionID string + Attrs map[string]string + // Generation set when Session.Prompt returned (even if Info empty). + HasGeneration bool + Provider string + Model string + InputTokens, OutputTokens, ReasoningTokens, CacheReadTokens, CacheWriteTokens int + Cost float64 +} + +// Build constructs the parent span and optional gen_ai.chat child. It does not write JSONL. +func Build(in RecordInput) (parent Span, child *Span) { + agent := in.Agent + if agent == "" { + agent = "squad" + } + status := "OK" + if in.Err != nil { + status = "ERROR" + } + attrs := make(map[string]string, len(in.Attrs)+1) + for k, v := range in.Attrs { + attrs[k] = v + } + if _, ok := attrs["agent"]; !ok { + attrs["agent"] = agent + } + + parent = Span{ + Name: in.ParentName, + Start: in.Start, + End: in.End, + Status: status, + Attributes: attrs, + SessionID: in.SessionID, + Agent: agent, + } + if parent.TraceID == "" { + if id, err := newHex(16); err == nil { + parent.TraceID = id + } + } + if parent.SpanID == "" { + if id, err := newHex(8); err == nil { + parent.SpanID = id + } + } + if !in.HasGeneration { + return parent, nil + } + cid, _ := newHex(8) + c := Span{ + Name: NameChat, + TraceID: parent.TraceID, + SpanID: cid, + ParentID: parent.SpanID, + Start: in.Start, + End: in.End, + Status: "OK", + SessionID: in.SessionID, + Agent: agent, + Provider: in.Provider, + Model: in.Model, + InputTokens: in.InputTokens, + OutputTokens: in.OutputTokens, + ReasoningTokens: in.ReasoningTokens, + CacheReadTokens: in.CacheReadTokens, + CacheWriteTokens: in.CacheWriteTokens, + Cost: in.Cost, + Prompt: in.Prompt, + Completion: in.Completion, + } + return parent, &c +} + +// Write appends parent (and child if non-nil) when projectRoot != "". +// If s.Endpoint != "" it then calls push (default Push). Errors are wrapped +// as "append: …" or "otlp push: …" so callers can log one stderr line. +func Write(projectRoot string, in RecordInput, s Settings, push func(context.Context, Settings, Span, *Span) error) error { + parent, child := Build(in) + if projectRoot != "" { + if err := Append(projectRoot, parent); err != nil { + return fmt.Errorf("append: %w", err) + } + if child != nil { + if err := Append(projectRoot, *child); err != nil { + return fmt.Errorf("append: %w", err) + } + } + } + if s.Endpoint == "" { + return nil + } + if push == nil { + push = Push + } + if err := push(context.Background(), s, parent, child); err != nil { + return fmt.Errorf("otlp push: %w", err) + } + return nil +} diff --git a/internal/traces/settings.go b/internal/traces/settings.go new file mode 100644 index 0000000..1646939 --- /dev/null +++ b/internal/traces/settings.go @@ -0,0 +1,69 @@ +package traces + +import ( + "fmt" + "strings" + + "github.com/xeaser/squad-opencode/internal/squad" +) + +const ( + ProtocolHTTP = "http/protobuf" + ProtocolGRPC = "grpc" +) + +type Settings struct { + Endpoint string + Protocol string + Capture bool +} + +func ParseCapture(v string) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case "true", "1", "yes": + return true + default: + return false + } +} + +func ResolveSettings(cfg *squad.Config, getenv func(string) string) (Settings, error) { + if getenv == nil { + getenv = func(string) string { return "" } + } + s := Settings{Protocol: ProtocolHTTP} + + if v := firstNonEmpty(getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"), getenv("OTEL_EXPORTER_OTLP_ENDPOINT")); v != "" { + s.Endpoint = v + } else if cfg != nil && cfg.OTLP != nil { + s.Endpoint = strings.TrimSpace(cfg.OTLP.Endpoint) + } + + if v := firstNonEmpty(getenv("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL"), getenv("OTEL_EXPORTER_OTLP_PROTOCOL")); v != "" { + s.Protocol = v + } else if cfg != nil && cfg.OTLP != nil && strings.TrimSpace(cfg.OTLP.Protocol) != "" { + s.Protocol = strings.TrimSpace(cfg.OTLP.Protocol) + } + + if v := getenv("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"); v != "" { + s.Capture = ParseCapture(v) + } else if cfg != nil && cfg.OTLP != nil && cfg.OTLP.CaptureContent != nil { + s.Capture = *cfg.OTLP.CaptureContent + } + + switch s.Protocol { + case ProtocolHTTP, ProtocolGRPC: + default: + return Settings{}, fmt.Errorf("unknown OTLP protocol %q (want %s or %s)", s.Protocol, ProtocolHTTP, ProtocolGRPC) + } + return s, nil +} + +func firstNonEmpty(vs ...string) string { + for _, v := range vs { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} diff --git a/internal/traces/settings_test.go b/internal/traces/settings_test.go new file mode 100644 index 0000000..ff5c2da --- /dev/null +++ b/internal/traces/settings_test.go @@ -0,0 +1,80 @@ +package traces + +import ( + "strings" + "testing" + + "github.com/xeaser/squad-opencode/internal/squad" +) + +func TestResolveSettingsEnvWinsAndDefaults(t *testing.T) { + capTrue := true + cfg := &squad.Config{OTLP: &squad.OTLPConfig{ + Endpoint: "http://from-file:4318", + Protocol: "grpc", + CaptureContent: &capTrue, + }} + env := map[string]string{ + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT": "http://from-traces-env:4318/v1/traces", + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://from-generic-env:4318", + "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL": "http/protobuf", + "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc", + "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "false", + } + getenv := func(k string) string { return env[k] } + s, err := ResolveSettings(cfg, getenv) + if err != nil { + t.Fatal(err) + } + if s.Endpoint != "http://from-traces-env:4318/v1/traces" { + t.Fatalf("endpoint %q", s.Endpoint) + } + if s.Protocol != "http/protobuf" { + t.Fatalf("protocol %q", s.Protocol) + } + if s.Capture { + t.Fatal("capture env false must win") + } + + s, err = ResolveSettings(nil, func(string) string { return "" }) + if err != nil { + t.Fatal(err) + } + if s.Endpoint != "" || s.Protocol != "http/protobuf" || s.Capture { + t.Fatalf("defaults %+v", s) + } +} + +func TestResolveSettingsConfigWhenEnvEmpty(t *testing.T) { + cfg := &squad.Config{OTLP: &squad.OTLPConfig{ + Endpoint: "http://127.0.0.1:3000/api/public/otel", + Protocol: "http/protobuf", + }} + s, err := ResolveSettings(cfg, func(string) string { return "" }) + if err != nil { + t.Fatal(err) + } + if s.Endpoint != "http://127.0.0.1:3000/api/public/otel" || s.Capture { + t.Fatalf("%+v", s) + } +} + +func TestResolveSettingsBadProtocol(t *testing.T) { + _, err := ResolveSettings(&squad.Config{OTLP: &squad.OTLPConfig{Protocol: "http/json"}}, func(string) string { return "" }) + if err == nil || !strings.Contains(err.Error(), "protocol") { + t.Fatalf("got %v", err) + } +} + +func TestParseCapture(t *testing.T) { + for _, v := range []string{"true", "TRUE", "1", "yes", "Yes"} { + if !ParseCapture(v) { + t.Fatalf("%q should be on", v) + } + } + for _, v := range []string{"", "false", "0", "no", "off"} { + if ParseCapture(v) { + t.Fatalf("%q should be off", v) + } + } +} diff --git a/internal/traces/traces.go b/internal/traces/traces.go index cd0434f..c57bf86 100644 --- a/internal/traces/traces.go +++ b/internal/traces/traces.go @@ -16,15 +16,32 @@ import ( "github.com/xeaser/squad-opencode/internal/squad" ) +// NameChat is the OTel gen_ai operation span name for chat generations. +const NameChat = "gen_ai.chat" + // Span is a local recorded interval. type Span struct { Name string `json:"name"` TraceID string `json:"traceId"` SpanID string `json:"spanId"` + ParentID string `json:"parentSpanId,omitempty"` Start time.Time `json:"start"` End time.Time `json:"end"` Status string `json:"status"` // OK | ERROR Attributes map[string]string `json:"attributes"` + + SessionID string `json:"sessionId,omitempty"` + Agent string `json:"agent,omitempty"` + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` + InputTokens int `json:"inputTokens,omitempty"` + OutputTokens int `json:"outputTokens,omitempty"` + ReasoningTokens int `json:"reasoningTokens,omitempty"` + CacheReadTokens int `json:"cacheReadTokens,omitempty"` + CacheWriteTokens int `json:"cacheWriteTokens,omitempty"` + Cost float64 `json:"cost,omitempty"` + Prompt string `json:"prompt,omitempty"` + Completion string `json:"completion,omitempty"` } // Dir is the traces folder under the resolved team directory. @@ -113,15 +130,35 @@ func FormatTable(spans []Span) string { return "(no traces)\n" } var b strings.Builder - b.WriteString("NAME\tSTATUS\tSTART\tDURATION\tATTRIBUTES\n") + b.WriteString("NAME\tSTATUS\tSTART\tDURATION\tMODEL\tTOKENS\tCOST\tATTRIBUTES\n") + var costSum float64 + var inSum, outSum, chatSpans int for _, s := range spans { dur := s.End.Sub(s.Start) if dur < 0 { dur = 0 } - fmt.Fprintf(&b, "%s\t%s\t%s\t%s\t%s\n", - s.Name, s.Status, s.Start.UTC().Format(time.RFC3339), dur.Round(time.Millisecond), formatAttrs(s.Attributes)) + model := s.Model + if model == "" { + model = "-" + } + tokens := "-" + if s.InputTokens != 0 || s.OutputTokens != 0 || s.Name == NameChat { + tokens = fmt.Sprintf("%d/%d", s.InputTokens, s.OutputTokens) + } + cost := "-" + if s.Name == NameChat { + cost = fmt.Sprintf("$%g", s.Cost) + costSum += s.Cost + inSum += s.InputTokens + outSum += s.OutputTokens + chatSpans++ + } + fmt.Fprintf(&b, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + s.Name, s.Status, s.Start.UTC().Format(time.RFC3339), dur.Round(time.Millisecond), + model, tokens, cost, formatAttrs(s.Attributes)) } + fmt.Fprintf(&b, "Cost: $%g in=%d out=%d spans=%d\n", costSum, inSum, outSum, chatSpans) return b.String() } @@ -175,7 +212,7 @@ func toOTLPSpans(spans []Span) []otlpSpan { if strings.EqualFold(s.Status, "ERROR") { code = 2 } - attrs := make([]otlpKeyValue, 0, len(s.Attributes)) + attrs := make([]otlpKeyValue, 0, len(s.Attributes)+12) keys := make([]string, 0, len(s.Attributes)) for k := range s.Attributes { keys = append(keys, k) @@ -184,9 +221,11 @@ func toOTLPSpans(spans []Span) []otlpSpan { for _, k := range keys { attrs = append(attrs, stringAttr(k, s.Attributes[k])) } + attrs = appendGenAIExportAttrs(attrs, s) out = append(out, otlpSpan{ TraceID: s.TraceID, SpanID: s.SpanID, + ParentSpanID: s.ParentID, Name: s.Name, Kind: 1, StartTimeUnixNano: unixNanoString(s.Start), @@ -198,6 +237,45 @@ func toOTLPSpans(spans []Span) []otlpSpan { return out } +// appendGenAIExportAttrs adds gen_ai.* metadata from first-class fields. +// Never includes Prompt, Completion, or message payloads. +func appendGenAIExportAttrs(attrs []otlpKeyValue, s Span) []otlpKeyValue { + if s.Name == NameChat { + attrs = append(attrs, stringAttr("gen_ai.operation.name", "chat")) + } + if s.Provider != "" { + attrs = append(attrs, stringAttr("gen_ai.provider.name", s.Provider)) + } + if s.Model != "" { + attrs = append(attrs, stringAttr("gen_ai.request.model", s.Model)) + } + if s.Agent != "" { + attrs = append(attrs, stringAttr("gen_ai.agent.name", s.Agent)) + } + if s.SessionID != "" { + attrs = append(attrs, stringAttr("gen_ai.conversation.id", s.SessionID)) + } + if s.InputTokens != 0 { + attrs = append(attrs, stringAttr("gen_ai.usage.input_tokens", fmt.Sprintf("%d", s.InputTokens))) + } + if s.OutputTokens != 0 { + attrs = append(attrs, stringAttr("gen_ai.usage.output_tokens", fmt.Sprintf("%d", s.OutputTokens))) + } + if s.ReasoningTokens != 0 { + attrs = append(attrs, stringAttr("gen_ai.usage.reasoning.output_tokens", fmt.Sprintf("%d", s.ReasoningTokens))) + } + if s.CacheReadTokens != 0 { + attrs = append(attrs, stringAttr("gen_ai.usage.cache_read.input_tokens", fmt.Sprintf("%d", s.CacheReadTokens))) + } + if s.CacheWriteTokens != 0 { + attrs = append(attrs, stringAttr("gen_ai.usage.cache_write.input_tokens", fmt.Sprintf("%d", s.CacheWriteTokens))) + } + if s.Name == NameChat { + attrs = append(attrs, stringAttr("gen_ai.usage.cost", fmt.Sprintf("%g", s.Cost))) + } + return attrs +} + func stringAttr(key, value string) otlpKeyValue { return otlpKeyValue{Key: key, Value: otlpValue{StringValue: value}} } @@ -239,6 +317,7 @@ type otlpScope struct { type otlpSpan struct { TraceID string `json:"traceId"` SpanID string `json:"spanId"` + ParentSpanID string `json:"parentSpanId,omitempty"` Name string `json:"name"` Kind int `json:"kind"` StartTimeUnixNano string `json:"startTimeUnixNano"` diff --git a/internal/traces/traces_test.go b/internal/traces/traces_test.go index 6ce5e85..de6e7ea 100644 --- a/internal/traces/traces_test.go +++ b/internal/traces/traces_test.go @@ -163,6 +163,142 @@ func TestFormatTable(t *testing.T) { } } +func TestAppendListParentChildAndBodies(t *testing.T) { + root := t.TempDir() + start := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) + parent := Span{ + Name: "squad-oc.run", + TraceID: "aabbccddeeff00112233445566778899", + SpanID: "1122334455667788", + Start: start, + End: start.Add(time.Second), + Status: "OK", + Agent: "lead", + Attributes: map[string]string{"agent": "lead", "prompt_bytes": "5"}, + } + child := Span{ + Name: NameChat, + TraceID: parent.TraceID, + SpanID: "99aabbccddeeff00", + ParentID: parent.SpanID, + Start: start, + End: start.Add(time.Second), + Status: "OK", + SessionID: "ses_1", + Agent: "lead", + Provider: "xai", + Model: "grok-4", + InputTokens: 12, + OutputTokens: 34, + Cost: 0.0025, + Prompt: "hello", + Completion: "world", + } + if err := Append(root, parent); err != nil { + t.Fatal(err) + } + if err := Append(root, child); err != nil { + t.Fatal(err) + } + got, err := List(root, 0) + if err != nil || len(got) != 2 { + t.Fatalf("%+v %v", got, err) + } + if got[0].Name != "squad-oc.run" || got[1].Name != NameChat { + t.Fatalf("%+v", got) + } + if got[1].ParentID != parent.SpanID || got[1].Prompt != "hello" || got[1].Completion != "world" { + t.Fatalf("child %+v", got[1]) + } + if got[1].Model != "grok-4" || got[1].InputTokens != 12 || got[1].Cost != 0.0025 { + t.Fatalf("usage %+v", got[1]) + } +} + +func TestFormatTableModelTokensCostAndFooter(t *testing.T) { + start := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) + parent := sampleSpan("squad-oc.run", start, map[string]string{"agent": "squad"}) + parent.Agent = "squad" + child := sampleSpan(NameChat, start.Add(time.Second), nil) + child.ParentID = parent.SpanID + child.Model = "grok-4" + child.InputTokens = 10 + child.OutputTokens = 20 + child.Cost = 0.01 + child.Prompt = "hello" + child.Completion = "world" + out := FormatTable([]Span{parent, child}) + for _, want := range []string{"NAME", "MODEL", "TOKENS", "COST", "squad-oc.run", NameChat, "grok-4", "10/20"} { + if !strings.Contains(out, want) { + t.Fatalf("missing %q in\n%s", want, out) + } + } + if strings.Contains(out, "hello") || strings.Contains(out, "world") { + t.Fatal("must not print bodies") + } + if !strings.Contains(out, "Cost:") || !strings.Contains(out, "in=10") || !strings.Contains(out, "out=20") || !strings.Contains(out, "spans=1") { + t.Fatalf("footer: %s", out) + } + // parents must not double-count + parent.Cost = 9 + parent.InputTokens = 99 + out = FormatTable([]Span{parent, child}) + if !strings.Contains(out, "spans=1") || strings.Contains(out, "in=109") { + t.Fatalf("parent leaked into footer: %s", out) + } +} + +func TestExportOTLPHasGenAINoBodies(t *testing.T) { + start := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) + child := sampleSpan(NameChat, start, map[string]string{"agent": "squad"}) + child.ParentID = "1122334455667788" + child.SessionID = "ses_1" + child.Agent = "squad" + child.Provider = "xai" + child.Model = "grok-4" + child.InputTokens = 3 + child.OutputTokens = 4 + child.ReasoningTokens = 1 + child.CacheReadTokens = 2 + child.CacheWriteTokens = 5 + child.Cost = 0 + child.Prompt = "SECRET_PROMPT" + child.Completion = "SECRET_COMPLETION" + dest := filepath.Join(t.TempDir(), "otlp.json") + if err := ExportOTLPFile([]Span{child}, dest); err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(dest) + if err != nil { + t.Fatal(err) + } + s := string(body) + for _, want := range []string{ + `"parentSpanId"`, + "gen_ai.operation.name", + "gen_ai.provider.name", + "gen_ai.request.model", + "gen_ai.usage.input_tokens", + "gen_ai.usage.output_tokens", + "gen_ai.usage.reasoning.output_tokens", + "gen_ai.usage.cache_read.input_tokens", + "gen_ai.usage.cache_write.input_tokens", + "gen_ai.usage.cost", + "gen_ai.agent.name", + "gen_ai.conversation.id", + } { + if !strings.Contains(s, want) { + t.Fatalf("missing %q in %s", want, s) + } + } + if strings.Contains(s, "SECRET_PROMPT") || strings.Contains(s, "SECRET_COMPLETION") { + t.Fatal("export leaked bodies") + } + if strings.Contains(s, "gen_ai.input.messages") || strings.Contains(s, "gen_ai.output.messages") { + t.Fatal("export must not include messages") + } +} + func TestInitGitignoreIgnoresTraces(t *testing.T) { root := t.TempDir() if _, err := squad.WriteDefaultPreset(squad.InitOptions{ProjectRoot: root}); err != nil { diff --git a/internal/watch/watch.go b/internal/watch/watch.go index a93503c..ba28714 100644 --- a/internal/watch/watch.go +++ b/internal/watch/watch.go @@ -16,6 +16,9 @@ import ( "github.com/xeaser/squad-opencode/internal/traces" ) +// pushOTLP is the OTel export hook. TestMain no-ops it; collector-down tests replace it. +var pushOTLP = traces.Push + // Issue is a work item (usually a GitHub issue). type Issue struct { Number int `json:"number"` @@ -294,25 +297,37 @@ func Pass(ctx context.Context, opts Options) (executed bool, summary string, err notify(opts, NotifyImportant, "execute started") start := time.Now() res, err := opts.Runner.Run(ctx, opencodeclient.RunRequest{ - Directory: opts.ProjectRoot, - Agent: "squad", - Prompt: ctxText, - Title: "squad-oc watch", + Directory: opts.ProjectRoot, + Agent: "squad", + Prompt: ctxText, + Title: "squad-oc watch", + SkipRecord: true, }) - status := "OK" - if err != nil { - status = "ERROR" - } - if opts.ProjectRoot != "" { - _ = traces.Append(opts.ProjectRoot, traces.Span{ - Name: "squad-oc.watch.execute", - Start: start, - End: time.Now(), - Status: status, - Attributes: map[string]string{ - "issues": strconv.Itoa(len(issues)), - }, - }) + s, rerr := traces.ResolveSettings(squad.Detect(opts.ProjectRoot).Config, os.Getenv) + if rerr != nil { + s = traces.Settings{} + } + if werr := traces.Write(opts.ProjectRoot, traces.RecordInput{ + ParentName: "squad-oc.watch.execute", + Start: start, + End: time.Now(), + Err: err, + Agent: "squad", + Prompt: ctxText, + Completion: res.Text, + SessionID: res.SessionID, + Attrs: map[string]string{"issues": strconv.Itoa(len(issues))}, + HasGeneration: res.HasGeneration, + Provider: res.Provider, + Model: res.Model, + InputTokens: res.InputTokens, + OutputTokens: res.OutputTokens, + ReasoningTokens: res.ReasoningTokens, + CacheReadTokens: res.CacheReadTokens, + CacheWriteTokens: res.CacheWriteTokens, + Cost: res.Cost, + }, s, pushOTLP); werr != nil { + fmt.Fprintln(os.Stderr, "traces:", werr) } if err != nil { notify(opts, NotifyImportant, "execute error: "+err.Error()) diff --git a/internal/watch/watch_test.go b/internal/watch/watch_test.go index 72f2573..0ba3ac3 100644 --- a/internal/watch/watch_test.go +++ b/internal/watch/watch_test.go @@ -15,6 +15,15 @@ import ( "github.com/xeaser/squad-opencode/internal/traces" ) +func TestMain(m *testing.M) { + pushOTLP = func(context.Context, traces.Settings, traces.Span, *traces.Span) error { + return nil + } + _ = os.Unsetenv("OTEL_EXPORTER_OTLP_ENDPOINT") + _ = os.Unsetenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") + os.Exit(m.Run()) +} + func TestBuildContextAndPass(t *testing.T) { root := t.TempDir() if _, err := squad.WriteDefaultPreset(squad.InitOptions{ProjectRoot: root}); err != nil { @@ -36,7 +45,7 @@ func TestBuildContextAndPass(t *testing.T) { t.Fatal(labeled) } - fake := &opencodeclient.FakeRunner{} + fake := &opencodeclient.FakeRunner{Text: "done"} ok, summary, err := Pass(context.Background(), Options{ ProjectRoot: root, Execute: true, @@ -49,6 +58,9 @@ func TestBuildContextAndPass(t *testing.T) { if len(fake.Calls) != 1 { t.Fatal(fake.Calls) } + if !fake.Calls[0].SkipRecord { + t.Fatal("watch must set SkipRecord so SDKRunner does not also write squad-oc.run") + } if !strings.Contains(summary, "issues=1") { t.Fatal(summary) } @@ -66,8 +78,8 @@ func TestBuildContextAndPass(t *testing.T) { if err != nil { t.Fatal(err) } - if len(spans) != 1 { - t.Fatalf("execute should record one span, got %+v", spans) + if len(spans) != 2 { + t.Fatalf("execute should record parent+child, got %+v", spans) } if spans[0].Name != "squad-oc.watch.execute" || spans[0].Status != "OK" { t.Fatalf("%+v", spans[0]) @@ -75,6 +87,12 @@ func TestBuildContextAndPass(t *testing.T) { if spans[0].Attributes["issues"] != "1" { t.Fatalf("issues attr: %+v", spans[0].Attributes) } + if spans[1].Name != traces.NameChat || spans[1].Completion != "done" { + t.Fatalf("child %+v", spans[1]) + } + if spans[1].SessionID != "fake-session" || spans[1].Prompt == "" { + t.Fatalf("child session/prompt %+v", spans[1]) + } } func TestPassNoExecuteDoesNotRecordSpan(t *testing.T) { @@ -125,6 +143,35 @@ func TestPassExecuteRecordsErrorSpan(t *testing.T) { } } +func TestPassCollectorFailureDoesNotFail(t *testing.T) { + root := t.TempDir() + if _, err := squad.WriteDefaultPreset(squad.InitOptions{ProjectRoot: root}); err != nil { + t.Fatal(err) + } + t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:1") + prev := pushOTLP + t.Cleanup(func() { pushOTLP = prev }) + pushOTLP = func(context.Context, traces.Settings, traces.Span, *traces.Span) error { + return errors.New("collector down") + } + ok, _, err := Pass(context.Background(), Options{ + ProjectRoot: root, + Execute: true, + Lister: StaticLister{Issues: []Issue{{Number: 1, Title: "x", State: "OPEN"}}}, + Runner: &opencodeclient.FakeRunner{Text: "ok"}, + }) + if err != nil || !ok { + t.Fatalf("push must not fail Pass: ok=%v err=%v", ok, err) + } + spans, err := traces.List(root, 10) + if err != nil || len(spans) != 2 { + t.Fatalf("JSONL must still be written: %+v %v", spans, err) + } + if spans[0].Name != "squad-oc.watch.execute" || spans[1].Name != traces.NameChat { + t.Fatalf("%+v", spans) + } +} + func TestOvernightSkipsExecute(t *testing.T) { root := t.TempDir() if _, err := squad.WriteDefaultPreset(squad.InitOptions{ProjectRoot: root}); err != nil { diff --git a/scripts/langfuse/.env b/scripts/langfuse/.env new file mode 100644 index 0000000..67c53a7 --- /dev/null +++ b/scripts/langfuse/.env @@ -0,0 +1,55 @@ +# Local-only Langfuse v4 init keys for scripts/live-traces.ps1. +# NOT production. Well-known dummy credentials for 127.0.0.1 only. + +NEXTAUTH_URL=http://localhost:3000 +NEXTAUTH_SECRET=squad-oc-local-nextauth-secret +SALT=squad-oc-local-salt +# 64 hex chars (openssl rand -hex 32); local-only, not production +ENCRYPTION_KEY=a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90 + +POSTGRES_USER=postgres +POSTGRES_PASSWORD=squad-oc-local-postgres +POSTGRES_DB=postgres +DATABASE_URL=postgresql://postgres:squad-oc-local-postgres@postgres:5432/postgres + +CLICKHOUSE_USER=clickhouse +CLICKHOUSE_PASSWORD=squad-oc-local-clickhouse +CLICKHOUSE_URL=http://clickhouse:8123 +CLICKHOUSE_MIGRATION_URL=clickhouse://clickhouse:9000 +CLICKHOUSE_CLUSTER_ENABLED=false + +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_AUTH=squad-oc-local-redis + +MINIO_ROOT_USER=minio +MINIO_ROOT_PASSWORD=squad-oc-local-minio +LANGFUSE_S3_EVENT_UPLOAD_BUCKET=langfuse +LANGFUSE_S3_EVENT_UPLOAD_REGION=auto +LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=minio +LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=squad-oc-local-minio +LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT=http://minio:9000 +LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE=true +LANGFUSE_S3_EVENT_UPLOAD_PREFIX=events/ +LANGFUSE_S3_MEDIA_UPLOAD_BUCKET=langfuse +LANGFUSE_S3_MEDIA_UPLOAD_REGION=auto +LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID=minio +LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY=squad-oc-local-minio +LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT=http://minio:9000 +LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE=true +LANGFUSE_S3_MEDIA_UPLOAD_PREFIX=media/ + +TELEMETRY_ENABLED=false +LANGFUSE_INGESTION_QUEUE_DELAY_MS=0 +LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=100 + +# Headless project/user (local-only, not production) +LANGFUSE_INIT_ORG_ID=squad-oc +LANGFUSE_INIT_ORG_NAME=squad-oc +LANGFUSE_INIT_PROJECT_ID=squad-oc +LANGFUSE_INIT_PROJECT_NAME=squad-oc +LANGFUSE_INIT_PROJECT_PUBLIC_KEY=pk-lf-local-squad-oc +LANGFUSE_INIT_PROJECT_SECRET_KEY=sk-lf-local-squad-oc +LANGFUSE_INIT_USER_EMAIL=local@squad-oc.test +LANGFUSE_INIT_USER_NAME=local +LANGFUSE_INIT_USER_PASSWORD=local-squad-oc diff --git a/scripts/langfuse/docker-compose.yml b/scripts/langfuse/docker-compose.yml new file mode 100644 index 0000000..81b623c --- /dev/null +++ b/scripts/langfuse/docker-compose.yml @@ -0,0 +1,145 @@ +# Minimal official Langfuse v4 stack for scripts/live-traces.ps1. +# Local-only (127.0.0.1). Not production. Do not clone the Langfuse git repo. +# Images: docker.langfuse.com …:4 + clickhouse/clickhouse-server:25.12. +name: squad-oc-langfuse + +services: + langfuse-worker: + image: docker.langfuse.com/langfuse/langfuse-worker:4 + restart: unless-stopped + depends_on: &langfuse-depends-on + postgres: + condition: service_healthy + minio: + condition: service_healthy + redis: + condition: service_healthy + clickhouse: + condition: service_healthy + env_file: + - .env + environment: &langfuse-worker-env + NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000} + DATABASE_URL: ${DATABASE_URL:-postgresql://postgres:squad-oc-local-postgres@postgres:5432/postgres} + SALT: ${SALT:-squad-oc-local-salt} + ENCRYPTION_KEY: ${ENCRYPTION_KEY:-a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90} + TELEMETRY_ENABLED: ${TELEMETRY_ENABLED:-false} + CLICKHOUSE_MIGRATION_URL: ${CLICKHOUSE_MIGRATION_URL:-clickhouse://clickhouse:9000} + CLICKHOUSE_URL: ${CLICKHOUSE_URL:-http://clickhouse:8123} + CLICKHOUSE_USER: ${CLICKHOUSE_USER:-clickhouse} + CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-squad-oc-local-clickhouse} + CLICKHOUSE_CLUSTER_ENABLED: ${CLICKHOUSE_CLUSTER_ENABLED:-false} + LANGFUSE_S3_EVENT_UPLOAD_BUCKET: ${LANGFUSE_S3_EVENT_UPLOAD_BUCKET:-langfuse} + LANGFUSE_S3_EVENT_UPLOAD_REGION: ${LANGFUSE_S3_EVENT_UPLOAD_REGION:-auto} + LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: ${LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID:-minio} + LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: ${LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY:-squad-oc-local-minio} + LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: ${LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT:-http://minio:9000} + LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: ${LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE:-true} + LANGFUSE_S3_EVENT_UPLOAD_PREFIX: ${LANGFUSE_S3_EVENT_UPLOAD_PREFIX:-events/} + LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: ${LANGFUSE_S3_MEDIA_UPLOAD_BUCKET:-langfuse} + LANGFUSE_S3_MEDIA_UPLOAD_REGION: ${LANGFUSE_S3_MEDIA_UPLOAD_REGION:-auto} + LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: ${LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID:-minio} + LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: ${LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY:-squad-oc-local-minio} + LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: ${LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT:-http://minio:9000} + LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: ${LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE:-true} + LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: ${LANGFUSE_S3_MEDIA_UPLOAD_PREFIX:-media/} + LANGFUSE_INGESTION_QUEUE_DELAY_MS: ${LANGFUSE_INGESTION_QUEUE_DELAY_MS:-0} + LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS: ${LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS:-100} + REDIS_HOST: ${REDIS_HOST:-redis} + REDIS_PORT: ${REDIS_PORT:-6379} + REDIS_AUTH: ${REDIS_AUTH:-squad-oc-local-redis} + + langfuse-web: + image: docker.langfuse.com/langfuse/langfuse:4 + restart: unless-stopped + depends_on: *langfuse-depends-on + ports: + - 127.0.0.1:3000:3000 + env_file: + - .env + environment: + <<: *langfuse-worker-env + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-squad-oc-local-nextauth-secret} + LANGFUSE_INIT_ORG_ID: ${LANGFUSE_INIT_ORG_ID:-squad-oc} + LANGFUSE_INIT_ORG_NAME: ${LANGFUSE_INIT_ORG_NAME:-squad-oc} + LANGFUSE_INIT_PROJECT_ID: ${LANGFUSE_INIT_PROJECT_ID:-squad-oc} + LANGFUSE_INIT_PROJECT_NAME: ${LANGFUSE_INIT_PROJECT_NAME:-squad-oc} + LANGFUSE_INIT_PROJECT_PUBLIC_KEY: ${LANGFUSE_INIT_PROJECT_PUBLIC_KEY:-pk-lf-local-squad-oc} + LANGFUSE_INIT_PROJECT_SECRET_KEY: ${LANGFUSE_INIT_PROJECT_SECRET_KEY:-sk-lf-local-squad-oc} + LANGFUSE_INIT_USER_EMAIL: ${LANGFUSE_INIT_USER_EMAIL:-local@squad-oc.test} + LANGFUSE_INIT_USER_NAME: ${LANGFUSE_INIT_USER_NAME:-local} + LANGFUSE_INIT_USER_PASSWORD: ${LANGFUSE_INIT_USER_PASSWORD:-local-squad-oc} + + clickhouse: + image: docker.io/clickhouse/clickhouse-server:25.12 + restart: unless-stopped + user: "101:101" + environment: + CLICKHOUSE_DB: default + CLICKHOUSE_USER: ${CLICKHOUSE_USER:-clickhouse} + CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-squad-oc-local-clickhouse} + volumes: + - squad-oc-langfuse-clickhouse:/var/lib/clickhouse + - squad-oc-langfuse-clickhouse-logs:/var/log/clickhouse-server + healthcheck: + test: wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1 + interval: 5s + timeout: 5s + retries: 10 + start_period: 1s + + minio: + image: cgr.dev/chainguard/minio + restart: unless-stopped + entrypoint: sh + command: -c 'mkdir -p /data/langfuse && minio server --address ":9000" --console-address ":9001" /data' + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minio} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-squad-oc-local-minio} + volumes: + - squad-oc-langfuse-minio:/data + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 1s + timeout: 5s + retries: 5 + start_period: 1s + + redis: + image: docker.io/redis:7 + restart: unless-stopped + command: > + --requirepass ${REDIS_AUTH:-squad-oc-local-redis} + --maxmemory-policy noeviction + healthcheck: + test: ["CMD", "redis-cli", "--no-auth-warning", "-a", "squad-oc-local-redis", "ping"] + interval: 3s + timeout: 10s + retries: 10 + + postgres: + image: docker.io/postgres:17 + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 3s + timeout: 3s + retries: 10 + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-squad-oc-local-postgres} + POSTGRES_DB: ${POSTGRES_DB:-postgres} + TZ: UTC + PGTZ: UTC + volumes: + - squad-oc-langfuse-postgres:/var/lib/postgresql/data + +volumes: + squad-oc-langfuse-postgres: + name: squad-oc-langfuse-postgres + squad-oc-langfuse-clickhouse: + name: squad-oc-langfuse-clickhouse + squad-oc-langfuse-clickhouse-logs: + name: squad-oc-langfuse-clickhouse-logs + squad-oc-langfuse-minio: + name: squad-oc-langfuse-minio diff --git a/scripts/live-models.ps1 b/scripts/live-models.ps1 index c88ca39..b00fe20 100644 --- a/scripts/live-models.ps1 +++ b/scripts/live-models.ps1 @@ -15,8 +15,8 @@ $MainDump = Join-Path $MainDumpDir "dummy-per-agent-model-config.json" $WorktreeDumpDir = Join-Path $RepoRoot ".playwright-mcp" $WorktreeDump = Join-Path $WorktreeDumpDir "dummy-per-agent-model-config.json" -$SquadModel = "xai/grok-3" -$LeadModel = "anthropic/claude-sonnet-4-5" +$SquadModel = "opencode/big-pickle" +$LeadModel = "opencode/hy3-free" $startedServe = $false $serveProc = $null diff --git a/scripts/live-traces.ps1 b/scripts/live-traces.ps1 new file mode 100644 index 0000000..b62571f --- /dev/null +++ b/scripts/live-traces.ps1 @@ -0,0 +1,484 @@ +# cwd: repo root. Uses D:\xAI\squad-oc-dummy only (never this git repo). +# Builds squad-oc.exe, inits the dummy, starts or attaches to +# opencode serve on 127.0.0.1:4096, run -p TRACEOK, asserts traces --json/--export. +# Unless -SkipLangfuse: attach or start local Langfuse v4, second run with OTLP, poll traces API. +# Leaves serve running. Does not stop a Langfuse this script did not start. +# Does not change live-e2e.ps1 (PONG check stays put). +param([switch]$SkipLangfuse) + +$ErrorActionPreference = "Stop" + +$RepoRoot = Split-Path -Parent $PSScriptRoot +$Dummy = "D:\xAI\squad-oc-dummy" +$BaseURL = "http://127.0.0.1:4096" +$LangfuseURL = "http://127.0.0.1:3000" +$SquadModel = "opencode/big-pickle" +$Prompt = "Reply with exactly TRACEOK and nothing else." +$LangfusePrompt = "Reply with exactly LANGFUSEOK and nothing else." +$Log = Join-Path $Dummy "live-traces.log" +$MainDumpDir = "D:\xAI\squad-opencode\.playwright-mcp" +$WorktreeDumpDir = Join-Path $RepoRoot ".playwright-mcp" +$MainDump = Join-Path $MainDumpDir "dummy-traces-summary.json" +$WorktreeDump = Join-Path $WorktreeDumpDir "dummy-traces-summary.json" +$MainLangfuseDump = Join-Path $MainDumpDir "dummy-langfuse-trace.json" +$WorktreeLangfuseDump = Join-Path $WorktreeDumpDir "dummy-langfuse-trace.json" + +$Pk = "pk-lf-local-squad-oc" +$Sk = "sk-lf-local-squad-oc" + +$startedServe = $false +$startedLangfuse = $false +$serveProc = $null + +function Write-Log { + param([string]$Message) + $line = "[{0}] {1}" -f (Get-Date -Format "yyyy-MM-ddTHH:mm:ssK"), $Message + Write-Host $line + $parent = Split-Path -Parent $Log + if (Test-Path $parent) { + Add-Content -Path $Log -Value $line + } +} + +function Get-Port4096Listeners { + Get-NetTCPConnection -LocalPort 4096 -State Listen -ErrorAction SilentlyContinue +} + +function Assert-Port4096Safe { + $listeners = @(Get-Port4096Listeners) + if ($listeners.Count -eq 0) { + return $false + } + $ok = @("127.0.0.1", "::1", "localhost") + $bad = @($listeners | Where-Object { $ok -notcontains $_.LocalAddress }) + if ($bad.Count -gt 0) { + $addrs = ($bad | ForEach-Object { $_.LocalAddress } | Sort-Object -Unique) -join ", " + throw "port 4096 is bound on non-localhost ($addrs); refusing to steal another server" + } + return $true +} + +function Wait-ServeReady { + param([int]$Seconds = 20) + $deadline = (Get-Date).AddSeconds($Seconds) + while ((Get-Date) -lt $deadline) { + try { + $r = Invoke-WebRequest -Uri "$BaseURL/global/health" -UseBasicParsing -TimeoutSec 2 + if ($r.StatusCode -ge 200 -and $r.StatusCode -lt 500) { + return + } + } catch { + Start-Sleep -Milliseconds 250 + } + } + throw "opencode serve at $BaseURL never became ready" +} + +function Invoke-SquadOc { + param( + [string]$Exe, + [string[]]$CommandArgs + ) + Write-Log ("squad-oc " + ($CommandArgs -join " ")) + & $Exe @CommandArgs + if ($LASTEXITCODE -ne 0) { + throw "squad-oc $($CommandArgs[0]) exited $LASTEXITCODE" + } +} + +function Get-OpencodeExe { + $cmd = Get-Command opencode -ErrorAction SilentlyContinue + if (-not $cmd) { + throw "opencode not on PATH" + } + $src = $cmd.Source + if ($src -like "*.ps1") { + $exe = Join-Path (Split-Path $src) "node_modules\opencode-ai\bin\opencode.exe" + if (Test-Path $exe) { + return $exe + } + } + return $src +} + +function Clear-OtlpEnv { + foreach ($n in @( + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_PROTOCOL", + "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", + "OTEL_EXPORTER_OTLP_HEADERS", + "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT" + )) { + Remove-Item "Env:$n" -ErrorAction SilentlyContinue + } +} + +function Write-JsonDump { + param( + [string[]]$Paths, + [object]$Record + ) + $json = $Record | ConvertTo-Json -Depth 12 + foreach ($p in $Paths) { + $dir = Split-Path -Parent $p + if (-not (Test-Path $dir)) { + New-Item -ItemType Directory -Path $dir | Out-Null + } + Set-Content -Path $p -Value $json -Encoding utf8 + Write-Log "wrote $p" + } + return $json +} + +function Test-LangfuseHealthy { + foreach ($url in @("$LangfuseURL/api/public/health", "$LangfuseURL/")) { + try { + $r = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 3 + if ($r.StatusCode -ge 200 -and $r.StatusCode -lt 500) { + return $true + } + } catch { + } + } + return $false +} + +function Get-LangfuseBasicB64 { + $pair = "{0}:{1}" -f $Pk, $Sk + return [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes($pair)) +} + +function Wait-LangfuseReady { + param([int]$Seconds = 180) + $deadline = (Get-Date).AddSeconds($Seconds) + while ((Get-Date) -lt $deadline) { + if (Test-LangfuseHealthy) { + return + } + Start-Sleep -Seconds 2 + } + throw "Langfuse at $LangfuseURL never became ready" +} + +function Invoke-LangfuseGet { + param( + [string]$Url, + [hashtable]$Headers + ) + try { + $r = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 10 -Headers $Headers + return [ordered]@{ + url = $Url + status = [int]$r.StatusCode + body = $r.Content + } + } catch { + $status = 0 + $resp = $_.Exception.Response + if ($null -ne $resp -and $null -ne $resp.StatusCode) { + $status = [int]$resp.StatusCode + } + return [ordered]@{ + url = $Url + status = $status + body = $null + error = $_.Exception.Message + } + } +} + +# This-run gate: both span names. LANGFUSEOK only if the payload actually has I/O fields. +function Test-LangfuseThisRun { + param([string]$Body) + if ([string]::IsNullOrWhiteSpace($Body)) { + return $false + } + if ($Body -notlike "*squad-oc.run*" -or $Body -notlike "*gen_ai.chat*") { + return $false + } + $hasIO = ($Body -like "*gen_ai.input.messages*") -or + ($Body -like "*gen_ai.output.messages*") -or + ($Body -match '"input"\s*:') -or + ($Body -match '"output"\s*:') + if ($hasIO -and $Body -notlike "*LANGFUSEOK*") { + return $false + } + return $true +} + +function Get-LangfuseTraces { + param( + [string]$B64, + [string]$FromStartTime + ) + $headers = @{ Authorization = "Basic $B64" } + # Try legacy traces first (v4 events_only → 404). 2xx only counts if this-run markers match. + $tracesUrl = "$LangfuseURL/api/public/traces?limit=20" + $tracesGot = Invoke-LangfuseGet -Url $tracesUrl -Headers $headers + if ($tracesGot.status -ge 200 -and $tracesGot.status -lt 300 -and (Test-LangfuseThisRun $tracesGot.body)) { + return $tracesGot + } + # v4 replacement, windowed so leftover observations cannot match. + $obsUrl = "$LangfuseURL/api/public/v2/observations?limit=20" + if (-not [string]::IsNullOrWhiteSpace($FromStartTime)) { + $obsUrl = $obsUrl + "&fromStartTime=" + [uri]::EscapeDataString($FromStartTime) + } + $obsGot = Invoke-LangfuseGet -Url $obsUrl -Headers $headers + if ($obsGot.status -ge 200 -and $obsGot.status -lt 300 -and (Test-LangfuseThisRun $obsGot.body)) { + return $obsGot + } + if ($null -ne $obsGot.url) { + return $obsGot + } + return $tracesGot +} + +try { + Set-Location $RepoRoot + + if (-not (Test-Path $Dummy)) { + throw "dummy missing: $Dummy (never use this git clone; never squad-oc-dummy-v2)" + } + if (Test-Path $Log) { + Remove-Item $Log -Force + } + Write-Log "repo=$RepoRoot dummy=$Dummy skipLangfuse=$SkipLangfuse" + + Write-Log "go build -o squad-oc.exe ./cmd/squad-oc" + go build -o squad-oc.exe ./cmd/squad-oc + if ($LASTEXITCODE -ne 0) { + throw "go build failed" + } + $exe = Join-Path $RepoRoot "squad-oc.exe" + if (-not (Test-Path $exe)) { + throw "squad-oc.exe missing after build" + } + + $prev = Get-Location + Set-Location $Dummy + try { + if (-not (Test-Path (Join-Path $Dummy ".squad\config.json"))) { + Invoke-SquadOc -Exe $exe -CommandArgs @("init", "--preset", "default", "--description", "dummy") + } else { + Write-Log "already initialized" + } + + # Pin a model this OpenCode instance actually has. xai/* / anthropic/* + # 500 when those providers are not configured (opencode models lists opencode/* only). + Invoke-SquadOc -Exe $exe -CommandArgs @("cast", "--model", "squad", $SquadModel) + + $opencodeExe = Get-OpencodeExe + Write-Log "opencode=$opencodeExe" + + if (Assert-Port4096Safe) { + Write-Log "attaching to existing localhost:4096" + } else { + Write-Log "starting opencode serve --hostname 127.0.0.1 --port 4096" + $serveProc = Start-Process -FilePath $opencodeExe -ArgumentList @("serve", "--hostname", "127.0.0.1", "--port", "4096") -WorkingDirectory $Dummy -PassThru -WindowStyle Hidden + $startedServe = $true + Write-Log "started serve pid=$($serveProc.Id) (leaving it running)" + } + Wait-ServeReady + + Clear-OtlpEnv + $parent = $null + $child = $null + $spans = $null + $runOut = "" + for ($attempt = 1; $attempt -le 3; $attempt++) { + $attemptStart = (Get-Date).AddSeconds(-2) + Write-Log "run -p TRACEOK (no OTLP env) attempt $attempt" + $runOut = & $exe run -p $Prompt 2>&1 | Out-String + Write-Host $runOut + Add-Content -Path $Log -Value $runOut + if ($LASTEXITCODE -ne 0) { + Write-Log "run attempt $attempt exited $LASTEXITCODE" + continue + } + + Write-Log "traces --json --last 8" + $tracesJson = & $exe traces --json --last 8 | Out-String + if ($LASTEXITCODE -ne 0) { + throw "squad-oc traces --json exited $LASTEXITCODE" + } + Write-Host $tracesJson + Add-Content -Path $Log -Value $tracesJson + $spans = $tracesJson | ConvertFrom-Json + if ($null -eq $spans) { + throw "traces --json produced no spans" + } + $child = @( + $spans | Where-Object { + $_.name -eq "gen_ai.chat" -and + [string]$_.prompt -like "*TRACEOK*" -and + -not [string]::IsNullOrWhiteSpace([string]$_.completion) -and + ([datetime]$_.start) -ge $attemptStart + } + ) | Select-Object -Last 1 + if ($null -eq $child) { + Write-Log ("attempt {0}: gen_ai.chat missing completion (OpenCode empty/upstream flake)" -f $attempt) + continue + } + $parent = @( + $spans | Where-Object { $_.name -eq "squad-oc.run" -and $_.traceId -eq $child.traceId } + ) | Select-Object -Last 1 + if ($null -ne $parent) { + break + } + } + if ($null -eq $parent -or $null -eq $child) { + throw "traces --json missing parent squad-oc.run + child gen_ai.chat with local prompt/completion" + } + if ([string]::IsNullOrWhiteSpace([string]$child.model)) { + throw "gen_ai.chat missing model from OpenCode" + } + $inTok = 0 + $outTok = 0 + if ($null -ne $child.inputTokens) { $inTok = [int]$child.inputTokens } + if ($null -ne $child.outputTokens) { $outTok = [int]$child.outputTokens } + if ($inTok -eq 0 -and $outTok -eq 0) { + Write-Log "WARN gen_ai.chat tokens are 0 (OpenCode omitempty / empty Info)" + } + Write-Log ("PASS jsonl: parent={0} child={1} model={2} tokens={3}/{4}" -f $parent.name, $child.name, $child.model, $inTok, $outTok) + + $exportPath = Join-Path $env:TEMP ("squad-oc-traces-export-{0}.json" -f [guid]::NewGuid().ToString("n")) + Write-Log "traces --export $exportPath --last 8" + Invoke-SquadOc -Exe $exe -CommandArgs @("traces", "--export", $exportPath, "--last", "8") + $exportText = Get-Content -Raw -Path $exportPath + if ($exportText -notlike "*gen_ai.*") { + throw "traces --export missing gen_ai. metadata" + } + if ($exportText -like "*gen_ai.input.messages*") { + throw "traces --export leaked gen_ai.input.messages" + } + if ($exportText -like "*$Prompt*") { + throw "traces --export leaked prompt text" + } + Write-Log "PASS export: has gen_ai. metadata, no bodies" + + $record = [ordered]@{ + dummy = $Dummy + startedServe = $startedServe + skipLangfuse = [bool]$SkipLangfuse + prompt = $Prompt + runPreview = $runOut.Substring(0, [Math]::Min(240, $runOut.Length)) + parentName = $parent.name + childName = $child.name + model = $child.model + provider = $child.provider + inputTokens = $child.inputTokens + outputTokens = $child.outputTokens + promptSeen = $child.prompt + completion = $child.completion + exportPath = $exportPath + traces = $spans + } + Write-JsonDump -Paths @($MainDump, $WorktreeDump) -Record $record | Out-Null + + if ($SkipLangfuse) { + Write-Log "Langfuse skipped: -SkipLangfuse" + } else { + if (Test-LangfuseHealthy) { + Write-Log "attaching to existing Langfuse $LangfuseURL" + } else { + $docker = Get-Command docker -ErrorAction SilentlyContinue + if (-not $docker) { + throw "docker not on PATH (needed unless -SkipLangfuse)" + } + $composeFile = Join-Path $RepoRoot "scripts\langfuse\docker-compose.yml" + $envFile = Join-Path $RepoRoot "scripts\langfuse\.env" + if (-not (Test-Path $composeFile)) { + throw "missing $composeFile" + } + Write-Log "docker compose -f scripts/langfuse/docker-compose.yml up -d" + & docker compose --project-name squad-oc-langfuse --env-file $envFile -f $composeFile up -d + if ($LASTEXITCODE -ne 0) { + throw "docker compose up failed ($LASTEXITCODE)" + } + $startedLangfuse = $true + Write-Log "started Langfuse compose (leaving it running); waiting for web" + Wait-LangfuseReady -Seconds 180 + } + + $b64 = Get-LangfuseBasicB64 + $env:OTEL_EXPORTER_OTLP_ENDPOINT = "http://127.0.0.1:3000/api/public/otel" + $env:OTEL_EXPORTER_OTLP_PROTOCOL = "http/protobuf" + $env:OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = "true" + $env:OTEL_EXPORTER_OTLP_HEADERS = "Authorization=Basic $b64,x-langfuse-ingestion-version=4" + Remove-Item Env:OTEL_EXPORTER_OTLP_TRACES_ENDPOINT -ErrorAction SilentlyContinue + Remove-Item Env:OTEL_EXPORTER_OTLP_TRACES_PROTOCOL -ErrorAction SilentlyContinue + + $fromStart = (Get-Date).ToUniversalTime().AddSeconds(-2).ToString("yyyy-MM-ddTHH:mm:ssZ") + Write-Log "Langfuse poll window fromStartTime=$fromStart" + + $lfRunOut = "" + $lfOk = $false + for ($attempt = 1; $attempt -le 3; $attempt++) { + Write-Log "run -p LANGFUSEOK (OTLP http/protobuf + capture on) attempt $attempt" + $lfRunOut = & $exe run -p $LangfusePrompt 2>&1 | Out-String + Write-Host $lfRunOut + Add-Content -Path $Log -Value $lfRunOut + if ($LASTEXITCODE -eq 0) { + $lfOk = $true + break + } + Write-Log "langfuse run attempt $attempt exited $LASTEXITCODE" + } + if (-not $lfOk) { + throw "squad-oc run (langfuse) failed after retries" + } + + $got = $null + $deadline = (Get-Date).AddSeconds(60) + while ((Get-Date) -lt $deadline) { + $got = Get-LangfuseTraces -B64 $b64 -FromStartTime $fromStart + Write-Log ("GET {0} status={1}" -f $got.url, $got.status) + if ($got.status -ge 200 -and $got.status -lt 300 -and (Test-LangfuseThisRun $got.body)) { + Write-Log "PASS Langfuse API found this-run parent+child" + break + } + $got = $null + Start-Sleep -Seconds 2 + } + if ($null -eq $got -or -not (Test-LangfuseThisRun $got.body)) { + throw "Langfuse GET never showed this-run squad-oc.run + gen_ai.chat" + } + + $lfRecord = [ordered]@{ + dummy = $Dummy + startedLangfuse = $startedLangfuse + endpoint = $env:OTEL_EXPORTER_OTLP_ENDPOINT + protocol = $env:OTEL_EXPORTER_OTLP_PROTOCOL + fromStartTime = $fromStart + prompt = $LangfusePrompt + runPreview = $lfRunOut.Substring(0, [Math]::Min(240, $lfRunOut.Length)) + tracesStatus = $got.status + tracesUrl = $got.url + traces = $got.body + } + Write-JsonDump -Paths @($MainLangfuseDump, $WorktreeLangfuseDump) -Record $lfRecord | Out-Null + } + } finally { + Set-Location $prev + Clear-OtlpEnv + } + + if ($startedServe) { + Write-Log "leaving serve running (pid=$($serveProc.Id))" + } else { + Write-Log "left existing serve running" + } + if (-not $SkipLangfuse) { + if ($startedLangfuse) { + Write-Log "leaving Langfuse compose running (this script started it)" + } else { + Write-Log "left existing Langfuse running" + } + } + Write-Log "live-traces ok" + exit 0 +} catch { + Write-Log ("FAIL: " + $_.Exception.Message) + throw +} diff --git a/scripts/release-tag.ps1 b/scripts/release-tag.ps1 new file mode 100644 index 0000000..39b9197 --- /dev/null +++ b/scripts/release-tag.ps1 @@ -0,0 +1,24 @@ +# Create an annotated vX.Y.Z tag on main only. Does not push. +param( + [Parameter(Mandatory = $true)] + [string]$Tag +) + +$ErrorActionPreference = "Stop" +if ($Tag -notmatch '^v\d+\.\d+\.\d+$') { + Write-Error "TAG must be vX.Y.Z" + exit 2 +} +$branch = git rev-parse --abbrev-ref HEAD +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} +if ($branch -ne "main") { + Write-Error "checkout main first (on $branch)" + exit 2 +} +git tag -a $Tag -m "squad-oc $Tag" +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} +Write-Host "created $Tag; publish with: task release:push TAG=$Tag" diff --git a/scripts/release-tag.sh b/scripts/release-tag.sh new file mode 100644 index 0000000..204103d --- /dev/null +++ b/scripts/release-tag.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Create an annotated vX.Y.Z tag on main only. Does not push. +set -euo pipefail +tag="${1:-${TAG:-}}" +if [ -z "$tag" ]; then + echo "TAG is required (vX.Y.Z)" >&2 + exit 2 +fi +if ! printf '%s\n' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "TAG must be vX.Y.Z" >&2 + exit 2 +fi +branch="$(git rev-parse --abbrev-ref HEAD)" +if [ "$branch" != "main" ]; then + echo "checkout main first (on $branch)" >&2 + exit 2 +fi +git tag -a "$tag" -m "squad-oc $tag" +echo "created $tag; publish with: task release:push TAG=$tag"