diff --git a/cmd/tapir/report.go b/cmd/tapir/report.go index 0031013..ee7abdc 100644 --- a/cmd/tapir/report.go +++ b/cmd/tapir/report.go @@ -6,6 +6,7 @@ import ( "io" "os" "text/tabwriter" + "time" "gitea.d-ma.be/mathias/tapir/internal/adapters/store" ) @@ -14,6 +15,27 @@ import ( // weeks. The gate passes when any user reaches it. const gateThreshold = 2 +// defaultGateStart is the date Stage-0 return-usage tracking begins: the morning +// the pilot was actually unblocked and summaries started flowing (2026-06-11). +// Activity before this — testing, the period the pilot was stuck on zero — is +// noise and must not count toward the gate. Override with TAPIR_USAGE_GATE_START +// (YYYY-MM-DD). The gate measures whether users RETURN once it genuinely works. +const defaultGateStart = "2026-06-11" + +// gateStart resolves the baseline date from TAPIR_USAGE_GATE_START or the default, +// parsed as a UTC calendar day. +func gateStart() (time.Time, error) { + v := os.Getenv("TAPIR_USAGE_GATE_START") + if v == "" { + v = defaultGateStart + } + t, err := time.Parse("2006-01-02", v) + if err != nil { + return time.Time{}, fmt.Errorf("TAPIR_USAGE_GATE_START=%q: want YYYY-MM-DD: %w", v, err) + } + return t, nil +} + // runReport prints the Stage-0 usage gate: per-user distinct active weeks (reads // UNION acts) and the pass/fail verdict. Read-only, cross-user — needs only // TAPIR_DB_DSN (not TAPIR_USER_ID; the report enumerates all users itself). @@ -28,16 +50,24 @@ func runReport(ctx context.Context, _ []string) error { } defer s.Close() - rows, err := s.ActiveWeeks(ctx) + since, err := gateStart() if err != nil { return err } - return formatReport(os.Stdout, rows) + + rows, err := s.ActiveWeeks(ctx, since) + if err != nil { + return err + } + return formatReport(os.Stdout, rows, since) } // formatReport renders the per-user week counts and the gate verdict. Pure: no DB, // no env — so the layout and verdict logic are unit-testable without Postgres. -func formatReport(w io.Writer, rows []store.UserActiveWeeks) error { +func formatReport(w io.Writer, rows []store.UserActiveWeeks, since time.Time) error { + if _, err := fmt.Fprintf(w, "Counting usage since %s (Stage-0 gate baseline)\n\n", since.Format("2006-01-02")); err != nil { + return err + } if len(rows) == 0 { _, err := fmt.Fprintln(w, "no users yet") return err diff --git a/cmd/tapir/report_test.go b/cmd/tapir/report_test.go index 04dc5b1..17374de 100644 --- a/cmd/tapir/report_test.go +++ b/cmd/tapir/report_test.go @@ -3,12 +3,15 @@ package main import ( "strings" "testing" + "time" "github.com/stretchr/testify/require" "gitea.d-ma.be/mathias/tapir/internal/adapters/store" ) +var testSince = time.Date(2026, 6, 11, 0, 0, 0, 0, time.UTC) + func TestFormatReportColumnsAndGatePass(t *testing.T) { rows := []store.UserActiveWeeks{ {UserID: "user-a", DisplayName: "Ada", ActiveWeeks: 3}, @@ -16,9 +19,10 @@ func TestFormatReportColumnsAndGatePass(t *testing.T) { } var b strings.Builder - require.NoError(t, formatReport(&b, rows)) + require.NoError(t, formatReport(&b, rows, testSince)) out := b.String() + require.Contains(t, out, "since 2026-06-11", "report states the gate baseline date") require.Contains(t, out, "USER") require.Contains(t, out, "ACTIVE_WEEKS") require.Contains(t, out, "Ada") @@ -34,12 +38,12 @@ func TestFormatReportGateNotMet(t *testing.T) { rows := []store.UserActiveWeeks{{UserID: "user-a", ActiveWeeks: 1}} var b strings.Builder - require.NoError(t, formatReport(&b, rows)) + require.NoError(t, formatReport(&b, rows, testSince)) require.Contains(t, b.String(), "NOT YET MET", "no user at >= 2 weeks fails the gate") } func TestFormatReportEmpty(t *testing.T) { var b strings.Builder - require.NoError(t, formatReport(&b, nil)) + require.NoError(t, formatReport(&b, nil, testSince)) require.Contains(t, b.String(), "no users yet") } diff --git a/docs/homelab-integration.md b/docs/homelab-integration.md index 1f452cc..af39e33 100644 --- a/docs/homelab-integration.md +++ b/docs/homelab-integration.md @@ -224,6 +224,9 @@ knobs plus one load-bearing deployment constraint: - `TAPIR_DISCOVERY_INTERVAL` — Go duration, e.g. `2h`. The cadence the serve process runs a discovery pass for every registered user (run-once-on-startup, then every interval). **Unset or `0` = disabled** (dev/tests never auto-fetch). +- `TAPIR_USAGE_GATE_START` — `YYYY-MM-DD`, default **`2026-06-11`** (the morning the pilot was + unblocked and summaries started flowing). `tapir report` counts return-usage (distinct active + weeks, ADR-016) only from this date, so pre-launch testing and the blocked period are excluded. - `TAPIR_FETCH_RATE` — Go duration, default `2s`. The **process-wide per-egress-IP caption-fetch rate gate** (ADR-014 item 2). Every caption fetch — scheduler runners *and* the web "Summarize" click-path — serialises through this one limiter so the pod cannot collectively trip 429s. `0` diff --git a/internal/adapters/store/report.go b/internal/adapters/store/report.go index 462e412..7fb81a8 100644 --- a/internal/adapters/store/report.go +++ b/internal/adapters/store/report.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "sort" + "time" "github.com/jackc/pgx/v5" ) @@ -32,7 +33,12 @@ type UserActiveWeeks struct { // Scope note: the enumeration covers users with a Dex identity (the web users the // gate is about). A CLI-only user created by the store sink without an identity // row would not appear — out of scope for this gate. -func (s *Store) ActiveWeeks(ctx context.Context) ([]UserActiveWeeks, error) { +// ActiveWeeks counts each user's distinct active weeks from `since` onward. A zero +// `since` means no lower bound (count all history). The Stage-0 gate baseline is +// set by the caller (the report command) to the date real usage tracking began, +// so pre-launch noise — testing, the period the pilot was blocked — does not count +// toward the return-usage signal (ADR-016). +func (s *Store) ActiveWeeks(ctx context.Context, since time.Time) ([]UserActiveWeeks, error) { userIDs, err := s.identityUserIDs(ctx) if err != nil { return nil, err @@ -40,7 +46,7 @@ func (s *Store) ActiveWeeks(ctx context.Context) ([]UserActiveWeeks, error) { out := make([]UserActiveWeeks, 0, len(userIDs)) for _, uid := range userIDs { - row, err := s.activeWeeksFor(ctx, uid) + row, err := s.activeWeeksFor(ctx, uid, since) if err != nil { return nil, err } @@ -85,18 +91,18 @@ func (s *Store) identityUserIDs(ctx context.Context) ([]string, error) { // activeWeeksFor counts one user's distinct active weeks (reads UNION acts) and // reads their display name, RLS-scoped via withUser. The UNION dedups a week that // has both a login and an action so it counts once. -func (s *Store) activeWeeksFor(ctx context.Context, userID string) (UserActiveWeeks, error) { +func (s *Store) activeWeeksFor(ctx context.Context, userID string, since time.Time) (UserActiveWeeks, error) { res := UserActiveWeeks{UserID: userID} if err := s.withUser(ctx, userID, func(tx pgx.Tx) error { if err := tx.QueryRow(ctx, `WITH weeks AS ( SELECT date_trunc('week', seen_at) AS wk - FROM login_events WHERE user_id = $1 + FROM login_events WHERE user_id = $1 AND seen_at >= $2 UNION SELECT date_trunc('week', acted_at) - FROM summary_actions WHERE user_id = $1 + FROM summary_actions WHERE user_id = $1 AND acted_at >= $2 ) - SELECT count(DISTINCT wk) FROM weeks`, userID).Scan(&res.ActiveWeeks); err != nil { + SELECT count(DISTINCT wk) FROM weeks`, userID, since).Scan(&res.ActiveWeeks); err != nil { return fmt.Errorf("store: count active weeks: %w", err) } if err := tx.QueryRow(ctx, diff --git a/internal/adapters/store/report_test.go b/internal/adapters/store/report_test.go index 52d5335..fdca725 100644 --- a/internal/adapters/store/report_test.go +++ b/internal/adapters/store/report_test.go @@ -3,6 +3,7 @@ package store_test import ( "context" "testing" + "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/stretchr/testify/require" @@ -54,7 +55,7 @@ func TestActiveWeeksCountsDistinctWeeksAcrossReadsAndActs(t *testing.T) { ($1, 'vid-2', 'saved', '2026-01-19T18:00:00Z')`, userA) require.NoError(t, err) - got, err := s.ActiveWeeks(ctx) + got, err := s.ActiveWeeks(ctx, time.Time{}) // zero since = no lower bound require.NoError(t, err) require.Len(t, got, 2, "both identity users must appear") @@ -72,7 +73,33 @@ func TestActiveWeeksEmptyWhenNoUsers(t *testing.T) { s := newStore(t) resetDB(t, rawPool(t)) - got, err := s.ActiveWeeks(ctx) + got, err := s.ActiveWeeks(ctx, time.Time{}) require.NoError(t, err) require.Empty(t, got) } + +// TestActiveWeeksExcludesBeforeGateStart proves the baseline cutoff: activity +// before `since` does not count, so pre-launch noise (testing, the pilot's blocked +// period) is excluded from the Stage-0 return-usage gate (ADR-016). +func TestActiveWeeksExcludesBeforeGateStart(t *testing.T) { + ctx := context.Background() + s := newStore(t) + p := rawPool(t) + resetDB(t, p) + + seedReportUser(t, p, userA, "subject-a", "Ada") + + // One read well before the baseline, two reads in distinct weeks after it. + _, err := p.Exec(ctx, + `INSERT INTO login_events (user_id, seen_at) VALUES + ($1, '2026-05-01T09:00:00Z'), + ($1, '2026-06-12T09:00:00Z'), + ($1, '2026-06-19T09:00:00Z')`, userA) + require.NoError(t, err) + + since := time.Date(2026, 6, 11, 0, 0, 0, 0, time.UTC) + got, err := s.ActiveWeeks(ctx, since) + require.NoError(t, err) + require.Len(t, got, 1) + require.Equal(t, 2, got[0].ActiveWeeks, "only the two post-baseline weeks count; the May read is excluded") +}