diff --git a/internal/installation/state.go b/internal/installation/state.go index e113994..86875e9 100644 --- a/internal/installation/state.go +++ b/internal/installation/state.go @@ -43,10 +43,6 @@ func UserPath() string { return filepath.Join(home, "Library", "Application Support", "Kontext", "installation.json") } -func Load() (State, error) { - return LoadFile(PathFromEnv()) -} - func LoadFile(path string) (State, error) { if err := validateStateFile(path); err != nil { return State{}, err @@ -61,10 +57,6 @@ func LoadFile(path string) (State, error) { return parse(data) } -func Ensure() (State, error) { - return EnsureFile(PathFromEnv()) -} - func EnsureFile(path string) (State, error) { state, err := LoadFile(path) if err == nil { diff --git a/internal/managedconfig/config.go b/internal/managedconfig/config.go index c95a321..c9465b5 100644 --- a/internal/managedconfig/config.go +++ b/internal/managedconfig/config.go @@ -114,13 +114,6 @@ type LoadedConfig struct { Scope Scope } -func PathFromEnv() string { - if path := strings.TrimSpace(os.Getenv(EnvPath)); path != "" { - return path - } - return DefaultPath -} - // DeploymentVersion returns the installed package version recorded in the // deployment marker, or "" if the marker is missing or unreadable. func DeploymentVersion() string { diff --git a/internal/managedconfig/config_test.go b/internal/managedconfig/config_test.go index b10efad..6530268 100644 --- a/internal/managedconfig/config_test.go +++ b/internal/managedconfig/config_test.go @@ -196,13 +196,6 @@ func TestResolveInstallTokenRejectsEmptyEnv(t *testing.T) { } } -func TestPathFromEnvHonorsOverride(t *testing.T) { - t.Setenv(EnvPath, " "+filepath.Join(t.TempDir(), "managed.json")+" ") - if got := PathFromEnv(); got != strings.TrimSpace(os.Getenv(EnvPath)) { - t.Fatalf("PathFromEnv() = %q", got) - } -} - func TestDeploymentVersionReadsAndTrimsMarker(t *testing.T) { marker := filepath.Join(t.TempDir(), "deployment-version") if err := os.WriteFile(marker, []byte(" 0.2.0\n"), 0o600); err != nil { diff --git a/internal/managedobserve/autherr.go b/internal/managedobserve/autherr.go index 59a9be4..aab13b3 100644 --- a/internal/managedobserve/autherr.go +++ b/internal/managedobserve/autherr.go @@ -53,8 +53,12 @@ func writeBreadcrumb(dbPath string, breadcrumb AuthError) error { return os.WriteFile(AuthErrorPath(dbPath), append(data, '\n'), 0o600) } -func ClearAuthError(dbPath string) { - _ = os.Remove(AuthErrorPath(dbPath)) +func ClearAuthError(dbPath string) error { + err := os.Remove(AuthErrorPath(dbPath)) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err } // LoadAuthError returns the breadcrumb, or nil when none exists. Unreadable or diff --git a/internal/managedobserve/autherr_test.go b/internal/managedobserve/autherr_test.go index 9e054f3..697cb5c 100644 --- a/internal/managedobserve/autherr_test.go +++ b/internal/managedobserve/autherr_test.go @@ -22,12 +22,16 @@ func TestAuthErrorRoundTrip(t *testing.T) { t.Fatalf("LoadAuthError = %+v", got) } - ClearAuthError(dbPath) + if err := ClearAuthError(dbPath); err != nil { + t.Fatal(err) + } if got := LoadAuthError(dbPath); got != nil { t.Fatalf("LoadAuthError after clear = %v, want nil", got) } // Clearing again is a no-op. - ClearAuthError(dbPath) + if err := ClearAuthError(dbPath); err != nil { + t.Fatal(err) + } } func TestStartupErrorRoundTrip(t *testing.T) { @@ -41,12 +45,30 @@ func TestStartupErrorRoundTrip(t *testing.T) { t.Fatalf("LoadAuthError = %+v", got) } - ClearAuthError(dbPath) + if err := ClearAuthError(dbPath); err != nil { + t.Fatal(err) + } if LoadAuthError(dbPath) != nil { t.Fatal("startup breadcrumb not cleared") } } +func TestClearAuthErrorSurfacesRemoveFailure(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "guard.db") + breadcrumbPath := AuthErrorPath(dbPath) + if err := os.Mkdir(breadcrumbPath, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(breadcrumbPath, "child"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + + if err := ClearAuthError(dbPath); err == nil { + t.Fatal("ClearAuthError() error = nil, want remove failure") + } +} + func TestLoadAuthErrorToleratesCorruptBreadcrumb(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "guard.db") if err := os.WriteFile(AuthErrorPath(dbPath), []byte("{corrupt"), 0o600); err != nil { diff --git a/internal/managedobserve/daemon.go b/internal/managedobserve/daemon.go index a512a35..9d42d81 100644 --- a/internal/managedobserve/daemon.go +++ b/internal/managedobserve/daemon.go @@ -72,7 +72,9 @@ func RunDaemon(ctx context.Context, opts DaemonOptions) error { } // Token resolved — clear any stale startup breadcrumb from a prior boot. if previous := LoadAuthError(dbPath); previous != nil && previous.Kind == "startup" { - ClearAuthError(dbPath) + if err := ClearAuthError(dbPath); err != nil { + opts.Diagnostic.Printf("clear startup-error breadcrumb: %v\n", err) + } } socketPath := opts.SocketPath @@ -126,7 +128,9 @@ func RunDaemon(ctx context.Context, opts DaemonOptions) error { } }, OnFlushSuccess: func() { - ClearAuthError(dbPath) + if err := ClearAuthError(dbPath); err != nil { + opts.Diagnostic.Printf("clear auth-error breadcrumb: %v\n", err) + } }, }) }() diff --git a/internal/managedobserve/doctor.go b/internal/managedobserve/doctor.go index ef18199..b86524c 100644 --- a/internal/managedobserve/doctor.go +++ b/internal/managedobserve/doctor.go @@ -37,8 +37,10 @@ func PrintStatus(out io.Writer) { identityPath := installationPathForScope(loaded.Scope) if state, err := installation.LoadFile(identityPath); err == nil { fmt.Fprintf(out, " installation: %s\n", state.InstallationID) - } else { + } else if errors.Is(err, installation.ErrNotFound) { fmt.Fprintf(out, " installation: not created yet (%s)\n", identityPath) + } else { + fmt.Fprintf(out, " installation: ERROR %v (%s)\n", err, identityPath) } // Resolve the token through the daemon's exact read path: a locked or diff --git a/internal/managedobserve/doctor_test.go b/internal/managedobserve/doctor_test.go new file mode 100644 index 0000000..7840e2d --- /dev/null +++ b/internal/managedobserve/doctor_test.go @@ -0,0 +1,36 @@ +package managedobserve + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kontext-security/kontext-cli/internal/installation" + "github.com/kontext-security/kontext-cli/internal/managedconfig" +) + +func TestPrintStatusReportsInstallationLoadError(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "managed.json") + installationPath := filepath.Join(dir, "installation.json") + + writeTestManagedConfig(t, configPath) + if err := os.WriteFile(installationPath, []byte(`{"installation_id":`), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(managedconfig.EnvPath, configPath) + t.Setenv(installation.EnvPath, installationPath) + t.Setenv("KONTEXT_INSTALL_TOKEN", "test-install-token") + + var out bytes.Buffer + PrintStatus(&out) + output := out.String() + if !strings.Contains(output, "installation: ERROR") { + t.Fatalf("PrintStatus() output = %q, want installation error", output) + } + if strings.Contains(output, "installation: not created yet") { + t.Fatalf("PrintStatus() output = %q, must not hide invalid state as missing", output) + } +} diff --git a/internal/managedstream/stream.go b/internal/managedstream/stream.go index 34f1c5a..c4084d3 100644 --- a/internal/managedstream/stream.go +++ b/internal/managedstream/stream.go @@ -96,6 +96,11 @@ type Device struct { } type State struct { + UpdatedAfter *time.Time + ActionID string +} + +type persistedState struct { UpdatedAfter string `json:"updated_after,omitempty"` ActionID string `json:"action_id,omitempty"` } @@ -169,19 +174,10 @@ func Flush(ctx context.Context, opts Options) error { return err } - var updatedAfter *time.Time - if state.UpdatedAfter != "" { - parsed, err := parseStateUpdatedAfter(state.UpdatedAfter) - if err != nil { - return fmt.Errorf("parse managed stream state: %w", err) - } - updatedAfter = &parsed - } - limit := batchLimit(opts.BatchLimit) for { batch, err := store.LedgerBatch(ctx, sqlite.LedgerExportOptions{ - UpdatedAfter: updatedAfter, + UpdatedAfter: state.UpdatedAfter, UpdatedAfterID: state.ActionID, Limit: limit, }) @@ -368,8 +364,9 @@ func advancePastMinimumBatch(statePath string, batch sqlite.LedgerBatch, reason } func saveCursor(statePath string, batch sqlite.LedgerBatch) error { + updatedAfter := batch.Cursor.UpdatedAt.UTC() return SaveState(statePath, State{ - UpdatedAfter: batch.Cursor.UpdatedAt.UTC().Format(time.RFC3339Nano), + UpdatedAfter: &updatedAfter, ActionID: batch.Cursor.ActionID, }) } @@ -456,11 +453,18 @@ func LoadState(path string) (State, error) { return State{}, err } var state State - if err := json.Unmarshal(data, &state); err != nil { + var persisted persistedState + if err := json.Unmarshal(data, &persisted); err != nil { return State{}, err } - state.UpdatedAfter = strings.TrimSpace(state.UpdatedAfter) - state.ActionID = strings.TrimSpace(state.ActionID) + if updatedAfter := strings.TrimSpace(persisted.UpdatedAfter); updatedAfter != "" { + parsed, err := parseStateUpdatedAfter(updatedAfter) + if err != nil { + return State{}, fmt.Errorf("parse managed stream state: %w", err) + } + state.UpdatedAfter = &parsed + } + state.ActionID = strings.TrimSpace(persisted.ActionID) return state, nil } @@ -468,7 +472,11 @@ func SaveState(path string, state State) error { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } - data, err := json.MarshalIndent(state, "", " ") + persisted := persistedState{ActionID: strings.TrimSpace(state.ActionID)} + if state.UpdatedAfter != nil { + persisted.UpdatedAfter = state.UpdatedAfter.UTC().Format(time.RFC3339Nano) + } + data, err := json.MarshalIndent(persisted, "", " ") if err != nil { return err } diff --git a/internal/managedstream/stream_test.go b/internal/managedstream/stream_test.go index eab6399..36c1500 100644 --- a/internal/managedstream/stream_test.go +++ b/internal/managedstream/stream_test.go @@ -60,7 +60,7 @@ func TestFlushPostsLedgerBatchWithInstallationIdentity(t *testing.T) { if err != nil { t.Fatalf("LoadState() error = %v", err) } - if state.UpdatedAfter == "" { + if state.UpdatedAfter == nil { t.Fatal("updated_after was not persisted") } } @@ -227,7 +227,7 @@ func TestFlushRetriesWithSmallerBatchWhenHostedBackendRejectsSize(t *testing.T) if err != nil { t.Fatalf("LoadState() error = %v", err) } - if state.UpdatedAfter == "" { + if state.UpdatedAfter == nil { t.Fatal("updated_after was not persisted after smaller retry") } } @@ -362,7 +362,7 @@ func TestFlushAdvancesCursorPastOversizedMinimumBatch(t *testing.T) { if err != nil { t.Fatalf("LoadState() error = %v", err) } - if state.UpdatedAfter == "" || state.ActionID == "" { + if state.UpdatedAfter == nil || state.ActionID == "" { t.Fatalf("state = %+v, want cursor advanced", state) } } @@ -392,7 +392,7 @@ func TestFlushDefaultsStatePathBesideLedgerDB(t *testing.T) { if err != nil { t.Fatalf("LoadState() error = %v", err) } - if state.UpdatedAfter == "" { + if state.UpdatedAfter == nil { t.Fatal("updated_after was not persisted") } } @@ -402,7 +402,8 @@ func TestFlushUsesUpdatedAfterCursor(t *testing.T) { saveTestDecision(t, store, "session-1", "toolu_1") statePath := filepath.Join(t.TempDir(), "stream-state.json") - if err := SaveState(statePath, State{UpdatedAfter: time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano)}); err != nil { + updatedAfter := time.Now().Add(time.Hour).UTC() + if err := SaveState(statePath, State{UpdatedAfter: &updatedAfter}); err != nil { t.Fatalf("SaveState() error = %v", err) } @@ -428,6 +429,30 @@ func TestFlushUsesUpdatedAfterCursor(t *testing.T) { } } +func TestStatePersistsUpdatedAfterAsRFC3339String(t *testing.T) { + statePath := filepath.Join(t.TempDir(), "stream-state.json") + updatedAfter := time.Date(2026, 6, 8, 12, 20, 7, 853885000, time.UTC) + if err := SaveState(statePath, State{UpdatedAfter: &updatedAfter, ActionID: "act_123"}); err != nil { + t.Fatalf("SaveState() error = %v", err) + } + + raw, err := os.ReadFile(statePath) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if got := string(raw); !strings.Contains(got, `"updated_after": "2026-06-08T12:20:07.853885Z"`) { + t.Fatalf("state json = %s, want RFC3339 updated_after string", got) + } + + state, err := LoadState(statePath) + if err != nil { + t.Fatalf("LoadState() error = %v", err) + } + if state.UpdatedAfter == nil || !state.UpdatedAfter.Equal(updatedAfter) || state.ActionID != "act_123" { + t.Fatalf("LoadState() = %+v, want typed cursor", state) + } +} + func TestParseStateUpdatedAfterAcceptsLegacyTimestampFormats(t *testing.T) { for _, value := range []string{ "2026-06-08T12:20:07.853885",