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:
2026-07-21 23:27:59 +05:30
parent ebf553eb68
commit ade1d68015
4 changed files with 103 additions and 1 deletions
@@ -3,6 +3,7 @@
// layer is faked (via kernel-client's own SocketLike seam), so the facade's wire mapping is
// exercised for real. No live IIOS required.
import { describe, it, expect } from 'vitest';
import { MessageSocket } from '@insignia/iios-kernel-client';
import type { Message as KernelMessage, SocketLike } from '@insignia/iios-kernel-client';
import { runAdapterConformance } from '../conformance';
@@ -113,6 +114,20 @@ function makeFakeRest(): RestPort {
async addParticipant() {
/* 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'] });
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 type {
AnnotationEvent,
DiscoveredThread,
Message as KernelMessage,
MessageEvents,
OpenThreadResult,
@@ -17,7 +18,10 @@ import type {
} from '@insignia/iios-kernel-client';
import type { MessagingAdapter } from '../adapter';
import type {
ChannelSummary,
ChannelVisibility,
Conversation,
CreateChannelInput,
Membership,
Message,
MessageEvent,
@@ -41,6 +45,8 @@ export interface RestPort {
listThreads(filter?: { metadata?: Record<string, string> }): Promise<ThreadSummary[]>;
createThread(opts: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record<string, unknown> }): Promise<{ threadId: string }>;
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 {
@@ -165,6 +171,43 @@ export class KernelClientAdapter implements MessagingAdapter {
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. */
close(): void {
for (const off of this.offs) off();