feat(triage): 3-state delivery + admin observer + admin cache
Triage channels now compute a 3-state delivery decision per recipient
(wake / observer / skip) instead of the binary wakeup flag, and route
according to:
1. author never gets back their own message → skip
2. wake_mapping member (on-duty) → wake
3. mention (NEW: was 'skip' for triage before) → wake
4. Center-scoped admin (at most 1) → observer
5. anyone else → skip
(was 'deliver wake=false')
Skipping means the websocket emit is omitted entirely — the recipient's
openclaw plugin never sees the message and the agent's session stays
free of background noise. Observer means delivered with wakeup=false
(silent UI / no model dispatch on the plugin side).
## What this PR ships
### realtime/realtime.gateway.ts
- new `computeDelivery()` returns DeliveryDecision = 'wake'|'observer'|'skip'
- old `computeWakeup()` kept as a deprecated wrapper for callers that
still want the boolean answer (treats observer + skip as false)
- `emitMessageCreated` accepts `adminUserId?: string|null` and now
short-circuits on 'skip' (no socket emit at all)
- general kept its current behavior; custom kept its current behavior
(members not in wake_mapping become observer instead of `wake=false`)
— the user-visible bit is just that the response field is the same
`wakeup: boolean`; the explicit 'skip' is new for triage
### common/center-auth.ts
- `fetchAdminEmail()` calls GET `${center}/auth/admin-email` with the
existing x-api-key (same auth as introspect/resolve-names). Returns
`{email, userId}` or `null` on either "no admin" or any error
### common/admin-cache.service.ts (NEW)
- `AdminCacheService` — in-memory cache, 1-day TTL, lazy refresh.
`get(force=true)` bypasses TTL for cli-triggered refresh
- exposed by MessagingModule
### messaging/messaging.controller.ts
- non-rotating branch threads `adminUserId` into emitMessageCreated
### cli/admin-refresh.ts (NEW)
- `node dist/cli/admin-refresh.js` — force-refresh cache and print
before/after JSON. Use after a Center `user set-admin` so triage
delivery picks up the new admin without waiting for 24h TTL
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
73
src/common/admin-cache.service.ts
Normal file
73
src/common/admin-cache.service.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Center-scoped admin cache.
|
||||
*
|
||||
* Holds the at-most-one admin user (email + userId) fetched from Center.
|
||||
* Used to decide who to deliver triage messages to as a silent observer
|
||||
* (wake=false), regardless of on-duty / mention status.
|
||||
*
|
||||
* Refresh policy (per spec, 2026-05-22):
|
||||
* • TTL = 1 day. Center admin changes are rare; agents tolerate a
|
||||
* day's stale cache without surprises
|
||||
* • on first lookup the cache lazy-fetches
|
||||
* • cli `admin refresh` forces an out-of-band refresh without waiting
|
||||
* for TTL expiry
|
||||
*
|
||||
* Failure mode: a Center fetch error is treated identically to "no
|
||||
* admin" — guild keeps operating without an observer. The cache holds
|
||||
* the failed-fetch decision for the same TTL so we don't hammer Center.
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { fetchAdminEmail } from './center-auth.js';
|
||||
|
||||
const ADMIN_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface CachedAdmin {
|
||||
email: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminCacheService {
|
||||
private readonly logger = new Logger(AdminCacheService.name);
|
||||
private cached: CachedAdmin | null = null;
|
||||
private cachedAt = 0;
|
||||
private inflight: Promise<CachedAdmin | null> | null = null;
|
||||
|
||||
/**
|
||||
* Return the cached admin, fetching from Center if the cache is empty
|
||||
* or older than the TTL. Returns null if no admin is set.
|
||||
*
|
||||
* `force=true` bypasses the cache and refreshes immediately — used by
|
||||
* the cli refresh command.
|
||||
*/
|
||||
async get(force = false): Promise<CachedAdmin | null> {
|
||||
const fresh = Date.now() - this.cachedAt < ADMIN_CACHE_TTL_MS;
|
||||
if (!force && this.cachedAt > 0 && fresh) {
|
||||
return this.cached;
|
||||
}
|
||||
if (this.inflight) return this.inflight;
|
||||
|
||||
this.inflight = (async () => {
|
||||
try {
|
||||
const result = await fetchAdminEmail();
|
||||
this.cached = result;
|
||||
this.cachedAt = Date.now();
|
||||
this.logger.log(
|
||||
`admin cache refreshed: ${result ? `${result.email} (${result.userId})` : 'no admin set'}`,
|
||||
);
|
||||
return result;
|
||||
} finally {
|
||||
this.inflight = null;
|
||||
}
|
||||
})();
|
||||
return this.inflight;
|
||||
}
|
||||
|
||||
/** Snapshot of the cached admin (no fetch). Returns null if not yet
|
||||
* populated. Used by the hot delivery path which doesn't want to
|
||||
* block on a Center round-trip. */
|
||||
snapshot(): CachedAdmin | null {
|
||||
return this.cached;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user