You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/09/21 14:58:41 UTC
(skywalking-ai-sessionizer) branch main updated: Make 0.5.0 ready to cut: one collector for every recorder, and every (#30)
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-ai-sessionizer.git
The following commit(s) were added to refs/heads/main by this push:
new aa9d1b9 Make 0.5.0 ready to cut: one collector for every recorder, and every (#30)
aa9d1b9 is described below
commit aa9d1b9c59f3e435c1b208b7c7b54cba28fae7e7
Author: 吴晟 Wu Sheng <wu...@foxmail.com>
AuthorDate: Mon Sep 21 22:58:35 2026 +0800
Make 0.5.0 ready to cut: one collector for every recorder, and every (#30)
promise the docs make kept
Two audits of the repository, one by hand and one by GPT-6, found what
stood between main and a 0.5.0 candidate. None of it was a rename: the
names 0.5.0 introduced are neutral where a thing is shared across
runtimes and specific only where it belongs to one, and adding an agent
built on the Claude Agent SDK or on Codex adds names without changing
these. What was wrong sat exactly where a machine runs more than one
runtime, and in a release step that did not exist.
The changes adapter may be named once per recorder directory, so a
machine that runs Claude Code's plugin and a LangChain shim collects
both. A second entry used to be refused, and the old name beside the
new one passed the check and silently replaced it, so a configuration
kept from 0.4.0 with the documented LangChain directory added stopped
collecting Claude Code's changes. Two entries that read one directory
are refused, and the old name counts as the same adapter.
claude_code.token.usage is derived from Claude Code's transcripts
alone. The derivation read every transcript in the root, and a
LangChain conversation carries calls and usage too, so with metrics on
its tokens went out as Claude Code's.
A session sent before its transcript landed - the recorder's files
often come first, and their adapter is shared by every runtime - was
attributed to Claude Code. It is attributed by the name asz gave it,
which says which receiver landed it.
The Claude Code plugin installer finds the plugin under its old name
and uninstalls it with the data kept before it removes the marketplace,
which deletes a plugin's data. It copies the old data beside the new
directory and renames it into place, so a copy that stops half way is
done again rather than skipped, and it finds Claude Code's directory
the way asz does. Upgrading from 0.4.0 is one ordered list on the
install page.
The LangChain plugin promised pip install and nothing published it. It
is published to PyPI after the vote, as the Homebrew tap and the apt
repository are written after it: the release guide has the step, the
pypi skill does it, the wheel carries LICENSE and NOTICE, and prepare
writes the version into the plugin.
A cursor names the recorder root its file is under. Two roots read by
one configuration can hold one session under one relative path - a
shim's flat directory and another - and one cursor for both read the
second against the first's position, a truncation conflict that
stopped both for good. The root is written relative to the storage root
when it lies inside it, so a scenario root copied whole keeps its
cursors, and absolute otherwise. A cursor written before 0.5.0 names no
root: it belongs to the root whose file it was read from - the file
with its device and inode, and the bytes before its offset, since two
files can share their last megabyte and differ before it - and that
root writes
its name into it on the next pass, even one that lands nothing. One
whose file was copied since is read again from the start, and the index
keeps the first record of an id. Two names for one directory are one directory, compared where
the paths are resolved and through any symbolic link.
The install scripts put asz-changes beside asz from an archive that
holds it, as Homebrew and apt do, and asz alone from an older version's
archive, which holds the recorder under its old name. The CI harness
that follows the install pages expected the script to leave the
recorder to the plugin installer, and expects both now.
Smaller: asz-changes says its own name in its help and its version;
a scenario's coverage check judges each change file against the cursor
it was landed behind, as collection does, so removal no longer waits
for ever on a session with two plugin directories; a failing Python
suite fails make check instead of reporting Python as missing;
asz.yaml writes headers out and its test reads the file's keys rather
than the loaded values; and the README, the configuration, install,
release and adapter pages say what 0.5.0 does.
---
.claude/skills/pypi/SKILL.md | 75 ++++++++
Makefile | 8 +-
README.md | 2 +-
asz.yaml | 4 +-
cmd/asz/pipeline_test.go | 96 ++++++++++
cmd/asz/push.go | 8 +
cmd/asz/refresh.go | 80 ++++++--
docs/en/adapters/langsmith.md | 17 +-
docs/en/changes/changes.md | 42 +++++
docs/en/formats/storage-root.md | 3 +-
docs/en/guides/how-to-release.md | 35 +++-
docs/en/setup/claude-code-plugin.md | 4 +-
docs/en/setup/configuration.md | 40 +++-
docs/en/setup/install.md | 34 +++-
docs/en/setup/langchain-plugin.md | 3 +-
install/asz.ps1 | 30 ++-
install/asz.sh | 17 +-
install/claude-code-plugin.ps1 | 29 ++-
install/claude-code-plugin.sh | 41 ++++-
internal/adapters/claudecode/tail.go | 13 ++
internal/adapters/claudecodechanges/collect.go | 71 +++++++-
.../adapters/claudecodechanges/collect_test.go | 163 +++++++++++++++++
internal/adapters/claudecodechanges/covered.go | 28 ++-
.../adapters/claudecodechanges/covered_test.go | 26 +++
internal/adapters/langsmith/identity.go | 7 +
internal/config/config.go | 26 ++-
internal/config/config_test.go | 93 ++++++++++
internal/export/otlp/push.go | 19 +-
internal/metrics/metrics.go | 16 +-
internal/metrics/metrics_test.go | 46 ++++-
internal/storage/cursor.go | 14 ++
plugins/claude-code/main.go | 19 +-
plugins/langchain/LICENSE | 202 +++++++++++++++++++++
plugins/langchain/NOTICE | 5 +
plugins/langchain/pyproject.toml | 1 +
tools/release/release.sh | 9 +-
tools/test/claude-code-check/main.go | 6 +-
37 files changed, 1222 insertions(+), 110 deletions(-)
diff --git a/.claude/skills/pypi/SKILL.md b/.claude/skills/pypi/SKILL.md
new file mode 100644
index 0000000..9fbf3aa
--- /dev/null
+++ b/.claude/skills/pypi/SKILL.md
@@ -0,0 +1,75 @@
+---
+name: pypi
+description: Publish a released version of the Apache SkyWalking AI Sessionizer LangChain plugin, apache-skywalking-asz-langchain, to PyPI. Downloads the voted source package, verifies it, builds the sdist and the wheel from plugins/langchain inside it, checks and installs them, and uploads them with twine. Use after a version is published.
+user-invocable: true
+---
+
+# PyPI
+
+`apache-skywalking-asz-langchain` is the LangChain plugin under `plugins/langchain`. It is in the
+voted source package, and PyPI is a convenience channel written after the vote, as the Homebrew tap
+and the apt repository are. Nothing is uploaded for a candidate, and the version uploaded is the
+released one, which `release.sh prepare` wrote into `plugins/langchain/pyproject.toml`.
+
+Ask for the version when it is not given. Then:
+
+1. **Download the voted source package and verify it.** Every released version is on
+ archive.apache.org; the newest is on downloads.apache.org too.
+
+ ```sh
+ v=VERSION
+ pkg=apache-skywalking-ai-sessionizer-$v-src.tgz
+ base=https://archive.apache.org/dist/skywalking/ai-sessionizer/$v
+ W=$(mktemp -d); cd "$W"
+ curl -fsSLO "$base/$pkg" && curl -fsSLO "$base/$pkg.sha512" && curl -fsSLO "$base/$pkg.asc"
+ curl -fsSL https://downloads.apache.org/skywalking/KEYS | gpg --import
+ shasum -a 512 -c "$pkg.sha512" && gpg --verify "$pkg.asc" "$pkg"
+ tar -xzf "$pkg"
+ ```
+
+ Stop if either check fails. Nothing is built from a package that did not verify.
+
+2. **Build from the package, not from a checkout.** A checkout can hold what the vote did not.
+
+ ```sh
+ python3 -m venv build-env && . build-env/bin/activate
+ pip install --quiet build twine
+ python -m build --outdir dist "apache-skywalking-ai-sessionizer-$v-src/plugins/langchain"
+ twine check dist/*
+ ```
+
+ `dist/` holds `apache_skywalking_asz_langchain-$v.tar.gz` and
+ `apache_skywalking_asz_langchain-$v-py3-none-any.whl`. Check the version in the file names is
+ `$v`, and that `unzip -l dist/*.whl` lists `LICENSE` and `NOTICE` under the `.dist-info`
+ directory.
+
+3. **Install the wheel where nothing else is, and run it.** `asz-langchain status` exits 1 while
+ the plugin is not enabled and 0 while it is, so the checks below say which exit code they
+ expect.
+
+ ```sh
+ python3 -m venv try-env && . try-env/bin/activate
+ pip install --quiet dist/*.whl
+ asz-langchain status && { echo "enabled before enable"; exit 1; }
+ asz-langchain enable && asz-langchain status || { echo "enable did not take"; exit 1; }
+ asz-langchain disable && ! asz-langchain status || { echo "disable did not take"; exit 1; }
+ deactivate
+ ```
+
+ `status` must say "not enabled", then "enabled at ...", then "not enabled" again. Stop if a
+ line prints its message.
+
+4. **Upload.** The project's PyPI account owns the name; the release manager has a token for it.
+
+ ```sh
+ . build-env/bin/activate
+ TWINE_USERNAME=__token__ TWINE_PASSWORD="$PYPI_TOKEN" twine upload dist/*
+ ```
+
+ PyPI refuses a file name it already holds, so a version is uploaded once; to correct one,
+ release the next version.
+
+5. **Check it landed.** `pip index versions apache-skywalking-asz-langchain` lists `$v`, and
+ `pip install apache-skywalking-asz-langchain==$v` in a fresh environment installs it.
+
+Then say what was uploaded and where, and clean up `$W`.
diff --git a/Makefile b/Makefile
index 3b1d6a5..c1e0879 100644
--- a/Makefile
+++ b/Makefile
@@ -75,9 +75,11 @@ test:
## test-langchain-shim: the Python half of the agreement with the receiver
.PHONY: test-langchain-shim
test-langchain-shim:
- @command -v python3 >/dev/null 2>&1 \
- && python3 -m unittest discover -s plugins/langchain/tests \
- || echo "python3 is not installed; the LangChain shim tests were skipped"
+ @if command -v python3 >/dev/null 2>&1; then \
+ python3 -m unittest discover -s plugins/langchain/tests; \
+ else \
+ echo "python3 is not installed; the LangChain shim tests were skipped"; \
+ fi
## test-e2e: the scenarios, the chain tests and the boundary rules, verbose
.PHONY: test-e2e
diff --git a/README.md b/README.md
index 584d527..321dc9a 100644
--- a/README.md
+++ b/README.md
@@ -58,7 +58,7 @@ adapter reports it as `unavailable` rather than approximating it.
| --- | --- | --- |
| [Claude Code](docs/en/adapters/claude-code.md) | collection implemented | local files — no configuration required, and it works on history that already exists. The [plugin](docs/en/setup/claude-code-plugin.md) adds which files each shell command changed. The [receiver](docs/en/setup/configuration.md#the-receiver-adapter), `claude-code-otlp`, is off by default. When Claude Code's own OpenTelemetry exporter is pointed at it, it lands the exporter's metrics requests. It drops the exporter [...]
| Codex | planned | — |
-| LangChain / LangGraph | planned | — |
+| [LangChain / LangGraph](docs/en/adapters/langsmith.md) | collection implemented | a receiver, `langsmith-ingest`, the tracing client every LangChain application carries is pointed at with four environment variables; nothing changes in the application. It lands the conversation and what each model call was sent. The [plugin](docs/en/setup/langchain-plugin.md) adds which files each tool call changed. |
## Quick start
diff --git a/asz.yaml b/asz.yaml
index 257f9ec..2476187 100644
--- a/asz.yaml
+++ b/asz.yaml
@@ -264,8 +264,8 @@ export:
# with underscores.
layer: AI_AGENT
# Sent with every request, as gRPC metadata or as HTTP headers, for
- # example
- # headers: { Authorization: "Bearer ..." }
+ # example { Authorization: "Bearer ..." }. Empty sends none.
+ headers: {}
# How many file bytes one request carries at most; a file larger than
# this is sent alone. 8 MiB keeps a request under the 10 MiB the OAP's
# HTTP server accepts.
diff --git a/cmd/asz/pipeline_test.go b/cmd/asz/pipeline_test.go
index d1af071..8899b87 100644
--- a/cmd/asz/pipeline_test.go
+++ b/cmd/asz/pipeline_test.go
@@ -21,6 +21,7 @@ import (
"io"
"os"
"path/filepath"
+ "runtime"
"strings"
"testing"
"time"
@@ -35,6 +36,7 @@ import (
"github.com/apache/skywalking-ai-sessionizer/internal/scenario"
"github.com/apache/skywalking-ai-sessionizer/internal/storage"
"github.com/apache/skywalking-ai-sessionizer/internal/view"
+ "github.com/apache/skywalking-ai-sessionizer/pkg/changes"
"github.com/apache/skywalking-ai-sessionizer/pkg/sessionflow"
)
@@ -917,3 +919,97 @@ func TestProviderBodiesReachesTheReceiverCollector(t *testing.T) {
}
}
}
+
+// changeRecord is one record a recorder wrote for a session, as a line.
+func changeRecord(session, tool string) string {
+ r := &changes.Record{
+ Schema: changes.Schema, ID: "p1/c1", CapturedBy: changes.CapturedByASZPlugin, Session: session, Stream: "main",
+ Tool: "toolu_1", ToolName: tool, Time: "2026-09-08T02:00:04Z", Basis: changes.BasisToolWindow,
+ Root: &changes.Root{Path: "/w"}, ChangedFiles: changes.Int(1),
+ Changes: []changes.FileChange{{Path: "a.go", Operation: changes.OpModify,
+ Before: changes.Endpoint{Present: true, Bytes: changes.Int64(2), SHA256: "x"},
+ After: changes.Endpoint{Present: true, Bytes: changes.Int64(2), SHA256: "y"},
+ Diff: changes.DiffAvailable, Additions: changes.Int(1), Deletions: changes.Int(1),
+ Hunks: []changes.Hunk{{OldStart: 1, OldLines: 1, NewStart: 1, NewLines: 1, Lines: []string{"-a", "+b"}}}}},
+ }
+ b, err := r.Marshal()
+ if err != nil {
+ panic(err)
+ }
+ return string(b) + "\n"
+}
+
+// TestTwoRecorderDirectoriesAreBothCollected.
+//
+// A machine can run Claude Code's plugin and a LangChain shim at once, each
+// writing its own directory: the plugin under Claude Code's plugin data,
+// the shim where ASZ_CHANGES_DATA points. The changes adapter is named once
+// per directory, and one pipeline collects both. Before, a second entry
+// silently replaced the first, and only one directory was read.
+func TestTwoRecorderDirectoriesAreBothCollected(t *testing.T) {
+ root := t.TempDir()
+ zone := storage.NewZone(root)
+ const claude = "11111111-2222-4333-8444-555555555555"
+ const langchain = "ls-agent-thread-1-abcdef012345"
+ pluginData := filepath.Join(root, "claude", "plugins", "data")
+ shimData := filepath.Join(root, "shim")
+ for _, w := range []struct{ path, session, tool string }{
+ {filepath.Join(pluginData, "file-changes-skywalking-ai-sessionizer", "output", claude, "main.jsonl"), claude, "Bash"},
+ {filepath.Join(shimData, "output", langchain, "main.jsonl"), langchain, "write_report"},
+ } {
+ if err := os.MkdirAll(filepath.Dir(w.path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(w.path, []byte(changeRecord(w.session, w.tool)), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ once := config.Collector{Mode: config.ModeOnce, Interval: time.Minute, MaxDeltaBytes: 1 << 20}
+ ads := []config.Adapter{
+ {Name: config.AdapterChanges, Enabled: true, SourceRoot: pluginData, Collector: once},
+ {Name: config.AdapterChanges, Enabled: true, SourceRoot: shimData, Collector: once},
+ }
+ ref, err := newRefresher(view.New(zone, nil), zone, ads, 2<<20, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(ref.close)
+ if got := len(ref.changes); got != 2 {
+ t.Fatalf("%d recorder directories are read, want both", got)
+ }
+ _ = ref.pass()
+ for _, session := range []string{claude, langchain} {
+ files, err := storage.LandedFiles(zone, session)
+ if err != nil || len(files) == 0 {
+ t.Errorf("%s: nothing landed (%v); its recorder directory was not read", session, err)
+ }
+ }
+}
+
+// Two names for one recorder directory are one directory - a symbolic
+// link to it, or the default beside its explicit path - and the default is
+// one only the collector can name, so the directories are compared where
+// they are resolved, through the link.
+func TestTwoSpellingsOfOneRecorderDirectoryAreRefused(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("a symbolic link needs a privilege on Windows")
+ }
+ root := t.TempDir()
+ zone := storage.NewZone(root)
+ data := filepath.Join(root, "shim")
+ if err := os.MkdirAll(data, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ alias := filepath.Join(root, "shim-link")
+ if err := os.Symlink(data, alias); err != nil {
+ t.Fatal(err)
+ }
+ once := config.Collector{Mode: config.ModeOnce, Interval: time.Minute, MaxDeltaBytes: 1 << 20}
+ ads := []config.Adapter{
+ {Name: config.AdapterChanges, Enabled: true, SourceRoot: data, Collector: once},
+ {Name: config.AdapterChanges, Enabled: true, SourceRoot: alias, Collector: once},
+ }
+ if _, err := newRefresher(view.New(zone, nil), zone, ads, 2<<20, true); err == nil || !strings.Contains(err.Error(), "both read") && !strings.Contains(err.Error(), "read "+data) {
+ t.Fatalf("two spellings of one directory were accepted: %v", err)
+ }
+}
diff --git a/cmd/asz/push.go b/cmd/asz/push.go
index de18a5e..efd4bc8 100644
--- a/cmd/asz/push.go
+++ b/cmd/asz/push.go
@@ -139,6 +139,14 @@ func newPusher(cfg *config.Config, zoneRoot string) (*otlp.Pusher, func(), error
Runtimes: map[string]string{claudecode.Name: claudecode.RuntimeName, claudecodechanges.Name: claudecodechanges.RuntimeName,
claudecodechanges.NameWas: claudecodechanges.RuntimeName,
langsmith.Name: langsmith.RuntimeName, claudecodeprovider.Name: claudecodeprovider.RuntimeName, mock.Name: mock.RuntimeName},
+ // A session whose transcript has not landed yet is named by asz
+ // itself when it came through the receiver, and that name says so.
+ SessionRuntime: func(session string) string {
+ if langsmith.IsSessionID(session) {
+ return langsmith.RuntimeName
+ }
+ return ""
+ },
InstanceID: o.InstanceID,
Layer: o.Layer,
BatchBytes: o.BatchBytes,
diff --git a/cmd/asz/refresh.go b/cmd/asz/refresh.go
index 421e6d2..61b6474 100644
--- a/cmd/asz/refresh.go
+++ b/cmd/asz/refresh.go
@@ -39,6 +39,42 @@ import (
"github.com/apache/skywalking-ai-sessionizer/internal/view"
)
+// changesSource is one recorder directory the changes adapter reads: its
+// collector, the session filter of that adapter entry, and the directory.
+type changesSource struct {
+ col *claudecodechanges.Collector
+ match func(claudecodechanges.Session) bool
+ src string
+}
+
+// changesFor is the changes source that reads root, or the first source
+// when none does, so a scenario remover that expects the build's own
+// directory can say which one is read instead. Empty when there is none.
+func (r *refresher) changesFor(root string) changesSource {
+ for _, cs := range r.changes {
+ if sameDir(cs.src, root) {
+ return cs
+ }
+ }
+ if len(r.changes) > 0 {
+ return r.changes[0]
+ }
+ return changesSource{}
+}
+
+// sameDir reports whether two paths name one directory: the same after
+// cleaning, or after following symbolic links where the paths exist.
+func sameDir(a, b string) bool {
+ canon := func(p string) string {
+ p = filepath.Clean(p)
+ if resolved, err := filepath.EvalSymlinks(p); err == nil {
+ return resolved
+ }
+ return p
+ }
+ return canon(a) == canon(b)
+}
+
// refresher is the pipeline: land what is new, parse what moved, and send
// what is now on disk.
//
@@ -59,8 +95,10 @@ type refresher struct {
deriver *metrics.Deriver
col *claudecode.Collector
match func(claudecode.Session) bool
- changes *claudecodechanges.Collector
- cmatch func(claudecodechanges.Session) bool
+ // changes holds one recorder directory per changes adapter entry. A
+ // machine can run Claude Code's plugin and a LangChain shim at once,
+ // each writing its own directory, and one collector reads them all.
+ changes []changesSource
// provider lands the bodies Claude Code wrote for its model provider,
// and providerAd holds its filter settings.
provider *claudecodeprovider.Collector
@@ -68,7 +106,6 @@ type refresher struct {
// The directories the collectors read. They are checked on every pass,
// not once, so a source that appears later is picked up.
colSource string
- changesSource string
providerSource string
interval time.Duration
maxRound int64
@@ -185,9 +222,17 @@ func newRefresher(srv *view.Server, zone *storage.Zone, ads []config.Adapter, ma
if err != nil {
return nil, err
}
- r.changes = claudecodechanges.New(src, zone, ad.Collector.MaxDeltaBytes)
- r.cmatch = changesMatch(ad)
- r.changesSource = src
+ // The configuration compared the spellings; this compares the
+ // directories, which is where the default gets its name, and
+ // through any symbolic link, since /tmp and /private/tmp are
+ // one directory on macOS.
+ for _, cs := range r.changes {
+ if sameDir(cs.src, src) {
+ return nil, fmt.Errorf("config: two %s adapters read %s; name each directory once", config.AdapterChanges, src)
+ }
+ }
+ r.changes = append(r.changes, changesSource{
+ col: claudecodechanges.New(src, zone, ad.Collector.MaxDeltaBytes), match: changesMatch(ad), src: src})
names, sources = append(names, ad.Name), append(sources, src)
case config.AdapterClaudeCodeProvider:
src, err := claudecodeprovider.ResolveSourceRoot(ad.SourceRoot)
@@ -200,7 +245,7 @@ func newRefresher(srv *view.Server, zone *storage.Zone, ads []config.Adapter, ma
names, sources = append(names, ad.Name), append(sources, src)
}
}
- if r.col == nil && r.changes == nil && r.provider == nil && r.langsmith == nil {
+ if r.col == nil && len(r.changes) == 0 && r.provider == nil && r.langsmith == nil {
// Nothing local is enabled and nothing feeds the pipeline. A root
// filled somewhere else, by the metrics receiver or copied from
// another machine, is served as it is.
@@ -214,9 +259,12 @@ func newRefresher(srv *view.Server, zone *storage.Zone, ads []config.Adapter, ma
// A build writes a session under claude-code-local's source, so only a
// pipeline that collects from there can remove one.
if r.col != nil {
+ // A build writes its plugin output under the local source, so the
+ // remover is given the changes source that reads there.
+ cs := r.changesFor(filepath.Join(r.colSource, "plugins", "data"))
r.remover = &remove.Remover{
Zone: zone, Source: r.colSource, Local: r.col, LocalMatch: r.match,
- Changes: r.changes, ChangesRoot: r.changesSource, ChangesMatch: r.cmatch,
+ Changes: cs.col, ChangesRoot: cs.src, ChangesMatch: cs.match,
Provider: r.provider, ProviderRoot: r.providerSource,
Derives: r.deriver != nil,
}
@@ -395,15 +443,17 @@ func (r *refresher) pass() error {
changed[id] = true
}
}
- changesHere := false
- if r.changes != nil {
- var cherr error
- if changesHere, cherr = present(r.changesSource, true); cherr != nil {
+ // Each recorder directory is collected on its own: one that is absent,
+ // or fails, leaves the others collected.
+ for _, cs := range r.changes {
+ changesHere, cherr := present(cs.src, true)
+ if cherr != nil {
errs = append(errs, cherr)
}
- }
- if changesHere {
- ch, err := r.changes.CollectAll(r.cmatch)
+ if !changesHere {
+ continue
+ }
+ ch, err := cs.col.CollectAll(cs.match)
if err != nil {
errs = append(errs, err)
ch = &claudecodechanges.Stats{}
diff --git a/docs/en/adapters/langsmith.md b/docs/en/adapters/langsmith.md
index 4e5a1fc..86823fe 100644
--- a/docs/en/adapters/langsmith.md
+++ b/docs/en/adapters/langsmith.md
@@ -136,14 +136,15 @@ trace carries `ls_method`, and no run of a graph trace does. A field that is a m
nothing else is still a repeat and is dropped; a field carrying anything beside it is kept whole,
because what is beside it is the function's own and is recorded nowhere else.
-`ls_method` is inherited, so it is never read as proof on its own. Decorating a function that
-wraps a whole graph marks that graph's ordinary tools as traced too, and a call is made up for a
-tool only when the tool names none, nothing resolved one for it, and its trace holds no model
-call that could have asked.
-
-Such a function also calls its tools itself, so no model call names them. The run is the call, and
-its arguments are landed from the tool run: without that the tool answered nothing and made no
-step at all.
+`ls_method` is inherited, so it is never read as proof on its own: decorating a function that
+wraps a whole graph marks that graph's ordinary tools as traced too.
+
+Such a function also calls its tools itself, so no model call names them, and a tool that names
+no call is landed answering none. No call is made up for it. Making one up was tried three ways,
+and each invented a second call for one real execution in some arrival order, because on this
+wire a request never proves that no model call asked: the model call may simply not have arrived
+yet. The tool's arguments are still landed from its run, so the step and its result are there;
+what is missing is the model call that asked for it, when none did.
### What each call was sent
diff --git a/docs/en/changes/changes.md b/docs/en/changes/changes.md
index e51a219..c9ccc5b 100644
--- a/docs/en/changes/changes.md
+++ b/docs/en/changes/changes.md
@@ -68,6 +68,22 @@
own records were landed in. A root can hold conversations from more than one runtime, and the
runtime's words have to be the ones belonging to what is being read.
+- The `changes` adapter may be named once per recorder directory, so a machine that runs Claude
+ Code's plugin and a LangChain shim collects both. Two entries that read one directory are
+ refused, and the adapter's old name counts as the same adapter; before, a second entry silently
+ replaced the first.
+
+- `claude_code.token.usage` is derived from Claude Code's transcripts alone. A LangChain
+ conversation in the same root carries calls and usage too, and was counted into it.
+
+- A session sent before its transcript landed - the recorder's files often come first - is
+ attributed by the name asz gave it, so a LangChain session's change records no longer leave
+ under Claude Code's name.
+
+- The LangChain plugin is published to PyPI after the vote, as the Homebrew tap and the apt
+ repository are written after it. [How to Release](../guides/how-to-release.md#pypi) has the
+ step, the `pypi` skill does it, and `prepare` writes the version into the plugin.
+
## Renamed
The change recorder is not Claude Code's: it records what any runtime's tool call changed, and a
@@ -111,6 +127,32 @@ every artifact would cost more than the upgrade does.
## Release
+- The install scripts put `asz-changes` beside `asz`, as Homebrew and apt do. `asz-changes` says
+ its own name in its help and its version, where it still said `asz-claude-plugin`.
+
+- A failing test of the LangChain plugin fails `make check`; it was reported as Python not
+ installed.
+
+- `asz.yaml` writes `headers` out, and the test that holds it to spelling out every value reads
+ the file's keys rather than the loaded values, which the defaults had filled in.
+
+- A scenario's coverage check judges each change file against the cursor it was landed behind,
+ as collection does, and a cursor names the recorder root its file is under: two roots can hold
+ one session under one relative path, and one cursor for both stopped both. A cursor from
+ before 0.5.0 belongs to the root whose file it was read from, by the file's device and inode
+ and the bytes before its offset together, and is written with that root on the next pass; one whose
+ file was copied or restored since is read again from the start, and the index keeps the first
+ record of an id. A session with two plugin directories was never reported as
+ covered, so a scenario's removal waited for ever.
+
+- [Configuration](../setup/configuration.md#more-than-one-agent) says which of several agents,
+ instances and machines one asz serves, and what needs one process each.
+
+- [Upgrading from 0.4.0](../setup/install.md#upgrading-from-040) is one ordered list. The plugin
+ installer finds the plugin under its old name and uninstalls it with its data kept, copies the
+ data beside the new directory and renames it into place - so a copy that stops half way is
+ done again rather than skipped - and finds Claude Code's directory the way asz does.
+
- The downloads page lists the source package and the binary archives only. `publish` listed the
Debian packages there too, beside the archives of the same binaries. They stay in the release
directory with their signatures and checksums, and apt installs them from the repository on the
diff --git a/docs/en/formats/storage-root.md b/docs/en/formats/storage-root.md
index 4f6fa43..49e9711 100644
--- a/docs/en/formats/storage-root.md
+++ b/docs/en/formats/storage-root.md
@@ -151,7 +151,8 @@ state active
| Field | What it is for |
| --- | --- |
| `source` | the source path, relative to the adapter's root, with forward slashes |
-| `dev`, `ino` | the device and the inode the file had when it was read, or on Windows its volume serial number and file index. Only `ino` is compared, and it is what this page calls the file's identity. A change means the file was replaced, or is being reached another way. The bytes decide which, see below. |
+| `origin` | the adapter's root the source is under, with forward slashes: relative to the storage root when the source root lies inside it, so a root copied whole keeps its cursors, and absolute otherwise. Written since 0.5.0 by an adapter that reads more than one root, since two roots can hold one relative path. A cursor without it was written before 0.5.0 and belongs to the root whose file it was read from - the file with its `dev` and `ino` and the bytes of its `tail_sha256`, since t [...]
+| `dev`, `ino` | the device and the inode the file had when it was read, or on Windows its volume serial number and file index. Resuming a cursor compares `ino` alone, and it is what this page calls the file's identity: a change means the file was replaced, or is being reached another way, and the bytes decide which, see below. Claiming a cursor that carries no `origin` compares both, since that decides whether the cursor was read from this file at all and not from another with the same [...]
| `offset` | the bytes consumed, always at the end of a line, and where the next read starts. Compared with the file's size, it says whether anything is new. A size below it means the file was cut short. |
| `ord` | the last source line consumed, so line numbers continue without counting again from byte 0 |
| `last_uuid` | the id of the last record that had one. For diagnosis only: many lines carry no id, and nothing compares it. |
diff --git a/docs/en/guides/how-to-release.md b/docs/en/guides/how-to-release.md
index f5835c1..d4e1e53 100644
--- a/docs/en/guides/how-to-release.md
+++ b/docs/en/guides/how-to-release.md
@@ -53,6 +53,7 @@ convenience made from its voted files.
| The announcement | the mail to the dev and announce lists | [The announcement](#the-announcement) | once the downloads page lists the version |
| The Homebrew tap, `Formula/` on main in this repository | `brew install` | [Homebrew](#homebrew), the `homebrew` skill, a pull request | after Publish |
| The apt repository, `static/apt` in apache/skywalking-website | `apt install` | [apt](#apt), the `apt` skill, a pull request | at least one hour after the move |
+| PyPI, `apache-skywalking-asz-langchain` | `pip install` | [PyPI](#pypi), the `pypi` skill, by hand | after Publish |
| A Scoop bucket | `scoop install` | [Scoop](#scoop), by hand | after Publish, once the PMC agrees |
| microsoft/winget-pkgs | `winget install` | [winget](#winget), by hand | after Publish, once the PMC agrees |
@@ -80,6 +81,7 @@ holds the formula templates.
| [The announcement](#the-announcement) | the release manager | the mail to the dev and announce lists |
| [Homebrew](#homebrew) | the release manager, with the `homebrew` skill | a pull request to `Formula/` on main in this repository |
| [apt](#apt) | the release manager, with the `apt` skill | a pull request to `static/apt` in apache/skywalking-website |
+| [PyPI](#pypi) | the release manager, with the `pypi` skill | the LangChain plugin, built from the voted source package and uploaded |
| [Scoop](#scoop) and [winget](#winget) | the release manager, once the PMC agrees to each | the manifests, submitted by hand |
| [Later: remove old versions](#later-remove-old-versions) | a PMC member | the release directory without the versions the new one replaces |
@@ -981,11 +983,11 @@ Users trust the tap by its URL before they tap it, as [Install](../setup/install
says. Homebrew 7 loads a tap that is not its own only after `brew trust`, and a `user/repository`
trust entry matches only a tap at its default remote, which this one is not.
-The tap is this repository: `Formula/` on main. It holds `asz@VERSION.rb` and
-`asz-claude-code@VERSION.rb` for every release, keg-only so they do not clash, and `asz.rb` and
-`asz-claude-code.rb` for the newest. `asz` installs `asz`, and `asz-claude-code` installs
-`asz-changes`, whose caveats give the two commands that install the plugin into Claude Code.
-Each installs `LICENSE`, `NOTICE` and `licenses/`, and its test runs its binary.
+The tap is this repository: `Formula/` on main. It holds `asz@VERSION.rb` for every release,
+keg-only so they do not clash, and `asz.rb` for the newest. From 0.5.0 one formula installs both
+programs, `asz` and `asz-changes`, with `LICENSE`, `NOTICE` and `licenses/`, and its test runs
+both. Versions before 0.5.0 keep the two formulae they were released with, `asz@VERSION.rb` and
+`asz-claude-code@VERSION.rb`, which stay as they are.
A formula downloads the voted binary package for the machine, macOS or Linux on ARM 64 or x86-64,
from the GitHub release. That URL keeps working after a newer version replaces this one on the
@@ -997,10 +999,11 @@ Homebrew/homebrew-core takes a formula only when it builds from source, and thes
built for each platform.
After Publish, on macOS, run the `homebrew` skill with Claude Code and name the version. It
-downloads the voted packages and checks each against its `.sha512`, writes both formulae of the
-version from its templates, and runs every new formula through `brew style`, `brew audit --strict`,
+downloads the voted packages and checks each against its `.sha512`, writes the version's formula
+from its template, and runs every new formula through `brew style`, `brew audit --strict`,
`brew install` from the GitHub release, and `brew test`, removing what it installed. It moves
-`asz.rb` and `asz-claude-code.rb` only forward, so adding an older version never moves them back.
+`asz.rb` only forward, so adding an older version never moves it back, and it deletes
+`asz-claude-code.rb` when 0.5.0 is added.
Then it opens a pull request to main in this repository. Anyone who tapped gets the version with
`brew update` once it merges.
@@ -1008,7 +1011,7 @@ Then it opens a pull request to main in this repository. Anyone who tapped gets
`https://skywalking.apache.org/apt` is an apt repository: `static/apt` in apache/skywalking-website.
It holds no package. It holds the index, which lists every released version of `asz` and
-`asz-claude-code`, signed with a key in KEYS, and a `.htaccess` that redirects each package's
+`asz-changes` (`asz-claude-code` before 0.5.0), signed with a key in KEYS, and a `.htaccess` that redirects each package's
address to the voted `.deb` on the Apache download sites: the newest version through the mirrors,
and every older one to archive.apache.org, which keeps every version. apt follows the redirect and
checks the file against the index. The index is written after the vote from the voted packages, and
@@ -1021,6 +1024,20 @@ and signs the index with your key. Then it serves the result with Apache httpd,
and apt in Debian and Ubuntu installs every version through the redirects. Last, it opens a pull
request to apache/skywalking-website. The website's CI publishes the change when it merges.
+## PyPI
+
+`apache-skywalking-asz-langchain`, the LangChain plugin under `plugins/langchain`, is in the voted
+source package, and is published to PyPI as a convenience after the vote, as the Homebrew tap and
+the apt repository are written after it. The version on PyPI is the released version, which
+`prepare` writes into `plugins/langchain/pyproject.toml`; nothing is uploaded for a candidate.
+
+After Publish, run the `pypi` skill with Claude Code and name the version. It downloads the voted
+source package and checks it against its `.sha512` and its `.asc` against KEYS, builds the sdist
+and the wheel from `plugins/langchain` inside it with `python -m build`, checks both with
+`twine check`, installs the wheel into a fresh environment and runs `asz-langchain status`, and
+uploads both with `twine upload` under the project's PyPI account. The wheel carries `LICENSE` and
+`NOTICE`, which `pyproject.toml` names.
+
## Scoop
A Scoop bucket holds one manifest, for the newest version. It downloads the Windows packages from
diff --git a/docs/en/setup/claude-code-plugin.md b/docs/en/setup/claude-code-plugin.md
index 9207f8e..27077e8 100644
--- a/docs/en/setup/claude-code-plugin.md
+++ b/docs/en/setup/claude-code-plugin.md
@@ -68,7 +68,9 @@ changes itself, and asz reads them from the transcript.
## Upgrade
Run the install script again with the new version. It keeps the plugin's settings and the records
-asz has not collected yet.
+asz has not collected yet. Coming from 0.4.0 or earlier, it finds the plugin under its old name,
+`asz-changes`, carries its data across, and uninstalls it with the data kept. See
+[Upgrading from 0.4.0](install.md#upgrading-from-040) for the order to do it in.
By hand, first move `asz-changes` to the new version: `brew upgrade asz`,
`sudo apt update && sudo apt upgrade`, or the binary from the new version's
diff --git a/docs/en/setup/configuration.md b/docs/en/setup/configuration.md
index 9f74bc4..47b238c 100644
--- a/docs/en/setup/configuration.md
+++ b/docs/en/setup/configuration.md
@@ -33,6 +33,23 @@ A list of the sources asz collects from.
`changes` was called `claude-code-changes` until 0.5.0, because only Claude Code wrote those
records. A configuration that still names it is read as the same adapter.
+### More than one agent
+
+- **Several Claude Code instances of one user, on one machine.** Supported with nothing to
+ configure. They all write under one directory, `~/.claude`, and one `claude-code-local` entry
+ discovers every session there; sessions are told apart by their own ids, and each is collected
+ and parsed behind a lock of its own.
+- **Several LangChain or LangGraph applications, or instances of one.** Supported by one
+ `langsmith-ingest` receiver. A conversation is owned by its project and its thread, so two
+ deployments never share one unless they share both.
+- **Several machines.** One asz on each, with its own storage root, exporting to one SkyWalking
+ OAP. `instance_id` says who is pushing, `user@host` by default, so instances stay apart there.
+- **Several recorder directories on one machine.** One `changes` entry per directory, as
+ [below](#the-changes-adapter).
+- **Several Claude Code directories on one machine** - two users, or instances started with
+ different `CLAUDE_CONFIG_DIR` values - need one asz process per directory today, each with its
+ own `source_root` and storage root. One process reads one Claude Code directory.
+
| Key | Default | Meaning |
| --- | --- | --- |
| `name` | | One of the adapters above. |
@@ -113,9 +130,26 @@ export:
## The changes adapter
-`changes` collects the records of the [Claude Code plugin](claude-code-plugin.md). It
-finds them under `plugins/data` in Claude Code's directory. It does nothing when the plugin is not
-installed.
+`changes` collects the records `asz-changes` writes: what each tool call changed on disk, for
+the [Claude Code plugin](claude-code-plugin.md) or the [LangChain plugin](langchain-plugin.md).
+With no `source_root` it reads Claude Code's plugin directory, `plugins/data` under
+`CLAUDE_CONFIG_DIR`, else `XDG_CONFIG_HOME/claude`, else `~/.claude`, and does nothing when the
+plugin is not installed. A LangChain recorder writes where `ASZ_CHANGES_DATA` points, so that is
+its `source_root`.
+
+One entry reads one directory. A machine that runs both names the adapter twice:
+
+```yaml
+adapters:
+ - name: changes
+ enabled: true
+ - name: changes
+ enabled: true
+ source_root: /var/lib/asz/changes
+```
+
+Two entries that read one directory are refused, and `claude-code-changes`, the adapter's name
+until 0.5.0, counts as the same adapter.
## The provider adapter
diff --git a/docs/en/setup/install.md b/docs/en/setup/install.md
index 95fc0b7..ec5f2dc 100644
--- a/docs/en/setup/install.md
+++ b/docs/en/setup/install.md
@@ -21,9 +21,9 @@ $Version = "<version>"
& ([scriptblock]::Create((Invoke-RestMethod -UseBasicParsing "https://raw.githubusercontent.com/apache/skywalking-ai-sessionizer/v$Version/install/asz.ps1"))) $Version
```
-The script checks the package's sha512 and puts `asz` in `~/.local/bin`, or
+The script checks the package's sha512 and puts `asz` and `asz-changes` in `~/.local/bin`, or
`%USERPROFILE%\.local\bin` on Windows, which it adds to your `Path`. Run it again with another
-version to upgrade. On Windows, stop `asz` first.
+version to upgrade. On Windows, stop `asz` and Claude Code first.
## Homebrew, on macOS and Linux
@@ -43,7 +43,9 @@ on disk. To wire a runtime to it, see [Claude Code Plugin](claude-code-plugin.md
[LangChain Plugin](langchain-plugin.md). Upgrade with `brew upgrade asz`.
Until 0.5.0 there were two formulae fetching the identical archive and installing one binary each.
-`asz-claude-code` is gone; `brew uninstall asz-claude-code` once `asz` is installed.
+`asz-claude-code` is gone. Coming from 0.4.0, follow [Upgrading from 0.4.0](#upgrading-from-040):
+`brew uninstall asz-claude-code` is its last step, after the Claude Code plugin has been
+reinstalled, because until then the plugin's hooks still call the binary that formula holds.
For an exact version, install `apache/skywalking-ai-sessionizer/asz@<version>`. Homebrew keeps it
off your `PATH`; `brew info` shows where it is.
@@ -83,12 +85,13 @@ apache-skywalking-ai-sessionizer-<version>-bin-windows-<arch>.zip arch: amd64,
Each holds `asz`, `asz-changes` (the change recorder), `LICENSE`, `NOTICE` and
`licenses/`.
-For Debian and Ubuntu, the same page links two `.deb` packages per architecture, which
-`sudo apt install ./<file>.deb` installs:
+For Debian and Ubuntu, the release directory holds two `.deb` packages per architecture, one for
+each program, which `sudo apt install ./<file>.deb` installs. They are what the apt repository
+above serves; the downloads page lists the archives only.
```text
-apache-skywalking-ai-sessionizer-<version>-bin-asz-<arch>.deb arch: amd64, arm64
-apache-skywalking-ai-sessionizer-<version>-bin-asz-claude-code-<arch>.deb arch: amd64, arm64
+apache-skywalking-ai-sessionizer-<version>-bin-asz-<arch>.deb arch: amd64, arm64
+apache-skywalking-ai-sessionizer-<version>-bin-asz-changes-<arch>.deb arch: amd64, arm64
```
On macOS or Linux:
@@ -186,3 +189,20 @@ go install "github.com/apache/skywalking-ai-sessionizer/cmd/asz@v$VERSION"
```
`asz version` then prints `dev`, and the Claude Code plugin's binary is not installed.
+
+## Upgrading from 0.4.0
+
+0.5.0 renamed the change recorder, its package and its plugin; [the changelog](../changes/changes.md#renamed)
+lists every name. The upgrade keeps every record, in this order:
+
+1. Stop Claude Code and the collector (`asz collect` or `asz server`).
+2. Install 0.5.0 by the way you installed 0.4.0. Each way installs both programs now: the install
+ script, `brew upgrade asz`, or `sudo apt install asz asz-changes`.
+3. Start the collector again. It reads the plugin's old directory and its new one.
+4. Run the [Claude Code plugin installer](claude-code-plugin.md) for 0.5.0. It carries the plugin's
+ data across, uninstalls the plugin under its old name with the data kept, and installs it under
+ the new one, whose hooks call `asz-changes`.
+5. Start Claude Code.
+6. Only then remove what 0.4.0 installed: `brew uninstall asz-claude-code` or
+ `sudo apt remove asz-claude-code`. Until step 4 the old plugin's hooks still call
+ `asz-claude-plugin`, which those packages hold.
diff --git a/docs/en/setup/langchain-plugin.md b/docs/en/setup/langchain-plugin.md
index 669e65a..7822d5c 100644
--- a/docs/en/setup/langchain-plugin.md
+++ b/docs/en/setup/langchain-plugin.md
@@ -26,7 +26,8 @@ pip install apache-skywalking-asz-langchain
asz-langchain enable
```
-and `asz-changes` on the machine, from the release archive, Homebrew or apt.
+or, from the source package of a release, `pip install ./plugins/langchain`. And `asz-changes` on
+the machine, which the [install script, Homebrew and apt](install.md) all put beside `asz`.
The application imports nothing and changes nothing. `enable` writes a loader
into the environment's site directory, so it runs at interpreter start and
diff --git a/install/asz.ps1 b/install/asz.ps1
index d5640e0..3ed40f9 100644
--- a/install/asz.ps1
+++ b/install/asz.ps1
@@ -40,6 +40,7 @@ $Arch = switch ((Get-CimInstance Win32_Processor | Select-Object -First 1).Archi
9 { "amd64" } 12 { "arm64" } default { throw "${Me}: there is no package for this processor" } }
$Pkg = "apache-skywalking-ai-sessionizer-$Version-bin-windows-$Arch.zip"
$Exe = "asz.exe"
+$Exes = @("asz.exe", "asz-changes.exe")
$Bin = Join-Path $env:USERPROFILE ".local\bin"
$Tmp = Join-Path ([IO.Path]::GetTempPath()) ("asz-install-" + [guid]::NewGuid())
New-Item -ItemType Directory -Path $Tmp | Out-Null
@@ -70,20 +71,31 @@ try {
throw "${Me}: the sha512 of $Pkg does not match $Pkg.sha512, so nothing was installed" }
$Unpacked = Join-Path $Tmp "pkg"
Expand-Archive -LiteralPath $Zip -DestinationPath $Unpacked
- & (Join-Path $Unpacked $Exe) version
- if ($LASTEXITCODE -ne 0) { throw "${Me}: $Exe does not run" }
+ # From 0.5.0 the archive holds asz and asz-changes, the change recorder,
+ # and both are installed, as Homebrew and apt install both. An older
+ # version's archive holds the recorder under its old name, and this
+ # installs asz alone from it.
+ $Exes = @($Exes | Where-Object { Test-Path -LiteralPath (Join-Path $Unpacked $_) })
+ foreach ($One in $Exes) {
+ & (Join-Path $Unpacked $One) version
+ if ($LASTEXITCODE -ne 0) { throw "${Me}: $One does not run" }
+ }
New-Item -ItemType Directory -Force -Path $Bin | Out-Null
# Windows does not replace the file of a running program. Finding that
- # out before the copy leaves the installed one whole.
- $Old = Join-Path $Bin $Exe
- if (Test-Path -LiteralPath $Old) {
- try { [IO.File]::Open($Old, "Open", "ReadWrite", "None").Dispose() }
- catch { throw "${Me}: $Old is in use. Stop asz, then run this again." } }
- Copy-Item -LiteralPath (Join-Path $Unpacked $Exe) -Destination $Bin -Force
+ # out before the copy leaves the installed ones whole.
+ foreach ($One in $Exes) {
+ $Old = Join-Path $Bin $One
+ if (Test-Path -LiteralPath $Old) {
+ try { [IO.File]::Open($Old, "Open", "ReadWrite", "None").Dispose() }
+ catch { throw "${Me}: $Old is in use. Stop asz and Claude Code, then run this again." } }
+ }
+ foreach ($One in $Exes) {
+ Copy-Item -LiteralPath (Join-Path $Unpacked $One) -Destination $Bin -Force
+ }
} finally {
Remove-Item -LiteralPath $Tmp -Recurse -Force -ErrorAction SilentlyContinue
}
-Write-Host "${Me}: installed asz into $Bin"
+Write-Host "${Me}: installed $($Exes -join ' and ') into $Bin"
$Sep = [IO.Path]::PathSeparator
$UserPath = [Environment]::GetEnvironmentVariable("Path", "User")
diff --git a/install/asz.sh b/install/asz.sh
index 9ffb599..efd7abf 100755
--- a/install/asz.sh
+++ b/install/asz.sh
@@ -90,10 +90,23 @@ curl -fsSL -o "$tmp/$pkg" "$from" || fail "cannot download $pkg"
want=$(cut -d ' ' -f 1 "$tmp/$pkg.sha512")
if command -v sha512sum >/dev/null 2>&1; then got=$(sha512sum "$tmp/$pkg"); else got=$(shasum -a 512 "$tmp/$pkg"); fi
[ "${#want}" -eq 128 ] && [ "${got%% *}" = "$want" ] || fail "the sha512 of $pkg does not match $pkg.sha512, so nothing was installed"
+# From 0.5.0 the archive holds asz and asz-changes, the change recorder,
+# and both are installed, as Homebrew and apt install both: a recorder of
+# one version beside asz of another is what one package is meant to
+# prevent. An older version's archive holds the recorder under its old
+# name, and this installs asz alone from it.
tar -xzf "$tmp/$pkg" -C "$tmp" asz
"$tmp/asz" version
-mv -f "$tmp/asz" "$bin/asz"
-say "installed asz into $bin"
+if tar -tzf "$tmp/$pkg" | grep -qx asz-changes; then
+ tar -xzf "$tmp/$pkg" -C "$tmp" asz-changes
+ "$tmp/asz-changes" version
+ mv -f "$tmp/asz" "$bin/asz"
+ mv -f "$tmp/asz-changes" "$bin/asz-changes"
+ say "installed asz and asz-changes into $bin"
+else
+ mv -f "$tmp/asz" "$bin/asz"
+ say "installed asz into $bin; $version has no asz-changes"
+fi
if [ "$(command -v asz || true)" != "$bin/asz" ]; then
say "Put $bin first on your PATH, in your shell profile, so asz there is the one that runs." >&2
fi
diff --git a/install/claude-code-plugin.ps1 b/install/claude-code-plugin.ps1
index 426ed4c..35e71e0 100644
--- a/install/claude-code-plugin.ps1
+++ b/install/claude-code-plugin.ps1
@@ -119,11 +119,17 @@ function Read-Claude {
if ($LASTEXITCODE -ne 0) { throw "${Me}: claude $args failed" }
if ($Text.Trim()) { return @($Text | ConvertFrom-Json) } else { return @() }
}
-$Installed = @(Read-Claude plugin list --json | ForEach-Object { $_ } | Where-Object { $_.id -eq $Id })
+$Plugins = @(Read-Claude plugin list --json | ForEach-Object { $_ })
+$Installed = @($Plugins | Where-Object { $_.id -eq $Id })
+# The plugin was named asz-changes until 0.5.0. One installed under that
+# name is moved the same way: its data carried across, then uninstalled
+# with the data kept, before its marketplace is removed.
+$WasId = "asz-changes@$Market"
+$WasInstalled = @($Plugins | Where-Object { $_.id -eq $WasId })
$Declared = @(Read-Claude plugin marketplace list --json | ForEach-Object { $_ } | Where-Object { $_.name -eq $Market })
-foreach ($Entry in $Installed) {
+foreach ($Entry in ($Installed + $WasInstalled)) {
if ($Entry.scope -ne "user") {
- throw "${Me}: asz-changes is installed at the $($Entry.scope) scope. Uninstall it there with --keep-data first. Moving the marketplace removes it from every scope, and deletes its data." }
+ throw "${Me}: the plugin is installed at the $($Entry.scope) scope. Uninstall it there with --keep-data first. Moving the marketplace removes it from every scope, and deletes its data." }
}
# 0.3.0 ran the plugin with --plugin-dir, whose data directory is
@@ -134,13 +140,25 @@ foreach ($Entry in $Installed) {
#
# asz-changes-inline 0.3.0, loaded with -PluginDir
# asz-changes-$Market 0.4.0, before this plugin was named file-changes
-$Config = if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { Join-Path $env:USERPROFILE ".claude" }
+#
+# The directory is found the way asz finds it: CLAUDE_CONFIG_DIR, else
+# XDG_CONFIG_HOME\claude, else the profile's .claude. The copy goes to a
+# directory beside the final one and is renamed into place, so a copy that
+# stops half way leaves nothing that looks finished, and running this
+# again copies again rather than moving on to remove the marketplace,
+# which deletes the old data.
+$Config = if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR }
+ elseif ($env:XDG_CONFIG_HOME) { Join-Path $env:XDG_CONFIG_HOME "claude" }
+ else { Join-Path $env:USERPROFILE ".claude" }
$Data = Join-Path $Config "plugins\data\file-changes-$Market"
if (-not (Test-Path -LiteralPath $Data)) {
foreach ($Was in @((Join-Path $Config "plugins\data\asz-changes-$Market"),
(Join-Path $Config "plugins\data\asz-changes-inline"))) {
if (Test-Path -LiteralPath $Was) {
- Copy-Item -LiteralPath $Was -Destination $Data -Recurse
+ $Partial = "$Data.partial"
+ if (Test-Path -LiteralPath $Partial) { Remove-Item -LiteralPath $Partial -Recurse -Force }
+ Copy-Item -LiteralPath $Was -Destination $Partial -Recurse
+ Move-Item -LiteralPath $Partial -Destination $Data
Write-Host "${Me}: carried the plugin data over from $(Split-Path -Leaf $Was)"
break
}
@@ -154,6 +172,7 @@ if ($Declared.Count -gt 0 -and $Ref -eq $Tag -and $Installed.Count -gt 0) {
}
if ($Declared.Count -gt 0 -and $Ref -ne $Tag) {
if ($Installed.Count -gt 0) { Invoke-Claude plugin uninstall $Id --keep-data }
+ if ($WasInstalled.Count -gt 0) { Invoke-Claude plugin uninstall $WasId --keep-data }
Invoke-Claude plugin marketplace remove $Market
$Declared = @()
}
diff --git a/install/claude-code-plugin.sh b/install/claude-code-plugin.sh
index 1472d59..4dbf196 100755
--- a/install/claude-code-plugin.sh
+++ b/install/claude-code-plugin.sh
@@ -114,17 +114,25 @@ tag=v$version
# its key within the object that names this plugin or this marketplace.
plugins=$(claude plugin list --json) || fail "claude plugin list failed"
markets=$(claude plugin marketplace list --json) || fail "claude plugin marketplace list failed"
-scopes=$(printf '%s\n' "$plugins" | awk -v id="$id" '
- /"id":/ { this = index($0, "\"" id "\"") > 0 }
- this && /"scope":/ { sub(/.*"scope": *"/, ""); sub(/".*/, ""); print }')
+scopes_of() {
+ printf '%s\n' "$plugins" | awk -v id="$1" '
+ /"id":/ { this = index($0, "\"" id "\"") > 0 }
+ this && /"scope":/ { sub(/.*"scope": *"/, ""); sub(/".*/, ""); print }'
+}
+scopes=$(scopes_of "$id")
+# The plugin was named asz-changes until 0.5.0. One installed under that
+# name is moved the same way: its data carried across, then uninstalled
+# with the data kept, before its marketplace is removed.
+was_id=asz-changes@$market
+was_scopes=$(scopes_of "$was_id")
declared=$(printf '%s\n' "$markets" | awk -v name="$market" '
/"name":/ { this = index($0, "\"" name "\"") > 0; if (this) print "yes" }')
ref=$(printf '%s\n' "$markets" | awk -v name="$market" '
/"name":/ { this = index($0, "\"" name "\"") > 0 }
this && /"ref":/ { sub(/.*"ref": *"/, ""); sub(/".*/, ""); print }')
-for scope in $scopes; do
- [ "$scope" = user ] || fail "file-changes is installed at the $scope scope. Uninstall it there with --keep-data first. Moving the marketplace removes it from every scope, and deletes its data."
+for scope in $scopes $was_scopes; do
+ [ "$scope" = user ] || fail "the plugin is installed at the $scope scope. Uninstall it there with --keep-data first. Moving the marketplace removes it from every scope, and deletes its data."
done
# A plugin's data directory is named after the plugin, so a rename would
@@ -134,14 +142,26 @@ done
#
# asz-changes-inline 0.3.0, loaded with --plugin-dir
# asz-changes-$market 0.4.0, before this plugin was named file-changes
-data=${CLAUDE_CONFIG_DIR:-$HOME/.claude}/plugins/data
+#
+# The directory is found the way asz finds it: CLAUDE_CONFIG_DIR, else
+# XDG_CONFIG_HOME/claude, else ~/.claude. The copy goes to a directory
+# beside the final one and is renamed into place, so a copy that stops
+# half way leaves nothing that looks finished, and running this again
+# copies again rather than moving on to remove the marketplace, which
+# deletes the old data.
+config=${CLAUDE_CONFIG_DIR:-${XDG_CONFIG_HOME:+$XDG_CONFIG_HOME/claude}}
+data=${config:-$HOME/.claude}/plugins/data
here="$data/file-changes-$market"
if [ ! -e "$here" ]; then
for was in "$data/asz-changes-$market" "$data/asz-changes-inline"; do
[ -d "$was" ] || continue
- mkdir -p "$here"
- (cd "$was" && tar cf - .) | (cd "$here" && tar xf -) ||
- fail "could not copy $was to $here; nothing was changed"
+ rm -rf "$here.partial"
+ mkdir -p "$here.partial"
+ # cp, not a tar pipe: a pipe's status is the reader's, so a member the
+ # writer could not read left a copy that looked complete.
+ cp -Rp "$was/." "$here.partial/" ||
+ fail "could not copy $was; nothing was changed. Run this again."
+ mv "$here.partial" "$here" || fail "could not move the copy into place; nothing was changed. Run this again."
say "carried the plugin data over from $(basename "$was")"
break
done
@@ -155,6 +175,9 @@ if [ "$declared" = yes ] && [ "$ref" != "$tag" ]; then
if [ -n "$scopes" ]; then
claude plugin uninstall "$id" --keep-data || fail "claude plugin uninstall failed, so the marketplace was left as it is"
fi
+ if [ -n "$was_scopes" ]; then
+ claude plugin uninstall "$was_id" --keep-data || fail "claude plugin uninstall of $was_id failed, so the marketplace was left as it is"
+ fi
claude plugin marketplace remove "$market" || fail "claude plugin marketplace remove failed. The plugin's data is kept. Run this again."
declared=
fi
diff --git a/internal/adapters/claudecode/tail.go b/internal/adapters/claudecode/tail.go
index b674fd6..d5d532d 100644
--- a/internal/adapters/claudecode/tail.go
+++ b/internal/adapters/claudecode/tail.go
@@ -264,3 +264,16 @@ func extractUUID(line []byte) string {
}
return string(rest[:j])
}
+
+// Identity is a source file's identity as a cursor records it: the inode on
+// Unix, the file index on Windows, and the device or volume beside it. Zero
+// when the platform gives none. It is for a reader that has to say whether
+// a cursor was read from this file and not from another with the same bytes
+// at its end.
+func Identity(path string) (dev, ino uint64, err error) {
+ info, err := statSource(path)
+ if err != nil {
+ return 0, 0, err
+ }
+ return info.dev, info.ino, nil
+}
diff --git a/internal/adapters/claudecodechanges/collect.go b/internal/adapters/claudecodechanges/collect.go
index 132ea4d..847d16f 100644
--- a/internal/adapters/claudecodechanges/collect.go
+++ b/internal/adapters/claudecodechanges/collect.go
@@ -25,6 +25,7 @@ import (
"io"
"os"
"path/filepath"
+ "strings"
"time"
"github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecode"
@@ -210,23 +211,77 @@ func (c *Collector) collectSession(s Session, st *Stats) error {
// The first source to arrive keeps the plain name, so nothing already
// landed is landed twice. Any other source gets its own, named for the
// source itself so it is the same name on every pass.
-func cursorFor(dir string, src Source) (string, error) {
+//
+// A source is its path relative to its recorder root, and the root: two
+// recorder directories read by one collector configuration can each hold
+// the same session under the same relative path - a shim's flat directory
+// and another one - and they are different files. A cursor written before
+// 0.5.0 names no root. It belongs to the root whose file it was read from,
+// which the cursor says twice: the file's identity, and the bytes before
+// its offset. A root whose file is another takes a cursor of its own
+// rather than reading its file against another's position, which is the
+// conflict that stopped both for good.
+func cursorFor(dir, root string, src Source) (string, error) {
plain := filepath.Join(dir, prefix+".cursor")
held, err := storage.LoadCursor(plain, storage.CursorAppend, src.Rel)
if err != nil {
return "", err
}
- if held.Source == "" || held.Source == src.Rel {
+ switch {
+ case held.Source == "", held.Source == src.Rel && held.Origin == root:
+ return plain, nil
+ case held.Source == src.Rel && held.Origin == "" && describes(held, src.Path):
return plain, nil
}
- sum := sha256.Sum256([]byte(src.Rel))
+ sum := sha256.Sum256([]byte(root + "\n" + src.Rel))
return filepath.Join(dir, prefix+"-"+hex.EncodeToString(sum[:])[:12]+".cursor"), nil
}
+// describes reports whether a cursor that names no root was read from this
+// file: nothing consumed yet, or the file is the one the cursor recorded -
+// the same device and the same inode, since two filesystems can give one
+// inode number to unrelated files - and the digest of the window before
+// the offset is the cursor's. The bytes alone are not enough: two files
+// can share their last megabyte and differ before it, and a cursor claimed
+// on the bytes alone would skip what the other file holds before them. A
+// file that cannot be read is not claimed. A legacy cursor whose file was
+// since copied, restored or reached through another mount, so its identity
+// changed, is not claimed either: that source is read again from its start
+// behind a cursor of its own, and the index keeps the first record it
+// holds for an id.
+func describes(cur *storage.Cursor, path string) bool {
+ if cur.Offset == 0 {
+ return true
+ }
+ if cur.TailSHA256 == "" || cur.Ino == 0 {
+ return false
+ }
+ dev, ino, err := claudecode.Identity(path)
+ if err != nil || ino == 0 || ino != cur.Ino || dev != cur.Dev {
+ return false
+ }
+ got, err := claudecode.TailDigestAt(path, int64(cur.Offset))
+ return err == nil && got == cur.TailSHA256
+}
+
+// origin is how this collector's root is written into a cursor, with
+// forward slashes so it reads the same on every pass. A root inside the
+// storage root - a scenario's, which a test copies whole to another place
+// - is written relative to it, so the copy keeps its cursors; any other
+// root is written absolute, since a source directory that moves is a
+// different source.
+func (c *Collector) origin() string {
+ root := filepath.Clean(c.SourceRoot)
+ if rel, err := filepath.Rel(c.Zone.Root(), root); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
+ return filepath.ToSlash(rel)
+ }
+ return filepath.ToSlash(root)
+}
+
// collectSource lands one window of one file, and says whether more waits.
func (c *Collector) collectSource(src Source, ix *index.Index, state *storage.SessionState, st *Stats, now time.Time) (landed, more bool, err error) {
dir := c.Zone.StreamDir(src.Session, src.Stream)
- cursorPath, err := cursorFor(dir, src)
+ cursorPath, err := cursorFor(dir, c.origin(), src)
if err != nil {
return false, false, err
}
@@ -234,6 +289,11 @@ func (c *Collector) collectSource(src Source, ix *index.Index, state *storage.Se
if err != nil {
return false, false, err
}
+ // A cursor from before 0.5.0 names no root. It is written once with
+ // this one, even on a pass that lands nothing, so the next root to
+ // read the same relative path finds it claimed.
+ claim := cur.Origin == ""
+ cur.Origin = c.origin()
if cur.State == storage.CursorConflict {
st.Conflicts++
return false, false, nil
@@ -262,6 +322,9 @@ func (c *Collector) collectSource(src Source, ix *index.Index, state *storage.Se
cur.Size, cur.MTime = chunk.Size, chunk.MTime
return false, false, cur.Save(cursorPath, now)
}
+ if claim {
+ return false, false, cur.Save(cursorPath, now)
+ }
return false, false, nil
}
diff --git a/internal/adapters/claudecodechanges/collect_test.go b/internal/adapters/claudecodechanges/collect_test.go
index c2480f6..9ed7ca9 100644
--- a/internal/adapters/claudecodechanges/collect_test.go
+++ b/internal/adapters/claudecodechanges/collect_test.go
@@ -19,6 +19,7 @@ package claudecodechanges_test
import (
"errors"
+ "fmt"
"io"
"os"
"path/filepath"
@@ -216,3 +217,165 @@ func TestMatchJudgesTheWorkspace(t *testing.T) {
t.Fatal("a session outside the exclusion was not collected")
}
}
+
+// Two recorder directories read by one configuration - a shim's flat
+// directory and another - can each hold the same session under the same
+// relative path. They are different files: one cursor for both read the
+// second against the first's position, a truncation conflict that stopped
+// both for good. Each is landed behind a cursor that names its root.
+func TestTheSameSessionInTwoRecorderRootsIsTwoSources(t *testing.T) {
+ zone := storage.NewZone(t.TempDir())
+ rootA, rootB := t.TempDir(), t.TempDir()
+ appendTo(t, filepath.Join(rootA, "output", session, "main.jsonl"), line("a/c1", "main", "toolu_1", "/w"))
+ appendTo(t, filepath.Join(rootB, "output", session, "main.jsonl"), line("b/c1", "main", "toolu_1", "/w")+line("b/c2", "main", "toolu_2", "/w"))
+ all := func(claudecodechanges.Session) bool { return true }
+ a := claudecodechanges.New(rootA, zone, 0)
+ b := claudecodechanges.New(rootB, zone, 0)
+ for name, c := range map[string]*claudecodechanges.Collector{"a": a, "b": b} {
+ st, err := c.CollectAll(all)
+ if err != nil || len(st.Errors) > 0 || st.Conflicts != 0 {
+ t.Fatalf("%s: %+v err=%v", name, st, err)
+ }
+ }
+ appendTo(t, filepath.Join(rootA, "output", session, "main.jsonl"), line("a/c2", "main", "toolu_2", "/w"))
+ st, err := a.CollectAll(all)
+ if err != nil || len(st.Errors) > 0 || st.Conflicts != 0 || st.Records != 1 {
+ t.Fatalf("a line the first root gained: %+v err=%v; want one record and no conflict", st, err)
+ }
+ if st, err := b.CollectAll(all); err != nil || st.Conflicts != 0 || st.Records != 0 {
+ t.Fatalf("the second root, unchanged: %+v err=%v; want nothing and no conflict", st, err)
+ }
+ files, err := storage.LandedFiles(zone, session)
+ if err != nil || len(files) != 3 {
+ t.Fatalf("%d landed files, want three: two roots, then the first root's second line (%v)", len(files), err)
+ }
+}
+
+// A cursor from before 0.5.0 names no root. It belongs to the root whose
+// file it describes - its tail digest says which - and not to the first
+// root to read the same relative path, which could be the other one. And
+// it is written with its root on the next pass, even one that lands
+// nothing, so the question is asked once.
+func TestALegacyCursorBelongsToTheRootWhoseFileItDescribes(t *testing.T) {
+ zone := storage.NewZone(t.TempDir())
+ rootA, rootB := t.TempDir(), t.TempDir()
+ appendTo(t, filepath.Join(rootA, "output", session, "main.jsonl"), line("a/c1", "main", "toolu_1", "/w"))
+ appendTo(t, filepath.Join(rootB, "output", session, "main.jsonl"), line("b/c1", "main", "toolu_1", "/w")+line("b/c2", "main", "toolu_2", "/w"))
+ all := func(claudecodechanges.Session) bool { return true }
+ a := claudecodechanges.New(rootA, zone, 0)
+ if st, err := a.CollectAll(all); err != nil || st.Records != 1 {
+ t.Fatalf("a: %+v err=%v", st, err)
+ }
+ // The cursor as 0.4.0 wrote it: no origin.
+ cursor := filepath.Join(zone.StreamDir(session, "main"), "changes.cursor")
+ raw, err := os.ReadFile(cursor)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var kept []string
+ for _, l := range strings.Split(string(raw), "\n") {
+ if !strings.HasPrefix(l, "origin") {
+ kept = append(kept, l)
+ }
+ }
+ if err := os.WriteFile(cursor, []byte(strings.Join(kept, "\n")), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ // The other root reads the same relative path first. The cursor does
+ // not describe its file, so it takes one of its own and lands whole.
+ b := claudecodechanges.New(rootB, zone, 0)
+ if st, err := b.CollectAll(all); err != nil || st.Conflicts != 0 || st.Records != 2 {
+ t.Fatalf("b against a legacy cursor of a: %+v err=%v; want two records and no conflict", st, err)
+ }
+ if raw, _ := os.ReadFile(cursor); strings.Contains(string(raw), "origin") {
+ t.Fatalf("b wrote its root into a's cursor:\n%s", raw)
+ }
+ // A's pass lands nothing and still writes its root into its cursor.
+ if st, err := a.CollectAll(all); err != nil || st.Conflicts != 0 || st.Records != 0 {
+ t.Fatalf("a, unchanged: %+v err=%v", st, err)
+ }
+ if raw, _ := os.ReadFile(cursor); !strings.Contains(string(raw), "origin") {
+ t.Fatalf("a's unchanged pass did not claim its cursor:\n%s", raw)
+ }
+ appendTo(t, filepath.Join(rootA, "output", session, "main.jsonl"), line("a/c2", "main", "toolu_2", "/w"))
+ if st, err := a.CollectAll(all); err != nil || st.Conflicts != 0 || st.Records != 1 {
+ t.Fatalf("a's next line: %+v err=%v", st, err)
+ }
+}
+
+// Two files can share their last megabyte and differ before it. A legacy
+// cursor claimed on the bytes before its offset alone would let the other
+// root skip everything it holds before that window, silently. The file's
+// identity decides with the bytes, so the other root lands all of its own.
+//
+// The two first lines are the same length, so the shared tail sits at the
+// same offset in both files and the digests before that offset are equal;
+// the test holds that before it collects, or it would prove nothing.
+func TestALegacyCursorIsNotClaimedOnASharedTailAlone(t *testing.T) {
+ zone := storage.NewZone(t.TempDir())
+ rootA, rootB := t.TempDir(), t.TempDir()
+ var shared strings.Builder
+ for i := 0; shared.Len() < (1<<20)+(64<<10); i++ {
+ shared.WriteString(line(fmt.Sprintf("s/c%d", i), "main", fmt.Sprintf("toolu_s%d", i), "/w"))
+ }
+ sharedLines := strings.Count(shared.String(), "\n")
+ fileA := filepath.Join(rootA, "output", session, "main.jsonl")
+ fileB := filepath.Join(rootB, "output", session, "main.jsonl")
+ first := func(id, tool string) string { return line(id, "main", tool, "/w") }
+ if len(first("a/c1", "toolu_a")) != len(first("b/c1", "toolu_b")) {
+ t.Fatal("the two first lines differ in length, so the tails would not sit at one offset")
+ }
+ appendTo(t, fileA, first("a/c1", "toolu_a")+shared.String())
+ appendTo(t, fileB, first("b/c1", "toolu_b")+shared.String())
+ all := func(claudecodechanges.Session) bool { return true }
+ a := claudecodechanges.New(rootA, zone, 0)
+ for {
+ st, err := a.CollectAll(all)
+ if err != nil || len(st.Errors) > 0 || st.Conflicts != 0 {
+ t.Fatalf("a: %+v err=%v", st, err)
+ }
+ if st.Pending == 0 {
+ break
+ }
+ }
+ cursor := filepath.Join(zone.StreamDir(session, "main"), "changes.cursor")
+ raw, err := os.ReadFile(cursor)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var kept []string
+ var offset int64
+ for _, l := range strings.Split(string(raw), "\n") {
+ if strings.HasPrefix(l, "offset") {
+ fmt.Sscanf(strings.TrimSpace(strings.TrimPrefix(l, "offset")), "%d", &offset)
+ }
+ if !strings.HasPrefix(l, "origin") {
+ kept = append(kept, l)
+ }
+ }
+ if err := os.WriteFile(cursor, []byte(strings.Join(kept, "\n")), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ // The precondition: at a's offset, the bytes before it digest the same
+ // in both files, so the bytes alone cannot tell the two apart.
+ da, errA := claudecode.TailDigestAt(fileA, offset)
+ db, errB := claudecode.TailDigestAt(fileB, offset)
+ if errA != nil || errB != nil || offset == 0 || da != db {
+ t.Fatalf("the tails at offset %d do not digest the same (%v %v %s %s), so this test would prove nothing", offset, errA, errB, da, db)
+ }
+ b := claudecodechanges.New(rootB, zone, 0)
+ records := 0
+ for {
+ st, err := b.CollectAll(all)
+ if err != nil || len(st.Errors) > 0 || st.Conflicts != 0 {
+ t.Fatalf("b: %+v err=%v", st, err)
+ }
+ records += st.Records
+ if st.Pending == 0 {
+ break
+ }
+ }
+ if want := 1 + sharedLines; records != want {
+ t.Fatalf("b landed %d records, want all %d of its own: a cursor read from a's file was claimed on the shared tail", records, want)
+ }
+}
diff --git a/internal/adapters/claudecodechanges/covered.go b/internal/adapters/claudecodechanges/covered.go
index fadc547..3d74c16 100644
--- a/internal/adapters/claudecodechanges/covered.go
+++ b/internal/adapters/claudecodechanges/covered.go
@@ -19,7 +19,6 @@ package claudecodechanges
import (
"fmt"
- "path/filepath"
"github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecode"
)
@@ -30,23 +29,34 @@ import (
// adapters tail their files with claudecode.TailAppend. Nothing is written,
// and the caller holds the session's lock.
func (c *Collector) Covered(s Session) (bool, string, error) {
- cursorOf := func(src Source) string {
- return filepath.Join(c.Zone.StreamDir(src.Session, src.Stream), prefix+".cursor")
+ // The cursor of a source is the one collection reads it behind: the
+ // plain one for the first source of a stream, its own for any other, so
+ // two plugin directories that each hold a file for one stream are
+ // judged each against its own cursor, as they were landed. Reading the
+ // plain cursor to tell them apart writes nothing.
+ cursorOf := func(src Source) (string, error) {
+ return cursorFor(c.Zone.StreamDir(src.Session, src.Stream), c.origin(), src)
}
- // Checked for every file before any is read. Two plugin directories can
- // each hold a file for one stream. Both map to one cursor, and a pass
- // would interleave them behind it, so neither can be said to be landed to
- // its end, even while another file is still waiting to land.
+ // Checked for every file before any is read: two sources behind one
+ // cursor would have been interleaved by a pass, and neither could be
+ // said to be landed to its end.
claimed := make(map[string]string, len(s.Sources))
for _, src := range s.Sources {
- cursorPath := cursorOf(src)
+ cursorPath, err := cursorOf(src)
+ if err != nil {
+ return false, "", err
+ }
if prev, dup := claimed[cursorPath]; dup && prev != src.Rel {
return false, "", fmt.Errorf("claudecodechanges: %s and %s both map to %s, so neither can be said to be landed", prev, src.Rel, cursorPath)
}
claimed[cursorPath] = src.Rel
}
for _, src := range s.Sources {
- ok, reason, err := claudecode.CoveredAppend(src.Path, src.Rel, cursorOf(src), c.MaxDelta)
+ cursorPath, err := cursorOf(src)
+ if err != nil {
+ return false, "", err
+ }
+ ok, reason, err := claudecode.CoveredAppend(src.Path, src.Rel, cursorPath, c.MaxDelta)
if err != nil || !ok {
return ok, reason, err
}
diff --git a/internal/adapters/claudecodechanges/covered_test.go b/internal/adapters/claudecodechanges/covered_test.go
index 202a58e..91a88a0 100644
--- a/internal/adapters/claudecodechanges/covered_test.go
+++ b/internal/adapters/claudecodechanges/covered_test.go
@@ -106,3 +106,29 @@ func TestCoveredRefusesTwoFilesForOneStream(t *testing.T) {
t.Fatalf("two files for one stream: covered=%v err=%v", ok, err)
}
}
+
+// After a pass, the first source of a stream is landed behind the plain
+// cursor and the second behind one of its own, so each is judged against
+// the cursor it was landed behind, and the session is covered. Judging both
+// against the plain cursor said neither was landed, and a scenario's removal
+// waited for ever.
+func TestCoveredJudgesEachFileByItsOwnCursorAfterAPass(t *testing.T) {
+ src := t.TempDir()
+ appendTo(t, filepath.Join(src, "asz-changes-inline", "output", session, "main.jsonl"), line("p1/c1", "main", "toolu_1", "/w"))
+ appendTo(t, filepath.Join(src, "asz-changes-market", "output", session, "main.jsonl"), line("p2/c1", "main", "toolu_1", "/w"))
+ c := claudecodechanges.New(src, storage.NewZone(t.TempDir()), 0)
+ if st, err := c.CollectAll(func(claudecodechanges.Session) bool { return true }); err != nil || len(st.Errors) > 0 || st.Records != 2 {
+ t.Fatalf("collect: %+v err=%v", st, err)
+ }
+ sessions, err := claudecodechanges.Discover(src)
+ if err != nil || len(sessions) != 1 || len(sessions[0].Sources) != 2 {
+ t.Fatalf("discover: %+v err=%v", sessions, err)
+ }
+ if ok, reason, err := c.Covered(sessions[0]); err != nil || !ok {
+ t.Fatalf("after a pass over two files of one stream: covered=%v reason=%q err=%v", ok, reason, err)
+ }
+ appendTo(t, filepath.Join(src, "asz-changes-market", "output", session, "main.jsonl"), line("p2/c2", "main", "toolu_2", "/w"))
+ if ok, _, err := c.Covered(sessions[0]); err != nil || ok {
+ t.Fatalf("a line the second file gained since the pass reads as landed: covered=%v err=%v", ok, err)
+ }
+}
diff --git a/internal/adapters/langsmith/identity.go b/internal/adapters/langsmith/identity.go
index 3a742ce..067a142 100644
--- a/internal/adapters/langsmith/identity.go
+++ b/internal/adapters/langsmith/identity.go
@@ -120,6 +120,13 @@ func StorageID(owner Owner) string {
return "ls-" + slug + "-" + hex.EncodeToString(sum[:6])
}
+// IsSessionID reports whether a session id is one StorageID derived, and so
+// one this adapter landed. The prefix is asz's own, so a reader with no
+// landed transcript to ask can still say which runtime a session came from.
+func IsSessionID(id string) bool {
+ return strings.HasPrefix(id, "ls-")
+}
+
// UnassignedID is where a trace with no supplied identity lands.
//
// It is named for the trace because there is nothing else to name it for, and
diff --git a/internal/config/config.go b/internal/config/config.go
index 42843db..fdb8547 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -461,15 +461,35 @@ func (c *Config) Validate() error {
return fmt.Errorf("config: storage.root must not be empty")
}
seen := map[string]bool{}
+ // A machine can run more than one recorder - Claude Code's plugin and
+ // a LangChain shim, each writing its own directory - so the changes
+ // adapter may be named more than once, one entry per directory. Every
+ // other adapter is one source, and an old name for an adapter is that
+ // adapter: two entries that read one directory would land it twice.
+ changesRoots := map[string]string{}
localMetrics, receiverMetrics := false, false
for i, a := range c.Adapters {
if a.Name == "" {
return fmt.Errorf("config: adapters[%d] has no name", i)
}
- if seen[a.Name] {
- return fmt.Errorf("config: duplicate adapter %q", a.Name)
+ if isChanges(a.Name) {
+ // Spellings of one directory are one directory; the resolved
+ // paths are compared again where they are resolved, since the
+ // default is a directory only the collector can name.
+ key, root := "", "Claude Code's plugin directory"
+ if a.SourceRoot != "" {
+ key, root = filepath.Clean(a.SourceRoot), a.SourceRoot
+ }
+ if prev, dup := changesRoots[key]; dup {
+ return fmt.Errorf("config: adapters %q and %q both read %s; name each directory once", prev, a.Name, root)
+ }
+ changesRoots[key] = a.Name
+ } else {
+ if seen[a.Name] {
+ return fmt.Errorf("config: duplicate adapter %q", a.Name)
+ }
+ seen[a.Name] = true
}
- seen[a.Name] = true
if a.Name == AdapterClaudeCodeOTLP && a.Enabled {
if a.Listen == "" {
return fmt.Errorf("config: adapter %q needs listen, the address the runtime's exporter is pointed at, such as 127.0.0.1:4317", a.Name)
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 1e403ef..90580f6 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -18,10 +18,13 @@
package config
import (
+ "fmt"
"os"
"path/filepath"
"reflect"
+ "strings"
"testing"
+ "time"
"gopkg.in/yaml.v3"
)
@@ -55,7 +58,29 @@ func TestRepoConfigMatchesDefaults(t *testing.T) {
// Nothing in the file may be left to the merge: every value has to be
// present, or the file stops being documentation of the defaults.
+//
+// Loading supplies the defaults, so a loaded configuration cannot say
+// whether a key was written. The file is read as a document and its keys are
+// held against the configuration's own, section by section. An adapter entry
+// is held to the keys every entry carries, its name and its switch, since a
+// receiver and a local adapter spell out different settings.
func TestRepoConfigSpellsOutEveryValue(t *testing.T) {
+ raw, err := os.ReadFile(filepath.Join("..", "..", "asz.yaml"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var doc yaml.Node
+ if err := yaml.Unmarshal(raw, &doc); err != nil {
+ t.Fatal(err)
+ }
+ if len(doc.Content) != 1 || doc.Content[0].Kind != yaml.MappingNode {
+ t.Fatal("asz.yaml is not one mapping")
+ }
+ var missing []string
+ keysWritten(reflect.TypeOf(Config{}), doc.Content[0], "", &missing)
+ if len(missing) > 0 {
+ t.Fatalf("asz.yaml leaves these to the defaults, and a reader who edits the file would see nothing change: %v", missing)
+ }
got, err := Load(filepath.Join("..", "..", "asz.yaml"))
if err != nil {
t.Fatal(err)
@@ -212,3 +237,71 @@ func TestProviderBodiesIsASettingOfTheReceiverAlone(t *testing.T) {
t.Error("another adapter was allowed provider_bodies")
}
}
+
+// keysWritten walks a configuration type's yaml keys against a mapping node
+// and collects every key the node does not carry. A nested section is
+// walked; a list of adapters is held to name and enabled per entry.
+func keysWritten(typ reflect.Type, node *yaml.Node, prefix string, missing *[]string) {
+ keys := map[string]*yaml.Node{}
+ for i := 0; i+1 < len(node.Content); i += 2 {
+ keys[node.Content[i].Value] = node.Content[i+1]
+ }
+ for i := 0; i < typ.NumField(); i++ {
+ f := typ.Field(i)
+ tag, _, _ := strings.Cut(f.Tag.Get("yaml"), ",")
+ if tag == "" || tag == "-" {
+ continue
+ }
+ child, ok := keys[tag]
+ if !ok {
+ *missing = append(*missing, prefix+tag)
+ continue
+ }
+ switch {
+ case f.Type.Kind() == reflect.Struct && child.Kind == yaml.MappingNode:
+ keysWritten(f.Type, child, prefix+tag+".", missing)
+ case f.Type.Kind() == reflect.Slice && f.Type.Elem().Kind() == reflect.Struct && child.Kind == yaml.SequenceNode:
+ for j, el := range child.Content {
+ for _, must := range []string{"name", "enabled"} {
+ found := false
+ for k := 0; k+1 < len(el.Content); k += 2 {
+ found = found || el.Content[k].Value == must
+ }
+ if !found {
+ *missing = append(*missing, fmt.Sprintf("%s%s[%d].%s", prefix, tag, j, must))
+ }
+ }
+ }
+ }
+ }
+}
+
+// TestSeveralChangeRecordersAreOneAdapterEach.
+//
+// A machine can run Claude Code's plugin and a LangChain shim at once, each
+// writing its own directory, so the changes adapter may be named once per
+// directory. Two entries reading one directory would land it twice, and the
+// adapter's old name is the same adapter, so both are refused - before, the
+// second silently replaced the first. Every other adapter is one entry.
+func TestSeveralChangeRecordersAreOneAdapterEach(t *testing.T) {
+ once := Collector{Mode: ModeOnce, Interval: time.Minute, MaxDeltaBytes: 1 << 20}
+ cfg := Default()
+ cfg.Adapters = append(cfg.Adapters, Adapter{Name: AdapterChanges, Enabled: true, SourceRoot: "/var/lib/asz/changes", Collector: once})
+ if err := cfg.Validate(); err != nil {
+ t.Fatalf("a second changes entry with a directory of its own was refused: %v", err)
+ }
+ cfg.Adapters = append(cfg.Adapters, Adapter{Name: AdapterClaudeCodeChanges, Enabled: true, SourceRoot: "/var/lib/asz/changes", Collector: once})
+ if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "both read") {
+ t.Fatalf("two entries reading one directory were accepted: %v", err)
+ }
+ cfg = Default()
+ cfg.Adapters = append(cfg.Adapters, Adapter{Name: AdapterClaudeCodeChanges, Enabled: true, Collector: once})
+ if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "both read") {
+ t.Fatalf("the old name beside the default entry, both reading Claude Code's directory, was accepted: %v", err)
+ }
+ cfg = Default()
+ cfg.Adapters = append(cfg.Adapters, Adapter{Name: AdapterClaudeCodeLocal, Enabled: true, Collector: once})
+ if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "duplicate") {
+ t.Fatalf("a second local adapter was accepted: %v", err)
+ }
+}
diff --git a/internal/export/otlp/push.go b/internal/export/otlp/push.go
index 521edd5..2d69464 100644
--- a/internal/export/otlp/push.go
+++ b/internal/export/otlp/push.go
@@ -74,6 +74,10 @@ type Pusher struct {
// configured. Empty means the runtime that produced each session, read
// off its landed header's adapter and named through Runtimes.
ServiceName string
+ // SessionRuntime names the runtime of a session from its id alone, for
+ // a session none of whose transcripts has landed yet. It answers only
+ // for a name asz derived itself, and empty otherwise.
+ SessionRuntime func(session string) string
// Runtimes names the runtime behind each adapter: claude-code-local is
// Claude Code, mock is Mock Agent. An adapter with no entry is named by
// its own name.
@@ -293,7 +297,7 @@ func (p *Pusher) Pass() (*Stats, error) {
}
// A session's records are attributed to the runtime that produced
// them, which its landed headers name.
- b.services[s.id] = p.serviceOf(s.files)
+ b.services[s.id] = p.serviceOf(s.id, s.files)
for _, lf := range s.files {
if b.stop != nil {
break
@@ -616,7 +620,7 @@ func (b *batch) flush() error {
// follows. Taking whichever header came first attributed a LangChain session
// to Claude Code. The transcript decides it, and the rest only when there is
// no transcript to ask.
-func (p *Pusher) serviceOf(files []storage.LandedFile) string {
+func (p *Pusher) serviceOf(session string, files []storage.LandedFile) string {
if p.ServiceName != "" {
return p.ServiceName
}
@@ -649,6 +653,15 @@ func (p *Pusher) serviceOf(files []storage.LandedFile) string {
fallback = name
}
}
+ // No transcript has landed yet. The recorder's files often come first,
+ // and their adapter is shared by every runtime, so it says nothing
+ // about which one this is. The session's name does, when asz derived
+ // the name itself, and the command wiring says how to read it.
+ if p.SessionRuntime != nil {
+ if runtime := p.SessionRuntime(session); runtime != "" {
+ return runtime
+ }
+ }
if fallback != "" {
return fallback
}
@@ -858,7 +871,7 @@ func (b *batch) addRound(rel, path, conv string) error {
if _, known := b.services[session]; !known {
files, err := storage.LandedFiles(b.p.Zone, session)
if err == nil {
- b.services[session] = b.p.serviceOf(files)
+ b.services[session] = b.p.serviceOf(session, files)
}
}
return b.add(session, record(stamp, now, data, attrs), rel, digest)
diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go
index 88d9702..f00f4e1 100644
--- a/internal/metrics/metrics.go
+++ b/internal/metrics/metrics.go
@@ -368,11 +368,25 @@ func deriveFile(lf storage.LandedFile, following []storage.LandedFile, since tim
return nil, err
}
hdr := rd.Header()
+ res := &result{lastEnd: map[string]time.Time{}}
+ // The metric is Claude Code's own family, derived from its transcripts
+ // alone. A root can hold another runtime's conversations beside them,
+ // and those carry calls and usage too, so without this a LangChain
+ // session's tokens went out as claude_code.token.usage. Every landed
+ // header names a dialect; the reader refuses one that does not. The
+ // mock dialect is a scenario writing Claude Code's shape directly, and
+ // the scenarios hold its metrics to the Claude Code build's.
+ if d, _, _ := strings.Cut(hdr.Dialect, "/"); d != "claude-code" && d != "mock" {
+ if _, err := io.Copy(h, f); err != nil {
+ return nil, err
+ }
+ res.digest = hex.EncodeToString(h.Sum(nil))
+ return res, nil
+ }
source := SourceSubagent
if hdr.Stream == "main" {
source = SourceMain
}
- res := &result{lastEnd: map[string]time.Time{}}
calls := map[string]*call{}
var order []string
var last *call
diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go
index dd498be..babbbf4 100644
--- a/internal/metrics/metrics_test.go
+++ b/internal/metrics/metrics_test.go
@@ -21,6 +21,7 @@ import (
"io"
"os"
"path/filepath"
+ "strings"
"testing"
"time"
@@ -81,11 +82,19 @@ func records(c call, main bool, ord *uint64) []*sessiondata.Record {
}
func land(t *testing.T, z *storage.Zone, session, stream string, seq uint64, calls []call) string {
+ t.Helper()
+ return landAs(t, z, session, stream, seq, calls, "claude-code/1")
+}
+
+// landAs lands a transcript under a dialect. The metric is derived from
+// Claude Code's transcripts, so every fixture is one unless a test says
+// otherwise.
+func landAs(t *testing.T, z *storage.Zone, session, stream string, seq uint64, calls []call, dialect string) string {
t.Helper()
path := filepath.Join(z.StreamDir(session, stream), storage.LandedName("transcript", storage.Stamp(time.Unix(int64(seq), 0)), seq))
err := storage.WriteAtomic(path, storage.PermLanded, func(w io.Writer) error {
hdr := &sessiondata.Header{Seq: seq, At: "2026-09-07T00:00:00Z", Kind: sessiondata.KindTranscript,
- Adapter: "test/0", Dialect: "test/1", Src: session + "/" + stream, Session: session, Stream: stream}
+ Adapter: "test/0", Dialect: dialect, Src: session + "/" + stream, Session: session, Stream: stream}
sw, err := sessiondata.NewWriter(w, hdr)
if err != nil {
return err
@@ -399,3 +408,38 @@ func TestLookbackAndReceivedMetricsBoundTheFirstPass(t *testing.T) {
t.Fatalf("what the receiver already landed must not be derived again: %v", got)
}
}
+
+// TestAnotherRuntimesTranscriptDerivesNothing.
+//
+// The metric is Claude Code's own family. A root can hold a LangChain
+// conversation beside Claude Code's, landed by the receiver with calls and
+// usage of its own, and those tokens went out as claude_code.token.usage
+// because the derivation read every transcript in the root.
+func TestAnotherRuntimesTranscriptDerivesNothing(t *testing.T) {
+ z := storage.NewZone(t.TempDir())
+ c := call{id: "c1", model: "m", at: base, frags: 1, in: 4, out: 60}
+ landAs(t, z, "ls-agent-thread-1-abcdef012345", "main", 1, []call{c}, "langsmith/1")
+ land(t, z, "s1", "main", 1, []call{c})
+ d := deriver(z, base.Add(time.Hour), metrics.Options{})
+ st, err := d.Pass(nil)
+ if err != nil || len(st.Errors) > 0 {
+ t.Fatal(err, st)
+ }
+ got, _ := points(t, z)
+ for series := range got {
+ if strings.Contains(series, "#ls-") {
+ t.Errorf("%s was derived from a LangChain session", series)
+ }
+ }
+ if n := total(got["main/output/m#s1"]); n != 60 {
+ t.Errorf("the Claude Code session derived %v output tokens, want 60; series: %v", n, seriesOf(got))
+ }
+}
+
+func seriesOf(m map[string][]got) []string {
+ var out []string
+ for k := range m {
+ out = append(out, k)
+ }
+ return out
+}
diff --git a/internal/storage/cursor.go b/internal/storage/cursor.go
index aacc4e6..e2b4f81 100644
--- a/internal/storage/cursor.go
+++ b/internal/storage/cursor.go
@@ -73,6 +73,15 @@ type Cursor struct {
Schema int
Kind CursorKind
Source string // path relative to the adapter's source root
+ // Origin is the source root the file is under, for an adapter that reads
+ // more than one: two roots can hold the same relative path, and one
+ // cursor for both stops collection of both. It is relative to the
+ // storage root when the source root lies inside it, so a root copied
+ // whole keeps its cursors, and absolute otherwise. Empty on a cursor
+ // written before 0.5.0, which belongs to the root whose file it was read
+ // from: the file with its device and inode, and the bytes before its
+ // offset.
+ Origin string
// Append fields.
Dev uint64
@@ -128,6 +137,8 @@ func LoadCursor(path string, kind CursorKind, source string) (*Cursor, error) {
c.Kind = CursorKind(val)
case "source":
c.Source = val
+ case "origin":
+ c.Origin = val
case "dev":
c.Dev, _ = strconv.ParseUint(val, 10, 64)
case "ino":
@@ -168,6 +179,9 @@ func (c *Cursor) Save(path string, now time.Time) error {
fmt.Fprintf(bw, "schema %d\n", c.Schema)
fmt.Fprintf(bw, "kind %s\n", c.Kind)
fmt.Fprintf(bw, "source %s\n", c.Source)
+ if c.Origin != "" {
+ fmt.Fprintf(bw, "origin %s\n", c.Origin)
+ }
if c.Kind == CursorAppend {
fmt.Fprintf(bw, "dev %d\n", c.Dev)
fmt.Fprintf(bw, "ino %d\n", c.Ino)
diff --git a/plugins/claude-code/main.go b/plugins/claude-code/main.go
index 5b1471d..0773559 100644
--- a/plugins/claude-code/main.go
+++ b/plugins/claude-code/main.go
@@ -15,14 +15,15 @@
// specific language governing permissions and limitations
// under the License.
-// Command asz-claude-plugin is the asz Claude Code plugin: it records
-// which files each tool call changed.
+// Command asz-changes records which files each tool call changed. Under
+// Claude Code it is the file-changes plugin's hook; under LangChain the
+// asz-langchain shim runs it.
//
// It runs as a Claude Code hook, one short process per event, and keeps
// its state on disk under the plugin's data directory. It needs nothing
// from asz: no server, no endpoint, no acknowledgement. Its only output is
// one JSON line per observed call, appended to a file per stream of a
-// session, which the asz claude-code-changes adapter tails when asz runs.
+// session, which the asz changes adapter tails when asz runs.
//
// A shell command is observed with a scan of the workspace before and
// after it, unless the command is classified read-only. An editing tool
@@ -75,13 +76,13 @@ func dataDir() string {
// version is set at build time from the tag or the commit.
var version = "dev"
-const usage = `asz-claude-plugin - the asz Claude Code plugin: which files each tool call changed
+const usage = `asz-changes - records which files each tool call changed, for asz
Usage:
- asz-claude-plugin hook run as a Claude Code hook; reads the event from standard input
- asz-claude-plugin status print the settings in force and the exclusion rules they expand to
- asz-claude-plugin prune drop idle snapshot bytes and expired output now
- asz-claude-plugin version print the version
+ asz-changes hook run as a hook; reads the event from standard input
+ asz-changes status print the settings in force and the exclusion rules they expand to
+ asz-changes prune drop idle snapshot bytes and expired output now
+ asz-changes version print the version
With no command, it runs as hook when standard input is a pipe, a socket, a
file, or anything else that is not a terminal or another character device.
@@ -95,7 +96,7 @@ func main() {
case "hook":
os.Exit(runHook(os.Stdin, dataDir(), os.Getenv("CLAUDE_PROJECT_DIR"), time.Now()))
case "version":
- fmt.Printf("asz-claude-plugin %s (%s %s/%s)\n", version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
+ fmt.Printf("asz-changes %s (%s %s/%s)\n", version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
case "status":
if err := status(); err != nil {
fmt.Fprintln(os.Stderr, err)
diff --git a/plugins/langchain/LICENSE b/plugins/langchain/LICENSE
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/plugins/langchain/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/plugins/langchain/NOTICE b/plugins/langchain/NOTICE
new file mode 100644
index 0000000..3da6f81
--- /dev/null
+++ b/plugins/langchain/NOTICE
@@ -0,0 +1,5 @@
+Apache SkyWalking AI Sessionizer
+Copyright 2026 The Apache Software Foundation
+
+This product includes software developed at
+The Apache Software Foundation (http://www.apache.org/).
diff --git a/plugins/langchain/pyproject.toml b/plugins/langchain/pyproject.toml
index 1f3c71f..70ac186 100644
--- a/plugins/langchain/pyproject.toml
+++ b/plugins/langchain/pyproject.toml
@@ -22,6 +22,7 @@ description = "Records what a LangChain agent's tool calls changed on disk, for
readme = "README.md"
requires-python = ">=3.9"
license = "Apache-2.0"
+license-files = ["LICENSE", "NOTICE"]
authors = [{ name = "Apache SkyWalking" }]
keywords = ["skywalking", "observability", "langchain", "langgraph", "agent"]
dependencies = ["langchain-core>=0.3"]
diff --git a/tools/release/release.sh b/tools/release/release.sh
index 6ce3a4c..9fd8abf 100755
--- a/tools/release/release.sh
+++ b/tools/release/release.sh
@@ -1449,12 +1449,17 @@ if doit; then
# Remove only the note's own lines and one blank line after them. A
# heading written straight under the note must stay in the changelog.
awk '/^> In development/{skip=1; next} skip && /^>/{next} skip && /^$/{skip=0; next} {skip=0; print}' "$dev_page" > "$dev_page.tmp" && mv "$dev_page.tmp" "$dev_page"
- git add "$dev_page"
+ # The Python plugin carries its version in its own file, which pip reads,
+ # so the candidate names the version there too.
+ pyproject=plugins/langchain/pyproject.toml
+ sed "s/^version = \".*\"/version = \"$version\"/" "$pyproject" > "$pyproject.tmp" && mv "$pyproject.tmp" "$pyproject"
+ git add "$dev_page" "$pyproject"
git commit -q -m "Prepare the $version candidate
The changelog of $version loses its in-development note and keeps its
path, docs/en/changes/changes.md, which the menu and the welcome page
-link. The tag goes on this commit, as the candidate for the vote."
+link, and the Python plugin's version is $version. The tag goes on this
+commit, as the candidate for the vote."
# The tag is the candidate the vote is about, not the release, so its
# message does not call it one, and neither does the commit.
git tag -a "$tag" -m "$project $version"
diff --git a/tools/test/claude-code-check/main.go b/tools/test/claude-code-check/main.go
index 7a499b4..9661bfb 100644
--- a/tools/test/claude-code-check/main.go
+++ b/tools/test/claude-code-check/main.go
@@ -362,8 +362,10 @@ func (c *check) installAsz() error {
if !strings.Contains(out, "through the Apache mirrors") {
return errors.New("install/asz did not take the version on the download site through the mirrors")
}
- if _, err := os.Stat(filepath.Join(bin, "asz-changes"+exe())); err == nil {
- return errors.New("install/asz installed asz-changes too, which is the plugin's own install")
+ // From 0.5.0 the script installs both programs, as Homebrew and apt
+ // do; the plugin installer then finds the recorder already there.
+ if err := c.reports(filepath.Join(bin, "asz-changes"+exe()), c.version); err != nil {
+ return fmt.Errorf("install/asz did not install asz-changes beside asz: %w", err)
}
c.aszBin = bin
}