From 1ba9744bbf2e80ace30a95298ef4252ea185066b Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Thu, 6 Aug 2026 09:44:14 +0000 Subject: [PATCH 1/2] Prepare Hackatime development environment for Amp orbs Amp-Thread-ID: https://ampcode.com/threads/T-019fd66a-515d-715f-a5ed-2b2a580f10a8 --- .agents/resume | 19 +++++++++++++ .agents/setup | 45 +++++++++++++++++++++++++++++++ .amp/services.yaml | 7 +++++ .gitignore | 3 ++- app/controllers/dev_controller.rb | 31 +++++++++++++++++++++ config/routes.rb | 8 +++++- 6 files changed, 111 insertions(+), 2 deletions(-) create mode 100755 .agents/resume create mode 100755 .agents/setup create mode 100644 .amp/services.yaml create mode 100644 app/controllers/dev_controller.rb diff --git a/.agents/resume b/.agents/resume new file mode 100755 index 000000000..865479357 --- /dev/null +++ b/.agents/resume @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! sudo docker info >/dev/null 2>&1; then + if systemctl cat amp-svc-docker-daemon.service >/dev/null 2>&1; then + amp orb service restart docker-daemon + else + amp orb service start docker-daemon --command 'sudo dockerd' + fi + + for _ in $(seq 1 8); do + sudo docker info >/dev/null 2>&1 && break + sleep 1 + done +fi + +sudo docker info >/dev/null +sudo docker compose up -d +amp orb services ensure diff --git a/.agents/setup b/.agents/setup new file mode 100755 index 000000000..f8ac4ee72 --- /dev/null +++ b/.agents/setup @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "==> Updating Bun" +bun upgrade + +if ! command -v docker >/dev/null 2>&1; then + echo "==> Installing Docker and Docker Compose" + sudo install -m 0755 -d /etc/apt/keyrings + sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc + sudo chmod a+r /etc/apt/keyrings/docker.asc + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | + sudo tee /etc/apt/sources.list.d/docker.list >/dev/null + sudo apt-get update + sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +fi + +if ! sudo docker info >/dev/null 2>&1; then + echo "==> Starting Docker" + if systemctl cat amp-svc-docker-daemon.service >/dev/null 2>&1; then + amp orb service restart docker-daemon + else + amp orb service start docker-daemon --command 'sudo dockerd' + fi + + for _ in $(seq 1 30); do + sudo docker info >/dev/null 2>&1 && break + sleep 1 + done + sudo docker info >/dev/null +fi + +if [ ! -f .env ]; then + echo "==> Creating local environment file" + cp -- .env.example .env +fi + +echo "==> Building and starting development containers" +sudo docker compose up -d --build + +echo "==> Preparing and seeding the development database" +sudo docker compose exec -T web bin/rails db:prepare db:seed + +echo "==> Ensuring the Hackatime portal service is running" +amp orb services ensure diff --git a/.amp/services.yaml b/.amp/services.yaml new file mode 100644 index 000000000..ffbb8d3f8 --- /dev/null +++ b/.amp/services.yaml @@ -0,0 +1,7 @@ +services: + hackatime: + command: sudo docker compose exec -T -e PORT=$PORT web bin/rails server -b 0.0.0.0 -p $PORT + port: 3000 + portal: + title: Hackatime + description: Use /__dev/log-me-in/test@example.com to sign in as the seeded development user. diff --git a/.gitignore b/.gitignore index 28414cb8d..9bd9fbd60 100644 --- a/.gitignore +++ b/.gitignore @@ -82,5 +82,6 @@ public/vite target.txt -.amp/ +.amp/* +!.amp/services.yaml plans/ diff --git a/app/controllers/dev_controller.rb b/app/controllers/dev_controller.rb new file mode 100644 index 000000000..64e0d6bbf --- /dev/null +++ b/app/controllers/dev_controller.rb @@ -0,0 +1,31 @@ +class DevController < ApplicationController + before_action :ensure_development_environment + + def index + render plain: <<~TEXT + Development endpoints: + GET /__dev/log-me-in/ + GET /__dev/log-me-out + TEXT + end + + def log_me_in + email_address = EmailAddress.find_by(email: params[:email].downcase) + return render plain: "No local user has that email address.\n", status: :not_found unless email_address + + reset_session + session[:user_id] = email_address.user_id + redirect_to root_path, notice: "Signed in as #{email_address.email}." + end + + def log_me_out + reset_session + redirect_to dev_path, notice: "Signed out." + end + + private + + def ensure_development_environment + raise ActionController::RoutingError, "Not Found" unless Rails.env.development? + end +end diff --git a/config/routes.rb b/config/routes.rb index bcbe46418..75426d546 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -90,7 +90,13 @@ def matches?(request) get "/stop_impersonating", to: "sessions#stop_impersonating", as: :stop_impersonating - mount LetterOpenerWeb::Engine, at: "/letter_opener" if Rails.env.development? + if Rails.env.development? + mount LetterOpenerWeb::Engine, at: "/letter_opener" + get "/__dev", to: "dev#index", as: :dev + get "/__dev/log-me-in/:email", to: "dev#log_me_in", as: :dev_log_me_in, + constraints: { email: /[^\/]+/ }, format: false + get "/__dev/log-me-out", to: "dev#log_me_out", as: :dev_log_me_out + end # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Can be used by load balancers and uptime monitors to verify that the app is live. From 497a23250522e66cf0b924169edde95fbe973245 Mon Sep 17 00:00:00 2001 From: Mahad Kalam Date: Thu, 6 Aug 2026 11:10:47 +0000 Subject: [PATCH 2/2] Improve orb development safety and performance Amp-Thread-ID: https://ampcode.com/threads/T-019fd66a-515d-715f-a5ed-2b2a580f10a8 --- .agents/setup | 6 + .amp/services.yaml | 4 +- AGENTS.md | 96 ++++++- app/controllers/sessions_controller.rb | 3 +- docker-compose.yml | 24 +- docs/architecture.md | 257 +++++++++++++++++++ test/controllers/sessions_controller_test.rb | 32 +++ 7 files changed, 415 insertions(+), 7 deletions(-) create mode 100644 docs/architecture.md diff --git a/.agents/setup b/.agents/setup index f8ac4ee72..d6cb00af5 100755 --- a/.agents/setup +++ b/.agents/setup @@ -38,8 +38,14 @@ fi echo "==> Building and starting development containers" sudo docker compose up -d --build +echo "==> Building Vite client assets" +sudo docker compose exec -T web bin/vite build + echo "==> Preparing and seeding the development database" sudo docker compose exec -T web bin/rails db:prepare db:seed +echo "==> Preparing the test database" +sudo docker compose exec -T web env RAILS_ENV=test bin/rails db:prepare + echo "==> Ensuring the Hackatime portal service is running" amp orb services ensure diff --git a/.amp/services.yaml b/.amp/services.yaml index ffbb8d3f8..62ea218f6 100644 --- a/.amp/services.yaml +++ b/.amp/services.yaml @@ -1,7 +1,7 @@ services: hackatime: - command: sudo docker compose exec -T -e PORT=$PORT web bin/rails server -b 0.0.0.0 -p $PORT - port: 3000 + command: sudo docker compose up -d db && sudo env PUBLIC_URL="$PUBLIC_URL" docker compose --profile portal up --no-deps portal + port: 3001 portal: title: Hackatime description: Use /__dev/log-me-in/test@example.com to sign in as the seeded development user. diff --git a/AGENTS.md b/AGENTS.md index 7454c71c8..ebee65441 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,69 @@ We do development using docker-compose. Run `docker compose ps` to see if the de - **Zeitwerk**: `docker compose exec web bin/rails zeitwerk:check` (autoloader check) - **Swagger**: `docker compose exec web bin/rails rswag:specs:swaggerize` (generate API docs) +## Bug Fixes + +Always reproduce a reported bug before changing code. Confirm the failure using the narrowest reliable reproduction and record the current behaviour so the fix can be verified against it. If reproduction is impossible because required data, credentials or services are unavailable, state that clearly before proceeding. + +After fixing the bug, rerun the original reproduction as well as the regression test. Test externally visible behaviour and durable state rather than private implementation details. Prefer real models and database behaviour over mocks. + +## Engineering Decisions + +Start with the smallest correct change in the current owner. Before editing, identify the source of truth and the invariant being protected. Read [the architecture guide](docs/architecture.md) when a change crosses subsystem boundaries. + +### Rails ownership ladder + +Put behaviour in the narrowest layer that naturally owns it: + +1. **Controller**: HTTP concerns, authentication, authorisation, strong parameters and response selection. +2. **Model**: invariants, state transitions and behaviour owned by persisted data. +3. **Scope or query object**: reusable data retrieval. Introduce a query object only when scopes stop composing clearly. +4. **Job**: delayed or retryable execution. Jobs must be idempotent. +5. **Service object**: an operation that genuinely coordinates multiple models, external systems or transaction boundaries. +6. **Concern**: a cohesive capability, not a place to hide unrelated model or controller size. + +Do not create a service object merely to make a controller or model shorter. Extract an operation only when it has a coherent responsibility that does not naturally belong to one model. + +### Correctness rules + +- Use database constraints for integrity and model validations for useful feedback. +- Put related writes in a transaction. Do not make network calls inside that transaction. +- Enqueue external side effects after commit where practical. +- Make GoodJob jobs safe to retry without duplicate records, notifications or state transitions. +- Treat caches, dashboard rollups and summaries as derived data. Identify and update the source of truth rather than repairing derived output. +- Check authorisation separately from authentication. +- Use bang methods when failure must abort the operation. +- Do not use callbacks for workflows spanning multiple models or external systems. +- Rescue only errors the code can meaningfully handle and rescue the narrowest exception class possible. Log enough context and re-raise unexpected failures. Never report success after a partial write. +- Check query count and eager loading when rendering collections. +- Store timestamps in UTC. For calendar boundaries and presentation, use `Time.current` or `Date.current` inside the user's `Time.use_zone(user.timezone)` context. Background jobs that calculate user-local calendar boundaries must establish that context explicitly. +- Use `Process.clock_gettime(Process::CLOCK_MONOTONIC)` rather than wall-clock time to measure elapsed execution time. + +### When to zoom out + +Zoom out only when there is evidence that the current ownership boundary prevents a correct implementation. Evidence includes: + +- The same business rule must change in three or more places. +- Two modules disagree about the source of truth. +- A correct operation cannot be made atomic within the current boundary. +- Callbacks, jobs or cache refreshes repeatedly compensate for unclear ownership. +- A collection of booleans represents an undocumented state machine. +- Retry, concurrency or ordering bugs recur because state is implicit. +- A public contract repeatedly leaks internal implementation details. +- Tests require extensive stubbing because responsibilities cannot be exercised independently. + +Do not re-architect merely because a class is long but cohesive, a method gained one branch, two snippets look similar, an abstraction feels inelegant or a hypothetical future caller might need flexibility. Do not add an abstraction with only one caller unless it creates a clear ownership boundary or protects a critical invariant. + +Use this escalation order: + +1. Fix the bug in the existing owner. +2. Strengthen the owner's invariant or API. +3. Remove proven duplication around that invariant. +4. Extract one coherent responsibility. +5. Rework the subsystem boundary only when the earlier steps cannot make it correct. + +Before a broad redesign, state the invariant, why the current owner cannot protect it, the smallest viable new boundary, migration and rollback risks and how behaviour will be preserved. Get user agreement on the plan unless the redesign is required to resolve an active correctness or security issue. + ## CI/Testing Requirements Before marking any task complete, you MUST check `config/ci.rb` and manually run the checks in that file which are relevant to your changes (with `docker compose exec`.) @@ -35,20 +98,49 @@ Skip running checks which aren't relevant to your changes. However, at the very - **Start containers**: `docker compose up -d` (must be running before using `exec`) - **Interactive shell**: `docker compose exec web /bin/bash` - **Initial setup**: `docker compose exec web bin/rails db:create db:schema:load db:seed` +- **Reset test database**: `docker compose exec web env RAILS_ENV=test bin/rails db:drop db:create db:schema:load` - **Cleanup**: Run commands with the `--remove-orphans` flag to remove unused containers and images +### Amp portal server + +The Amp portal runs Rails in the dedicated `portal` Compose profile while the `web` container remains available for commands. Keep the portal process attached to `docker compose up`; do not change it back to `docker compose exec`, because stopping the outer exec process can orphan Puma inside the container. Use `amp orb service restart hackatime` after changing the portal service configuration. + +Orb setup prebuilds the Vite client bundle so the first portal request does not block on a build. After changing frontend source, run `docker compose exec web bin/vite build` before asking the user to review the portal; otherwise their first request can spend several seconds compiling assets. + +## Development Authentication + +Development-only endpoints are available to bypass OAuth when working locally or in an Amp orb: + +- `GET /__dev` lists the available development endpoints. +- `GET /__dev/log-me-in/` signs the browser in as the local user with that email. Use `/__dev/log-me-in/test@example.com` for the seeded development user. +- `GET /__dev/log-me-out` signs the browser out. + +These endpoints are only available in the development environment. + ## Git Practices - **NEVER commit `config/database.yml`** unless explicitly asked to - contains sensitive local/production database credentials - **NEVER use `git add .`** - always add files individually to avoid accidentally committing unwanted files - Use `git add ` or `git add /` for targeted commits +## Pull Requests + +- Always use the GitHub PR template at `.github/pull_request_template.md` when creating a PR. +- Write PR titles and descriptions in British English. +- Never use em dashes or en dashes. +- Never use Oxford commas. +- Do not list commands you ran in the PR description. +- Do not mention tests passing in the PR description. +- Bias towards including screenshots or other media, particularly for visual changes. +- Keep descriptions short and simple while still explaining the problem and the useful parts of the change. Avoid waffle. + ## Code Style (rubocop-rails-omakase) - **Naming**: snake_case files/methods/vars, PascalCase classes, 2-space indent - **Controllers**: Inherit `ApplicationController`, use `before_action`, strong params with `.permit()` -- **Models**: Inherit `ApplicationRecord`, extensive use of concerns/enums/scopes -- **Error Handling**: `rescue => e` + `Rails.logger.error`, graceful degradation in jobs +- **Models**: Inherit `ApplicationRecord`; keep domain behaviour with the model that owns the data; use enums and composable scopes where they clarify the domain +- **Concerns**: Use concerns only for cohesive capabilities with a clear name, not as miscellaneous storage +- **Error Handling**: Rescue specific failures that can be handled meaningfully; log context and let unexpected failures surface - **Imports**: Use `include` for concerns, `helper_method` for view access - **API**: Namespace under `api/v1/`, structured JSON responses with status codes - **Jobs**: GoodJob with 4 priority queues, inherit from `ApplicationJob`, concurrency control for cache jobs diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 741d508bb..5c8c9d6b7 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -137,7 +137,8 @@ def email HandleEmailSigninJob.perform_later(email, continue_param, client_ip) else token = HandleEmailSigninJob.perform_now(email, continue_param, client_ip) - session[:dev_magic_link] = auth_token_url(token) + public_url = ENV["PUBLIC_URL"].presence || root_url + session[:dev_magic_link] = URI.join(public_url, auth_token_path(token)).to_s end redirect_path = params[:redirect_to] == "signin" ? signin_path(sign_in_email: true) : root_path(sign_in_email: true) diff --git a/docker-compose.yml b/docker-compose.yml index d21461b40..0de6217bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,5 @@ services: - web: + web: &web build: context: . dockerfile: Dockerfile.dev @@ -18,9 +18,24 @@ services: - POSTGRES_PASSWORD=secureorpheus123 - TEST_DATABASE_URL=postgres://postgres:secureorpheus123@db:5432/app_test depends_on: - - db + db: + condition: service_healthy command: ["sleep", "infinity"] + portal: + <<: *web + profiles: + - portal + ports: + - "3001:3000" + environment: + - RAILS_ENV=development + - DATABASE_URL=postgres://postgres:secureorpheus123@db:5432/app_development + - TEST_DATABASE_URL=postgres://postgres:secureorpheus123@db:5432/app_test + - PUBLIC_URL=${PUBLIC_URL:-} + entrypoint: [] + command: ["bin/rails", "server", "-b", "0.0.0.0", "-p", "3000", "-P", "/tmp/portal-server.pid"] + db: image: postgres:16 volumes: @@ -29,6 +44,11 @@ services: - POSTGRES_PASSWORD=secureorpheus123 - POSTGRES_USER=postgres - POSTGRES_DB=app_development + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d app_development"] + interval: 2s + timeout: 5s + retries: 15 ports: - "5432:5432" diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 000000000..c2fc18203 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,257 @@ +# Hackatime architecture guide + +This is a map of current ownership and invariants, not a proposed design. Follow +the linked source when behavior and this summary disagree. + +## Sources of truth and derived state + +| Domain | Source of truth | Derived / disposable state | +| --- | --- | --- | +| Identity | `users`, `email_addresses`, provider IDs and encrypted provider tokens | Session cookie | +| Coding activity | Non-deleted `heartbeats` | Durations, dashboard/profile payloads, rollups, leaderboards, streaks, Rails caches | +| Heartbeat project identity | `heartbeats.project` | Project lists and project statistics | +| Per-user project settings | `project_repo_mappings` keyed by project name | Discovery retry/coalescing cache keys | +| Repository identity | Shared `repositories` row (`url`, host, owner, name) | Stars, languages, homepage, commit count, sync timestamps, imported commits | +| Async work | Pending/scheduled `good_jobs` rows and their serialized arguments | Finished execution history, process records, cron history and enqueue cache keys, subject to retention policy | + +Do not write a rollup, duration, profile statistic, or cache as though it were +primary data. Change its input or the derivation, invalidate it, and let it be +rebuilt. + +## 1. Rails request and Inertia/Svelte UI flow + +1. Routes dispatch to Rails controllers. Browser controllers inherit + [`ApplicationController`](../app/controllers/application_controller.rb), + which supplies session identity, lockout enforcement, no-store headers, + error reporting, PaperTrail attribution, and the user's time zone. +2. Inertia controllers inherit + [`InertiaController`](../app/controllers/inertia_controller.rb). It shares + layout props (navigation, flash, theme, CSRF token, footer and impersonation + state) on every Inertia response. Controllers select a page with + `render inertia: "Directory/Page", props: ...`; server props are the + request's serialized boundary, not a second model layer. +3. [`inertia.ts`](../app/javascript/entrypoints/inertia.ts) resolves that name + to `app/javascript/pages/Directory/Page.svelte` and wraps pages in + `AppLayout.svelte`. Inertia ``, `
` and `router` calls make later + visits while Rails still owns routing, authorization, validation and writes. +4. Svelte pages own presentation and truly local/editable UI state. Keep domain + calculations and authorization on the server. Shared props belong in + `InertiaController`; page-specific props belong in the rendering controller. + +Client route strings come from **js_from_routes**, not controller props or +hand-built URLs. Add a named route to `EXPORTED_ROUTES` in +[`js_from_routes.rb`](../config/initializers/js_from_routes.rb), regenerate, and +import the controller module from `app/javascript/api`. Call `.path()` with +path/query parameters. The generated directory is gitignored. The allowlist +exports named routes and nameless siblings for an already-exported controller; +it intentionally avoids exposing every Rails route. + +Keep server-built URLs only when the client lacks required information (for +example, request host), or for external links. API-only controllers may inherit +`ActionController::API`; they are not part of the Inertia boundary. + +## 2. Identity and authorization boundaries + +### Browser identity + +[`ApplicationController#current_user`](../app/controllers/application_controller.rb) +is exactly `User.find_by(id: session[:user_id])`. HCA, Slack, and single-use +email-link sign-in converge on a `User`; successful login resets the session +before assigning that ID. Slack and GitHub callback state is consumed and +compared with `secure_compare`; HCA currently does not use an OAuth state +nonce. Continuation URLs must be local paths (not `//...`). See +[`SessionsController`](../app/controllers/sessions_controller.rb). + +[`EmailAddress`](../app/models/email_address.rb) owns normalized, globally +unique login addresses and their provenance. Provider/preserved addresses +cannot be unlinked, and a user cannot remove their last address. Provider IDs +and encrypted HCA/Slack/GitHub access tokens live on +[`User`](../app/models/user.rb); provider concerns own token exchange and remote +profile synchronization. + +### API identities + +* [`ApiKey`](../app/models/api_key.rb) is a user credential (UUIDv4 for WakaTime + compatibility). The Hackatime-compatible controller accepts Bearer, Basic, + or legacy `api_key` query input, resolves the key's user, then calls the + ingestion service. It skips CSRF because it is token-authenticated; pending + deletion blocks writes. See + [`HackatimeController`](../app/controllers/api/hackatime/v1/hackatime_controller.rb). +* Doorkeeper is a separate delegated-user boundary. Its configured scopes are + `profile` (default), `read`, and `admin`; validate token acceptability and + required scopes, then load the resource owner. Ordinary OAuth/API access is + denied for convicted or pending-deletion users via `api_access_restricted?`. + See [`doorkeeper.rb`](../config/initializers/doorkeeper.rb) and + `ApplicationController#oauth_bearer_user`. +* Admin API credentials are either active `AdminApiKey`s or acceptable + Doorkeeper `admin` tokens. OAuth admin access additionally requires a + confidential, verified, admin-scoped application. The API boundary is + [`Api::Admin::ApplicationController`](../app/controllers/api/admin/application_controller.rb). + +### Admin authorization + +Use [`AuthHelpers`](../app/controllers/concerns/auth_helpers.rb) and explicit +controller/model predicates; hiding a nav link is not authorization. `viewer` +can enter read-only admin surfaces and use admin API authentication, but general +browser admin writes require `admin`, `superadmin`, or `ultraadmin`. + +The enum's stored numeric order is historical and **not privilege order**. +Use `User::ADMIN_LEVEL_RANK` and helpers. Effective order is +`default < viewer < admin < superadmin < ultraadmin`. Role/trust changes prohibit +self-action and require the actor to strictly outrank the target; only +superadmin+ changes admin levels, only ultraadmin grants ultraadmin, and a red +trust conviction requires superadmin+. + +## 3. Heartbeat ingestion and duration semantics + +Non-deleted [`Heartbeat`](../app/models/heartbeat.rb) rows are authoritative +activity. All direct and imported writes should flow through +[`HeartbeatIngest`](../app/services/heartbeat_ingest.rb), which owns: + +* accepted input normalization, sane epoch validation/repair, null/control + cleanup, default categories, language and user-agent inference, source type, + request metadata, and WakaTime placeholder handling; +* model validation before bulk insertion (bulk insertion deliberately bypasses + callbacks), plus explicit callback-equivalent fields; +* deduplication and race-safe persistence; and +* scheduling rollup refresh and best-effort project mapping only after inserts. + +`fields_hash` is the persisted identity of a normalized heartbeat and includes +the user, time and activity metadata listed by +`Heartbeat.indexed_attributes` (plus present AI attributes). Direct batches +collapse equal hashes; `insert_all ... unique_by` lets the database settle +cross-request races, then ingestion fetches the winning row. Import batches +keep the latest row per hash and also check legacy hashes so normalization +changes do not duplicate old imports. During the Timescale cutover, uniqueness +may be `(fields_hash)` or `(fields_hash, time_epoch)`; ingestion detects the +schema, explicitly sets the partition epoch, refreshes stale schema metadata, +and retries only outside an open transaction. + +Soft deletion is implemented by `deleted_at`; the model's default scope hides +those rows. Use `soft_delete` / `restore`, which also invalidate rollups. + +Duration is not stored. [`Heartbeatable`](../app/models/concerns/heartbeatable.rb) +derives it from ordered heartbeat timestamps. The default timeout is 2 minutes: + +* the first heartbeat contributes zero; +* each later heartbeat contributes `min(current_time - previous_time, 120s)`; +* grouped duration partitions by the requested group, while + `attributed_durations_by` computes globally ordered gaps and attributes each + gap to the current heartbeat's bucket; +* `to_span` splits when a gap exceeds the timeout and caps the prior span's tail + at the timeout; and +* boundary-aware calculations include the preceding heartbeat so a requested + window does not incorrectly lose its opening interval. + +Preserve deterministic ordering by `time, id`, timestamp validity filters, and +the timeout cap when adding reports. Eligibility scopes additionally distinguish +coding, browser activity and the `<>` sentinel. + +## 4. Dashboard/profile rollups and caches + +[`DashboardStats`](../app/services/dashboard_stats.rb) is the read facade. An +unfiltered all-time dashboard can use `dashboard_rollups`; filtered/custom time +ranges query heartbeats. A missing aggregate total falls back to live +calculation and schedules a refresh. A dirty or stale aggregate total is served +while refresh is scheduled. Invalid activity-graph/today fragments and +malformed filter options fall back to live calculation and schedule refresh. +Short Rails caches (currently 1/5/15 minutes depending on fragment) are also +disposable. + +[`DashboardRollupRefreshService`](../app/services/dashboard_rollup_refresh_service.rb) +rebuilds totals, dimensions, weekly projects, project details, filter options, +activity graph and today's stats from the user's non-archived heartbeats. It +atomically replaces all of one user's rows in a transaction. The refresh job +marks the user dirty before enqueue, coalesces scheduling with a cache key, and +uses a per-user GoodJob concurrency limit. Heartbeat commits, soft-delete/ +restore, timezone changes, and project archive changes schedule refreshes. + +[`ProfileStatsService`](../app/services/profile_stats_service.rb) is a thin +projection of `DashboardStats`, including OG-image totals. It has no independent +authoritative statistic. Change shared duration/snapshot logic below both +dashboard and profile rather than patching profile output independently. + +## 5. Projects, repositories and repo hosts + +[`ProjectRepoMapping`](../app/models/project_repo_mapping.rb) owns a user's +repository association, archive state and sharing state keyed by a heartbeat +project name. The heartbeat remains authoritative for the project identity and +a mapping may not exist. Archiving affects dashboard scope and invalidates +rollups. Ingestion asynchronously attempts mapping for new non-sentinel project +names; discovery currently searches the linked GitHub user and organizations. + +[`Repository`](../app/models/repository.rb) is shared by URL and owns parsed +host/owner/name plus synchronized host metadata. Mapping callbacks create/reuse +it and trigger metadata/commit work. A mapping is user-specific; a repository +is not. Do not put user preferences on `Repository` or shared host metadata on +the mapping. + +External repository calls belong behind +[`RepoHost::ServiceFactory`](../app/services/repo_host/service_factory.rb) and +[`BaseService`](../app/services/repo_host/base_service.rb). Only GitHub is +supported today; [`GithubService`](../app/services/repo_host/github_service.rb) +owns GitHub headers, existence checks, metadata requests and rate-limit/error +translation. Extending hosts requires factory/host validation and a service +implementation, plus updating jobs that currently contain GitHub-specific +discovery/event logic. Several periodic repository scan/sync cron entries are +currently disabled; do not assume they run. + +## 6. GoodJob, mail and Slack + +All jobs inherit [`ApplicationJob`](../app/jobs/application_job.rb), which +provides shared error-reporting helpers and discards deserialization and +concurrency-limit failures. +[`good_job.rb`](../config/initializers/good_job.rb) is the queue/cron source of +truth: development runs async threads, non-development expects external +workers, and cron is production-only. Choose a queue by latency/ownership; do +not perform slow remote work in request controllers merely because development +can execute jobs in-process. + +Action Mailer owns message composition/delivery. Production SMTP configuration +and the `latency_10s` `deliver_later` queue live in +[`production.rb`](../config/environments/production.rb). Some jobs intentionally +call `deliver_now` *inside an already-queued job*; preserve that boundary unless +changing retry/queue semantics deliberately. + +Slack has three boundaries: OAuth/provider identity in the user concerns, +signed command ingress in [`SlackController`](../app/controllers/slack_controller.rb), +and queued command/profile/status work. Outside development, commands require a +valid Slack HMAC signature and timestamp within five minutes. Remote API calls, +token choice and typed rate-limit behavior live in +[`SlackIntegration`](../app/models/concerns/slack_integration.rb); controllers +should authenticate, validate and enqueue. + +## 7. Time zones, transactions and concurrency + +Heartbeat `time` is Unix epoch time. Calendar concepts (today, week, streak, +activity dates) use the validated `User#timezone`. Browser requests run inside +`Time.use_zone(current_user.timezone)`; services/jobs without that wrapper must +use `Time.use_zone` explicitly. SQL day grouping converts epochs with the user +timezone and falls back to UTC only where the reporting code explicitly guards +invalid legacy data. A timezone change invalidates both old/new activity cache +keys and rollups. + +Use database constraints/upserts for cross-process correctness, transactions +for multi-row replacement, `after_commit` for derived work, and GoodJob +concurrency plus cache coalescing for expensive idempotent refreshes. Rails +cache alone is an optimization, not a lock or source of truth. In particular, +keep heartbeat dedup race-safe and rollup replacement atomic. + +## Where should this change go? + +| Change | Put it here | +| --- | --- | +| Parse/normalize/accept heartbeat input | `HeartbeatIngest`; controller only permits/authenticates/responds | +| Change activity or gap math | `Heartbeatable` / shared snapshot query code, then verify every consumer | +| Add dashboard/profile statistic | `DashboardData::Snapshots` + `DashboardStats`; add rollup dimension only if appropriate | +| Change page data or validation | Rails controller/service/model; serialize minimal Inertia props | +| Change page interaction/presentation | Svelte page/component; use Inertia primitives | +| Add a frontend Rails URL | Rails route + `js_from_routes` allowlist + generated helper import | +| Add browser/admin/API authorization | Existing auth concern/controller boundary and model capability predicate | +| Change sign-in/provider identity | `SessionsController` plus the relevant user OAuth/provider concern | +| Change project archive/share/user mapping | `ProjectRepoMapping` and its controller/job | +| Change shared repository metadata/API calls | `Repository` + `RepoHost` service + sync job | +| Add slow, scheduled or retryable work | `ApplicationJob` subclass and GoodJob queue/cron config as needed | +| Compose/send mail | Mailer; enqueue from the owning lifecycle/job | +| Handle Slack command/API behavior | Verified ingress controller, Slack concern/client boundary, then job | +| Change a calendar-day report | Explicit user-zone service/query; invalidate timezone-sensitive derived data | diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index f0e2c3fdc..10a523cc9 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -87,6 +87,38 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest assert_equal oauth_path, token.continue_param end + test "email auth uses the public URL for the development sign-in link" do + original_public_url = ENV["PUBLIC_URL"] + ENV["PUBLIC_URL"] = "https://hackatime.example.test/" + user = User.create! + email = "public-url-test-#{SecureRandom.hex(4)}@example.com" + user.email_addresses.create!(email: email) + host! "3000-orb-id.e2b.app" + + post email_auth_path, params: { email: email } + + token = SignInToken.last + assert_equal "https://hackatime.example.test/auth/token/#{token.token}", session[:dev_magic_link] + ensure + ENV["PUBLIC_URL"] = original_public_url + end + + test "email auth uses the request URL when the public URL is blank" do + original_public_url = ENV["PUBLIC_URL"] + ENV["PUBLIC_URL"] = "" + user = User.create! + email = "blank-public-url-test-#{SecureRandom.hex(4)}@example.com" + user.email_addresses.create!(email: email) + host! "hackatime.local" + + post email_auth_path, params: { email: email } + + token = SignInToken.last + assert_equal "http://hackatime.local/auth/token/#{token.token}", session[:dev_magic_link] + ensure + ENV["PUBLIC_URL"] = original_public_url + end + test "email token redirects to continue param after sign in" do user = User.create! oauth_path = "/oauth/authorize?client_id=test&response_type=code"