feat(fleet): F4 Phase 1 — chat connector abstraction + Matrix design (#617)
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #617.
This commit is contained in:
@@ -113,6 +113,35 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"connector": {
|
||||
"description": "Orchestrator chat connector (F4). Optional — absent means tmux (back-compat). Secrets (access/bot tokens) come from the environment, never this file.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind"],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"enum": ["tmux", "discord", "matrix"]
|
||||
},
|
||||
"matrix": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["homeserver_url", "user_id", "room_id"],
|
||||
"properties": {
|
||||
"homeserver_url": { "type": "string" },
|
||||
"user_id": { "type": "string" },
|
||||
"room_id": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["channel_id"],
|
||||
"properties": {
|
||||
"channel_id": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
85
packages/mosaic/src/fleet/connectors/registry.spec.ts
Normal file
85
packages/mosaic/src/fleet/connectors/registry.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
KNOWN_CONNECTOR_KINDS,
|
||||
isKnownConnectorKind,
|
||||
resolveConnectorKind,
|
||||
registerConnector,
|
||||
hasConnector,
|
||||
createConnector,
|
||||
ConnectorNotImplementedError,
|
||||
_resetConnectorRegistry,
|
||||
} from './registry.js';
|
||||
import type { ConnectorConfig, OrchestratorConnector } from './types.js';
|
||||
|
||||
function fakeConnector(kind: 'tmux' | 'discord' | 'matrix'): OrchestratorConnector {
|
||||
return {
|
||||
kind,
|
||||
send: async () => ({ delivered: true, messageId: 'x' }),
|
||||
subscribe: () => () => {},
|
||||
health: async () => ({ reachable: true, authenticated: true }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('connector registry (F4 Phase 1)', () => {
|
||||
beforeEach(() => {
|
||||
_resetConnectorRegistry();
|
||||
});
|
||||
|
||||
it('knows the three peer connector kinds', () => {
|
||||
expect(KNOWN_CONNECTOR_KINDS).toEqual(['tmux', 'discord', 'matrix']);
|
||||
});
|
||||
|
||||
it('isKnownConnectorKind guards correctly', () => {
|
||||
expect(isKnownConnectorKind('matrix')).toBe(true);
|
||||
expect(isKnownConnectorKind('irc')).toBe(false);
|
||||
expect(isKnownConnectorKind(42)).toBe(false);
|
||||
});
|
||||
|
||||
it('resolveConnectorKind defaults to tmux when config is absent (back-compat)', () => {
|
||||
expect(resolveConnectorKind(undefined)).toBe('tmux');
|
||||
expect(resolveConnectorKind({ kind: 'matrix' })).toBe('matrix');
|
||||
});
|
||||
|
||||
it('createConnector throws ConnectorNotImplementedError for an unregistered kind', () => {
|
||||
const cfg: ConnectorConfig = { kind: 'matrix' };
|
||||
expect(() => createConnector(cfg)).toThrow(ConnectorNotImplementedError);
|
||||
expect(() => createConnector(cfg)).toThrow(/not implemented yet/i);
|
||||
});
|
||||
|
||||
it('createConnector with no config resolves the default kind (tmux) and reports it unimplemented in Phase 1', () => {
|
||||
try {
|
||||
createConnector();
|
||||
throw new Error('expected throw');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ConnectorNotImplementedError);
|
||||
expect((err as ConnectorNotImplementedError).kind).toBe('tmux');
|
||||
}
|
||||
});
|
||||
|
||||
it('register → has → create resolves a registered factory', () => {
|
||||
expect(hasConnector('matrix')).toBe(false);
|
||||
registerConnector('matrix', (cfg) => fakeConnector(cfg.kind));
|
||||
expect(hasConnector('matrix')).toBe(true);
|
||||
|
||||
const connector = createConnector({ kind: 'matrix' });
|
||||
expect(connector.kind).toBe('matrix');
|
||||
});
|
||||
|
||||
it('passes the config through to the factory', () => {
|
||||
let received: ConnectorConfig | null = null;
|
||||
registerConnector('matrix', (cfg) => {
|
||||
received = cfg;
|
||||
return fakeConnector(cfg.kind);
|
||||
});
|
||||
const cfg: ConnectorConfig = {
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserverUrl: 'https://matrix.internal',
|
||||
userId: '@mos:internal',
|
||||
roomId: '!room:internal',
|
||||
},
|
||||
};
|
||||
createConnector(cfg);
|
||||
expect(received).toEqual(cfg);
|
||||
});
|
||||
});
|
||||
76
packages/mosaic/src/fleet/connectors/registry.ts
Normal file
76
packages/mosaic/src/fleet/connectors/registry.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Connector registry (F4 Phase 1).
|
||||
*
|
||||
* A tiny extensible registry so connector implementations (Phase 2: tmux,
|
||||
* Discord, Matrix) register a factory by kind and fleet core resolves one from
|
||||
* roster config without branching on kind. Phase 1 ships the registry + the
|
||||
* config→kind resolution; the connector factories land in Phase 2.
|
||||
*/
|
||||
|
||||
import {
|
||||
type ConnectorConfig,
|
||||
type ConnectorKind,
|
||||
type OrchestratorConnector,
|
||||
DEFAULT_CONNECTOR_KIND,
|
||||
} from './types.js';
|
||||
|
||||
/** The set of connector kinds the framework recognizes. */
|
||||
export const KNOWN_CONNECTOR_KINDS: readonly ConnectorKind[] = ['tmux', 'discord', 'matrix'];
|
||||
|
||||
/** Type guard: is `value` a known connector kind? */
|
||||
export function isKnownConnectorKind(value: unknown): value is ConnectorKind {
|
||||
return typeof value === 'string' && (KNOWN_CONNECTOR_KINDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the connector kind from roster config. Absent config ⇒ the default
|
||||
* (tmux) so existing rosters keep working unchanged (back-compat).
|
||||
*/
|
||||
export function resolveConnectorKind(config?: ConnectorConfig): ConnectorKind {
|
||||
return config?.kind ?? DEFAULT_CONNECTOR_KIND;
|
||||
}
|
||||
|
||||
/** A factory builds a live connector from its validated config. */
|
||||
export type ConnectorFactory = (config: ConnectorConfig) => OrchestratorConnector;
|
||||
|
||||
/** Thrown when no factory is registered for a requested kind. */
|
||||
export class ConnectorNotImplementedError extends Error {
|
||||
constructor(public readonly kind: ConnectorKind) {
|
||||
super(
|
||||
`Connector "${kind}" is not implemented yet. ` +
|
||||
`Register a factory via registerConnector('${kind}', …) (F4 Phase 2).`,
|
||||
);
|
||||
this.name = 'ConnectorNotImplementedError';
|
||||
}
|
||||
}
|
||||
|
||||
const registry = new Map<ConnectorKind, ConnectorFactory>();
|
||||
|
||||
/** Register a connector factory for a kind (idempotent — last registration wins). */
|
||||
export function registerConnector(kind: ConnectorKind, factory: ConnectorFactory): void {
|
||||
registry.set(kind, factory);
|
||||
}
|
||||
|
||||
/** True when a factory is registered for `kind`. */
|
||||
export function hasConnector(kind: ConnectorKind): boolean {
|
||||
return registry.has(kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a connector from roster config. Throws `ConnectorNotImplementedError`
|
||||
* when no factory is registered for the resolved kind (the Phase-1 default for
|
||||
* every kind until Phase 2 registers them).
|
||||
*/
|
||||
export function createConnector(config?: ConnectorConfig): OrchestratorConnector {
|
||||
const kind = resolveConnectorKind(config);
|
||||
const factory = registry.get(kind);
|
||||
if (!factory) {
|
||||
throw new ConnectorNotImplementedError(kind);
|
||||
}
|
||||
return factory(config ?? { kind });
|
||||
}
|
||||
|
||||
/** Test/runtime helper: drop all registrations. */
|
||||
export function _resetConnectorRegistry(): void {
|
||||
registry.clear();
|
||||
}
|
||||
111
packages/mosaic/src/fleet/connectors/types.ts
Normal file
111
packages/mosaic/src/fleet/connectors/types.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Orchestrator chat connectors (F4).
|
||||
*
|
||||
* A connector mediates the chat channel between the fleet **orchestrator** and
|
||||
* its human operator. Connectors are PEERS — tmux (default), Discord, Matrix,
|
||||
* and future first-party plugins — selected per fleet, never hardwired. Fleet
|
||||
* core depends only on the small uniform interface below, so a new connector
|
||||
* drops in without touching the fleet.
|
||||
*
|
||||
* The interface is deliberately minimal: send (orchestrator → human),
|
||||
* subscribe (human → orchestrator), health (reachable/authed liveness). Thread
|
||||
* support is optional metadata (`threadId`) so thread-capable connectors
|
||||
* (Matrix rooms/threads, the future Mosaic Discord plugin) fit without an
|
||||
* interface change.
|
||||
*/
|
||||
|
||||
/** The connector kinds shipped/known to the framework. */
|
||||
export type ConnectorKind = 'tmux' | 'discord' | 'matrix';
|
||||
|
||||
/** A message the orchestrator sends out to the human operator. */
|
||||
export interface OutboundMessage {
|
||||
/** Message body (markdown where the connector supports it). */
|
||||
text: string;
|
||||
/** Optional thread/topic id for thread-capable connectors. */
|
||||
threadId?: string;
|
||||
/** Optional attachment references (paths or URLs); connector-dependent. */
|
||||
attachments?: string[];
|
||||
}
|
||||
|
||||
/** A message received from the human operator. */
|
||||
export interface InboundMessage {
|
||||
/** Message body. */
|
||||
text: string;
|
||||
/** Thread/topic id if the connector carries one. */
|
||||
threadId?: string;
|
||||
/** Opaque sender identifier (connector-scoped). */
|
||||
sender: string;
|
||||
/** ISO-8601 timestamp the connector assigns/observes. */
|
||||
ts: string;
|
||||
}
|
||||
|
||||
/** Result of a send — the "ack" half of ack/health. */
|
||||
export interface SendResult {
|
||||
/** True when the connector accepted/delivered the message. */
|
||||
delivered: boolean;
|
||||
/** Connector-assigned message id when available. */
|
||||
messageId?: string;
|
||||
/** Reason when `delivered` is false. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Liveness of a connector — the "health" half of ack/health. */
|
||||
export interface ConnectorHealth {
|
||||
/** The transport endpoint is reachable. */
|
||||
reachable: boolean;
|
||||
/** Credentials are valid / the connector is authenticated. */
|
||||
authenticated: boolean;
|
||||
/** ISO-8601 of the last successful interaction, if any. */
|
||||
lastSeen?: string;
|
||||
/** Human-readable detail (e.g. failure reason). */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Unsubscribe handle returned by `subscribe`. */
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
/**
|
||||
* The uniform contract every orchestrator chat connector implements. Small by
|
||||
* design — send / subscribe / health — so connectors are interchangeable and
|
||||
* fleet core never branches on connector kind.
|
||||
*/
|
||||
export interface OrchestratorConnector {
|
||||
/** Which kind of connector this is. */
|
||||
readonly kind: ConnectorKind;
|
||||
/** Send a message from the orchestrator to the operator. */
|
||||
send(message: OutboundMessage): Promise<SendResult>;
|
||||
/** Subscribe to inbound operator messages; returns an unsubscribe handle. */
|
||||
subscribe(handler: (message: InboundMessage) => void): Unsubscribe;
|
||||
/** Report connector liveness (reachable + authenticated). */
|
||||
health(): Promise<ConnectorHealth>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connector configuration carried by the roster (the `connector` block).
|
||||
* Secrets (access tokens, bot tokens) are NEVER stored here — they come from
|
||||
* the environment (the gateway env-config pattern). Absent config ⇒ tmux.
|
||||
*/
|
||||
export interface ConnectorConfig {
|
||||
kind: ConnectorKind;
|
||||
/** Matrix connector settings (homeserver + room); token via env. */
|
||||
matrix?: MatrixConnectorConfig;
|
||||
/** Discord connector settings (channel); token via env. */
|
||||
discord?: DiscordConnectorConfig;
|
||||
}
|
||||
|
||||
export interface MatrixConnectorConfig {
|
||||
/** Local homeserver base URL, e.g. https://matrix.example.internal */
|
||||
homeserverUrl: string;
|
||||
/** Full Matrix user id of the orchestrator, e.g. @mos:example.internal */
|
||||
userId: string;
|
||||
/** Room id/alias the orchestrator converses in. */
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
export interface DiscordConnectorConfig {
|
||||
/** Channel id the orchestrator converses in. */
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
/** The default connector when a roster declares none (back-compat). */
|
||||
export const DEFAULT_CONNECTOR_KIND: ConnectorKind = 'tmux';
|
||||
Reference in New Issue
Block a user