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
7 changes: 4 additions & 3 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## Metadata
- Domain: late.sh - Terminal Clubhouse for Developers
- Primary audience: LLM agents working on this codebase, human contributors
- Last updated: 2026-05-13 (CLI details in `late-cli/CONTEXT.md`; Web details in `late-web/CONTEXT.md`; Arcade details in `late-ssh/src/app/arcade/CONTEXT.md`; Rooms details in `late-ssh/src/app/rooms/CONTEXT.md`; Chat details in `late-ssh/src/app/chat/CONTEXT.md`; Artboard details in `late-ssh/src/app/artboard/CONTEXT.md`)
- Last updated: 2026-05-14 (CLI details in `late-cli/CONTEXT.md`; Web details in `late-web/CONTEXT.md`; Arcade details in `late-ssh/src/app/arcade/CONTEXT.md`; Rooms details in `late-ssh/src/app/rooms/CONTEXT.md`; Chat details in `late-ssh/src/app/chat/CONTEXT.md`; Artboard details in `late-ssh/src/app/artboard/CONTEXT.md`)
- Status: Active
- Stability note: Sections marked `[STABLE]` should change rarely. Sections marked `[VOLATILE]` are expected to change often.

Expand Down Expand Up @@ -245,7 +245,7 @@ flowchart LR
- `ProfileService` (in `app/profile/svc.rs`) exposes per-user `watch` snapshots backed by service-owned maps (`subscribe_snapshot(user_id)`).
- `LeaderboardService` exposes a shared `watch::Receiver<Arc<LeaderboardData>>` refreshed from DB every 30s. Contains today's champions, daily completion statuses, extended all-time/monthly high scores (Tetris, 2048, Snake), monthly chip earners, monthly Arcade champion points, and chip leaders (top balances). Compact Hub leaderboard panels render top rows plus calculation hints and an "around you" slice when the current user is outside the visible top list; Arcade Wins uses daily puzzle weighting (easy/draw-1 = 1, medium = 3, hard/draw-3 = 5). Chat username glyphs come from bonsai state.
- `Hub` (in `app/hub`) is the global modal opened by Ctrl+G. It owns cross-product surfaces such as Leaderboard, Dailies, Shop, and Events. It may summarize data from Arcade, Rooms, and economy services, but those domains keep their own runtime/service ownership.
- `ChipService` (in `app/arcade/chips/svc.rs`) manages the Late Chips economy: `ensure_chips(user_id)` grants the daily 500-chip stipend on login, `grant_daily_bonus_task(user_id, difficulty_key)` awards 50/100/150 chips on daily puzzle completion. Daily Arcade services hold a `ChipService` clone and call it in `record_win_task()`.
- `ChipService` (in `app/arcade/chips/svc.rs`) manages the Late Chips economy: `ensure_chips(user_id)` creates new chip rows with 1000 chips, `grant_daily_bonus_task(user_id, difficulty_key)` awards 50/100/150 chips on daily puzzle completion. Daily Arcade services hold a `ChipService` clone and call it in `record_win_task()`.
- `Activity` (in `app/activity`) owns the structured global user-action event type, channel helpers, and `ActivityPublisher` username lookup helper. `ActivityEvent` carries `user_id`, `username`, display `action`, structured `ActivityKind`, category, and timestamp. Dashboard/sidebar display drains the same global broadcast stream through `ActivityFilter::dashboard()`. Future daily-challenge systems should subscribe to this channel and consume every event in order rather than adding per-feature challenge hooks.
- `RoomsService` (in `app/rooms/svc.rs`) owns persistent game-room creation/listing/deletion over `game_rooms` + associated `chat_rooms`, publishes `RoomsSnapshot` via `watch`, and emits `RoomsEvent` success/failure banners.
- `BlackjackTableManager` / `BlackjackService` own process-local per-room Blackjack runtime state. Detailed Rooms/Blackjack contracts live in `late-ssh/src/app/rooms/CONTEXT.md`.
Expand Down Expand Up @@ -564,7 +564,7 @@ late-sh/
| BonsaiTree | `bonsai_trees` | `user_id` UNIQUE, growth_points, last_watered DATE, seed BIGINT, is_alive BOOLEAN |
| BonsaiGrave | `bonsai_graveyard` | `user_id` FK (not unique — multiple deaths), survived_days, died_at |
| BonsaiDailyCare | `bonsai_daily_care` | `UNIQUE(user_id, care_date)`, UTC daily care row with watered flag, generated branch goal, cut branch ids, and one-shot water/prune penalty flags |
| UserChips | `user_chips` | `user_id` PK/FK, `balance` BIGINT (floor=100), `last_stipend_date` DATE |
| UserChips | `user_chips` | `user_id` PK/FK, `balance` BIGINT (new users start at 1000; busted-player floor restore is 100), `last_stipend_date` DATE |
| Showcase | `showcases` | `user_id` FK; `title` 1-120, `url` 1-2000, `description` 1-800, `tags` TEXT[] (lowercased, ≤8). Listed newest-first, edit/delete restricted to author or admin |
| ShowcaseFeedRead | `showcase_feed_reads` | `user_id` PK/FK, `last_read_at` timestamp cursor for per-user Showcase unread counts |
| WorkProfile | `work_profiles` | `user_id` UNIQUE FK; `slug` UNIQUE (`w_` + 12 lowercase alnum), `headline`, status (`open`, `casual`, `not-looking`), type/location, links, skills, summary. Listed latest-update-first, edit/delete restricted to author or admin |
Expand Down Expand Up @@ -627,6 +627,7 @@ Known gaps/risks:
- **Single-replica assumption:** Several structures are purely in-memory and not shared across processes (see multi-replica notes below)
- **SSH pod drain window:** `infra/service-ssh.tf` sets `termination_grace_period_seconds = 21600` (6h) so rolling updates can stop new connections while allowing existing SSH sessions to drain for a long window before Kubernetes sends SIGKILL.
- **SSH ingress reload risk:** `ssh late.sh` currently reaches `late-ssh` through RKE2 ingress-nginx TCP passthrough (`infra/ssh-tcp.tf`, port `22 -> service-ssh-sv:2222::PROXY`). Long-lived SSH sessions can be dropped after any ingress-nginx config reload because old workers are terminated after `worker_shutdown_timeout` (observed 2026-04-29 after cert-manager renewed `service-web-tls`: reload at `19:56:37Z`, mass SSH/WS disconnect at `20:00:38Z`, matching the 240s timeout). Future infra improvement: stop routing SSH through ingress-nginx; use a dedicated TCP LoadBalancer/NodePort/host proxy for SSH so HTTP/TLS reloads cannot kill SSH sessions. Short-term mitigation: increase ingress-nginx `worker-shutdown-timeout`, but that only delays the disconnect.
- **Postgres primary CPU saturation from discover-room fanout:** Observed 2026-05-14 in Kubernetes: CNPG primary `postgres-1` was healthy but pinned near its `1` CPU limit, while the node still had spare CPU. `pg_stat_activity` showed `service-ssh` (`10.42.0.47`) running 8 concurrent `app` sessions on the same public topic-room discover query. The old query joined `chat_rooms -> chat_room_members -> chat_messages` and then used `COUNT(DISTINCT ...)`, producing an estimated ~4.48M joined rows before aggregation. Preferred fix is query shape first: aggregate member/message counts separately (current `ChatRoom::list_discover_public_topic_rooms` uses `LATERAL` aggregates), then raise the CNPG CPU limit from `1` to `2` only for headroom. Secondary log noise during the same check: repeated `idx_users_username_lower` duplicate-key errors from profile updates; do not mistake those for the main CPU source unless active queries point there.
- **IPv6 ingress status:** RKE2/CNI `hostPort` exposes the current ingress-nginx path for IPv4 only; do not switch the main ingress controller to `hostNetwork` without a rollout plan. Public IPv6 is handled by the separate `kube-system/ipv6-proxy` HAProxy DaemonSet in `infra/ipv6-proxy.tf`, binding `2a01:4f9:c013:2ae1::1` on `80`, `443`, and `22`; HTTP(S) forwards to localhost ingress hostPorts, while SSH forwards to `service-ssh-sv:2222` with PROXY protocol. Verified working externally on 2026-05-03; `Network is unreachable` during `ssh -6 late.sh` means the client lacks IPv6 egress.
- **Stateful VT parsing in `late-ssh/src/app/input.rs`:** SSH input now runs through a persistent `vte::Parser`, so CSI/SS3 sequences and bracketed paste survive split russh reads instead of assuming the whole escape sequence lands in one chunk. That removes the old split-paste failure where `[200~` / `[201~` residue or embedded newlines could leak through as live keystrokes. The app still keeps two pragmatic layers on top: `is_likely_paste` heuristically treats large printable unmarked chunks as paste for terminals without bracketed paste, and `sanitize_paste_markers`/`strip_paste_markers` still scrub stored residue defensively when copying URLs from older polluted state. Standalone `Esc` is resolved on a short tick delay so split escape sequences are not mistaken for cancel keys.

Expand Down
22 changes: 15 additions & 7 deletions late-core/src/models/chat_room.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,18 +241,26 @@ impl ChatRoom {
.query(
"SELECT r.id,
r.slug,
COUNT(DISTINCT m.user_id)::bigint AS member_count,
COUNT(DISTINCT msg.id)::bigint AS message_count,
MAX(msg.created) AS last_message_at
COALESCE(m.member_count, 0)::bigint AS member_count,
COALESCE(msg.message_count, 0)::bigint AS message_count,
msg.last_message_at
FROM chat_rooms r
LEFT JOIN chat_room_members m ON m.room_id = r.id
LEFT JOIN chat_messages msg ON msg.room_id = r.id
LEFT JOIN LATERAL (
SELECT COUNT(*)::bigint AS member_count
FROM chat_room_members m
WHERE m.room_id = r.id
) m ON true
LEFT JOIN LATERAL (
SELECT COUNT(*)::bigint AS message_count,
MAX(created) AS last_message_at
FROM chat_messages msg
WHERE msg.room_id = r.id
) msg ON true
WHERE r.kind = 'topic'
AND r.visibility = 'public'
AND r.permanent = false
GROUP BY r.id, r.slug
ORDER BY
COALESCE(MAX(msg.created), r.created) DESC,
COALESCE(msg.last_message_at, r.created) DESC,
message_count DESC,
member_count DESC,
r.slug ASC",
Expand Down
4 changes: 3 additions & 1 deletion late-core/src/models/chips.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use uuid::Uuid;

pub const BONSAI_WATER_BONUS: i64 = 200;
pub const CHIP_FLOOR: i64 = 100;
pub const INITIAL_CHIP_BALANCE: i64 = 1_000;

/// Map a difficulty key to its chip bonus.
pub fn difficulty_bonus(key: &str) -> i64 {
Expand Down Expand Up @@ -44,7 +45,7 @@ impl UserChips {
VALUES ($1, $2)
ON CONFLICT (user_id) DO NOTHING
RETURNING *",
&[&user_id, &CHIP_FLOOR],
&[&user_id, &INITIAL_CHIP_BALANCE],
)
.await;
match row {
Expand Down Expand Up @@ -205,5 +206,6 @@ mod tests {
fn constants() {
assert_eq!(BONSAI_WATER_BONUS, 200);
assert_eq!(CHIP_FLOOR, 100);
assert_eq!(INITIAL_CHIP_BALANCE, 1_000);
}
}
10 changes: 9 additions & 1 deletion late-core/src/models/mention_feed_read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,17 @@ impl MentionFeedRead {
.query_one(
"SELECT COUNT(n.id)::bigint AS unread_count
FROM notifications n
JOIN users recipient ON recipient.id = n.user_id
LEFT JOIN mention_feed_reads mfr ON mfr.user_id = $1
WHERE n.user_id = $1
AND n.created > COALESCE(mfr.last_read_at, '-infinity'::timestamptz)",
AND n.created > COALESCE(mfr.last_read_at, '-infinity'::timestamptz)
AND NOT (
COALESCE(recipient.settings, '{}'::jsonb)
@> jsonb_build_object(
'ignored_user_ids',
jsonb_build_array(n.actor_id::text)
)
)",
&[&user_id],
)
.await?;
Expand Down
57 changes: 37 additions & 20 deletions late-core/src/models/notification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,26 +45,20 @@ impl Notification {
return Ok(0);
}

// Build a multi-row INSERT: ($1, $2, $3, $4), ($5, $2, $3, $4), ...
// where $2=actor_id, $3=message_id, $4=room_id are shared, and each $N is a user_id.
let mut params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> =
Vec::with_capacity(user_ids.len() + 3);
params.push(&actor_id); // $1
params.push(&message_id); // $2
params.push(&room_id); // $3

let mut value_clauses = Vec::with_capacity(user_ids.len());
for (i, uid) in user_ids.iter().enumerate() {
params.push(uid); // $4, $5, $6, ...
value_clauses.push(format!("(${}, $1, $2, $3)", i + 4));
}

let query = format!(
"INSERT INTO notifications (user_id, actor_id, message_id, room_id) VALUES {} ON CONFLICT DO NOTHING",
value_clauses.join(", ")
);

let count = client.execute(&query, &params).await?;
let count = client
.execute(
"INSERT INTO notifications (user_id, actor_id, message_id, room_id)
SELECT mentioned.user_id, $1, $2, $3
FROM UNNEST($4::uuid[]) AS mentioned(user_id)
JOIN users recipient ON recipient.id = mentioned.user_id
WHERE NOT (
COALESCE(recipient.settings, '{}'::jsonb)
@> jsonb_build_object('ignored_user_ids', jsonb_build_array(($1::uuid)::text))
)
ON CONFLICT DO NOTHING",
&[&actor_id, &message_id, &room_id, &user_ids],
)
.await?;
Ok(count)
}

Expand All @@ -82,9 +76,17 @@ impl Notification {
LEFT(m.body, 120) AS message_preview
FROM notifications n
JOIN users u ON u.id = n.actor_id
JOIN users recipient ON recipient.id = n.user_id
JOIN chat_rooms r ON r.id = n.room_id
JOIN chat_messages m ON m.id = n.message_id
WHERE n.user_id = $1
AND NOT (
COALESCE(recipient.settings, '{}'::jsonb)
@> jsonb_build_object(
'ignored_user_ids',
jsonb_build_array(n.actor_id::text)
)
)
AND r.kind <> 'game'
AND (
r.kind = 'dm'
Expand Down Expand Up @@ -120,9 +122,17 @@ impl Notification {
"SELECT COUNT(n.id)::bigint AS unread_count
FROM notifications n
JOIN chat_rooms r ON r.id = n.room_id
JOIN users recipient ON recipient.id = n.user_id
LEFT JOIN mention_feed_reads mfr ON mfr.user_id = $1
WHERE n.user_id = $1
AND n.created > COALESCE(mfr.last_read_at, '-infinity'::timestamptz)
AND NOT (
COALESCE(recipient.settings, '{}'::jsonb)
@> jsonb_build_object(
'ignored_user_ids',
jsonb_build_array(n.actor_id::text)
)
)
AND r.kind <> 'game'
AND (
r.kind = 'dm'
Expand Down Expand Up @@ -169,6 +179,13 @@ impl Notification {
ON m.room_id = r.id AND m.user_id = u.id \
WHERE LOWER(u.username) = ANY($1) \
AND u.id <> $2 \
AND NOT (
COALESCE(u.settings, '{}'::jsonb)
@> jsonb_build_object(
'ignored_user_ids',
jsonb_build_array(($2::uuid)::text)
)
) \
AND r.kind <> 'game' \
AND (
(r.kind = 'dm' AND u.id IN (r.dm_user_a, r.dm_user_b))
Expand Down
109 changes: 109 additions & 0 deletions late-core/tests/mention_feed_read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use late_core::{
chat_room::ChatRoom,
mention_feed_read::MentionFeedRead,
notification::Notification,
user::User,
},
test_utils::{create_test_user, test_db},
};
Expand Down Expand Up @@ -86,3 +87,111 @@ async fn mention_feed_unread_uses_timestamp_cursor() {
.expect("count unread after new mention");
assert_eq!(unread_after_new, 1);
}

#[tokio::test]
async fn mention_notifications_skip_recipients_who_ignore_actor() {
let test_db = test_db().await;
let client = test_db.db.get().await.expect("db client");
let room = ChatRoom::ensure_general(&client)
.await
.expect("ensure general");
let actor = create_test_user(&test_db.db, "mention-ignored-actor").await;
let reader = create_test_user(&test_db.db, "mention-ignore-reader").await;

User::add_ignored_user_id(&client, reader.id, actor.id)
.await
.expect("ignore actor");

let message = ChatMessage::create(
&client,
ChatMessageParams {
room_id: room.id,
user_id: actor.id,
body: "@mention-ignore-reader hello".to_string(),
},
)
.await
.expect("create mention message");

let inserted =
Notification::create_mentions_batch(&client, &[reader.id], actor.id, message.id, room.id)
.await
.expect("create mention notification");
assert_eq!(inserted, 0);

let list = Notification::list_for_user(&client, reader.id, 50)
.await
.expect("list notifications");
assert!(list.is_empty());

let unread = Notification::unread_count(&client, reader.id)
.await
.expect("count notifications");
assert_eq!(unread, 0);
let feed_unread = MentionFeedRead::unread_count_for_user(&client, reader.id)
.await
.expect("count feed notifications");
assert_eq!(feed_unread, 0);
}

#[tokio::test]
async fn mention_feed_hides_existing_notifications_after_actor_is_ignored() {
let test_db = test_db().await;
let client = test_db.db.get().await.expect("db client");
let room = ChatRoom::ensure_general(&client)
.await
.expect("ensure general");
let actor = create_test_user(&test_db.db, "mention-hide-actor").await;
let reader = create_test_user(&test_db.db, "mention-hide-reader").await;

let message = ChatMessage::create(
&client,
ChatMessageParams {
room_id: room.id,
user_id: actor.id,
body: "@mention-hide-reader hello".to_string(),
},
)
.await
.expect("create mention message");
Notification::create_mentions_batch(&client, &[reader.id], actor.id, message.id, room.id)
.await
.expect("create mention notification");

assert_eq!(
Notification::unread_count(&client, reader.id)
.await
.expect("count before ignore"),
1
);
assert_eq!(
Notification::list_for_user(&client, reader.id, 50)
.await
.expect("list before ignore")
.len(),
1
);

User::add_ignored_user_id(&client, reader.id, actor.id)
.await
.expect("ignore actor");

assert_eq!(
Notification::unread_count(&client, reader.id)
.await
.expect("count after ignore"),
0
);
assert_eq!(
MentionFeedRead::unread_count_for_user(&client, reader.id)
.await
.expect("count feed after ignore"),
0
);
assert!(
Notification::list_for_user(&client, reader.id, 50)
.await
.expect("list after ignore")
.is_empty()
);
}
Loading