feat(guild): announce channel type + agent-presence + busy-discard

Phase 1 of DIALECTIC-V2 — adds Fabric infrastructure for
system-broadcast channels with HF-status-aware delivery filtering.

New channel x_type 'announce':
- channels.entity.ts + channels.service.ts + realtime.gateway.ts
  enum + union extended.
- computeDelivery() adds an 'announce' case: recipient with
  presence='busy' → 'skip' (discarded silently); other presences →
  'observer' (delivered, no wake). System-broadcast semantics —
  agents proactively check their announce inbox when they're ready,
  not interrupted out of band.
- messaging.controller POST guard: announce-type channels reject
  posts that don't present x-fabric-system-key header matching
  FABRIC_BACKEND_GUILD_SYSTEM_API_KEY env. Empty env = no system
  caller is valid (closed-by-default).

New entity + module agent_presences:
- agent-presence.entity.ts: per-user (userId PK) status enum
  (idle/on_call/busy/exhausted/offline/unknown), source tag, updatedAt
- agent-presence.service.ts: getStatus/getStatusMap (bulk for
  delivery-time fanout) + setStatus (upsert)
- agent-presence.controller.ts: GET + PUT /agents/:userId/presence
- agent-presence.module.ts: TypeORM forFeature + wired into AppModule
- buildTypeOrmConfig() entities list extended

RealtimeGateway wiring:
- New optional  field on the gateway (typed loosely to avoid
  circular import). RealtimeModule.onModuleInit() assigns from the
  injected AgentPresenceService — degrades gracefully (no busy-discard,
  treat all as 'unknown') if presence wiring is ever removed.
- emitMessageCreated pre-loads presence per fanout only when xType is
  'announce' (other xTypes bypass the lookup entirely).

Note: actual presence data writes come from Fabric.OpenclawPlugin's
presence-sync loop (separate commit on that submodule); without it,
all rows are 'unknown' and announce delivery falls through to the
default observer behavior (no busy filtering). System-only POST gate
is independent and works immediately.

See /home/hzhang/arch/DIALECTIC-V2-DESIGN.md sections 7 + 10 Phase 1.
This commit is contained in:
h z
2026-05-23 11:31:47 +01:00
parent 801b562999
commit 80ee9082f3
11 changed files with 231 additions and 8 deletions

View File

@@ -158,6 +158,7 @@ export class MessagingController {
@Body() body: CreateMessageDto,
@Req() req: { userId?: string },
@Headers('idempotency-key') idempotencyKey?: string,
@Headers('x-fabric-system-key') systemKey?: string,
) {
const scope = `POST:/channels/${channelId}/messages`;
const existed = await this.getIdempotentResponse(scope, idempotencyKey);
@@ -174,6 +175,23 @@ export class MessagingController {
}
const xType = channel.xType ?? 'general';
const isRotating = xType === 'discuss' || xType === 'work';
// announce channels: posts only allowed when the caller presents a
// valid system key (matches FABRIC_BACKEND_GUILD_SYSTEM_API_KEY env).
// The ApiKeyGuard has already validated user identity; this is an
// additional system-only gate on top. Non-system posts are silently
// discarded (return 403 + log) so misbehaving clients don't pollute
// the broadcast.
if (xType === 'announce') {
const expected = process.env.FABRIC_BACKEND_GUILD_SYSTEM_API_KEY ?? '';
if (!expected || systemKey !== expected) {
// log + reject; treat empty env as "no system caller is ever valid"
throw new ForbiddenException({
error: 'announce_system_only',
message: 'announce-type channels accept system-signed posts only',
});
}
}
const authorUserId = userId;
// ---- translate <@user.name:NAME> -> <@userId> (outside backticks) via