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/10/17 14:49:19 UTC
(skywalking-ai-sessionizer) branch main updated: Warn when retire_after is shorter than a runtime keeps its history (#74)
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 f6e3274 Warn when retire_after is shorter than a runtime keeps its history (#74)
f6e3274 is described below
commit f6e3274847c33384be13bb01fbc3218a97c4f6b0
Author: 吴晟 Wu Sheng <wu...@foxmail.com>
AuthorDate: Sat Oct 17 22:49:14 2026 +0800
Warn when retire_after is shorter than a runtime keeps its history (#74)
A retired session is never collected again, even when its runtime
resumes it. When storage.retention.retire_after is set, asz now says how
it compares with how long each enabled runtime keeps a session.
For Claude Code, asz reads cleanupPeriodDays from the user's
settings.json and from the managed settings for the whole machine:
managed-settings.json, then managed-settings.d/*.json in the order
Claude Code sorts them. A managed value wins over the user's. A file is
read as Claude Code reads it: a regular file only, a leading byte-order
mark stripped, UTF-16LE after FF FE, and empty settings by JavaScript's
trim. Only a whole number of days of at least 1 counts. A file asz
cannot read or parse, and an invalid value, is named, and nothing is
assumed for it.
This is an inspected value, never the effective one. asz always says
that Claude Code's effective retention was not determined: other
settings can change it, Claude Desktop and Cowork keep sessions at any
age by default, and settings it cannot read pause its cleanup. It warns
in addition when retire_after is shorter than the inspected value, or
than the default of 30 days when none is set. With an explicit
source_root it inspects nothing and says so. Kiro keeps a session until
it is deleted, so it warns whenever kiro-local is enabled. LangChain has
no source and gets no warning.
The lines are said at startup, in asz sources, on the list page's
status, and once in the first pass's log.
---
cmd/asz/kiro.go | 3 +
cmd/asz/main.go | 3 +
cmd/asz/refresh.go | 22 +-
cmd/asz/retention_warning.go | 120 ++++++++
cmd/asz/retention_warning_test.go | 340 +++++++++++++++++++++
cmd/asz/view.go | 6 +-
docs/en/adapters/claude-code.md | 5 +-
docs/en/adapters/kiro.md | 1 +
docs/en/changes/changes.md | 6 +
docs/en/formats/storage-root.md | 43 ++-
docs/en/guides/command-line-internals.md | 18 +-
docs/en/setup/configuration.md | 2 +-
internal/adapters/claudecode/retention.go | 159 ++++++++++
internal/adapters/claudecode/retention_test.go | 334 ++++++++++++++++++++
.../adapters/claudecode/retention_unix_test.go | 76 +++++
internal/view/index.html | 6 +-
internal/view/status.go | 2 +
internal/view/status_test.go | 1 +
18 files changed, 1132 insertions(+), 15 deletions(-)
diff --git a/cmd/asz/kiro.go b/cmd/asz/kiro.go
index 7e68593..cdfcd42 100644
--- a/cmd/asz/kiro.go
+++ b/cmd/asz/kiro.go
@@ -31,6 +31,9 @@ import (
// cmdSourcesKiro lists the Kiro sessions the adapter can see, and what it
// sees and does not collect.
func cmdSourcesKiro(cfg *config.Config, ad config.Adapter, _ bool) error {
+ if err := printSourceRetention(cfg, ad); err != nil {
+ return err
+ }
root, err := kiro.ResolveSourceRoot(ad.SourceRoot)
if err != nil {
return err
diff --git a/cmd/asz/main.go b/cmd/asz/main.go
index b65c378..5acebde 100644
--- a/cmd/asz/main.go
+++ b/cmd/asz/main.go
@@ -294,6 +294,9 @@ func cmdSources(cfg *config.Config, ad config.Adapter, once bool) error {
if ad.Name == config.AdapterKiroLocal {
return cmdSourcesKiro(cfg, ad, once)
}
+ if err := printSourceRetention(cfg, ad); err != nil {
+ return err
+ }
root, err := claudecode.ResolveSourceRoot(ad.SourceRoot)
if err != nil {
return err
diff --git a/cmd/asz/refresh.go b/cmd/asz/refresh.go
index 7ffd0fb..6aad15a 100644
--- a/cmd/asz/refresh.go
+++ b/cmd/asz/refresh.go
@@ -90,6 +90,14 @@ func sameDir(a, b string) bool {
// next; with none, asz collect prints the same numbers and nothing else
// changes. The page itself never writes.
type refresher struct {
+ // adapters are the sources the pipeline was built for, which the
+ // retention warning compares retire_after with.
+ adapters []config.Adapter
+ // retentionWarnings are said at startup, once in the pass log, and on
+ // every status the page shows, so a person who sees only one of them
+ // still learns that a resumed session may not be collected.
+ retentionWarnings []string
+ saidRetentionWarnings bool
// saidRetired names the retired sessions this process has said it found
// evidence of, so each is said once.
saidRetired map[string]bool
@@ -197,7 +205,7 @@ type refresher struct {
// appears later has to be picked up without a restart; every pass looks
// again.
func newRefresher(srv *view.Server, zone *storage.Zone, ads []config.Adapter, mc config.Metrics, maxRound int64, once bool) (*refresher, error) {
- r := &refresher{srv: srv, zone: zone, maxRound: maxRound, full: true, retry: map[string]bool{}, unparsed: map[string]bool{}}
+ r := &refresher{srv: srv, zone: zone, adapters: ads, maxRound: maxRound, full: true, retry: map[string]bool{}, unparsed: map[string]bool{}}
// One pipeline serves every adapter, so it needs one mode and one
// period, and both have to be settled before anything is built: the
@@ -443,6 +451,8 @@ func present(dir string, optional bool) (bool, error) {
// still finished.
func (r *refresher) retain(metadataOnly, retire time.Duration) {
r.retention = &retention.Remover{Zone: r.zone, MetadataOnlyAfter: metadataOnly, RetireAfter: retire, Metrics: r.deriver != nil}
+ r.retentionWarnings = retentionWarnings(r.adapters, retire)
+ r.setStatus(r.base)
}
// forget drops what the page cached of sessions whose files were removed,
@@ -503,8 +513,13 @@ func (r *refresher) holdRoot() (*storage.SessionLock, error) {
// than leave a backfill half done without saying so. A watching pass logs
// the same and goes round again.
func (r *refresher) pass() error {
- // Before anything else. Over a scenario root, a pass that cannot hold
- // the root lands, parses and sends nothing.
+ if !r.saidRetentionWarnings {
+ for _, line := range r.retentionWarnings {
+ fmt.Fprintf(os.Stderr, " warning: %s\n", line)
+ }
+ r.saidRetentionWarnings = true
+ }
+ // A pass that cannot hold a scenario root lands, parses and sends nothing.
if held, err := r.holdScenario(); !held {
return err
}
@@ -937,6 +952,7 @@ func (r *refresher) pass() error {
// setStatus records the pass for the page, when there is one.
func (r *refresher) setStatus(st view.Status) {
if r.srv != nil {
+ st.RetentionWarning = strings.Join(r.retentionWarnings, "\n")
r.srv.SetStatus(st)
}
}
diff --git a/cmd/asz/retention_warning.go b/cmd/asz/retention_warning.go
new file mode 100644
index 0000000..8c1ea5e
--- /dev/null
+++ b/cmd/asz/retention_warning.go
@@ -0,0 +1,120 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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.
+
+package main
+
+import (
+ "fmt"
+ "path/filepath"
+ "time"
+
+ "github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecode"
+ "github.com/apache/skywalking-ai-sessionizer/internal/config"
+)
+
+var claudeManagedDir = claudecode.ManagedSettingsDir()
+
+const claudeRetentionNotDetermined = "Claude Code's effective retention was not determined. " +
+ "Project settings, command-line settings, device policy and settings managed by a server can change it. " +
+ "From version 2.1.248, Claude Desktop and Cowork keep sessions at any age by default. " +
+ "desktopSessionCleanupPeriodDays or a managed cleanupPeriodDays can limit them. " +
+ "The organization's HIPAA configuration also ends this exemption when it applies. " +
+ "Settings that cannot be read or parsed pause Claude Code's cleanup unless managed settings provide cleanupPeriodDays."
+
+func retentionWarnings(ads []config.Adapter, retire time.Duration) []string {
+ if retire <= 0 {
+ return nil
+ }
+ var lines []string
+ for _, ad := range ads {
+ if !ad.Enabled {
+ continue
+ }
+ switch ad.Name {
+ case config.AdapterClaudeCodeLocal:
+ lines = append(lines, claudeRetentionWarnings(ad, retire)...)
+ case config.AdapterKiroLocal:
+ lines = append(lines, retirementWarning("Kiro", "until it is deleted", retire))
+ }
+ }
+ return lines
+}
+
+func claudeRetentionWarnings(ad config.Adapter, retire time.Duration) []string {
+ if ad.SourceRoot != "" {
+ return []string{
+ "No Claude Code settings were inspected. An explicit source_root does not identify the user's configuration directory.",
+ claudeRetentionNotDetermined,
+ }
+ }
+ root, err := claudecode.ResolveSourceRoot("")
+ if err != nil {
+ return []string{fmt.Sprintf("No Claude Code settings were inspected. The configuration directory could not be resolved: %v", err), claudeRetentionNotDetermined}
+ }
+ dir := filepath.Dir(root)
+ inspected := claudecode.InspectRetention(dir, claudeManagedDir)
+ var lines []string
+ for _, problem := range inspected.Problems {
+ lines = append(lines, fmt.Sprintf("Claude Code settings: %v. No value was assumed for it.", problem))
+ }
+ days := inspected.Days
+ basis := "inspected value"
+ switch {
+ case days > 0:
+ kind := "user"
+ if inspected.Managed {
+ kind = "managed"
+ }
+ lines = append(lines, fmt.Sprintf("The inspected %s settings %q set cleanupPeriodDays to %s.", kind, inspected.Path, retentionPeriod(time.Duration(days)*24*time.Hour)))
+ case len(inspected.Problems) == 0:
+ days = 30
+ basis = "default"
+ lines = append(lines, fmt.Sprintf("No inspected file in %q or %q sets cleanupPeriodDays. Claude Code's default is 30 days.", dir, claudeManagedDir))
+ default:
+ lines = append(lines, "No valid cleanupPeriodDays value was found in the inspected files. No default was assumed.")
+ }
+ lines = append(lines, claudeRetentionNotDetermined)
+ if days > 0 && retire < time.Duration(days)*24*time.Hour {
+ lines = append(lines, "For the "+basis+": "+retirementWarning("Claude Code", "for "+retentionPeriod(time.Duration(days)*24*time.Hour), retire))
+ }
+ return lines
+}
+
+func retirementWarning(runtime, keeps string, retire time.Duration) string {
+ return fmt.Sprintf("%s keeps a session %s; storage.retention.retire_after is %s. A session resumed after it is retired is not collected.", runtime, keeps, retentionPeriod(retire))
+}
+
+func retentionPeriod(period time.Duration) string {
+ if period == 24*time.Hour {
+ return "1 day"
+ }
+ if period%(24*time.Hour) == 0 {
+ return fmt.Sprintf("%d days", period/(24*time.Hour))
+ }
+ return period.String()
+}
+
+func printSourceRetention(cfg *config.Config, ad config.Adapter) error {
+ _, retire, err := cfg.Storage.Retention.Durations()
+ if err != nil {
+ return err
+ }
+ for _, line := range retentionWarnings([]config.Adapter{ad}, retire) {
+ fmt.Printf("warning : %s\n", line)
+ }
+ return nil
+}
diff --git a/cmd/asz/retention_warning_test.go b/cmd/asz/retention_warning_test.go
new file mode 100644
index 0000000..efd1cbd
--- /dev/null
+++ b/cmd/asz/retention_warning_test.go
@@ -0,0 +1,340 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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.
+
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/apache/skywalking-ai-sessionizer/internal/config"
+ "github.com/apache/skywalking-ai-sessionizer/internal/storage"
+ "github.com/apache/skywalking-ai-sessionizer/internal/view"
+)
+
+func warningConfig(t *testing.T, retire string) *config.Config {
+ t.Helper()
+ root := t.TempDir()
+ t.Setenv("HOME", filepath.Join(root, "home"))
+ t.Setenv("USERPROFILE", filepath.Join(root, "home"))
+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "xdg"))
+ t.Setenv("CLAUDE_CONFIG_DIR", filepath.Join(root, "user"))
+ if err := os.MkdirAll(filepath.Join(root, "user", "projects"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ was := claudeManagedDir
+ claudeManagedDir = filepath.Join(root, "managed")
+ t.Cleanup(func() { claudeManagedDir = was })
+ cfg := config.Default()
+ cfg.Storage.Root = filepath.Join(root, "landed")
+ cfg.Storage.Retention = config.Retention{MetadataOnlyAfter: "1d", RetireAfter: retire}
+ cfg.Adapters = []config.Adapter{{
+ Name: config.AdapterClaudeCodeLocal, Enabled: true,
+ Collector: config.Collector{Mode: config.ModeOnce, MaxDeltaBytes: 1 << 20},
+ }}
+ return cfg
+}
+
+func writeWarningSettings(t *testing.T, path, body string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func warningText(t *testing.T, cfg *config.Config) string {
+ t.Helper()
+ _, retire, err := cfg.Storage.Retention.Durations()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return strings.Join(retentionWarnings(cfg.Adapters, retire), "\n")
+}
+
+func checkWarningText(t *testing.T, text string, wants ...string) {
+ t.Helper()
+ for _, want := range wants {
+ if !strings.Contains(text, want) {
+ t.Errorf("warning does not contain %q:\n%s", want, text)
+ }
+ }
+}
+
+func TestRetentionWarningComparesInspectedDays(t *testing.T) {
+ for _, retire := range []string{"7d", "30d", "60d", "720h"} {
+ t.Run(retire, func(t *testing.T) {
+ cfg := warningConfig(t, retire)
+ path := filepath.Join(os.Getenv("CLAUDE_CONFIG_DIR"), "settings.json")
+ writeWarningSettings(t, path, `{"cleanupPeriodDays":30}`)
+ text := warningText(t, cfg)
+ checkWarningText(t, text, "The inspected user settings", "set cleanupPeriodDays to 30 days.",
+ "Claude Code's effective retention was not determined.", "Project settings", "command-line settings", "device policy", "settings managed by a server",
+ "version 2.1.248", "Claude Desktop and Cowork keep sessions at any age by default.",
+ "desktopSessionCleanupPeriodDays or a managed cleanupPeriodDays can limit them.",
+ "The organization's HIPAA configuration also ends this exemption when it applies.",
+ "Settings that cannot be read or parsed pause Claude Code's cleanup unless managed settings provide cleanupPeriodDays.")
+ if !strings.Contains(text, filepath.Base(path)) {
+ t.Errorf("warning does not name the inspected file:\n%s", text)
+ }
+ if warns := strings.Contains(text, "A session resumed after it is retired is not collected."); warns != (retire == "7d") {
+ t.Errorf("retire_after %s comparison:\n%s", retire, text)
+ }
+ if retire == "7d" {
+ checkWarningText(t, text, "For the inspected value: Claude Code keeps a session for 30 days; storage.retention.retire_after is 7 days.")
+ }
+ })
+ }
+}
+
+func TestRetentionWarningUsesDefaultOnlyWhenUnset(t *testing.T) {
+ for _, c := range []struct{ name, body, want string }{
+ {"unset", `{}`, "Claude Code's default is 30 days."},
+ {"invalid", `{"cleanupPeriodDays":0}`, "No default was assumed."},
+ {"cannot parse", `{`, "No default was assumed."},
+ {"not an object", `[]`, "No default was assumed."},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ cfg := warningConfig(t, "7d")
+ path := filepath.Join(os.Getenv("CLAUDE_CONFIG_DIR"), "settings.json")
+ writeWarningSettings(t, path, c.body)
+ text := warningText(t, cfg)
+ checkWarningText(t, text, c.want, "Claude Code's effective retention was not determined.")
+ if c.name == "unset" {
+ checkWarningText(t, text, "No inspected file", "For the default: Claude Code keeps a session for 30 days;", "is 7 days.")
+ } else {
+ checkWarningText(t, text, path, "No value was assumed for it.")
+ if strings.Contains(text, "keeps a session for") || strings.Contains(text, "default is 30 days") {
+ t.Errorf("a failed inspection assumed a duration:\n%s", text)
+ }
+ }
+ })
+ }
+}
+
+func TestRetentionWarningUsesManagedValue(t *testing.T) {
+ cfg := warningConfig(t, "7d")
+ writeWarningSettings(t, filepath.Join(os.Getenv("CLAUDE_CONFIG_DIR"), "settings.json"), `{"cleanupPeriodDays":30}`)
+ path := filepath.Join(claudeManagedDir, "managed-settings.d", "policy.json")
+ writeWarningSettings(t, path, `{"cleanupPeriodDays":2}`)
+ text := warningText(t, cfg)
+ checkWarningText(t, text, "The inspected managed settings", "policy.json", "set cleanupPeriodDays to 2 days.", "effective retention was not determined")
+ if strings.Contains(text, "keeps a session for") || strings.Contains(text, "30 days") {
+ t.Errorf("the comparison used the user value:\n%s", text)
+ }
+}
+
+func TestRetentionWarningResolvesUserSettings(t *testing.T) {
+ for _, source := range []string{"CLAUDE_CONFIG_DIR", "XDG_CONFIG_HOME", "home"} {
+ t.Run(source, func(t *testing.T) {
+ cfg := warningConfig(t, "7d")
+ paths := []string{
+ filepath.Join(os.Getenv("CLAUDE_CONFIG_DIR"), "settings.json"),
+ filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "claude", "settings.json"),
+ filepath.Join(os.Getenv("HOME"), ".claude", "settings.json"),
+ }
+ for i, body := range []string{`{"cleanupPeriodDays":10}`, `{"cleanupPeriodDays":20}`, `{"cleanupPeriodDays":40}`} {
+ writeWarningSettings(t, paths[i], body)
+ }
+ want := "10 days"
+ if source != "CLAUDE_CONFIG_DIR" {
+ t.Setenv("CLAUDE_CONFIG_DIR", "")
+ want = "20 days"
+ }
+ if source == "home" {
+ t.Setenv("XDG_CONFIG_HOME", "")
+ want = "40 days"
+ }
+ checkWarningText(t, warningText(t, cfg), "set cleanupPeriodDays to "+want+".")
+ })
+ }
+}
+
+func TestRetentionWarningWithExplicitSourceDoesNotInspect(t *testing.T) {
+ cfg := warningConfig(t, "7d")
+ cfg.Adapters[0].SourceRoot = t.TempDir()
+ writeWarningSettings(t, filepath.Join(os.Getenv("CLAUDE_CONFIG_DIR"), "settings.json"), "invalid")
+ writeWarningSettings(t, filepath.Join(claudeManagedDir, "managed-settings.json"), "invalid")
+ text := warningText(t, cfg)
+ checkWarningText(t, text, "No Claude Code settings were inspected.", "explicit source_root", "effective retention was not determined", "Claude Desktop and Cowork", "pause Claude Code's cleanup unless managed settings provide cleanupPeriodDays")
+ if strings.Contains(text, "cannot parse") || strings.Contains(text, "keeps a session for") || strings.Contains(text, "default is") {
+ t.Errorf("an explicit source_root inspected or assumed settings:\n%s", text)
+ }
+}
+
+func TestRetentionWarningForKiro(t *testing.T) {
+ for _, retire := range []string{"7d", "60d", "100000d"} {
+ t.Run(retire, func(t *testing.T) {
+ cfg := warningConfig(t, retire)
+ cfg.Adapters = []config.Adapter{{Name: config.AdapterKiroLocal, Enabled: true}}
+ checkWarningText(t, warningText(t, cfg), "Kiro keeps a session until it is deleted; storage.retention.retire_after is", "A session resumed after it is retired is not collected.")
+ })
+ }
+}
+
+func TestRetentionWarningIsSilentWhenNotNeeded(t *testing.T) {
+ for _, c := range []struct {
+ name, retire string
+ ads []config.Adapter
+ }{
+ {"off", "-1", []config.Adapter{{Name: config.AdapterClaudeCodeLocal, Enabled: true}, {Name: config.AdapterKiroLocal, Enabled: true}}},
+ {"omitted", "", []config.Adapter{{Name: config.AdapterClaudeCodeLocal, Enabled: true}, {Name: config.AdapterKiroLocal, Enabled: true}}},
+ {"disabled", "7d", []config.Adapter{{Name: config.AdapterClaudeCodeLocal}, {Name: config.AdapterKiroLocal}}},
+ {"LangChain alone", "7d", []config.Adapter{{Name: config.AdapterLangSmithIngest, Enabled: true}, {Name: config.AdapterChanges, Enabled: true}}},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ cfg := warningConfig(t, c.retire)
+ cfg.Adapters = c.ads
+ if text := warningText(t, cfg); text != "" {
+ t.Fatalf("unexpected warning:\n%s", text)
+ }
+ })
+ }
+}
+
+func TestRetentionWarningAtStartupInStatusAndFirstPass(t *testing.T) {
+ cfg := warningConfig(t, "7d")
+ writeWarningSettings(t, filepath.Join(os.Getenv("CLAUDE_CONFIG_DIR"), "settings.json"), `{"cleanupPeriodDays":30}`)
+ ref := warningRefresher(t, cfg)
+ text := warningText(t, cfg)
+ startup := stdoutOf(t, func() { printPipeline(ref, cfg) })
+ for _, line := range strings.Split(text, "\n") {
+ if strings.Count(startup, "warning : "+line+"\n") != 1 {
+ t.Errorf("startup did not print the warning once:\n%s", startup)
+ }
+ }
+ if strings.Index(startup, "warning :") < strings.Index(startup, "retention:") {
+ t.Errorf("startup printed the warning before retention:\n%s", startup)
+ }
+ for _, st := range []view.Status{ref.base, {Refreshing: true}, {LastError: "a pass failed"}, {}} {
+ ref.setStatus(st)
+ checkRetentionStatus(t, ref.srv, text)
+ }
+ for pass := range 2 {
+ output := stderrOf(t, func() {
+ stdoutOf(t, func() {
+ if err := ref.pass(); err != nil {
+ t.Fatal(err)
+ }
+ })
+ })
+ if pass == 0 {
+ for _, line := range strings.Split(text, "\n") {
+ if strings.Count(output, " warning: "+line+"\n") != 1 {
+ t.Errorf("first pass did not print the warning once on standard error:\n%s", output)
+ }
+ }
+ } else if output != "" {
+ t.Errorf("next pass repeated a warning:\n%s", output)
+ }
+ checkRetentionStatus(t, ref.srv, text)
+ }
+}
+
+func TestRetentionWarningOnAWaitingFirstPass(t *testing.T) {
+ cfg := warningConfig(t, "7d")
+ ref := warningRefresher(t, cfg)
+ lock, err := storage.LockRoot(ref.zone.Root())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = lock.Unlock() })
+ for pass := range 2 {
+ output := stderrOf(t, func() {
+ stdoutOf(t, func() {
+ if err := ref.pass(); err != nil {
+ t.Fatal(err)
+ }
+ })
+ })
+ if warned := strings.Contains(output, "effective retention was not determined"); warned != (pass == 0) {
+ t.Errorf("waiting pass %d warning:\n%s", pass, output)
+ }
+ checkRetentionStatus(t, ref.srv, warningText(t, cfg))
+ }
+}
+
+func warningRefresher(t *testing.T, cfg *config.Config) *refresher {
+ t.Helper()
+ zone := storage.NewZone(cfg.Storage.Root)
+ ref, err := newRefresher(view.New(zone, nil), zone, cfg.Adapters, noMetrics, 2<<20, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(ref.close)
+ metadataOnly, retire, err := cfg.Storage.Retention.Durations()
+ if err != nil {
+ t.Fatal(err)
+ }
+ ref.retain(metadataOnly, retire)
+ return ref
+}
+
+func checkRetentionStatus(t *testing.T, srv *view.Server, want string) {
+ t.Helper()
+ rec := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/status", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status response: %d", rec.Code)
+ }
+ var status struct {
+ RetentionWarning string `json:"retention_warning"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &status); err != nil {
+ t.Fatal(err)
+ }
+ if status.RetentionWarning != want {
+ t.Fatalf("status warning = %q, want %q", status.RetentionWarning, want)
+ }
+}
+
+func TestRetentionWarningInSources(t *testing.T) {
+ for _, name := range []string{config.AdapterClaudeCodeLocal, config.AdapterKiroLocal} {
+ t.Run(name, func(t *testing.T) {
+ cfg := warningConfig(t, "7d")
+ writeWarningSettings(t, filepath.Join(os.Getenv("CLAUDE_CONFIG_DIR"), "settings.json"), `{"cleanupPeriodDays":30}`)
+ claude := cfg.Adapters[0]
+ kiro := config.Adapter{Name: config.AdapterKiroLocal, Enabled: true, SourceRoot: t.TempDir()}
+ cfg.Adapters = []config.Adapter{claude, kiro}
+ ad := claude
+ if name == config.AdapterKiroLocal {
+ ad = kiro
+ }
+ output := stdoutOf(t, func() {
+ if err := cmdSources(cfg, ad, false); err != nil {
+ t.Fatal(err)
+ }
+ })
+ for _, line := range retentionWarnings([]config.Adapter{ad}, 7*24*time.Hour) {
+ if strings.Count(output, "warning : "+line+"\n") != 1 {
+ t.Errorf("sources did not print the warning once:\n%s", output)
+ }
+ }
+ if name == config.AdapterClaudeCodeLocal && strings.Contains(output, "Kiro keeps") || name == config.AdapterKiroLocal && strings.Contains(output, "Claude Code") {
+ t.Errorf("sources printed another runtime's warning:\n%s", output)
+ }
+ })
+ }
+}
diff --git a/cmd/asz/view.go b/cmd/asz/view.go
index 6bbda7d..aec73c1 100644
--- a/cmd/asz/view.go
+++ b/cmd/asz/view.go
@@ -271,8 +271,10 @@ func printPipeline(ref *refresher, cfg *config.Config) {
fmt.Printf("retention: a conversation idle for %s is retired: everything of it is removed, and it is never collected again, even when its runtime resumes it\n", rt.RetireAfter)
}
}
- // A root a claude-code scenario build wrote. Only there does a pipeline
- // remove a session whole, and only one a build marked.
+ for _, line := range ref.retentionWarnings {
+ fmt.Printf("warning : %s\n", line)
+ }
+ // A scenario root keeps the removal policy its build chose.
if fi, err := os.Stat(filepath.Join(ref.zone.Root(), storage.ScenarioDir)); err == nil && fi.IsDir() {
fmt.Printf("scenario : %s\n", ref.removalSays())
}
diff --git a/docs/en/adapters/claude-code.md b/docs/en/adapters/claude-code.md
index 1efdceb..b724da6 100644
--- a/docs/en/adapters/claude-code.md
+++ b/docs/en/adapters/claude-code.md
@@ -126,7 +126,10 @@ that source with a conflict rather than read from a position that no longer mean
A session is never finished. A transcript quiet for weeks can grow again when the session is
resumed, so the adapter never marks a session done, and every pass checks every source again. A
session retired by `storage.retention.retire_after` is the one exception: it is never collected
-again, even when Claude Code resumes it. Set `retire_after` longer than `cleanupPeriodDays`.
+again, even when Claude Code resumes it. Set `retire_after` longer than Claude Code keeps its history.
+When `retire_after` is set, asz reports what it could inspect in Claude Code's settings.
+It warns when retirement is shorter than the inspected `cleanupPeriodDays` or the reported default.
+It also says that effective retention was not determined.
See [Retirement](../formats/storage-root.md#retirement).
Collection polls, as [collector](../setup/configuration.md#collector) describes. There is no file
system watcher.
diff --git a/docs/en/adapters/kiro.md b/docs/en/adapters/kiro.md
index 2f4ddc9..1e53837 100644
--- a/docs/en/adapters/kiro.md
+++ b/docs/en/adapters/kiro.md
@@ -38,6 +38,7 @@ not read : kiro-local: 24 session(s) of kiro-cli's V2 engine under sessions/cli;
Kiro keeps a session until a person or Kiro Crew deletes it. A session retired by
`storage.retention.retire_after` is never collected again, its sub-executions included, even when
Kiro resumes it. See [Retirement](../formats/storage-root.md#retirement).
+asz warns whenever `kiro-local` is enabled and `retire_after` is set because Kiro keeps a session until it is deleted.
## What is read
diff --git a/docs/en/changes/changes.md b/docs/en/changes/changes.md
index 1f0b97d..376039f 100644
--- a/docs/en/changes/changes.md
+++ b/docs/en/changes/changes.md
@@ -372,6 +372,12 @@
but still sends its waiting metrics requests, and `asz repack`, an sd scenario build and the
scenario runner refuse it. The pass log says once a process that a retired session's source was
found and not collected, `asz sources` names it, and `asz verify` counts the retired sessions.
+- With `retire_after` set, asz reports what it could inspect in Claude Code's settings.
+ It says that effective retention was not determined. It names invalid or unreadable settings
+ and assumes no value for them. It warns when retirement is shorter than the inspected value or the reported
+ default of 30 days. Kiro always warns when retirement is set. These lines appear at startup,
+ in `asz sources`, in the list page's status, and on standard error in the first pass only.
+ See [Retirement](../formats/storage-root.md#retirement) for the inspection's limits.
## Configuration
diff --git a/docs/en/formats/storage-root.md b/docs/en/formats/storage-root.md
index 8aff2eb..8d3678e 100644
--- a/docs/en/formats/storage-root.md
+++ b/docs/en/formats/storage-root.md
@@ -558,9 +558,46 @@ a skeleton keeps no records to re-cut.
`storage.retention.retire_after` retires a conversation idle for longer than it: everything of it is
removed, its metadata too, and a marker keeps it from ever being collected again. That holds even
when its runtime resumes it, so set `retire_after` longer than the runtime keeps its own history.
-Claude Code keeps a transcript for `cleanupPeriodDays`, 30 days when it is not set, and a session
-whose transcript it removed cannot be resumed. Kiro keeps its sessions until a person or Kiro Crew
-deletes them. A LangChain conversation has no source of its own: asz alone gives it a lifetime.
+Claude Code's `cleanupPeriodDays` defaults to 30 days. This alone does not establish how long it
+keeps a session. A session whose transcript Claude Code removed cannot be resumed. Kiro keeps its sessions until a person or Kiro Crew deletes them. A LangChain
+conversation has no source of its own. asz alone gives it a lifetime.
+
+When `retire_after` is set, asz compares it with an **inspected value** for Claude Code.
+It reads only `cleanupPeriodDays` from the user's `settings.json` and the machine's managed
+settings. The user directory follows `CLAUDE_CONFIG_DIR`, then `XDG_CONFIG_HOME/claude`, then
+`~/.claude`. A managed value wins over the user's. asz reads `managed-settings.json` first,
+then the files in `managed-settings.d/` in JavaScript's default sort order.
+Names are compared by UTF-16 code units. A later value wins.
+Hidden files and files that do not end in `.json` are ignored.
+Only regular files or symbolic links to them are read.
+
+asz reports what it could inspect and says that Claude Code's effective retention was not
+determined. Project settings, command-line settings, device policy and settings managed by a
+server can change it. From version 2.1.248, Claude Desktop and Cowork keep sessions at any age by default.
+`desktopSessionCleanupPeriodDays` or a managed `cleanupPeriodDays` can limit them.
+The organization's HIPAA configuration also ends this exemption when it applies.
+Settings that cannot be read or parsed pause Claude Code's cleanup unless managed settings
+provide `cleanupPeriodDays`.
+asz reports these limits without inspecting another setting. These rules follow Claude Code's
+[directory](https://code.claude.com/docs/en/claude-directory),
+[managed settings](https://code.claude.com/docs/en/managed-settings) and
+[data usage](https://code.claude.com/docs/en/data-usage) documentation read on 2026-10-17.
+
+A value must be a whole number of days of at least 1 that asz can compare. `30.0` counts as 30.
+asz names each invalid value or file it cannot read or parse and assumes no value for it.
+It also names an unreadable managed settings directory or a file whose top level is not an object.
+Missing files are allowed. A leading UTF-8 byte-order mark is allowed.
+Empty files and files with only whitespace set no value and cause no problem.
+If no inspected file sets the value and none has a problem, asz reports the default of 30 days.
+With an explicit `source_root`, it inspects no settings and says the
+retention was not determined.
+
+asz also warns when `retire_after` is shorter than the inspected value or the reported default.
+The comparison says that a session resumed after it is retired is not collected.
+Kiro warns for any `retire_after`. LangChain has no source and gets no warning.
+These lines appear at startup, in `asz sources`, and in the list page's refresh status.
+The first pass also writes them to standard error. Later passes do not repeat them.
+With `retire_after` off, none of these lines is shown.
A pipeline, `asz collect` or `asz server`, retires at the end of a pass, after the send, before it
looks at level 1. A conversation is retired when all of these hold:
diff --git a/docs/en/guides/command-line-internals.md b/docs/en/guides/command-line-internals.md
index 93eb3d2..3ea1ae6 100644
--- a/docs/en/guides/command-line-internals.md
+++ b/docs/en/guides/command-line-internals.md
@@ -47,11 +47,23 @@ retention:
```text
retention: a conversation idle for 72h0m0s keeps only its metadata; its content is removed, and only a receiver that accepted it still has it
retention: a conversation idle for 2160h0m0s is retired: everything of it is removed, and it is never collected again, even when its runtime resumes it
+warning : Kiro keeps a session until it is deleted; storage.retention.retire_after is 90 days. A session resumed after it is retired is not collected.
```
-When both are the same, a conversation is retired with no metadata-only step first, and only the
-second line is said. `asz parse` and `asz push` finish a retirement left in progress before they do
-anything else.
+When both durations are the same, a conversation is retired without first keeping only its metadata.
+Only the second retention line is said. `asz parse` and `asz push` finish a retirement left in
+progress before they do anything else.
+
+With `retire_after` set, `warning :` lines follow the retention lines.
+The Kiro line above appears when `kiro-local` is enabled.
+For `claude-code-local`, asz reports what it could inspect in Claude Code's settings.
+It says that effective retention was not determined. It also warns when `retire_after` is shorter
+than the inspected `cleanupPeriodDays` or the reported default of 30 days.
+The inspected value does not establish Claude Code's effective retention.
+See [Retirement](../formats/storage-root.md#retirement) for the inspection's limits.
+`asz sources` prints the same lines for each runtime. The list page shows a retention warning in
+its refresh status and the full text in its title. The first pass writes the lines to standard
+error once per process. With `retire_after` off, none of these lines is shown.
When metrics are turned on after a conversation kept only its metadata, the skeletons give its
tokens, and not whether an execution was a call to an MCP server. The first pass of a process says,
diff --git a/docs/en/setup/configuration.md b/docs/en/setup/configuration.md
index 5ec6673..5f7492f 100644
--- a/docs/en/setup/configuration.md
+++ b/docs/en/setup/configuration.md
@@ -18,7 +18,7 @@ curl -fsSL -o asz.yaml "https://raw.githubusercontent.com/apache/skywalking-ai-s
| --- | --- | --- |
| `root` | `./data` | Where collected data lands and conversations are assembled. A relative path is resolved from the working directory. |
| `retention.metadata_only_after` | `-1` | How long a conversation may be idle, with nothing new landed for it, before the root keeps only its metadata, to save disk: what was said and run is removed, and who did what, when, in what order and at what cost stays. New activity continues the same conversation. The removed content cannot be restored from the root; only a receiver that accepted it still has it. A duration such as `72h`, or days such as `30d`. It applies whether the conversati [...]
-| `retention.retire_after` | `-1` | How long a conversation may be idle before everything of it is removed, its metadata too, and it is retired. A retired conversation is never collected again, even when its runtime resumes it, so set this longer than the runtime keeps its own history. It is measured on the same clock as `metadata_only_after`, and is at least as long. Equal to it, a conversation is retired with no metadata-only step first. Set with `metadata_only_after` off, or shorter t [...]
+| `retention.retire_after` | `-1` | How long a conversation may be idle before everything of it is removed, its metadata too, and it is retired. A retired conversation is never collected again, even when its runtime resumes it. Set this longer than the runtime keeps its own history. It uses the same clock as `metadata_only_after` and must be at least as long. Equal values retire a conversation with no metadata-only step first. Setting it with `metadata_only_after` off or shorter than tha [...]
## adapters
diff --git a/internal/adapters/claudecode/retention.go b/internal/adapters/claudecode/retention.go
new file mode 100644
index 0000000..3dbf78c
--- /dev/null
+++ b/internal/adapters/claudecode/retention.go
@@ -0,0 +1,159 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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.
+
+package claudecode
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "math"
+ "os"
+ "path/filepath"
+ "runtime"
+ "slices"
+ "strings"
+ "time"
+ "unicode"
+ "unicode/utf16"
+)
+
+// RetentionInspection cannot establish Claude Code's effective retention.
+// Other settings can change it, and some sessions are exempt from cleanup.
+// Days is zero when no inspected file supplies a valid value.
+// Problems must be checked before using the default.
+type RetentionInspection struct {
+ Days int64
+ Path string
+ Managed bool
+ Problems []error
+}
+
+// ManagedSettingsDir follows Claude Code's documented machine settings path.
+func ManagedSettingsDir() string {
+ switch runtime.GOOS {
+ case "darwin":
+ return "/Library/Application Support/ClaudeCode"
+ case "windows":
+ return `C:\Program Files\ClaudeCode`
+ default:
+ return "/etc/claude-code"
+ }
+}
+
+// InspectRetention reads only cleanupPeriodDays. Managed values take priority
+// over the user's value. Problems remain separate from valid inspected values.
+// A problem must never be taken to mean that Claude Code uses its default.
+func InspectRetention(configDir, managedDir string) RetentionInspection {
+ var result RetentionInspection
+ result.read(filepath.Join(configDir, "settings.json"), false)
+ result.read(filepath.Join(managedDir, "managed-settings.json"), true)
+ dir := filepath.Join(managedDir, "managed-settings.d")
+ // A file in its place is there and cannot be read as a directory. Listing
+ // it says so on Unix, and says "not found" on Windows, so it is asked
+ // first.
+ var entries []os.DirEntry
+ if info, err := os.Stat(dir); err == nil && !info.IsDir() {
+ result.Problems = append(result.Problems, fmt.Errorf("%s: cannot read managed settings directory: it is not a directory", dir))
+ } else if entries, err = os.ReadDir(dir); err != nil && !os.IsNotExist(err) {
+ result.Problems = append(result.Problems, fmt.Errorf("%s: cannot read managed settings directory: %w", dir, err))
+ }
+ // Claude Code sorts names by UTF-16 code units. ReadDir uses UTF-8 bytes.
+ slices.SortFunc(entries, func(a, b os.DirEntry) int {
+ return slices.Compare(utf16.Encode([]rune(a.Name())), utf16.Encode([]rune(b.Name())))
+ })
+ for _, entry := range entries {
+ if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") || !strings.HasSuffix(entry.Name(), ".json") {
+ continue
+ }
+ result.read(filepath.Join(dir, entry.Name()), true)
+ }
+ return result
+}
+
+func (r *RetentionInspection) read(path string, managed bool) {
+ // Claude Code reads only regular files or symbolic links to them.
+ // Check the target before opening so a named pipe cannot block startup.
+ info, err := os.Stat(path)
+ if os.IsNotExist(err) {
+ return
+ }
+ if err != nil {
+ r.Problems = append(r.Problems, fmt.Errorf("%s: cannot inspect settings path: %w", path, err))
+ return
+ }
+ if !info.Mode().IsRegular() {
+ r.Problems = append(r.Problems, fmt.Errorf("%s: skipped settings path because it is not a regular file", path))
+ return
+ }
+ data, err := os.ReadFile(path)
+ if os.IsNotExist(err) {
+ return
+ }
+ if err != nil {
+ r.Problems = append(r.Problems, fmt.Errorf("%s: cannot read settings file: %w", path, err))
+ return
+ }
+ // Match Claude Code's reader, which selects UTF-16LE only for FF FE.
+ if bytes.HasPrefix(data, []byte{0xff, 0xfe}) {
+ // Buffer.toString ignores an incomplete final code unit.
+ units := make([]uint16, len(data)/2)
+ for i := range units {
+ units[i] = uint16(data[2*i]) | uint16(data[2*i+1])<<8
+ }
+ data = []byte(string(utf16.Decode(units)))
+ }
+ data = bytes.TrimPrefix(data, []byte("\xef\xbb\xbf"))
+ // Claude Code uses JavaScript trim only to check for empty settings.
+ if len(bytes.TrimFunc(data, func(c rune) bool {
+ switch c {
+ case '\t', '\n', '\v', '\f', '\r', '\ufeff', '\u2028', '\u2029':
+ return true
+ }
+ return unicode.Is(unicode.Zs, c)
+ })) == 0 {
+ return
+ }
+ var fields map[string]json.RawMessage
+ if err := json.Unmarshal(data, &fields); err != nil {
+ var wrongType *json.UnmarshalTypeError
+ if errors.As(err, &wrongType) {
+ r.Problems = append(r.Problems, fmt.Errorf("%s: the top level must be an object", path))
+ } else {
+ r.Problems = append(r.Problems, fmt.Errorf("%s: cannot parse settings file: %w", path, err))
+ }
+ return
+ }
+ if fields == nil {
+ r.Problems = append(r.Problems, fmt.Errorf("%s: the top level must be an object", path))
+ return
+ }
+ value, ok := fields["cleanupPeriodDays"]
+ if !ok {
+ return
+ }
+ // JavaScript reads 30.0 as 30. Check the range before converting to a
+ // duration, since an overflow could reverse the retention comparison.
+ var days float64
+ const maxDays = math.MaxInt64 / int64(24*time.Hour)
+ if err := json.Unmarshal(value, &days); err != nil || days < 1 || math.Trunc(days) != days || days > float64(maxDays) {
+ r.Problems = append(r.Problems, fmt.Errorf("%s: cleanupPeriodDays %q is invalid. It must be a whole number of days from 1 to %d", path, value, maxDays))
+ return
+ }
+ r.Days, r.Path, r.Managed = int64(days), path, managed
+}
diff --git a/internal/adapters/claudecode/retention_test.go b/internal/adapters/claudecode/retention_test.go
new file mode 100644
index 0000000..443947e
--- /dev/null
+++ b/internal/adapters/claudecode/retention_test.go
@@ -0,0 +1,334 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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.
+
+package claudecode_test
+
+import (
+ "encoding/binary"
+ "fmt"
+ "math"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+ "unicode/utf16"
+
+ "github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecode"
+)
+
+func writeRetentionSettings(t *testing.T, root, path, body string) string {
+ t.Helper()
+ path = filepath.Join(root, path)
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return path
+}
+
+func TestInspectRetention(t *testing.T) {
+ for _, c := range []struct {
+ name string
+ files map[string]string
+ days int64
+ path string
+ managed bool
+ }{
+ {name: "missing files"},
+ {name: "unset", files: map[string]string{
+ "user/settings.json": `{"desktopSessionCleanupPeriodDays":1}`,
+ "managed/managed-settings.json": `{}`,
+ "managed/managed-settings.d/a.json": `{}`,
+ }},
+ {name: "user value", files: map[string]string{
+ "user/settings.json": `{"cleanupPeriodDays":30}`,
+ }, days: 30, path: "user/settings.json"},
+ {name: "managed wins over user", files: map[string]string{
+ "user/settings.json": `{"cleanupPeriodDays":60}`,
+ "managed/managed-settings.json": `{"cleanupPeriodDays":10}`,
+ }, days: 10, path: "managed/managed-settings.json", managed: true},
+ {name: "later managed file wins", files: map[string]string{
+ "user/settings.json": `{"cleanupPeriodDays":60}`,
+ "managed/managed-settings.json": `{"cleanupPeriodDays":40}`,
+ "managed/managed-settings.d/a.json": `{"cleanupPeriodDays":20}`,
+ "managed/managed-settings.d/z.json": `{"cleanupPeriodDays":10}`,
+ }, days: 10, path: "managed/managed-settings.d/z.json", managed: true},
+ {name: "managed names follow UTF-16 order", files: map[string]string{
+ "managed/managed-settings.d/A.json": `{"cleanupPeriodDays":30}`,
+ "managed/managed-settings.d/😀.json": `{"cleanupPeriodDays":2}`,
+ }, days: 30, path: "managed/managed-settings.d/A.json", managed: true},
+ {name: "unset managed file keeps earlier value", files: map[string]string{
+ "managed/managed-settings.json": `{"cleanupPeriodDays":40}`,
+ "managed/managed-settings.d/z.json": `{}`,
+ }, days: 40, path: "managed/managed-settings.json", managed: true},
+ {name: "empty managed file keeps earlier value", files: map[string]string{
+ "managed/managed-settings.json": `{"cleanupPeriodDays":40}`,
+ "managed/managed-settings.d/z.json": "\xef\xbb\xbf \t\r\n",
+ }, days: 40, path: "managed/managed-settings.json", managed: true},
+ {name: "hidden and other files ignored", files: map[string]string{
+ "managed/managed-settings.d/a.json": `{"cleanupPeriodDays":10}`,
+ "managed/managed-settings.d/.hidden.json": `invalid`,
+ "managed/managed-settings.d/z.json.bak": `invalid`,
+ "managed/managed-settings.d/z.JSON": `invalid`,
+ "managed/managed-settings.d/directory.json/settings.json": `invalid`,
+ }, days: 10, path: "managed/managed-settings.d/a.json", managed: true},
+ {name: "30.0 is a whole number", files: map[string]string{
+ "user/settings.json": `{"cleanupPeriodDays":30.0}`,
+ }, days: 30, path: "user/settings.json"},
+ {name: "one day", files: map[string]string{
+ "user/settings.json": `{"cleanupPeriodDays":1}`,
+ }, days: 1, path: "user/settings.json"},
+ {name: "largest comparable value", files: map[string]string{
+ "user/settings.json": fmt.Sprintf(`{"cleanupPeriodDays":%d}`, math.MaxInt64/int64(24*time.Hour)),
+ }, days: math.MaxInt64 / int64(24*time.Hour), path: "user/settings.json"},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ root := t.TempDir()
+ for path, body := range c.files {
+ writeRetentionSettings(t, root, path, body)
+ }
+ got := claudecode.InspectRetention(filepath.Join(root, "user"), filepath.Join(root, "managed"))
+ path := ""
+ if c.path != "" {
+ path = filepath.Join(root, c.path)
+ }
+ if got.Days != c.days || got.Path != path || got.Managed != c.managed || len(got.Problems) != 0 {
+ t.Fatalf("inspection = %+v, want %d days from %q, managed %t, with no problems", got, c.days, path, c.managed)
+ }
+ })
+ }
+}
+
+func TestInspectRetentionNormalizesSettings(t *testing.T) {
+ encodeUTF16 := func(body string, order binary.ByteOrder) string {
+ units := utf16.Encode([]rune(body))
+ data := make([]byte, len(units)*2)
+ for i, unit := range units {
+ order.PutUint16(data[i*2:], unit)
+ }
+ return string(data)
+ }
+ const value = `{"cleanupPeriodDays":30}`
+ littleEndian := encodeUTF16("\ufeff"+value, binary.LittleEndian)
+ const whitespace = "\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
+ for _, path := range []string{"user/settings.json", "managed/managed-settings.json", "managed/managed-settings.d/a.json"} {
+ for _, c := range []struct {
+ name, body string
+ days int64
+ problem string
+ }{
+ {name: "empty"},
+ {name: "whitespace", body: " \t\r\n"},
+ {name: "space then byte-order mark", body: " \ufeff"},
+ {name: "JavaScript whitespace", body: whitespace},
+ {name: "byte-order mark", body: "\xef\xbb\xbf"},
+ {name: "byte-order mark and whitespace", body: "\xef\xbb\xbf \t\r\n"},
+ {name: "byte-order mark and value", body: "\xef\xbb\xbf" + `{"cleanupPeriodDays":30}`, days: 30},
+ {name: "byte-order mark and spaced value", body: "\xef\xbb\xbf \t\r\n" + `{"cleanupPeriodDays":30}` + "\n", days: 30},
+ {name: "UTF-16LE value", body: littleEndian, days: 30},
+ {name: "UTF-16LE empty", body: "\xff\xfe"},
+ {name: "UTF-16LE whitespace", body: encodeUTF16("\ufeff"+whitespace, binary.LittleEndian)},
+ {name: "UTF-16LE spaced value", body: encodeUTF16("\ufeff \r\n"+value+"\r\n", binary.LittleEndian), days: 30},
+ {name: "UTF-16LE incomplete trailing code unit", body: littleEndian + "\x00", days: 30},
+ {name: "UTF-16LE incomplete closing brace", body: littleEndian[:len(littleEndian)-1], problem: "cannot parse"},
+ {name: "UTF-16LE without byte-order mark", body: encodeUTF16(value, binary.LittleEndian), problem: "cannot parse"},
+ {name: "UTF-16BE value", body: encodeUTF16("\ufeff"+value, binary.BigEndian), problem: "cannot parse"},
+ {name: "UTF-16LE repeated byte-order mark", body: encodeUTF16("\ufeff\ufeff"+value, binary.LittleEndian), problem: "cannot parse"},
+ {name: "U+0085 is not whitespace", body: "\u0085", problem: "cannot parse"},
+ {name: "space then byte-order mark before value", body: " \ufeff" + value, problem: "cannot parse"},
+ {name: "byte-order mark after value", body: value + "\ufeff", problem: "cannot parse"},
+ {name: "space separator before value", body: "\u00a0" + value, problem: "cannot parse"},
+ {name: "repeated byte-order mark before value", body: "\ufeff\ufeff" + value, problem: "cannot parse"},
+ } {
+ t.Run(path+"/"+c.name, func(t *testing.T) {
+ root := t.TempDir()
+ full := writeRetentionSettings(t, root, path, c.body)
+ got := claudecode.InspectRetention(filepath.Join(root, "user"), filepath.Join(root, "managed"))
+ if c.problem != "" {
+ checkRetentionProblem(t, got, full, c.problem)
+ return
+ }
+ wantPath := ""
+ managed := false
+ if c.days > 0 {
+ wantPath = full
+ managed = strings.HasPrefix(path, "managed/")
+ }
+ if got.Days != c.days || got.Path != wantPath || got.Managed != managed || len(got.Problems) != 0 {
+ t.Fatalf("inspection = %+v, want %d days from %q, managed %t, with no problems", got, c.days, wantPath, managed)
+ }
+ })
+ }
+ }
+}
+
+func TestInspectRetentionFollowsSymbolicLinks(t *testing.T) {
+ for _, path := range []string{"user/settings.json", "managed/managed-settings.json", "managed/managed-settings.d/a.json"} {
+ for _, directory := range []bool{false, true} {
+ t.Run(fmt.Sprintf("%s/directory=%t", path, directory), func(t *testing.T) {
+ root := t.TempDir()
+ target := t.TempDir()
+ if !directory {
+ target = writeRetentionSettings(t, root, "target.json", `{"cleanupPeriodDays":30}`)
+ }
+ full := filepath.Join(root, path)
+ if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(target, full); err != nil {
+ t.Skipf("Cannot create a symbolic link: %v", err)
+ }
+ got := claudecode.InspectRetention(filepath.Join(root, "user"), filepath.Join(root, "managed"))
+ if directory {
+ checkRetentionProblem(t, got, full, "skipped settings path because it is not a regular file")
+ return
+ }
+ managed := strings.HasPrefix(path, "managed/")
+ if got.Days != 30 || got.Path != full || got.Managed != managed || len(got.Problems) != 0 {
+ t.Fatalf("inspection = %+v, want 30 days from %q, managed %t, with no problems", got, full, managed)
+ }
+ })
+ }
+ }
+}
+
+func TestInspectRetentionNamesInvalidValues(t *testing.T) {
+ for _, path := range []string{"user/settings.json", "managed/managed-settings.json", "managed/managed-settings.d/a.json"} {
+ for _, value := range []string{"1.5", "0", "-1", `"30"`, "1e300", "null", "true", "[]", "{}", fmt.Sprint(math.MaxInt64/int64(24*time.Hour) + 1)} {
+ t.Run(path+"/"+value, func(t *testing.T) {
+ root := t.TempDir()
+ path := writeRetentionSettings(t, root, path, `{"cleanupPeriodDays":`+value+`}`)
+ got := claudecode.InspectRetention(filepath.Join(root, "user"), filepath.Join(root, "managed"))
+ checkRetentionProblem(t, got, path, "cleanupPeriodDays")
+ if !strings.Contains(got.Problems[0].Error(), fmt.Sprintf("%q is invalid", value)) {
+ t.Errorf("problem does not name the invalid value: %v", got.Problems[0])
+ }
+ })
+ }
+ }
+}
+
+func TestInspectRetentionNamesInvalidFiles(t *testing.T) {
+ for _, c := range []struct{ name, body, problem string }{
+ {"cannot parse", `{"cleanupPeriodDays":30`, "cannot parse"},
+ {"second object", `{} {}`, "cannot parse"},
+ {"array", `[]`, "top level must be an object"},
+ {"null", `null`, "top level must be an object"},
+ {"string", `"settings"`, "top level must be an object"},
+ {"number", `30`, "top level must be an object"},
+ {"boolean", `true`, "top level must be an object"},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ root := t.TempDir()
+ path := writeRetentionSettings(t, root, "user/settings.json", c.body)
+ got := claudecode.InspectRetention(filepath.Join(root, "user"), filepath.Join(root, "managed"))
+ checkRetentionProblem(t, got, path, c.problem)
+ })
+ }
+}
+
+func TestInspectRetentionNamesReadFailures(t *testing.T) {
+ for _, path := range []string{"user/settings.json", "managed/managed-settings.json", "managed/managed-settings.d"} {
+ t.Run(path, func(t *testing.T) {
+ root := t.TempDir()
+ full := filepath.Join(root, path)
+ problem := "skipped settings path because it is not a regular file"
+ if strings.HasSuffix(path, ".d") {
+ writeRetentionSettings(t, root, path, "not a directory")
+ problem = "cannot read managed settings directory"
+ } else if err := os.MkdirAll(full, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ got := claudecode.InspectRetention(filepath.Join(root, "user"), filepath.Join(root, "managed"))
+ checkRetentionProblem(t, got, full, problem)
+ })
+ }
+}
+
+func TestInspectRetentionNamesUnreadablePaths(t *testing.T) {
+ for _, directory := range []bool{false, true} {
+ t.Run(fmt.Sprintf("directory=%t", directory), func(t *testing.T) {
+ root := t.TempDir()
+ path := filepath.Join(root, "managed", "managed-settings.d")
+ problem := "cannot read managed settings directory"
+ if directory {
+ if err := os.MkdirAll(path, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ } else {
+ path = writeRetentionSettings(t, root, "user/settings.json", `{"cleanupPeriodDays":30}`)
+ problem = "cannot read settings file"
+ }
+ if err := os.Chmod(path, 0); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.Chmod(path, 0o700) })
+ var err error
+ if directory {
+ _, err = os.ReadDir(path)
+ } else {
+ _, err = os.ReadFile(path)
+ }
+ if err == nil {
+ t.Skip("This process can read paths with all permissions removed.")
+ }
+ got := claudecode.InspectRetention(filepath.Join(root, "user"), filepath.Join(root, "managed"))
+ checkRetentionProblem(t, got, path, problem)
+ })
+ }
+}
+
+func TestInspectRetentionKeepsValidValuesAndNamesProblems(t *testing.T) {
+ root := t.TempDir()
+ user := writeRetentionSettings(t, root, "user/settings.json", `{"cleanupPeriodDays":30}`)
+ bad := writeRetentionSettings(t, root, "managed/managed-settings.json", `{"cleanupPeriodDays":0}`)
+ got := claudecode.InspectRetention(filepath.Join(root, "user"), filepath.Join(root, "managed"))
+ if got.Days != 30 || got.Path != user || got.Managed || len(got.Problems) != 1 || !strings.Contains(got.Problems[0].Error(), bad) {
+ t.Fatalf("inspection lost the valid user value or the invalid managed value: %+v", got)
+ }
+}
+
+func TestInspectRetentionNamesEveryProblem(t *testing.T) {
+ root := t.TempDir()
+ paths := []string{
+ writeRetentionSettings(t, root, "user/settings.json", `{"cleanupPeriodDays":0}`),
+ writeRetentionSettings(t, root, "managed/managed-settings.json", `[]`),
+ writeRetentionSettings(t, root, "managed/managed-settings.d/a.json", `{`),
+ }
+ got := claudecode.InspectRetention(filepath.Join(root, "user"), filepath.Join(root, "managed"))
+ if got.Days != 0 || got.Path != "" || len(got.Problems) != len(paths) {
+ t.Fatalf("inspection = %+v, want every problem and no assumed value", got)
+ }
+ for i, path := range paths {
+ if !strings.Contains(got.Problems[i].Error(), path) {
+ t.Errorf("problem %q does not name %q", got.Problems[i], path)
+ }
+ }
+}
+
+func checkRetentionProblem(t *testing.T, got claudecode.RetentionInspection, path, problem string) {
+ t.Helper()
+ if got.Days != 0 || got.Path != "" || got.Managed || len(got.Problems) != 1 {
+ t.Fatalf("inspection = %+v, want one problem and no assumed value", got)
+ }
+ if text := got.Problems[0].Error(); !strings.Contains(text, path) || !strings.Contains(text, problem) {
+ t.Fatalf("problem %q does not name %q and %q", text, path, problem)
+ }
+}
diff --git a/internal/adapters/claudecode/retention_unix_test.go b/internal/adapters/claudecode/retention_unix_test.go
new file mode 100644
index 0000000..d5dc6c1
--- /dev/null
+++ b/internal/adapters/claudecode/retention_unix_test.go
@@ -0,0 +1,76 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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.
+
+//go:build !windows
+
+package claudecode_test
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "syscall"
+ "testing"
+ "time"
+
+ "github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecode"
+)
+
+func TestInspectRetentionSkipsNamedPipes(t *testing.T) {
+ if root := os.Getenv("ASZ_TEST_RETENTION_PIPE_ROOT"); root != "" {
+ got := claudecode.InspectRetention(filepath.Join(root, "user"), filepath.Join(root, "managed"))
+ checkRetentionProblem(t, got, os.Getenv("ASZ_TEST_RETENTION_PIPE_PATH"), "skipped settings path because it is not a regular file")
+ return
+ }
+ for _, path := range []string{"user/settings.json", "managed/managed-settings.json", "managed/managed-settings.d/a.json"} {
+ for _, link := range []bool{false, true} {
+ t.Run(fmt.Sprintf("%s/link=%t", path, link), func(t *testing.T) {
+ root := t.TempDir()
+ full := filepath.Join(root, path)
+ if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ pipe := full
+ if link {
+ pipe = filepath.Join(root, "named-pipe")
+ }
+ if err := syscall.Mkfifo(pipe, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if link {
+ if err := os.Symlink(pipe, full); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // A child process lets the test stop a blocked pipe read.
+ ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestInspectRetentionSkipsNamedPipes$")
+ cmd.Env = append(os.Environ(), "ASZ_TEST_RETENTION_PIPE_ROOT="+root, "ASZ_TEST_RETENTION_PIPE_PATH="+full)
+ output, err := cmd.CombinedOutput()
+ if ctx.Err() != nil {
+ t.Fatalf("inspection blocked on named pipe %q: %v\n%s", full, ctx.Err(), output)
+ }
+ if err != nil {
+ t.Fatalf("inspection failed: %v\n%s", err, output)
+ }
+ })
+ }
+ }
+}
diff --git a/internal/view/index.html b/internal/view/index.html
index f4d3473..68973ed 100644
--- a/internal/view/index.html
+++ b/internal/view/index.html
@@ -267,11 +267,13 @@ function poll() {
el.hidden = true;
} else {
el.hidden = false;
- el.title = `${s.adapter} · ${s.source}` + (s.last_error ? `\n${s.last_error}` : "");
+ el.title = `${s.adapter} · ${s.source}` + (s.last_error ? `\n${s.last_error}` : "")
+ + (s.retention_warning ? `\n${s.retention_warning}` : "");
el.innerHTML = (s.refreshing ? `<i class="dot busy"></i><span>refreshing…</span>` : `<i class="dot"></i>`)
+ `<span>last refresh <strong>${clock(s.last_refresh)}</strong></span>`
+ (s.next_refresh ? `<span>next <strong>${clock(s.next_refresh)}</strong></span>` : "")
- + (s.last_error ? `<span class="warn">last pass had errors</span>` : "");
+ + (s.last_error ? `<span class="warn">last pass had errors</span>` : "")
+ + (s.retention_warning ? `<span class="warn">retention warning</span>` : "");
}
if (seenRefresh >= 0 && s.last_refresh !== seenRefresh) load();
seenRefresh = s.last_refresh;
diff --git a/internal/view/status.go b/internal/view/status.go
index c875e4e..5bd19ac 100644
--- a/internal/view/status.go
+++ b/internal/view/status.go
@@ -42,6 +42,8 @@ type Status struct {
Refreshing bool `json:"refreshing"`
// LastError is empty when the last pass was clean.
LastError string `json:"last_error,omitempty"`
+ // RetentionWarning stays visible after a pass succeeds.
+ RetentionWarning string `json:"retention_warning,omitempty"`
// Counts from the last completed pass.
Landed int `json:"landed"`
Records int `json:"records"`
diff --git a/internal/view/status_test.go b/internal/view/status_test.go
index b571ec7..8c1d602 100644
--- a/internal/view/status_test.go
+++ b/internal/view/status_test.go
@@ -61,6 +61,7 @@ func TestStatusRoundTrips(t *testing.T) {
Mode: "watch", Adapter: "claude-code-local", Source: "/src",
IntervalMS: 5000, LastRefresh: 1000, NextRefresh: 6000,
Landed: 2, Records: 7, Rounds: 1, TookMS: 42, LastError: "x: y",
+ RetentionWarning: "An inspected value.\nEffective retention was not determined.",
}
s.SetStatus(want)
if got := getStatus(t, s); got != want {