Skip to content
Merged
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
28 changes: 19 additions & 9 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,36 +47,46 @@ func (a *App) GetAPIKey() error {
// emitting counts or summaries: success only means the local files were
// processed and the server accepted the request.
//
// Without a configured API key the scan still runs and its events stay queued;
// only the upload half is skipped, so a user who has not signed in yet keeps
// accumulating history instead of losing it.
//
// Callers that coordinate multiple processes call the two phases separately —
// Ingest under the data lock, Upload under the upload lock — so a slow drain
// never blocks another process's ingestion.
func (a *App) Sync(ctx context.Context) error {
if err := a.Ingest(); err != nil {
return err
}
return a.Upload(ctx)
}

// Ingest scans the selected providers into the shared local queue. It writes
// the database, so the caller holds the data lock.
func (a *App) Ingest() error {
settings, err := a.Agent.Settings()
if err != nil {
return err
}
if settings.APIKey == "" {
return errors.New("API key is required in ~/.tokitoki/api_key")
return nil
}
_, err = a.Scanner.Scan(a.ProviderDirs)
return a.Upload(ctx)
}

// Ingest scans the selected providers into the shared local queue. Scanning
// is pure local work and needs no API key: events queue in the database until
// a key exists to upload them. It writes the database, so the caller holds
// the data lock.
func (a *App) Ingest() error {
_, err := a.Scanner.Scan(a.ProviderDirs)
return err
}

// Upload drains queued events to the server.
// Upload drains queued events to the server — the half of a sync that needs
// the API key.
func (a *App) Upload(ctx context.Context) error {
settings, err := a.Agent.Settings()
if err != nil {
return err
}
if settings.APIKey == "" {
return errors.New("API key is required in ~/.tokitoki/api_key")
}
if err := usageupload.SyncPending(ctx, settings, a.UsageDB); err != nil {
return err
}
Expand Down
39 changes: 33 additions & 6 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,32 @@ import (

"github.com/tokitoki-dev/tokitoki-cli/internal/agent"
"github.com/tokitoki-dev/tokitoki-cli/internal/store"
"github.com/tokitoki-dev/tokitoki-cli/internal/usagedb"
"github.com/tokitoki-dev/tokitoki-cli/internal/usagescan"
)

func TestSyncRequiresAPIKey(t *testing.T) {
// Scanning is offline work, so a missing API key only skips the upload half:
// Sync ingests and returns cleanly, leaving events queued for a later run.
func TestSyncWithoutAPIKeyScansOffline(t *testing.T) {
app := newApp(t)
err := app.Sync(context.Background())
if err := app.Sync(context.Background()); err != nil {
t.Fatalf("Sync() without API key = %v, want offline scan to succeed", err)
}
}

func TestIngestWorksWithoutAPIKey(t *testing.T) {
app := newApp(t)
if err := app.Ingest(); err != nil {
t.Fatalf("Ingest() without API key = %v, want offline scan to succeed", err)
}
}

// Upload is the half that needs the key, so calling it directly still fails.
func TestUploadRequiresAPIKey(t *testing.T) {
app := newApp(t)
err := app.Upload(context.Background())
if err == nil || !strings.Contains(err.Error(), "API key is required") {
t.Fatalf("Sync() error = %v, want API key requirement", err)
t.Fatalf("Upload() error = %v, want API key requirement", err)
}
}

Expand Down Expand Up @@ -61,12 +80,20 @@ func TestGetAPIKeyRequiresConfiguredKey(t *testing.T) {

func newApp(t *testing.T) *App {
t.Helper()
fileStore, err := store.Open(t.TempDir())
dataDir := t.TempDir()
fileStore, err := store.Open(dataDir)
if err != nil {
t.Fatal(err)
}
usageDB, err := usagedb.Open(store.UsageDBPath(dataDir))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = usageDB.Close() })
return &App{
Agent: agent.New(fileStore, slog.New(slog.NewTextHandler(io.Discard, nil))),
Out: &bytes.Buffer{},
Agent: agent.New(fileStore, slog.New(slog.NewTextHandler(io.Discard, nil))),
UsageDB: usageDB,
Scanner: usagescan.New(usageDB),
Out: &bytes.Buffer{},
}
}
23 changes: 19 additions & 4 deletions pkg/agentlib/agentlib.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,9 @@ func (c *Client) VerifyAPIKey(ctx context.Context) (bool, error) {
return deviceauth.VerifyKey(ctx, usageupload.BaseURL(), apiKey)
}

// Sync scans selected provider directories and uploads newly discovered events.
// Sync scans selected provider directories and uploads newly discovered
// events. Scanning is local and always runs; without a configured API key the
// events simply stay queued and upload resumes once a key is saved.
func (c *Client) Sync(ctx context.Context, options SyncOptions) error {
providerDirs := normalizeProviderDirs(options.ProviderDirs)
if len(providerDirs) == 0 {
Expand Down Expand Up @@ -275,6 +277,14 @@ func (c *Client) Sync(ctx context.Context, options SyncOptions) error {
if err := c.withDataLock(app.Ingest); err != nil {
return err
}
settings, err := agent.New(fileStore, c.logger).Settings()
if err != nil {
return err
}
if settings.APIKey == "" {
c.logger.Debug("skip upload; API key is not configured")
return nil
}
return c.withUploadLock(func() error { return app.Upload(ctx) })
}

Expand Down Expand Up @@ -354,6 +364,10 @@ func (c *Client) SendHeartbeat(ctx context.Context, heartbeat Heartbeat) error {
// Queue the event under the data lock, then drain under the upload lock.
// The drain can take the whole network timeout; heartbeats from other
// editors must be able to enqueue while it runs, not wait behind it.
//
// Queueing is local work and never depends on the API key: an editor that
// starts sending heartbeats before the user configures one must not drop
// them. The key only gates the upload half below.
var settings agent.Settings
if err := c.withDataLock(func() error {
fileStore, err := store.Open(c.dataDir)
Expand All @@ -364,15 +378,16 @@ func (c *Client) SendHeartbeat(ctx context.Context, heartbeat Heartbeat) error {
if err != nil {
return err
}
if settings.APIKey == "" {
return ErrMissingAPIKey
}
_, err = usageDB.InsertEvents([]usage.Entry{entry})
return err
}); err != nil {
return err
}

if settings.APIKey == "" {
c.logger.Debug("skip upload; API key is not configured")
return nil
}
return c.withUploadLock(func() error {
return usageupload.SyncPending(ctx, settings, usageDB)
})
Expand Down
43 changes: 39 additions & 4 deletions pkg/agentlib/agentlib_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/tokitoki-dev/tokitoki-cli/internal/config"
"github.com/tokitoki-dev/tokitoki-cli/internal/store"
"github.com/tokitoki-dev/tokitoki-cli/internal/usagedb"
)

func TestNewUsesDefaultDataDir(t *testing.T) {
Expand Down Expand Up @@ -115,15 +117,48 @@ func TestDefaultProviderDirsIncludesBuiltInProviders(t *testing.T) {
}
}

func TestSyncRequiresAPIKey(t *testing.T) {
// Scanning is offline; a missing API key only means the upload half is
// skipped, so Sync succeeds and events queue locally for later.
func TestSyncWithoutAPIKeyScansOffline(t *testing.T) {
client := newTestClient(t)
claudeDir := t.TempDir()

err := client.Sync(context.Background(), SyncOptions{
ProviderDirs: map[Provider][]string{ProviderClaude: {claudeDir}},
})
if err == nil || !strings.Contains(err.Error(), "API key is required") {
t.Fatalf("Sync() error = %v, want API key requirement", err)
if err != nil {
t.Fatalf("Sync() without API key = %v, want offline scan to succeed", err)
}
if _, err := os.Stat(store.UsageDBPath(client.DataDir())); err != nil {
t.Fatalf("usage database missing after offline sync: %v", err)
}
}

// An editor may start sending heartbeats before the user signs in. The event
// must still be queued locally; only the upload is skipped.
func TestSendHeartbeatWithoutAPIKeyQueuesEvent(t *testing.T) {
client := newTestClient(t)

err := client.SendHeartbeat(context.Background(), Heartbeat{
Entity: filepath.Join(t.TempDir(), "main.go"),
Editor: "vscode",
})
if err != nil {
t.Fatalf("SendHeartbeat() without API key = %v, want queued event", err)
}

usageDB, err := usagedb.Open(store.UsageDBPath(client.DataDir()))
if err != nil {
t.Fatal(err)
}
defer usageDB.Close()

pending, err := usageDB.PendingEvents(time.Now(), 0)
if err != nil {
t.Fatal(err)
}
if len(pending) != 1 {
t.Fatalf("pending events = %d, want 1 queued heartbeat", len(pending))
}
}

Expand Down