From 6afb977ac45f16d51336f5fc8a2a23c5caf3b875 Mon Sep 17 00:00:00 2001 From: Parag Sharma Date: Fri, 28 Aug 2026 10:16:39 +0530 Subject: [PATCH 1/2] feat(traces): ingest OpenCode SQLite TUI sessions Read OpenCode's SQLite read-only so TUI/Web/serve turns appear in traces and optional OTLP, keyed by assistant messageId. Dedup keeps CLI Prompt rows unique while later TUI turns on the same ses_* still ingest. traces --follow polls every 2s. --- README.md | 5 +- docs/get-started.md | 2 +- docs/use-cases.md | 2 +- go.mod | 8 + go.sum | 46 ++++ internal/cli/cli.go | 56 ++++- internal/cli/cli_test.go | 110 +++++++++ internal/opencodeclient/run.go | 4 +- internal/opencodestore/open.go | 18 ++ internal/opencodestore/path.go | 62 +++++ internal/opencodestore/path_test.go | 95 ++++++++ internal/opencodestore/read.go | 354 ++++++++++++++++++++++++++++ internal/opencodestore/read_test.go | 91 +++++++ internal/squad/types.go | 2 + internal/traces/ingest.go | 144 +++++++++++ internal/traces/ingest_test.go | 244 +++++++++++++++++++ internal/traces/otel.go | 2 + internal/traces/otel_test.go | 9 +- internal/traces/record.go | 8 + internal/traces/traces.go | 5 + internal/traces/traces_test.go | 1 + internal/watch/watch.go | 1 + 22 files changed, 1262 insertions(+), 7 deletions(-) create mode 100644 internal/opencodestore/open.go create mode 100644 internal/opencodestore/path.go create mode 100644 internal/opencodestore/path_test.go create mode 100644 internal/opencodestore/read.go create mode 100644 internal/opencodestore/read_test.go create mode 100644 internal/traces/ingest.go create mode 100644 internal/traces/ingest_test.go diff --git a/README.md b/README.md index cdf3ec0..df7388f 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 (`.squad/traces/spans.jsonl`); `--export` writes OTLP JSON; optional live push via `OTEL_EXPORTER_OTLP_*` | +| `traces [--last N] [--json] [--export file] [--follow]` | Local spans plus OpenCode SQLite ingest (TUI/Web/serve); `--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//` | @@ -111,6 +111,7 @@ internal/watch/ # issue triage (Ralph): health, overnight, backends internal/githubissues/ # gh issue list internal/share/ # upstream / pack / link internal/traces/ # local JSONL spans + OTLP export +internal/opencodestore/ # read-only OpenCode SQLite (TUI ingest) internal/selfupdate/ # upgrade --self internal/updatecheck/ internal/version/ @@ -133,7 +134,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`. 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. +`traces` lists local spans from `run` and `watch --execute`, and ingests completed TUI/Web/serve turns from OpenCode SQLite (default `$XDG_DATA_HOME/opencode/opencode.db` or `~/.local/share/opencode/opencode.db` on every OS, including Windows `%USERPROFILE%\.local\share\opencode\opencode.db`). Override with `OPENCODE_DB` or `opencode_db` in `.squad/config.json`. Dedup is by assistant `messageId` so a later TUI turn on a CLI session still ingests. `--follow` polls every 2s. `--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` and on ingest. 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 groups turns by OpenCode `ses_*` (`session.id`). For in-process streaming, the official Langfuse OpenCode plugin is optional and separate; we do not install it. Aspire Dashboard remains a valid OTLP **consumer**, not a shipped `squad-oc` command. ### Share extra agents (`upstream` / `pack`) diff --git a/docs/get-started.md b/docs/get-started.md index 7b4a8ed..18a44ca 100644 --- a/docs/get-started.md +++ b/docs/get-started.md @@ -174,7 +174,7 @@ Plain `opencode` opens the interactive UI and does **not** listen on 4096. - `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 (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 traces` lists local run/watch spans and ingests completed TUI/Web/serve turns from OpenCode SQLite (default `~/.local/share/opencode/opencode.db` / `%USERPROFILE%\.local\share\opencode\opencode.db`; override `OPENCODE_DB` or `opencode_db`). `--follow` polls every 2s. `--export` writes OTLP JSON. Set `OTEL_EXPORTER_OTLP_ENDPOINT` (or traces-specific) to push during `run` / `watch --execute` and ingest. 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`. Same Langfuse session id is the OpenCode `ses_*`. The official Langfuse OpenCode plugin is optional for in-process streaming; we do not install it. 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 623124b..f3be761 100644 --- a/docs/use-cases.md +++ b/docs/use-cases.md @@ -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 / observability | `squad-oc traces` (`.squad/traces/spans.jsonl`) + optional OTLP push via `OTEL_EXPORTER_OTLP_ENDPOINT` | +| Local spans / observability | `squad-oc traces` (JSONL + OpenCode SQLite ingest for TUI/Web/serve) + optional OTLP; Langfuse plugin optional for streaming | | 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 6692d6d..d9b20f4 100644 --- a/go.mod +++ b/go.mod @@ -9,15 +9,20 @@ require ( 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 + modernc.org/sqlite v1.57.0 ) require ( github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dustin/go-humanize v1.0.1 // 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/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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 @@ -33,4 +38,7 @@ require ( 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 + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 40467e3..2d51d73 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x 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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/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= @@ -11,10 +13,20 @@ 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/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= 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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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= @@ -53,12 +65,18 @@ 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/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= 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= @@ -69,3 +87,31 @@ 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= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg= +modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 8d962af..1932dcd 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -7,9 +7,11 @@ import ( "errors" "fmt" "os" + "os/signal" "path/filepath" "strconv" "strings" + "syscall" "time" "github.com/xeaser/squad-opencode/internal/brief" @@ -132,7 +134,7 @@ Commands: link --sync link --off update-check [--json] [--refresh] - traces [--last N] [--json] [--export file] # local spans.jsonl; OTEL_EXPORTER_OTLP_* optional push + traces [--last N] [--json] [--export file] [--follow] # JSONL + OpenCode SQLite ingest; OTEL optional mcp apply | list | init marketplace add | list | remove | browse [name] | install [--from ] plugin install @ | list | uninstall @@ -1059,11 +1061,14 @@ func cmdTraces(args []string) int { last := 20 asJSON := false exportPath := "" + follow := false for i := 0; i < len(args); i++ { a := args[i] switch { case a == "--json": asJSON = true + case a == "--follow": + follow = true case a == "--last" && i+1 < len(args): i++ n, err := strconv.Atoi(args[i]) @@ -1093,6 +1098,12 @@ func cmdTraces(args []string) int { if code != 0 { return code } + if follow { + return tracesFollowFn(root) + } + if code := tracesIngestOnce(root); code != 0 { + return code + } spans, err := traces.List(root, last) if err != nil { fmt.Fprintln(os.Stderr, err) @@ -1123,6 +1134,49 @@ func cmdTraces(args []string) int { return 0 } +var tracesFollowFn = tracesFollow + +// tracesIngestPush is the OTLP export hook for CLI ingest; tests replace it. +var tracesIngestPush = traces.Push + +func tracesIngestOnce(root string) int { + _, err := traces.Ingest(root, squad.Detect(root).Config, os.Getenv, tracesIngestPush) + if err != nil { + if traces.IsOTLPPushError(err) { + fmt.Fprintln(os.Stderr, "traces:", err) + return 0 + } + fmt.Fprintln(os.Stderr, err) + return 1 + } + return 0 +} + +func tracesFollow(root string) int { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + for { + n, err := traces.Ingest(root, squad.Detect(root).Config, os.Getenv, tracesIngestPush) + if err != nil { + if traces.IsOTLPPushError(err) { + fmt.Fprintln(os.Stderr, "traces:", err) + // JSONL kept; keep polling + } else { + fmt.Fprintln(os.Stderr, err) + return 1 + } + } + if n > 0 { + fmt.Printf("ingested %d\n", n) + } + select { + case <-ctx.Done(): + return 0 + case <-time.After(2 * time.Second): + } + } +} + func cmdMCP(args []string) int { if len(args) < 1 { fmt.Fprintln(os.Stderr, "mcp apply|list|init") diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 75d2304..763d934 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "context" + "database/sql" "encoding/json" "fmt" "io" @@ -14,11 +15,31 @@ import ( "testing" "time" + _ "modernc.org/sqlite" + "github.com/xeaser/squad-opencode/internal/brief" "github.com/xeaser/squad-opencode/internal/squad" "github.com/xeaser/squad-opencode/internal/traces" ) +func TestMain(m *testing.M) { + tracesIngestPush = func(context.Context, traces.Settings, traces.Span, *traces.Span) error { + return nil + } + _ = os.Unsetenv("OPENCODE_DB") + _ = os.Unsetenv("OTEL_EXPORTER_OTLP_ENDPOINT") + _ = os.Unsetenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") + xdg, err := os.MkdirTemp("", "squad-cli-xdg-*") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + _ = os.Setenv("XDG_DATA_HOME", xdg) + code := m.Run() + _ = os.RemoveAll(xdg) + os.Exit(code) +} + func TestHelpAndUnknown(t *testing.T) { if Execute(nil) != 0 { t.Fatal("help") @@ -141,12 +162,20 @@ func TestRunWatchBadOTLPProtocolExit2(t *testing.T) { func TestTracesCLI(t *testing.T) { root := t.TempDir() + t.Setenv("XDG_DATA_HOME", filepath.Join(root, "xdg")) prev, _ := os.Getwd() if err := os.Chdir(root); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = os.Chdir(prev) }) + origFollow := tracesFollowFn + tracesFollowFn = func(string) int { return 0 } + t.Cleanup(func() { tracesFollowFn = origFollow }) + if Execute([]string{"traces", "--follow"}) != 0 { + t.Fatal("follow flag") + } + if Execute([]string{"traces", "--nope"}) != 2 { t.Fatal("unknown flag") } @@ -224,6 +253,87 @@ func TestTracesCLI(t *testing.T) { } } +func TestTracesCLIIngestThenExport(t *testing.T) { + root := t.TempDir() + t.Setenv("XDG_DATA_HOME", filepath.Join(root, "xdg")) + dbPath := filepath.Join(root, "oc.db") + writeCLIIngestDB(t, dbPath, root) + t.Setenv("OPENCODE_DB", dbPath) + + prev, _ := os.Getwd() + if err := os.Chdir(root); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(prev) }) + + dest := filepath.Join(root, "ingest.otlp.json") + out := captureStdout(t, func() { + if Execute([]string{"traces", "--export", dest}) != 0 { + t.Fatal("traces --export after ingest") + } + }) + if !strings.Contains(out, dest) { + t.Fatalf("export msg: %s", out) + } + body, err := os.ReadFile(dest) + if err != nil { + t.Fatal(err) + } + s := string(body) + for _, want := range []string{`"resourceSpans"`, traces.NameSession, traces.NameChat, "session.id", "gen_ai.conversation.id"} { + if !strings.Contains(s, want) { + t.Fatalf("missing %q in %s", want, s) + } + } + if strings.Contains(s, "gen_ai.input.messages") || strings.Contains(s, "SECRET") { + t.Fatal("export leaked bodies/messages") + } + spans, err := traces.List(root, 0) + if err != nil { + t.Fatal(err) + } + var chats int + for _, sp := range spans { + if sp.Name == traces.NameChat && sp.MessageID != "" && sp.SessionID == "ses_cli" { + chats++ + } + } + if chats != 1 { + t.Fatalf("ingested chats=%d spans=%d", chats, len(spans)) + } +} + +func writeCLIIngestDB(t *testing.T, path, projectRoot string) { + t.Helper() + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + defer db.Close() + _, err = db.Exec(` +CREATE TABLE session ( + id text, parent_id text, directory text, agent text, model text, + time_created integer, time_updated integer, time_archived integer +); +CREATE TABLE message ( + id text, session_id text, time_created integer, time_updated integer, data text +); +CREATE TABLE part ( + id text, message_id text, session_id text, time_created integer, time_updated integer, data text +); +INSERT INTO session VALUES ('ses_cli', NULL, ?, 'squad', '{"id":"m","providerID":"p"}', 1, 2, NULL); +INSERT INTO message VALUES + ('u1', 'ses_cli', 1000, 1000, '{"role":"user","time":{"created":1000}}'), + ('msg_cli', 'ses_cli', 1100, 1200, '{"role":"assistant","parentID":"u1","mode":"squad","modelID":"m","providerID":"p","cost":0.1,"tokens":{"input":1,"output":2,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1100,"completed":1200}}'); +INSERT INTO part VALUES + ('p1', 'u1', 'ses_cli', 1000, 1000, '{"type":"text","text":"hi"}'), + ('p2', 'msg_cli', 'ses_cli', 1100, 1200, '{"type":"text","text":"yo"}'); +`, projectRoot) + if err != nil { + t.Fatal(err) + } +} + type stubFailTickets struct{} func (stubFailTickets) ListOpen(context.Context) ([]brief.Ticket, error) { diff --git a/internal/opencodeclient/run.go b/internal/opencodeclient/run.go index 1c77333..f1b5e27 100644 --- a/internal/opencodeclient/run.go +++ b/internal/opencodeclient/run.go @@ -25,7 +25,7 @@ type RunRequest struct { // RunResult is the assistant text from a session. type RunResult struct { - SessionID string + SessionID, MessageID string Text string HasGeneration bool Provider, Model string @@ -107,6 +107,7 @@ func (r SDKRunner) run(ctx context.Context, req RunRequest) (RunResult, error) { } res := RunResult{SessionID: sess.ID, Text: b.String(), HasGeneration: true} if resp != nil { + res.MessageID = resp.Info.ID res.Provider = resp.Info.ProviderID res.Model = resp.Info.ModelID res.Cost = resp.Info.Cost @@ -136,6 +137,7 @@ func recordRun(req RunRequest, start time.Time, runErr error, res RunResult) { Prompt: req.Prompt, Completion: res.Text, SessionID: res.SessionID, + MessageID: res.MessageID, Attrs: map[string]string{"prompt_bytes": strconv.Itoa(len(req.Prompt))}, HasGeneration: res.HasGeneration, Provider: res.Provider, diff --git a/internal/opencodestore/open.go b/internal/opencodestore/open.go new file mode 100644 index 0000000..5ed5fe6 --- /dev/null +++ b/internal/opencodestore/open.go @@ -0,0 +1,18 @@ +package opencodestore + +import ( + "database/sql" + "path/filepath" + + _ "modernc.org/sqlite" +) + +// OpenReadOnly opens path with a WAL-safe read-only URI. +func OpenReadOnly(path string) (*sql.DB, error) { + abs, err := filepath.Abs(path) + if err != nil { + return nil, err + } + uri := "file:" + filepath.ToSlash(abs) + "?mode=ro" + return sql.Open("sqlite", uri) +} diff --git a/internal/opencodestore/path.go b/internal/opencodestore/path.go new file mode 100644 index 0000000..639c4e5 --- /dev/null +++ b/internal/opencodestore/path.go @@ -0,0 +1,62 @@ +// Package opencodestore reads OpenCode SQLite read-only for traces ingest. +package opencodestore + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/xeaser/squad-opencode/internal/squad" +) + +// userHomeDir is os.UserHomeDir; tests replace it. +var userHomeDir = os.UserHomeDir + +// ResolveDBPath returns the OpenCode SQLite file. +// explicit is true when OPENCODE_DB or config opencode_db is set. +func ResolveDBPath(projectRoot string, cfg *squad.Config, getenv func(string) string) (path string, explicit bool, err error) { + if getenv == nil { + getenv = os.Getenv + } + if v := strings.TrimSpace(getenv("OPENCODE_DB")); v != "" { + if v == ":memory:" { + return "", true, fmt.Errorf("OPENCODE_DB :memory: is not supported") + } + if filepath.IsAbs(v) { + return filepath.Clean(v), true, nil + } + dir, err := dataDir(getenv) + if err != nil { + return "", true, err + } + return filepath.Join(dir, v), true, nil + } + if cfg != nil { + if v := strings.TrimSpace(cfg.OpenCodeDB); v != "" { + if !filepath.IsAbs(v) { + if projectRoot == "" { + return "", true, fmt.Errorf("opencode_db relative path requires project root") + } + v = filepath.Join(projectRoot, v) + } + return filepath.Clean(v), true, nil + } + } + dir, err := dataDir(getenv) + if err != nil { + return "", false, err + } + return filepath.Join(dir, "opencode.db"), false, nil +} + +func dataDir(getenv func(string) string) (string, error) { + if v := strings.TrimSpace(getenv("XDG_DATA_HOME")); v != "" { + return filepath.Join(v, "opencode"), nil + } + home, err := userHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".local", "share", "opencode"), nil +} diff --git a/internal/opencodestore/path_test.go b/internal/opencodestore/path_test.go new file mode 100644 index 0000000..dba2d83 --- /dev/null +++ b/internal/opencodestore/path_test.go @@ -0,0 +1,95 @@ +package opencodestore + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/xeaser/squad-opencode/internal/squad" +) + +func TestResolveDBPathDefaultXDGThenHome(t *testing.T) { + orig := userHomeDir + userHomeDir = func() (string, error) { return "/home/u", nil } + t.Cleanup(func() { userHomeDir = orig }) + + p, expl, err := ResolveDBPath("", nil, func(string) string { return "" }) + if err != nil || expl { + t.Fatalf("default: %q %v %v", p, expl, err) + } + want := filepath.Join("/home/u", ".local", "share", "opencode", "opencode.db") + if p != want { + t.Fatalf("got %q want %q", p, want) + } + + p, expl, err = ResolveDBPath("", nil, func(k string) string { + if k == "XDG_DATA_HOME" { + return "/xdg/data" + } + return "" + }) + if err != nil || expl { + t.Fatal(err) + } + want = filepath.Join("/xdg/data", "opencode", "opencode.db") + if p != want { + t.Fatalf("xdg: %q want %q", p, want) + } +} + +func TestResolveDBPathEnvAndConfig(t *testing.T) { + orig := userHomeDir + userHomeDir = func() (string, error) { return "/home/u", nil } + t.Cleanup(func() { userHomeDir = orig }) + + abs := filepath.Join(t.TempDir(), "custom.db") + p, expl, err := ResolveDBPath("/proj", nil, func(k string) string { + if k == "OPENCODE_DB" { + return abs + } + return "" + }) + if err != nil || !expl || p != filepath.Clean(abs) { + t.Fatalf("abs env: %q %v %v", p, expl, err) + } + + p, expl, err = ResolveDBPath("/proj", nil, func(k string) string { + if k == "OPENCODE_DB" { + return "chan.db" + } + return "" + }) + if err != nil || !expl { + t.Fatal(err) + } + wantRel := filepath.Join("/home/u", ".local", "share", "opencode", "chan.db") + if p != wantRel { + t.Fatalf("rel env: %q want %q", p, wantRel) + } + + cfg := &squad.Config{OpenCodeDB: "local.db"} + p, expl, err = ResolveDBPath("/proj", cfg, func(string) string { return "" }) + if err != nil || !expl || p != filepath.Join("/proj", "local.db") { + t.Fatalf("config: %q %v %v", p, expl, err) + } + + p, expl, err = ResolveDBPath("/proj", cfg, func(k string) string { + if k == "OPENCODE_DB" { + return abs + } + return "" + }) + if err != nil || !expl || p != filepath.Clean(abs) { + t.Fatalf("env wins: %q %v %v", p, expl, err) + } + + _, _, err = ResolveDBPath("", nil, func(k string) string { + if k == "OPENCODE_DB" { + return ":memory:" + } + return "" + }) + if err == nil || !strings.Contains(err.Error(), ":memory:") { + t.Fatalf("memory: %v", err) + } +} diff --git a/internal/opencodestore/read.go b/internal/opencodestore/read.go new file mode 100644 index 0000000..b216b6b --- /dev/null +++ b/internal/opencodestore/read.go @@ -0,0 +1,354 @@ +package opencodestore + +import ( + "database/sql" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + "time" +) + +// Query strings — tests assert they never name credential tables. +const ( + qSessions = `SELECT id, parent_id, directory, agent, model, time_created, time_updated, time_archived FROM session` + qMessages = `SELECT id, session_id, time_created, time_updated, data FROM message` + qParts = `SELECT message_id, data FROM part` +) + +var schemaSkipOnce sync.Once + +// Turn is one completed assistant message in a project session. +type Turn struct { + SessionID, MessageID, ParentMessageID, ParentSessionID string + Agent, Provider, Model string + UserStart, AssistantStart, AssistantEnd time.Time + InputTokens, OutputTokens, ReasoningTokens int + CacheReadTokens, CacheWriteTokens int + Cost float64 + Prompt, Completion string + Err bool +} + +// ListTurns returns completed assistant turns for projectRoot. +func ListTurns(db *sql.DB, projectRoot string) ([]Turn, error) { + sessions, err := loadSessions(db, projectRoot) + if err != nil { + return nil, err + } + if len(sessions) == 0 { + return nil, nil + } + msgs, err := loadMessages(db) + if err != nil { + return nil, err + } + parts, err := loadParts(db) + if err != nil { + return nil, err + } + + var out []Turn + for _, m := range msgs { + if m.role != "assistant" || m.completed.IsZero() { + continue + } + sess, ok := sessions[m.sessionID] + if !ok { + continue + } + t := Turn{ + SessionID: m.sessionID, + MessageID: m.id, + ParentMessageID: m.parentID, + ParentSessionID: sess.parentID, + Agent: firstNonEmpty(m.mode, m.agent, sess.agent, "squad"), + Provider: m.providerID, + Model: m.modelID, + AssistantStart: m.created, + AssistantEnd: m.completed, + InputTokens: m.input, + OutputTokens: m.output, + ReasoningTokens: m.reasoning, + CacheReadTokens: m.cacheRead, + CacheWriteTokens: m.cacheWrite, + Cost: m.cost, + Err: m.hasErr, + } + if t.Model == "" { + t.Model = sess.modelID + } + if t.Provider == "" { + t.Provider = sess.providerID + } + if u, ok := msgs[m.parentID]; ok { + t.UserStart = u.created + t.Prompt = strings.TrimSpace(parts.text[u.id]) + } else { + t.UserStart = m.created + } + t.Completion = strings.TrimSpace(parts.text[m.id]) + if t.InputTokens == 0 && t.OutputTokens == 0 { + if sf, ok := parts.stepFinish[m.id]; ok { + t.InputTokens = sf.input + t.OutputTokens = sf.output + t.ReasoningTokens = sf.reasoning + t.CacheReadTokens = sf.cacheRead + t.CacheWriteTokens = sf.cacheWrite + if t.Cost == 0 { + t.Cost = sf.cost + } + } + } + out = append(out, t) + } + sort.Slice(out, func(i, j int) bool { + if !out[i].AssistantStart.Equal(out[j].AssistantStart) { + return out[i].AssistantStart.Before(out[j].AssistantStart) + } + return out[i].MessageID < out[j].MessageID + }) + return out, nil +} + +type sessionRow struct { + parentID, agent, modelID, providerID string +} + +type msgRow struct { + id, sessionID, parentID, role, mode, agent, modelID, providerID string + created, completed time.Time + input, output, reasoning, cacheRead, cacheWrite int + cost float64 + hasErr bool +} + +type partStore struct { + text map[string]string + stepFinish map[string]msgRow +} + +func loadSessions(db *sql.DB, projectRoot string) (map[string]sessionRow, error) { + rows, err := db.Query(qSessions) + if err != nil { + if isMissingSchema(err) { + schemaSkip("session") + return nil, nil + } + return nil, err + } + defer rows.Close() + out := map[string]sessionRow{} + for rows.Next() { + var id string + var parent, dir, agent, model sql.NullString + var created, updated, archived sql.NullInt64 + if err := rows.Scan(&id, &parent, &dir, &agent, &model, &created, &updated, &archived); err != nil { + return nil, err + } + if archived.Valid && archived.Int64 != 0 { + continue + } + if !sameDir(dir.String, projectRoot) { + continue + } + mid, pid := parseSessionModel(model.String) + out[id] = sessionRow{parentID: parent.String, agent: agent.String, modelID: mid, providerID: pid} + } + return out, rows.Err() +} + +func loadMessages(db *sql.DB) (map[string]msgRow, error) { + rows, err := db.Query(qMessages) + if err != nil { + if isMissingSchema(err) { + schemaSkip("message") + return nil, nil + } + return nil, err + } + defer rows.Close() + out := map[string]msgRow{} + for rows.Next() { + var id, sid string + var created, updated int64 + var data string + if err := rows.Scan(&id, &sid, &created, &updated, &data); err != nil { + return nil, err + } + m := parseMessageData(id, sid, created, data) + out[id] = m + } + return out, rows.Err() +} + +func loadParts(db *sql.DB) (partStore, error) { + ps := partStore{text: map[string]string{}, stepFinish: map[string]msgRow{}} + rows, err := db.Query(qParts) + if err != nil { + if isMissingSchema(err) { + schemaSkip("part") + return ps, nil + } + return ps, err + } + defer rows.Close() + for rows.Next() { + var mid, data string + if err := rows.Scan(&mid, &data); err != nil { + return ps, err + } + var obj map[string]any + if json.Unmarshal([]byte(data), &obj) != nil { + continue + } + typ, _ := obj["type"].(string) + if typ == "text" { + if s, ok := obj["text"].(string); ok && s != "" { + if ps.text[mid] != "" { + ps.text[mid] += "\n" + } + ps.text[mid] += s + } + } + if typ == "step-finish" { + ps.stepFinish[mid] = tokensFromMap(obj) + } + } + return ps, rows.Err() +} + +func parseMessageData(id, sid string, created int64, data string) msgRow { + m := msgRow{id: id, sessionID: sid, created: msTime(created)} + var obj map[string]any + if json.Unmarshal([]byte(data), &obj) != nil { + return m + } + m.role, _ = obj["role"].(string) + m.parentID, _ = obj["parentID"].(string) + m.mode, _ = obj["mode"].(string) + m.agent, _ = obj["agent"].(string) + m.modelID, _ = obj["modelID"].(string) + m.providerID, _ = obj["providerID"].(string) + if _, ok := obj["error"]; ok && obj["error"] != nil { + m.hasErr = true + } + if c, ok := asFloat(obj["cost"]); ok { + m.cost = c + } + if tok, ok := obj["tokens"].(map[string]any); ok { + m.input = asInt(tok["input"]) + m.output = asInt(tok["output"]) + m.reasoning = asInt(tok["reasoning"]) + if cache, ok := tok["cache"].(map[string]any); ok { + m.cacheRead = asInt(cache["read"]) + m.cacheWrite = asInt(cache["write"]) + } + } + if tm, ok := obj["time"].(map[string]any); ok { + if v, ok := asFloat(tm["created"]); ok { + m.created = msTime(int64(v)) + } + if v, ok := asFloat(tm["completed"]); ok { + m.completed = msTime(int64(v)) + } + } + return m +} + +func parseSessionModel(raw string) (id, provider string) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "" + } + var obj struct { + ID string `json:"id"` + ProviderID string `json:"providerID"` + } + if json.Unmarshal([]byte(raw), &obj) != nil { + return "", "" + } + return obj.ID, obj.ProviderID +} + +func tokensFromMap(obj map[string]any) msgRow { + var m msgRow + if c, ok := asFloat(obj["cost"]); ok { + m.cost = c + } + tok, _ := obj["tokens"].(map[string]any) + if tok == nil { + return m + } + m.input = asInt(tok["input"]) + m.output = asInt(tok["output"]) + m.reasoning = asInt(tok["reasoning"]) + if cache, ok := tok["cache"].(map[string]any); ok { + m.cacheRead = asInt(cache["read"]) + m.cacheWrite = asInt(cache["write"]) + } + return m +} + +func sameDir(a, b string) bool { + a = filepath.ToSlash(filepath.Clean(a)) + b = filepath.ToSlash(filepath.Clean(b)) + if runtime.GOOS == "windows" { + return strings.EqualFold(a, b) + } + return a == b +} + +func msTime(ms int64) time.Time { + if ms <= 0 { + return time.Time{} + } + return time.UnixMilli(ms).UTC() +} + +func asFloat(v any) (float64, bool) { + switch n := v.(type) { + case float64: + return n, true + case json.Number: + f, err := n.Float64() + return f, err == nil + default: + return 0, false + } +} + +func asInt(v any) int { + f, ok := asFloat(v) + if !ok { + return 0 + } + return int(f) +} + +func firstNonEmpty(vs ...string) string { + for _, v := range vs { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +func isMissingSchema(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "no such table") || strings.Contains(msg, "no such column") +} + +func schemaSkip(what string) { + schemaSkipOnce.Do(func() { + fmt.Fprintf(os.Stderr, "opencode db schema skip: %s\n", what) + }) +} diff --git a/internal/opencodestore/read_test.go b/internal/opencodestore/read_test.go new file mode 100644 index 0000000..8e7771a --- /dev/null +++ b/internal/opencodestore/read_test.go @@ -0,0 +1,91 @@ +package opencodestore + +import ( + "database/sql" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestQueryConstantsOmitSecrets(t *testing.T) { + blob := qSessions + qMessages + qParts + for _, bad := range []string{"account", "credential", "control_account", "session_share"} { + if strings.Contains(blob, bad) { + t.Fatalf("query mentions %s", bad) + } + } +} + +func TestListTurnsFilterAndMap(t *testing.T) { + path := filepath.Join(t.TempDir(), "opencode.db") + writeFixture(t, path) + db, err := OpenReadOnly(path) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + proj := `D:/proj` + turns, err := ListTurns(db, proj) + if err != nil { + t.Fatal(err) + } + if len(turns) != 1 { + t.Fatalf("turns=%d %+v", len(turns), turns) + } + tr := turns[0] + if tr.MessageID != "msg_done" || tr.SessionID != "ses_here" { + t.Fatalf("%+v", tr) + } + if tr.Provider != "opencode" || tr.Model != "big-pickle" { + t.Fatalf("model %+v", tr) + } + if tr.InputTokens != 10 || tr.OutputTokens != 2 || tr.Cost != 0 { + t.Fatalf("tokens %+v", tr) + } + if tr.Prompt != "hello" || tr.Completion != "ok" { + t.Fatalf("bodies %+v", tr) + } +} + +func writeFixture(t *testing.T, path string) { + t.Helper() + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + defer db.Close() + _, err = db.Exec(` +CREATE TABLE session ( + id text, parent_id text, directory text, agent text, model text, + time_created integer, time_updated integer, time_archived integer +); +CREATE TABLE message ( + id text, session_id text, time_created integer, time_updated integer, data text +); +CREATE TABLE part ( + id text, message_id text, session_id text, time_created integer, time_updated integer, data text +); +INSERT INTO session VALUES + ('ses_here', '', 'D:/proj', 'squad', '{"id":"big-pickle","providerID":"opencode"}', 1000, 2000, NULL), + ('ses_other', '', 'D:/other', 'squad', '', 1000, 2000, NULL), + ('ses_arch', '', 'D:/proj', 'squad', '', 1000, 2000, 9); +INSERT INTO message VALUES + ('msg_user', 'ses_here', 1000, 1000, '{"role":"user","time":{"created":1000}}'), + ('msg_done', 'ses_here', 1100, 1200, '{"role":"assistant","parentID":"msg_user","mode":"squad","modelID":"big-pickle","providerID":"opencode","cost":0,"tokens":{"input":10,"output":2,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1100,"completed":1200}}'), + ('msg_inc', 'ses_here', 1300, 1300, '{"role":"assistant","parentID":"msg_user","mode":"squad","time":{"created":1300}}'), + ('msg_away', 'ses_other', 1100, 1200, '{"role":"assistant","mode":"squad","time":{"created":1100,"completed":1200}}'), + ('msg_arch', 'ses_arch', 1100, 1200, '{"role":"assistant","mode":"squad","modelID":"big-pickle","providerID":"opencode","cost":0,"tokens":{"input":1,"output":1,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1100,"completed":1200}}'); +INSERT INTO part VALUES + ('p1', 'msg_user', 'ses_here', 1000, 1000, '{"type":"text","text":"hello"}'), + ('p2', 'msg_done', 'ses_here', 1100, 1200, '{"type":"text","text":"ok"}'), + ('p3', 'msg_arch', 'ses_arch', 1100, 1200, '{"type":"text","text":"archived"}'); +`) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); err != nil { + t.Fatal(err) + } +} diff --git a/internal/squad/types.go b/internal/squad/types.go index bd88a9e..81aae15 100644 --- a/internal/squad/types.go +++ b/internal/squad/types.go @@ -29,6 +29,8 @@ type Config struct { ThemeOrigin string `json:"themeOrigin,omitempty"` // OTLP is optional live OTLP export settings (env wins at resolve time). OTLP *OTLPConfig `json:"otlp,omitempty"` + // OpenCodeDB is an optional path to opencode.db (OPENCODE_DB wins). + OpenCodeDB string `json:"opencode_db,omitempty"` } // MentionRow is one row in .squad/mentions.md (slugs without @). diff --git a/internal/traces/ingest.go b/internal/traces/ingest.go new file mode 100644 index 0000000..4892d6c --- /dev/null +++ b/internal/traces/ingest.go @@ -0,0 +1,144 @@ +package traces + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/xeaser/squad-opencode/internal/opencodestore" + "github.com/xeaser/squad-opencode/internal/squad" +) + +// Ingest reads OpenCode SQLite and appends new turns. n is the number of turns written +// to JSONL. OTLP push failures still count toward n (JSONL is kept) and are returned +// after the batch so callers can log and continue. +func Ingest(projectRoot string, cfg *squad.Config, getenv func(string) string, push func(context.Context, Settings, Span, *Span) error) (int, error) { + path, explicit, err := opencodestore.ResolveDBPath(projectRoot, cfg, getenv) + if err != nil { + return 0, err + } + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) && !explicit { + return 0, nil + } + return 0, err + } + db, err := opencodestore.OpenReadOnly(path) + if err != nil { + return 0, err + } + defer db.Close() + turns, err := opencodestore.ListTurns(db, projectRoot) + if err != nil { + return 0, err + } + existing, err := List(projectRoot, 0) + if err != nil { + return 0, err + } + seen := map[string]bool{} + for _, s := range existing { + if s.MessageID != "" { + seen[s.MessageID] = true + } + } + legacy := legacySkipFirst(existing, turns) + s, err := ResolveSettings(cfg, getenv) + if err != nil { + s = Settings{} + } + n := 0 + var pushErr error + for _, t := range turns { + if t.MessageID == "" || seen[t.MessageID] { + continue + } + if legacy[t.SessionID] == t.MessageID { + continue + } + attrs := map[string]string{"source": "sqlite", "message_id": t.MessageID} + if t.ParentMessageID != "" { + attrs["parent_message_id"] = t.ParentMessageID + } + if t.ParentSessionID != "" { + attrs["parent_session_id"] = t.ParentSessionID + } + in := RecordInput{ + ParentName: NameSession, + Start: t.UserStart, + End: t.AssistantEnd, + Agent: t.Agent, + Prompt: t.Prompt, + Completion: t.Completion, + SessionID: t.SessionID, + MessageID: t.MessageID, + Attrs: attrs, + HasGeneration: true, + Provider: t.Provider, + Model: t.Model, + InputTokens: t.InputTokens, + OutputTokens: t.OutputTokens, + ReasoningTokens: t.ReasoningTokens, + CacheReadTokens: t.CacheReadTokens, + CacheWriteTokens: t.CacheWriteTokens, + Cost: t.Cost, + } + if t.Err { + in.Err = fmt.Errorf("opencode message error") + } + parent, child := Build(in) + // gen_ai.chat starts when the assistant message is created, not the user turn. + if child != nil && !t.AssistantStart.IsZero() { + child.Start = t.AssistantStart + } + if err := writeBuilt(projectRoot, parent, child, s, push); err != nil { + if IsOTLPPushError(err) { + // JSONL already written; keep going and surface push once. + seen[t.MessageID] = true + n++ + if pushErr == nil { + pushErr = err + } + continue + } + return n, err + } + seen[t.MessageID] = true + n++ + } + return n, pushErr +} + +// IsOTLPPushError reports whether err is from Write's OTLP export step +// (JSONL append already succeeded). +func IsOTLPPushError(err error) bool { + return err != nil && strings.Contains(err.Error(), "otlp push:") +} + +// legacySkipFirst skips the earliest completed assistant when a session has a +// squad-oc.run / squad-oc.watch.execute span with empty MessageID (CLI parent +// already covered that turn). Durable across later ingests: once such a CLI +// parent exists, the earliest assistant stays skipped even after other +// messageIds appear in JSONL. +func legacySkipFirst(spans []Span, turns []opencodestore.Turn) map[string]string { + hasCLIEmpty := map[string]bool{} + for _, s := range spans { + if s.SessionID == "" { + continue + } + if (s.Name == "squad-oc.run" || s.Name == "squad-oc.watch.execute") && s.MessageID == "" { + hasCLIEmpty[s.SessionID] = true + } + } + skip := map[string]string{} + for _, t := range turns { + if !hasCLIEmpty[t.SessionID] { + continue + } + if _, ok := skip[t.SessionID]; !ok { + skip[t.SessionID] = t.MessageID + } + } + return skip +} diff --git a/internal/traces/ingest_test.go b/internal/traces/ingest_test.go new file mode 100644 index 0000000..56b118c --- /dev/null +++ b/internal/traces/ingest_test.go @@ -0,0 +1,244 @@ +package traces + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "strings" + "testing" + "time" + + _ "modernc.org/sqlite" + + "github.com/xeaser/squad-opencode/internal/squad" +) + +func TestIngestDedupAndSecondTurn(t *testing.T) { + root := t.TempDir() + dbPath := filepath.Join(root, "oc.db") + writeIngestFixture(t, dbPath) + cfg := &squad.Config{OpenCodeDB: dbPath} + env := func(string) string { return "" } + + n, err := Ingest(root, cfg, env, nil) + if err != nil || n != 2 { + t.Fatalf("first n=%d err=%v", n, err) + } + spans, err := List(root, 0) + if err != nil { + t.Fatal(err) + } + var chats, sessions int + wantChatStart := map[string]time.Time{ + "msg_a": time.UnixMilli(1100).UTC(), + "msg_b": time.UnixMilli(1400).UTC(), + } + wantSessionStart := map[string]time.Time{ + "msg_a": time.UnixMilli(1000).UTC(), + "msg_b": time.UnixMilli(1300).UTC(), + } + for _, s := range spans { + switch s.Name { + case NameChat: + chats++ + if s.MessageID == "" || s.SessionID != "ses_here" { + t.Fatalf("%+v", s) + } + if !s.Start.Equal(wantChatStart[s.MessageID]) { + t.Fatalf("chat %s start=%v want assistant created %v", s.MessageID, s.Start, wantChatStart[s.MessageID]) + } + case NameSession: + sessions++ + if s.MessageID == "" || s.SessionID != "ses_here" { + t.Fatalf("%+v", s) + } + if !s.Start.Equal(wantSessionStart[s.MessageID]) { + t.Fatalf("session %s start=%v want user %v", s.MessageID, s.Start, wantSessionStart[s.MessageID]) + } + } + } + if chats != 2 || sessions != 2 { + t.Fatalf("chats=%d sessions=%d", chats, sessions) + } + + n, err = Ingest(root, cfg, env, nil) + if err != nil || n != 0 { + t.Fatalf("second n=%d err=%v", n, err) + } +} + +func TestIngestSkipsKnownMessageIDIngestsLater(t *testing.T) { + root := t.TempDir() + dbPath := filepath.Join(root, "oc.db") + writeIngestFixture(t, dbPath) + start := time.Date(2026, 8, 28, 1, 0, 0, 0, time.UTC) + if err := Append(root, Span{ + Name: NameChat, SessionID: "ses_here", MessageID: "msg_a", + Start: start, End: start.Add(time.Second), Status: "OK", + }); err != nil { + t.Fatal(err) + } + n, err := Ingest(root, &squad.Config{OpenCodeDB: dbPath}, func(string) string { return "" }, nil) + if err != nil || n != 1 { + t.Fatalf("n=%d err=%v want 1", n, err) + } + var got []string + spans, _ := List(root, 0) + for _, s := range spans { + if s.Name == NameChat { + got = append(got, s.MessageID) + } + } + if len(got) != 2 || got[0] != "msg_a" || got[1] != "msg_b" { + t.Fatalf("chats=%v", got) + } +} + +func TestIngestLegacySkipsEarliestCLI(t *testing.T) { + root := t.TempDir() + dbPath := filepath.Join(root, "oc.db") + writeIngestFixture(t, dbPath) + cfg := &squad.Config{OpenCodeDB: dbPath} + env := func(string) string { return "" } + start := time.Date(2026, 8, 28, 1, 0, 0, 0, time.UTC) + if err := Append(root, Span{ + Name: "squad-oc.run", SessionID: "ses_here", + Start: start, End: start.Add(time.Second), Status: "OK", + }); err != nil { + t.Fatal(err) + } + n, err := Ingest(root, cfg, env, nil) + if err != nil || n != 1 { + t.Fatalf("legacy n=%d err=%v", n, err) + } + assertNoEarliestCLIChat(t, root) + + // After a later turn is in JSONL, hasMsg would be true under the old + // gate — second poll must still skip msg_a (durable legacy skip). + n, err = Ingest(root, cfg, env, nil) + if err != nil || n != 0 { + t.Fatalf("second n=%d err=%v", n, err) + } + assertNoEarliestCLIChat(t, root) +} + +func assertNoEarliestCLIChat(t *testing.T, root string) { + t.Helper() + spans, err := List(root, 0) + if err != nil { + t.Fatal(err) + } + for _, s := range spans { + if s.Name == NameChat && s.MessageID == "msg_a" { + t.Fatal("earliest CLI turn ingested") + } + } +} + +func TestIngestDefaultMissingSilent(t *testing.T) { + root := t.TempDir() + t.Setenv("XDG_DATA_HOME", filepath.Join(root, "xdg")) + n, err := Ingest(root, nil, os.Getenv, nil) + if err != nil || n != 0 { + t.Fatalf("n=%d err=%v", n, err) + } +} + +func TestIngestConfiguredMissingErrors(t *testing.T) { + root := t.TempDir() + _, err := Ingest(root, &squad.Config{OpenCodeDB: filepath.Join(root, "nope.db")}, func(string) string { return "" }, nil) + if err == nil { + t.Fatal("expected error") + } +} + +func TestIngestPushFailureKeepsJSONL(t *testing.T) { + root := t.TempDir() + dbPath := filepath.Join(root, "oc.db") + writeIngestFixture(t, dbPath) + t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:1") + n, err := Ingest(root, &squad.Config{OpenCodeDB: dbPath}, os.Getenv, func(context.Context, Settings, Span, *Span) error { + return context.Canceled + }) + if err == nil || !IsOTLPPushError(err) { + t.Fatalf("want otlp push error, got %v", err) + } + if n != 2 { + t.Fatalf("n=%d want 2 (jsonl kept for both turns)", n) + } + spans, _ := List(root, 0) + if len(spans) == 0 { + t.Fatal("jsonl should keep appended turn") + } +} + +func TestIngestExportHasSessionIDNoBodies(t *testing.T) { + root := t.TempDir() + dbPath := filepath.Join(root, "oc.db") + writeIngestFixture(t, dbPath) + n, err := Ingest(root, &squad.Config{OpenCodeDB: dbPath}, func(string) string { return "" }, nil) + if err != nil || n != 2 { + t.Fatalf("n=%d err=%v", n, err) + } + spans, err := List(root, 0) + if err != nil { + t.Fatal(err) + } + dest := filepath.Join(root, "out.otlp.json") + if err := ExportOTLPFile(spans, dest); err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(dest) + if err != nil { + t.Fatal(err) + } + s := string(body) + for _, want := range []string{"session.id", "gen_ai.conversation.id", NameSession, NameChat} { + if !strings.Contains(s, want) { + t.Fatalf("missing %q in %s", want, s) + } + } + if strings.Contains(s, `"text":"a"`) || strings.Contains(s, "SECRET") { + 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 writeIngestFixture(t *testing.T, path string) { + t.Helper() + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + defer db.Close() + proj := filepath.Dir(path) + _, err = db.Exec(` +CREATE TABLE session ( + id text, parent_id text, directory text, agent text, model text, + time_created integer, time_updated integer, time_archived integer +); +CREATE TABLE message ( + id text, session_id text, time_created integer, time_updated integer, data text +); +CREATE TABLE part ( + id text, message_id text, session_id text, time_created integer, time_updated integer, data text +); +INSERT INTO session VALUES ('ses_here', NULL, ?, 'squad', '{"id":"m","providerID":"p"}', 1, 2, NULL); +INSERT INTO message VALUES + ('u1', 'ses_here', 1000, 1000, '{"role":"user","time":{"created":1000}}'), + ('msg_a', 'ses_here', 1100, 1200, '{"role":"assistant","parentID":"u1","mode":"squad","modelID":"m","providerID":"p","cost":0,"tokens":{"input":1,"output":1,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1100,"completed":1200}}'), + ('u2', 'ses_here', 1300, 1300, '{"role":"user","time":{"created":1300}}'), + ('msg_b', 'ses_here', 1400, 1500, '{"role":"assistant","parentID":"u2","mode":"squad","modelID":"m","providerID":"p","cost":0,"tokens":{"input":2,"output":2,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1400,"completed":1500}}'); +INSERT INTO part VALUES + ('p1', 'u1', 'ses_here', 1000, 1000, '{"type":"text","text":"a"}'), + ('p2', 'msg_a', 'ses_here', 1100, 1200, '{"type":"text","text":"A"}'), + ('p3', 'u2', 'ses_here', 1300, 1300, '{"type":"text","text":"b"}'), + ('p4', 'msg_b', 'ses_here', 1400, 1500, '{"type":"text","text":"B"}'); +`, proj) + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/traces/otel.go b/internal/traces/otel.go index b115539..08ae2c1 100644 --- a/internal/traces/otel.go +++ b/internal/traces/otel.go @@ -153,6 +153,7 @@ func parentOTelAttrs(s Span) []attribute.KeyValue { } if s.SessionID != "" { attrs = append(attrs, attribute.String("gen_ai.conversation.id", s.SessionID)) + attrs = append(attrs, attribute.String("session.id", s.SessionID)) } if v := s.Attributes["issues"]; v != "" { attrs = append(attrs, attribute.String("issues", v)) @@ -187,6 +188,7 @@ func childOTelAttrs(s Span, capture bool) []attribute.KeyValue { } if s.SessionID != "" { attrs = append(attrs, attribute.String("gen_ai.conversation.id", s.SessionID)) + attrs = append(attrs, attribute.String("session.id", s.SessionID)) } if capture { attrs = append(attrs, diff --git a/internal/traces/otel_test.go b/internal/traces/otel_test.go index 43296ad..ef423a1 100644 --- a/internal/traces/otel_test.go +++ b/internal/traces/otel_test.go @@ -27,6 +27,7 @@ func TestBuildParentChildAndParentOnly(t *testing.T) { Prompt: "hi", Completion: "yo", SessionID: "ses_1", + MessageID: "msg_1", Attrs: map[string]string{"agent": "lead", "prompt_bytes": "2"}, HasGeneration: true, Provider: "xai", @@ -41,9 +42,12 @@ func TestBuildParentChildAndParentOnly(t *testing.T) { if child.TraceID != parent.TraceID || child.ParentID != parent.SpanID { t.Fatal("tree") } - if child.Prompt != "hi" || child.Cost != 0 || child.Model != "grok-4" { + if child.Prompt != "hi" || child.Cost != 0 || child.Model != "grok-4" || child.MessageID != "msg_1" { t.Fatalf("child %+v", child) } + if parent.MessageID != "msg_1" { + t.Fatalf("parent message %q", parent.MessageID) + } parent, child = Build(RecordInput{ParentName: "squad-oc.run", Err: errors.New("boom"), Agent: "squad"}) if parent.Status != "ERROR" || child != nil { @@ -140,6 +144,9 @@ func TestRecordToSpanRecorderTypedAttrs(t *testing.T) { if attrs["gen_ai.request.model"] != "grok-4" { t.Fatalf("%v", attrs) } + if attrs["session.id"] != "ses_1" || attrs["gen_ai.conversation.id"] != "ses_1" { + t.Fatalf("session attrs %v", attrs) + } if _, ok := attrs["gen_ai.input.messages"]; ok { t.Fatal("capture off") } diff --git a/internal/traces/record.go b/internal/traces/record.go index 42c2024..3172c67 100644 --- a/internal/traces/record.go +++ b/internal/traces/record.go @@ -15,6 +15,7 @@ type RecordInput struct { Prompt string Completion string SessionID string + MessageID string Attrs map[string]string // Generation set when Session.Prompt returned (even if Info empty). HasGeneration bool @@ -49,6 +50,7 @@ func Build(in RecordInput) (parent Span, child *Span) { Status: status, Attributes: attrs, SessionID: in.SessionID, + MessageID: in.MessageID, Agent: agent, } if parent.TraceID == "" { @@ -74,6 +76,7 @@ func Build(in RecordInput) (parent Span, child *Span) { End: in.End, Status: "OK", SessionID: in.SessionID, + MessageID: in.MessageID, Agent: agent, Provider: in.Provider, Model: in.Model, @@ -94,6 +97,11 @@ func Build(in RecordInput) (parent Span, child *Span) { // 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) + return writeBuilt(projectRoot, parent, child, s, push) +} + +// writeBuilt appends and optionally pushes already-built spans. +func writeBuilt(projectRoot string, parent Span, child *Span, s Settings, push func(context.Context, Settings, Span, *Span) error) error { if projectRoot != "" { if err := Append(projectRoot, parent); err != nil { return fmt.Errorf("append: %w", err) diff --git a/internal/traces/traces.go b/internal/traces/traces.go index c57bf86..89f37e7 100644 --- a/internal/traces/traces.go +++ b/internal/traces/traces.go @@ -19,6 +19,9 @@ import ( // NameChat is the OTel gen_ai operation span name for chat generations. const NameChat = "gen_ai.chat" +// NameSession is the parent span for an ingested OpenCode turn. +const NameSession = "squad-oc.session" + // Span is a local recorded interval. type Span struct { Name string `json:"name"` @@ -31,6 +34,7 @@ type Span struct { Attributes map[string]string `json:"attributes"` SessionID string `json:"sessionId,omitempty"` + MessageID string `json:"messageId,omitempty"` Agent string `json:"agent,omitempty"` Provider string `json:"provider,omitempty"` Model string `json:"model,omitempty"` @@ -254,6 +258,7 @@ func appendGenAIExportAttrs(attrs []otlpKeyValue, s Span) []otlpKeyValue { } if s.SessionID != "" { attrs = append(attrs, stringAttr("gen_ai.conversation.id", s.SessionID)) + attrs = append(attrs, stringAttr("session.id", s.SessionID)) } if s.InputTokens != 0 { attrs = append(attrs, stringAttr("gen_ai.usage.input_tokens", fmt.Sprintf("%d", s.InputTokens))) diff --git a/internal/traces/traces_test.go b/internal/traces/traces_test.go index de6e7ea..1f95a22 100644 --- a/internal/traces/traces_test.go +++ b/internal/traces/traces_test.go @@ -286,6 +286,7 @@ func TestExportOTLPHasGenAINoBodies(t *testing.T) { "gen_ai.usage.cost", "gen_ai.agent.name", "gen_ai.conversation.id", + "session.id", } { if !strings.Contains(s, want) { t.Fatalf("missing %q in %s", want, s) diff --git a/internal/watch/watch.go b/internal/watch/watch.go index ba28714..e6cdc2f 100644 --- a/internal/watch/watch.go +++ b/internal/watch/watch.go @@ -316,6 +316,7 @@ func Pass(ctx context.Context, opts Options) (executed bool, summary string, err Prompt: ctxText, Completion: res.Text, SessionID: res.SessionID, + MessageID: res.MessageID, Attrs: map[string]string{"issues": strconv.Itoa(len(issues))}, HasGeneration: res.HasGeneration, Provider: res.Provider, From 1803904e4ab95d1212953847e55c0527cbac05ff Mon Sep 17 00:00:00 2001 From: Parag Sharma Date: Fri, 28 Aug 2026 10:28:24 +0530 Subject: [PATCH 2/2] fix(opencodestore): match project dirs across symlink spellings macOS /var is a symlink to /private/var. TestTracesCLIIngestThenExport stored t.TempDir() and compared it to Getwd(), so ingest dropped every session and exported empty OTLP spans. sameDir now uses SameFile and EvalSymlinks. --- internal/opencodestore/read.go | 25 ++++++++++++++--- internal/opencodestore/read_test.go | 42 +++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/internal/opencodestore/read.go b/internal/opencodestore/read.go index b216b6b..aee5537 100644 --- a/internal/opencodestore/read.go +++ b/internal/opencodestore/read.go @@ -294,13 +294,30 @@ func tokensFromMap(obj map[string]any) msgRow { return m } +// sameDir treats distinct spellings of the same directory as equal +// (macOS /var vs /private/var; Windows junctions). func sameDir(a, b string) bool { - a = filepath.ToSlash(filepath.Clean(a)) - b = filepath.ToSlash(filepath.Clean(b)) + if a == "" || b == "" { + return a == b + } + if fa, err := os.Stat(a); err == nil { + if fb, err := os.Stat(b); err == nil && os.SameFile(fa, fb) { + return true + } + } + return normDir(a) == normDir(b) +} + +func normDir(p string) string { + p = filepath.Clean(p) + if r, err := filepath.EvalSymlinks(p); err == nil { + p = r + } + p = filepath.ToSlash(p) if runtime.GOOS == "windows" { - return strings.EqualFold(a, b) + return strings.ToLower(p) } - return a == b + return p } func msTime(ms int64) time.Time { diff --git a/internal/opencodestore/read_test.go b/internal/opencodestore/read_test.go index 8e7771a..f4bfdbc 100644 --- a/internal/opencodestore/read_test.go +++ b/internal/opencodestore/read_test.go @@ -3,7 +3,9 @@ package opencodestore import ( "database/sql" "os" + "os/exec" "path/filepath" + "runtime" "strings" "testing" ) @@ -49,6 +51,46 @@ func TestListTurnsFilterAndMap(t *testing.T) { } } +// macOS /var is a symlink to /private/var. OpenCode may store one spelling +// while Getwd() returns the other; ingest must still match the project. +func TestSameDirViaSymlink(t *testing.T) { + root := t.TempDir() + real := filepath.Join(root, "proj") + if err := os.Mkdir(real, 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(root, "alias") + if err := os.Symlink(real, link); err != nil { + if runtime.GOOS == "windows" { + out, jerr := exec.Command("cmd", "/c", "mklink", "/J", link, real).CombinedOutput() + if jerr != nil { + t.Skipf("symlink: %v; junction: %v %s", err, jerr, out) + } + } else { + resolved, err2 := filepath.EvalSymlinks(root) + if err2 != nil || filepath.Clean(resolved) == filepath.Clean(root) { + t.Skipf("symlink: %v", err) + } + real, link = root, resolved + } + } + if !sameDir(real, link) { + t.Fatalf("sameDir(%q, %q) = false", real, link) + } + if sameDir(real, filepath.Join(root, "other")) { + t.Fatal("different dirs matched") + } +} + +func TestSameDirEmpty(t *testing.T) { + if sameDir("", "/tmp/proj") || sameDir("/tmp/proj", "") { + t.Fatal("empty matched") + } + if !sameDir("", "") { + t.Fatal("empty != empty") + } +} + func writeFixture(t *testing.T, path string) { t.Helper() db, err := sql.Open("sqlite", path)