From 8d7c48d8c128a37aeb9d145a10405f1ea248a79c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 26 Apr 2026 11:43:24 +0200 Subject: [PATCH 1/3] Include the demo persona on /explore so users can follow it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The directory was filtering out the demo persona on the rationale that "the demo bot is not a real user and should not appear in real discovery." That's exactly backwards โ€” the whole point of having a demo persona is to give new users a follow target so the platform doesn't feel empty when they arrive. Hiding the bot from the discovery surface defeats its purpose. Concretely on flagship: only one local user (ullrich) was visible on /explore today, even though Bruno (the demo persona) is public-by-default and posting public activities. After this change both appear; Bruno carries a small "๐Ÿ• Demo account" badge next to his display name so viewers know what they're following. - apps/journal/app/lib/explore.server.ts โ€” drop the ne(users.username, persona.username) clause from exclusionFilters. The demo persona is now treated like any other public user. Banned/ suspended scaffolding stays for forward-compat. - apps/journal/app/routes/explore.tsx โ€” loader computes isDemoUser per row (cheap, just username comparison against loadPersona().username). DirectoryRow renders the demo badge inline with the display name, matching the existing pattern on /users/:username. - openspec/specs/explore/spec.md โ€” updated the "Excluded users" requirement to remove the demo persona, replaced the "demo excluded" scenario with "demo appears with badge", and updated the "Active recently" requirement + scenarios accordingly. - apps/journal/app/lib/explore.integration.test.ts โ€” flipped the demo-persona test from "is excluded" to "is included". Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/lib/explore.integration.test.ts | 8 +++--- apps/journal/app/lib/explore.server.ts | 13 ++++------ apps/journal/app/routes/explore.tsx | 26 ++++++++++++++----- openspec/specs/explore/spec.md | 23 +++++++++------- 4 files changed, 44 insertions(+), 26 deletions(-) diff --git a/apps/journal/app/lib/explore.integration.test.ts b/apps/journal/app/lib/explore.integration.test.ts index f8ad466..93f916f 100644 --- a/apps/journal/app/lib/explore.integration.test.ts +++ b/apps/journal/app/lib/explore.integration.test.ts @@ -80,12 +80,14 @@ describe.skipIf(!runIntegration)("explore.server integration", () => { expect(rows.find((r) => r.id === id)).toBeUndefined(); }); - it("demo persona is excluded from the directory", async () => { + it("demo persona is INCLUDED in the directory", async () => { const persona = loadPersona(); - // Insert a user with the persona's username โ€” should still be filtered out. + // Insert a user with the persona's username โ€” should appear like any + // other public user. The /explore loader is responsible for the + // demo-badge tagging at render time, not the directory query. const id = await makeUser({ username: persona.username }); const { rows } = await listDirectory({ page: 1, perPage: 50 }); - expect(rows.find((r) => r.id === id)).toBeUndefined(); + expect(rows.find((r) => r.id === id)).toBeDefined(); }); it("orders by most-recent public activity, NULLS LAST", async () => { diff --git a/apps/journal/app/lib/explore.server.ts b/apps/journal/app/lib/explore.server.ts index 69fb1a2..a604075 100644 --- a/apps/journal/app/lib/explore.server.ts +++ b/apps/journal/app/lib/explore.server.ts @@ -1,7 +1,6 @@ -import { and, count, desc, eq, gte, inArray, isNotNull, ne, sql } from "drizzle-orm"; +import { and, count, desc, eq, gte, inArray, isNotNull, sql } from "drizzle-orm"; import { getDb } from "./db.ts"; import { activities, follows, users } from "@trails-cool/db/schema/journal"; -import { loadPersona } from "./demo-bot.server.ts"; import { localActorIri } from "./actor-iri.ts"; const DEFAULT_PAGE_SIZE = 20; @@ -42,13 +41,11 @@ function clampPage(raw: number | undefined): number { } function exclusionFilters() { - // Public-only and not the demo persona. Banned/suspended users would + // Public-only. The demo persona IS included on /explore โ€” its whole + // purpose is to give new users a follow target, and the per-row demo + // badge in the UI signals what it is. Banned/suspended users would // be filtered here too once such a status column exists โ€” see design.md. - const persona = loadPersona(); - return and( - eq(users.profileVisibility, "public"), - ne(users.username, persona.username), - ); + return eq(users.profileVisibility, "public"); } /** diff --git a/apps/journal/app/routes/explore.tsx b/apps/journal/app/routes/explore.tsx index a773005..1479acb 100644 --- a/apps/journal/app/routes/explore.tsx +++ b/apps/journal/app/routes/explore.tsx @@ -10,6 +10,7 @@ import { listActiveRecently, listDirectory, } from "~/lib/explore.server"; +import { loadPersona } from "~/lib/demo-bot.server"; import { FollowButton } from "~/components/FollowButton"; const BIO_TRUNCATE = 120; @@ -44,6 +45,7 @@ export async function loader({ request }: Route.LoaderArgs) { : new Map(); const isSelf = (rowId: string) => viewer?.id === rowId; + const personaUsername = loadPersona().username; const decorate = (row: typeof allRows[number]) => ({ id: row.id, @@ -53,6 +55,7 @@ export async function loader({ request }: Route.LoaderArgs) { followerCount: followerCounts.get(row.id) ?? 0, followState: followStates.get(row.id) ?? null, isSelf: isSelf(row.id), + isDemoUser: row.username === personaUsername, }); // Resolved page size (after loader-side clamping inside listDirectory) @@ -85,6 +88,7 @@ interface DecoratedRow { followerCount: number; followState: { following: boolean; pending: boolean } | null; isSelf: boolean; + isDemoUser: boolean; } function DirectoryRow({ row, isSignedIn }: { row: DecoratedRow; isSignedIn: boolean }) { @@ -92,12 +96,22 @@ function DirectoryRow({ row, isSignedIn }: { row: DecoratedRow; isSignedIn: bool return (
  • - - {row.displayName ?? row.username} - +
    + + {row.displayName ?? row.username} + + {row.isDemoUser && ( + + {t("demo.badge")} + + )} +

    @{row.username} ยท {t("social.followers.count", { count: row.followerCount })}

    diff --git a/openspec/specs/explore/spec.md b/openspec/specs/explore/spec.md index 5c61c68..635f704 100644 --- a/openspec/specs/explore/spec.md +++ b/openspec/specs/explore/spec.md @@ -43,25 +43,26 @@ The directory SHALL be ordered by `MAX(activities.created_at) DESC` per user, co The directory SHALL exclude: 1. Users with `profile_visibility = 'private'` โ€” they have explicitly opted out of public discovery (Mastodon-style locked accounts). -2. The instance's demo persona, identified by the username returned by `loadPersona()` โ€” the demo bot is not a real user and should not appear in real discovery. -3. (Forward-compat) Users in any future banned/suspended state โ€” when such a status column exists, it SHALL be added to the exclusion filter. +2. (Forward-compat) Users in any future banned/suspended state โ€” when such a status column exists, it SHALL be added to the exclusion filter. Excluded users SHALL NOT appear on `/explore` even if they have public activities and would otherwise sort to the top of the directory. +The demo persona (identified by `loadPersona().username`) is **not** excluded โ€” its purpose is to give new users a follow target, so it appears in the directory like any other public user. The directory row SHALL render a "demo account" badge next to the display name so viewers know what they're following. + #### Scenario: Private profile is excluded from the directory - **WHEN** user A has `profile_visibility = 'private'` and any activity history - **THEN** A does not appear in the `/explore` directory regardless of which page is requested -#### Scenario: Demo persona is excluded -- **WHEN** the demo persona username (per `loadPersona()`) matches a row that would otherwise appear -- **THEN** that row is filtered out of the directory +#### Scenario: Demo persona appears with a demo badge +- **WHEN** the demo persona username (per `loadPersona()`) matches a row in the directory +- **THEN** the row is rendered like any other public user, with an additional small "demo account" badge next to the display name #### Scenario: Public user with no activities is included - **WHEN** user A has `profile_visibility = 'public'` but has never created an activity - **THEN** A still appears in the directory (sorted toward the end by the recency rule) ### Requirement: "Active recently" sub-section -The `/explore` page SHALL render an "Active recently" sub-section at the top of the directory, listing up to N (default 5) public users who have created at least one public activity in the last 30 days, ordered by `MAX(activities.created_at) DESC`. The sub-section SHALL apply the same exclusion rules as the main directory (private profiles, demo persona, future banned/suspended users). When fewer than 1 user qualifies, the sub-section SHALL be omitted entirely (no empty header). +The `/explore` page SHALL render an "Active recently" sub-section at the top of the directory, listing up to N (default 5) public users who have created at least one public activity in the last 30 days, ordered by `MAX(activities.created_at) DESC`. The sub-section SHALL apply the same exclusion rules as the main directory (private profiles, future banned/suspended users โ€” the demo persona is included like any other public user, with the same demo-badge treatment). When fewer than 1 user qualifies, the sub-section SHALL be omitted entirely (no empty header). #### Scenario: Active-recently strip rendered with qualifying users - **WHEN** at least one public local user (excluding private/demo) has a public activity within the last 30 days @@ -71,9 +72,13 @@ The `/explore` page SHALL render an "Active recently" sub-section at the top of - **WHEN** no public local user (excluding private/demo) has any public activity within the last 30 days - **THEN** the "Active recently" sub-section is not rendered at all; the main directory is the only listing on the page -#### Scenario: Strip respects exclusion rules -- **WHEN** a private profile or the demo persona has a recent public activity -- **THEN** they are not included in the "Active recently" strip โ€” the same exclusion rules apply as for the main directory +#### Scenario: Strip excludes private profiles +- **WHEN** a private profile has a recent public activity +- **THEN** they are not included in the "Active recently" strip โ€” the same private-profile exclusion as the main directory + +#### Scenario: Strip includes the demo persona +- **WHEN** the demo persona has a recent public activity +- **THEN** it appears in the "Active recently" strip like any other public user, carrying the same demo-account badge as in the main directory ### Requirement: Pagination The directory SHALL paginate via `?page=N` (1-indexed) and `?perPage=K` query parameters. Page size SHALL default to 20 per page and SHALL be capped at 100; `perPage` values outside `[1, 100]` SHALL be clamped to that range without raising an error. The response SHALL surface a "Next page" link when more rows exist past the current page, and a "Previous page" link when `page > 1`. From 5c4b6fd9af13eb89103e5f2562ce5c592570c630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 26 Apr 2026 11:51:13 +0200 Subject: [PATCH 2/3] Stop the caddy-502-rate alert firing on every deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The journal/planner deploy in cd-apps.yml does `docker compose up -d journal planner`, which stops the old container and starts the new one โ€” Caddy keeps forwarding requests during the ~10โ€“30s gap and returns 502s. The caddy-502-rate alert (threshold > 0 for 2m) correctly trips, every time. Two production changes plus a long-broken workflow detail: - infrastructure/Caddyfile โ€” add `lb_try_duration 30s` / `lb_try_interval 250ms` to the journal and planner reverse_proxy blocks. Caddy now holds and retries the upstream for up to 30s during a restart instead of 502'ing immediately. Real outages (upstream unreachable longer than 30s) still 502 and the alert still fires for those. - infrastructure/grafana/provisioning/alerting/alerts.yml โ€” add a comment documenting why caddy-502-rate stays at threshold > 0: with lb_try_duration in front of it, the alert no longer conflates "deploy in flight" with "real outage." - .github/workflows/cd-apps.yml โ€” fix a long-silent bug: the Grafana deploy-annotation step was reading GRAFANA_SERVICE_TOKEN from `.env`, but the secrets file we scp to /opt/trails-cool is named `app.env`. The token check failed silently and the curl was being skipped on every deploy. Switching to `app.env` so deploys actually annotate Grafana. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/cd-apps.yml | 10 ++++++++-- infrastructure/Caddyfile | 18 ++++++++++++++++-- .../grafana/provisioning/alerting/alerts.yml | 6 ++++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cd-apps.yml b/.github/workflows/cd-apps.yml index 5ba0f06..6277543 100644 --- a/.github/workflows/cd-apps.yml +++ b/.github/workflows/cd-apps.yml @@ -115,8 +115,14 @@ jobs: docker image prune -af docker compose ps - # Annotate deploy in Grafana - GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env | cut -d= -f2- 2>/dev/null) + # Annotate deploy in Grafana. The token lives in the + # decrypted SOPS env file we just scp'd to /opt/trails-cool + # โ€” that file is `app.env`, not `.env`. (Pre-fix this read + # the wrong path, so annotations were silently no-op'ing + # every deploy.) `2>/dev/null` keeps a missing token from + # failing the deploy; `|| true` keeps the curl from + # failing the deploy if Grafana itself is unhealthy. + GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN app.env 2>/dev/null | cut -d= -f2-) if [ -n "$GRAFANA_TOKEN" ]; then docker compose exec -T grafana curl -sf -X POST \ -H "Authorization: Bearer $GRAFANA_TOKEN" \ diff --git a/infrastructure/Caddyfile b/infrastructure/Caddyfile index f2a61b6..6d80f0a 100644 --- a/infrastructure/Caddyfile +++ b/infrastructure/Caddyfile @@ -28,7 +28,17 @@ output stdout format json } - reverse_proxy journal:3000 + reverse_proxy journal:3000 { + # During an `apps` deploy the journal container is briefly down + # (~10โ€“30s) while compose swaps containers. Without these, + # Caddy returns 502 immediately and the `caddy-502-rate` alert + # trips on every deploy. With them, Caddy holds and retries + # against the upstream for up to 30s โ€” restart becomes + # invisible to clients. A real outage longer than 30s still + # 502s and correctly trips the alert. + lb_try_duration 30s + lb_try_interval 250ms + } } www.{$DOMAIN:trails.cool} { @@ -53,5 +63,9 @@ planner.{$DOMAIN:trails.cool} { output stdout format json } - reverse_proxy planner:3001 + reverse_proxy planner:3001 { + # Same rationale as the journal block โ€” see the comment there. + lb_try_duration 30s + lb_try_interval 250ms + } } diff --git a/infrastructure/grafana/provisioning/alerting/alerts.yml b/infrastructure/grafana/provisioning/alerting/alerts.yml index aaae096..1ce2546 100644 --- a/infrastructure/grafana/provisioning/alerting/alerts.yml +++ b/infrastructure/grafana/provisioning/alerting/alerts.yml @@ -206,6 +206,12 @@ groups: annotations: summary: "BRouter host metrics scrape has been failing for 2+ minutes โ€” the dedicated host, vSwitch, or cAdvisor may be down" + # The threshold here is intentionally `> 0` for 2m โ€” *any* + # sustained 502 stream is real. Deploy-time restarts no longer + # produce 502s thanks to `lb_try_duration` on Caddy's reverse + # proxy (see `infrastructure/Caddyfile`); if 502s appear here + # it means the upstream has been unreachable for longer than + # Caddy's retry window, which is a genuine outage. - uid: caddy-502-rate title: Caddy 502 errors detected condition: B From 55c9154f05f801d282965636b15e9fb63f885ddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 26 Apr 2026 11:53:55 +0200 Subject: [PATCH 3/3] Revert cd-apps annotation path: GRAFANA_SERVICE_TOKEN is in .env, not app.env The token lives in `secrets.infra.env`, which `cd-infra.yml` merges together with `secrets.app.env` into the server's `/opt/trails-cool/.env`. The cd-apps workflow's own `app.env` intentionally does NOT carry the token (apps don't need it at runtime), so the original `grep ... .env` was correct. My earlier edit in this branch swapped the path to `app.env` and would have broken the annotation hook the moment it actually worked. Restored `.env` and updated the inline comment to make the file ownership explicit (cd-infra populates it; cd-apps reads it). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/cd-apps.yml | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/cd-apps.yml b/.github/workflows/cd-apps.yml index 6277543..cc6d706 100644 --- a/.github/workflows/cd-apps.yml +++ b/.github/workflows/cd-apps.yml @@ -115,14 +115,16 @@ jobs: docker image prune -af docker compose ps - # Annotate deploy in Grafana. The token lives in the - # decrypted SOPS env file we just scp'd to /opt/trails-cool - # โ€” that file is `app.env`, not `.env`. (Pre-fix this read - # the wrong path, so annotations were silently no-op'ing - # every deploy.) `2>/dev/null` keeps a missing token from - # failing the deploy; `|| true` keeps the curl from - # failing the deploy if Grafana itself is unhealthy. - GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN app.env 2>/dev/null | cut -d= -f2-) + # Annotate deploy in Grafana. GRAFANA_SERVICE_TOKEN lives + # in secrets.infra.env (decrypted by cd-infra.yml into the + # merged /opt/trails-cool/.env on the server). cd-apps's + # own app.env intentionally does NOT carry it โ€” apps don't + # need it at runtime. So we read from the merged `.env` + # that cd-infra populated. If cd-infra has never run on + # this host, .env may not exist; the `2>/dev/null` and + # the `if -n` guard make the annotation a silent no-op + # rather than a deploy failure in that case. + GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env 2>/dev/null | cut -d= -f2-) if [ -n "$GRAFANA_TOKEN" ]; then docker compose exec -T grafana curl -sf -X POST \ -H "Authorization: Bearer $GRAFANA_TOKEN" \