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:
53
src/agents/agent-presence.service.ts
Normal file
53
src/agents/agent-presence.service.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AgentPresence } from '../entities/agent-presence.entity.js';
|
||||
|
||||
export type PresenceStatus = 'idle' | 'on_call' | 'busy' | 'exhausted' | 'offline' | 'unknown';
|
||||
|
||||
@Injectable()
|
||||
export class AgentPresenceService {
|
||||
constructor(
|
||||
@InjectRepository(AgentPresence)
|
||||
private readonly repo: Repository<AgentPresence>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get a user's current presence. Returns 'unknown' if no row.
|
||||
* Used by `RealtimeGateway` per-recipient when xType === 'announce'.
|
||||
*/
|
||||
async getStatus(userId: string): Promise<PresenceStatus> {
|
||||
if (!userId) return 'unknown';
|
||||
const row = await this.repo.findOne({ where: { userId } });
|
||||
return row?.status ?? 'unknown';
|
||||
}
|
||||
|
||||
/** Bulk variant for delivery-time lookups across many recipients in one trip. */
|
||||
async getStatusMap(userIds: string[]): Promise<Map<string, PresenceStatus>> {
|
||||
const out = new Map<string, PresenceStatus>();
|
||||
for (const id of userIds) out.set(id, 'unknown');
|
||||
if (userIds.length === 0) return out;
|
||||
const rows = await this.repo
|
||||
.createQueryBuilder('p')
|
||||
.where('p.userId IN (:...ids)', { ids: userIds })
|
||||
.getMany();
|
||||
for (const r of rows) out.set(r.userId, r.status);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a user's presence. Source is a free-text tag for debugging
|
||||
* (e.g. "hf-plugin", "manual", "test"). PUT /agents/:id/presence
|
||||
* calls this; the plugin pushes only on diff so writes are sparse.
|
||||
*/
|
||||
async setStatus(userId: string, status: PresenceStatus, source: string): Promise<AgentPresence> {
|
||||
const existing = await this.repo.findOne({ where: { userId } });
|
||||
if (existing) {
|
||||
existing.status = status;
|
||||
existing.source = source;
|
||||
return this.repo.save(existing);
|
||||
}
|
||||
const row = this.repo.create({ userId, status, source });
|
||||
return this.repo.save(row);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user