feat: channels on the direct-IIOS path — kernel-client + KernelClientAdapter
- iios-kernel-client: RestClient.discoverThreads (GET /v1/threads/discover) + leaveThread (DELETE /v1/threads/:id/me); DiscoveredThread type - KernelClientAdapter implements the four channel methods: browseChannels maps discovered public channels → ChannelSummary; createChannel → createThread (membership=channel, ADMIN, visibility/topic metadata); joinChannel → socket.openThread (governed public self-join); leaveChannel → rest.leaveThread - adapter channel test over the fake transport (13 kernel-adapter tests); 57 green Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import type { IngestInteractionRequest } from '@insignia/iios-contracts';
|
import type { IngestInteractionRequest } from '@insignia/iios-contracts';
|
||||||
import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem, ThreadSummary, SavedItem, LoginResult } from './types';
|
import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem, ThreadSummary, DiscoveredThread, SavedItem, LoginResult } from './types';
|
||||||
|
|
||||||
export interface RestConfig {
|
export interface RestConfig {
|
||||||
serviceUrl: string;
|
serviceUrl: string;
|
||||||
@@ -103,6 +103,27 @@ export class RestClient {
|
|||||||
return this.post<{ threadId: string }>('/v1/threads', opts);
|
return this.post<{ threadId: string }>('/v1/threads', opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover threads across your scope matching an opaque metadata filter (e.g. browse public
|
||||||
|
* channels: `{ membership: 'channel', visibility: 'public' }`). Returns ones you have NOT joined
|
||||||
|
* too, each flagged `joined`.
|
||||||
|
*/
|
||||||
|
async discoverThreads(filter?: { metadata?: Record<string, string> }): Promise<DiscoveredThread[]> {
|
||||||
|
const qs = filter?.metadata
|
||||||
|
? '?' + Object.entries(filter.metadata).map(([k, v]) => `metadata[${encodeURIComponent(k)}]=${encodeURIComponent(v)}`).join('&')
|
||||||
|
: '';
|
||||||
|
const r = await fetch(this.url(`/v1/threads/discover${qs}`), { headers: this.headers() });
|
||||||
|
if (!r.ok) throw new Error(`discoverThreads ${r.status}`);
|
||||||
|
return (await r.json()) as DiscoveredThread[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Self-leave a thread (e.g. leave a channel). */
|
||||||
|
async leaveThread(threadId: string): Promise<{ threadId: string; participantCount: number }> {
|
||||||
|
const r = await fetch(this.url(`/v1/threads/${threadId}/me`), { method: 'DELETE', headers: this.headers() });
|
||||||
|
if (!r.ok) throw new Error(`leaveThread ${r.status}`);
|
||||||
|
return (await r.json()) as { threadId: string; participantCount: number };
|
||||||
|
}
|
||||||
|
|
||||||
/** My saved messages (personal bookmarks), newest first, with thread context. */
|
/** My saved messages (personal bookmarks), newest first, with thread context. */
|
||||||
async listSaved(): Promise<SavedItem[]> {
|
async listSaved(): Promise<SavedItem[]> {
|
||||||
const r = await fetch(this.url('/v1/threads/my-annotations?type=save'), { headers: this.headers() });
|
const r = await fetch(this.url('/v1/threads/my-annotations?type=save'), { headers: this.headers() });
|
||||||
|
|||||||
@@ -64,6 +64,15 @@ export interface MessageEvents {
|
|||||||
annotation: (e: AnnotationEvent) => void;
|
annotation: (e: AnnotationEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A discoverable thread from GET /v1/threads/discover — includes ones you have NOT joined. */
|
||||||
|
export interface DiscoveredThread {
|
||||||
|
threadId: string;
|
||||||
|
subject: string | null;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
participantCount: number;
|
||||||
|
joined: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/** A "my threads" entry from GET /v1/threads (server-authoritative). */
|
/** A "my threads" entry from GET /v1/threads (server-authoritative). */
|
||||||
export interface ThreadSummary {
|
export interface ThreadSummary {
|
||||||
threadId: string;
|
threadId: string;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// layer is faked (via kernel-client's own SocketLike seam), so the facade's wire mapping is
|
// layer is faked (via kernel-client's own SocketLike seam), so the facade's wire mapping is
|
||||||
// exercised for real. No live IIOS required.
|
// exercised for real. No live IIOS required.
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
import { MessageSocket } from '@insignia/iios-kernel-client';
|
import { MessageSocket } from '@insignia/iios-kernel-client';
|
||||||
import type { Message as KernelMessage, SocketLike } from '@insignia/iios-kernel-client';
|
import type { Message as KernelMessage, SocketLike } from '@insignia/iios-kernel-client';
|
||||||
import { runAdapterConformance } from '../conformance';
|
import { runAdapterConformance } from '../conformance';
|
||||||
@@ -113,6 +114,20 @@ function makeFakeRest(): RestPort {
|
|||||||
async addParticipant() {
|
async addParticipant() {
|
||||||
/* governed server-side; a fake always allows */
|
/* governed server-side; a fake always allows */
|
||||||
},
|
},
|
||||||
|
async discoverThreads() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
threadId: 'th_pub',
|
||||||
|
subject: 'general',
|
||||||
|
metadata: { membership: 'channel', visibility: 'public', topic: 'Company-wide' },
|
||||||
|
participantCount: 3,
|
||||||
|
joined: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
async leaveThread(threadId) {
|
||||||
|
return { threadId, participantCount: 0 };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,3 +137,17 @@ function makeAdapter(): KernelClientAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
runAdapterConformance({ makeAdapter, seededThreadId: SEEDED, openWith: ['pp_a'] });
|
runAdapterConformance({ makeAdapter, seededThreadId: SEEDED, openWith: ['pp_a'] });
|
||||||
|
|
||||||
|
describe('KernelClientAdapter channels', () => {
|
||||||
|
it('browse maps discovered public channels; create/join/leave delegate to the transport', async () => {
|
||||||
|
const adapter = makeAdapter();
|
||||||
|
const list = await adapter.browseChannels!();
|
||||||
|
expect(list[0]).toMatchObject({ threadId: 'th_pub', name: 'general', visibility: 'public', joined: false, memberCount: 3, topic: 'Company-wide' });
|
||||||
|
|
||||||
|
const { threadId } = await adapter.createChannel!({ name: 'design', topic: 'UI', visibility: 'public' });
|
||||||
|
expect(typeof threadId).toBe('string');
|
||||||
|
|
||||||
|
await expect(adapter.joinChannel!('th_pub')).resolves.toBeUndefined();
|
||||||
|
await expect(adapter.leaveChannel!('th_pub')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { MessageSocket, RestClient } from '@insignia/iios-kernel-client';
|
import { MessageSocket, RestClient } from '@insignia/iios-kernel-client';
|
||||||
import type {
|
import type {
|
||||||
AnnotationEvent,
|
AnnotationEvent,
|
||||||
|
DiscoveredThread,
|
||||||
Message as KernelMessage,
|
Message as KernelMessage,
|
||||||
MessageEvents,
|
MessageEvents,
|
||||||
OpenThreadResult,
|
OpenThreadResult,
|
||||||
@@ -17,7 +18,10 @@ import type {
|
|||||||
} from '@insignia/iios-kernel-client';
|
} from '@insignia/iios-kernel-client';
|
||||||
import type { MessagingAdapter } from '../adapter';
|
import type { MessagingAdapter } from '../adapter';
|
||||||
import type {
|
import type {
|
||||||
|
ChannelSummary,
|
||||||
|
ChannelVisibility,
|
||||||
Conversation,
|
Conversation,
|
||||||
|
CreateChannelInput,
|
||||||
Membership,
|
Membership,
|
||||||
Message,
|
Message,
|
||||||
MessageEvent,
|
MessageEvent,
|
||||||
@@ -41,6 +45,8 @@ export interface RestPort {
|
|||||||
listThreads(filter?: { metadata?: Record<string, string> }): Promise<ThreadSummary[]>;
|
listThreads(filter?: { metadata?: Record<string, string> }): Promise<ThreadSummary[]>;
|
||||||
createThread(opts: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record<string, unknown> }): Promise<{ threadId: string }>;
|
createThread(opts: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record<string, unknown> }): Promise<{ threadId: string }>;
|
||||||
addParticipant(threadId: string, userId: string): Promise<void>;
|
addParticipant(threadId: string, userId: string): Promise<void>;
|
||||||
|
discoverThreads(filter?: { metadata?: Record<string, string> }): Promise<DiscoveredThread[]>;
|
||||||
|
leaveThread(threadId: string): Promise<{ threadId: string; participantCount: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KernelClientAdapterConfig {
|
export interface KernelClientAdapterConfig {
|
||||||
@@ -165,6 +171,43 @@ export class KernelClientAdapter implements MessagingAdapter {
|
|||||||
await this.socket.markRead(threadId, messageId);
|
await this.socket.markRead(threadId, messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Channels ────────────────────────────────────────────────────
|
||||||
|
async browseChannels(): Promise<ChannelSummary[]> {
|
||||||
|
const found = await this.rest.discoverThreads({ metadata: { membership: 'channel', visibility: 'public' } });
|
||||||
|
return found.map((d) => {
|
||||||
|
const bag = (d.metadata as { topic?: string; visibility?: string } | null) ?? {};
|
||||||
|
const visibility: ChannelVisibility = bag.visibility === 'private' ? 'private' : 'public';
|
||||||
|
return {
|
||||||
|
threadId: d.threadId,
|
||||||
|
name: d.subject ?? 'channel',
|
||||||
|
topic: bag.topic ?? null,
|
||||||
|
visibility,
|
||||||
|
memberCount: d.participantCount,
|
||||||
|
joined: d.joined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createChannel(input: CreateChannelInput): Promise<{ threadId: string }> {
|
||||||
|
return this.rest.createThread({
|
||||||
|
membership: 'channel',
|
||||||
|
creatorRole: 'ADMIN',
|
||||||
|
subject: input.name,
|
||||||
|
metadata: { visibility: input.visibility, ...(input.topic ? { topic: input.topic } : {}) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async joinChannel(threadId: string): Promise<void> {
|
||||||
|
// Self-join is a governed open_thread; OPA allows it for a public channel.
|
||||||
|
await this.socket.openThread(threadId);
|
||||||
|
this.joined.add(threadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async leaveChannel(threadId: string): Promise<void> {
|
||||||
|
await this.rest.leaveThread(threadId);
|
||||||
|
this.joined.delete(threadId);
|
||||||
|
}
|
||||||
|
|
||||||
/** Detach socket handlers. Not part of the contract — call on teardown to avoid leaks. */
|
/** Detach socket handlers. Not part of the contract — call on teardown to avoid leaks. */
|
||||||
close(): void {
|
close(): void {
|
||||||
for (const off of this.offs) off();
|
for (const off of this.offs) off();
|
||||||
|
|||||||
Reference in New Issue
Block a user