Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions internal/installation/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
7 changes: 0 additions & 7 deletions internal/managedconfig/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 0 additions & 7 deletions internal/managedconfig/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 6 additions & 2 deletions internal/managedobserve/autherr.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 25 additions & 3 deletions internal/managedobserve/autherr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down
8 changes: 6 additions & 2 deletions internal/managedobserve/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
},
})
}()
Expand Down
4 changes: 3 additions & 1 deletion internal/managedobserve/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions internal/managedobserve/doctor_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
38 changes: 23 additions & 15 deletions internal/managedstream/stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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,
})
Expand Down Expand Up @@ -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,
})
}
Expand Down Expand Up @@ -456,19 +453,30 @@ 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
}

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
}
Expand Down
35 changes: 30 additions & 5 deletions internal/managedstream/stream_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Expand Down Expand Up @@ -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")
}
}
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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")
}
}
Expand All @@ -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)
}

Expand All @@ -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",
Expand Down
Loading