Compare commits
1 Commits
feat/f4-ma
...
feat/f3-m3
| Author | SHA1 | Date | |
|---|---|---|---|
| dcb7477007 |
@@ -58,7 +58,3 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
|||||||
## F3-m3 — mosaic update re-seeds framework + relaunches agents (#609) — feat/f3-m3-update-reseed
|
## F3-m3 — mosaic update re-seeds framework + relaunches agents (#609) — feat/f3-m3-update-reseed
|
||||||
|
|
||||||
- Status: implemented + tested. Closes R13: `mosaic update` now re-seeds the framework (data-safe MOSAIC_SYNC_ONLY) after the CLI install so shipped launcher/runtime changes activate; `--relaunch` restarts rostered agents; `--no-reseed` opts out. Detail: scratchpads/f3-m3-update-reseed.md.
|
- Status: implemented + tested. Closes R13: `mosaic update` now re-seeds the framework (data-safe MOSAIC_SYNC_ONLY) after the CLI install so shipped launcher/runtime changes activate; `--relaunch` restarts rostered agents; `--no-reseed` opts out. Detail: scratchpads/f3-m3-update-reseed.md.
|
||||||
|
|
||||||
## F4 — Orchestrator chat connector + Matrix (#616) — feat/f4-matrix-connector
|
|
||||||
|
|
||||||
- Status: Phase 1 done (abstraction + scaffold). Connector interface (send/subscribe/health) + registry + roster connector schema + design doc; tmux default/back-compat; matrix factory + CS-API client landed (Phase 2a, #617-stacked); 20 connector tests green; no fleet.ts changes (independent of #615). Detail: scratchpads/f4-matrix-connector.md.
|
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
# F4 — Orchestrator chat connector + Matrix (local homeserver)
|
|
||||||
|
|
||||||
> **Issue:** #616 · **Doctrine:** `docs/fleet/north-star.md` (#613) — orchestrator-chat-connector decision.
|
|
||||||
> **Status:** Phase 1 (abstraction + scaffold) in this PR; Phase 2+ are follow-ups (below).
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
The fleet **orchestrator** is the operator's single point of contact. The north-star makes the
|
|
||||||
chat channel a **user-chosen connector** — tmux today, Discord live ("Mos"), with Matrix /
|
|
||||||
Telegram / Slack configurable. F4 adds **Matrix** (local homeserver) as a **peer** connector and,
|
|
||||||
first, the small **connector abstraction** that makes connectors pluggable without touching fleet
|
|
||||||
core.
|
|
||||||
|
|
||||||
## The abstraction (Phase 1 — this PR)
|
|
||||||
|
|
||||||
Connectors implement one small, uniform interface (`src/fleet/connectors/types.ts`):
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface OrchestratorConnector {
|
|
||||||
readonly kind: 'tmux' | 'discord' | 'matrix';
|
|
||||||
send(message: OutboundMessage): Promise<SendResult>; // orchestrator → human
|
|
||||||
subscribe(handler: (m: InboundMessage) => void): Unsubscribe; // human → orchestrator
|
|
||||||
health(): Promise<ConnectorHealth>; // reachable + authenticated
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **send / subscribe / health** — the only surface fleet core depends on. `SendResult` is the
|
|
||||||
ack half; `health()` is the liveness half.
|
|
||||||
- **Thread-aware by metadata** — `OutboundMessage.threadId` / `InboundMessage.threadId` are
|
|
||||||
optional, so thread-capable connectors (Matrix rooms/threads, the future first-party Mosaic
|
|
||||||
Discord plugin) fit **without an interface change**.
|
|
||||||
- **Registry** (`registry.ts`) — implementations register a factory by kind; `createConnector(config)`
|
|
||||||
resolves one from roster config. Phase 1 ships the registry + `resolveConnectorKind` (defaults
|
|
||||||
`tmux` when a roster declares no connector — **back-compat**); the factories land in Phase 2.
|
|
||||||
|
|
||||||
### Config model
|
|
||||||
|
|
||||||
A roster may carry an optional `connector` block (`roster.schema.json`); absent ⇒ tmux.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
connector:
|
|
||||||
kind: matrix # tmux | discord | matrix
|
|
||||||
matrix:
|
|
||||||
homeserver_url: https://matrix.example.internal
|
|
||||||
user_id: '@mos:example.internal'
|
|
||||||
room_id: '!abc:example.internal'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Secrets are never in the roster.** `MATRIX_ACCESS_TOKEN` / `DISCORD_BOT_TOKEN` come from the
|
|
||||||
environment (the gateway env-config pattern that already masks them). The sanitization gate would
|
|
||||||
reject a token committed to a shipped file anyway.
|
|
||||||
|
|
||||||
## Matrix connector (Phase 2)
|
|
||||||
|
|
||||||
The connector speaks the **Matrix client-server API** directly over HTTPS (`fetch` — no SDK needed
|
|
||||||
for MVP), so it is **homeserver-agnostic**:
|
|
||||||
|
|
||||||
| Op | Matrix CS-API |
|
|
||||||
| ----------- | ------------------------------------------------------------------------ |
|
|
||||||
| `send` | `PUT /_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId}` |
|
|
||||||
| `subscribe` | `GET /_matrix/client/v3/sync` (long-poll, `since` token) → room timeline |
|
|
||||||
| `health` | `GET /_matrix/client/versions` (reachable) + `…/account/whoami` (authed) |
|
|
||||||
| threads | `m.thread` relations ↔ `threadId` |
|
|
||||||
|
|
||||||
## Local homeserver (infra, not connector code)
|
|
||||||
|
|
||||||
Strategic default: a **self-hosted** homeserver on our own infra — no third-party gateway.
|
|
||||||
|
|
||||||
- **Default: Conduit** (Rust, single binary, low resource) — trivial to stand up for a fleet/dev
|
|
||||||
homeserver.
|
|
||||||
- **Alternative: Synapse** (mature, feature-complete) for scale.
|
|
||||||
|
|
||||||
The connector only needs `homeserver_url` + `user_id` + `room_id` + an access token, so the
|
|
||||||
homeserver choice is a **deployment** concern (a Phase-2 deploy guide), not connector code.
|
|
||||||
|
|
||||||
## Phasing
|
|
||||||
|
|
||||||
| Phase | Scope | This PR |
|
|
||||||
| ----- | --------------------------------------------------------------------------------------- | ------- |
|
|
||||||
| **1** | Connector interface + types, registry + kind resolution, roster `connector` schema, doc | ✅ yes |
|
|
||||||
| 2 | Matrix CS-API client (fetch-based send/sync/health) + registered factory + tests | follow |
|
|
||||||
| 2 | `fleet init` / `configure` connector-selection UX; roster parse wires the block | follow |
|
|
||||||
| 2 | systemd launch wiring so the orchestrator starts on the chosen connector | follow |
|
|
||||||
| 3 | Conduit deploy guide; first-party Mosaic Discord (threads) registers as a connector | follow |
|
|
||||||
|
|
||||||
## Back-compat & boundaries
|
|
||||||
|
|
||||||
- Existing rosters (no `connector`) resolve to tmux — **zero change**.
|
|
||||||
- Fleet core never branches on connector kind; it depends only on the interface.
|
|
||||||
- Cross-host reach rides the **federation** layer (W1), not a bespoke broker (north-star assumption).
|
|
||||||
- Phase 1 touches **no** `fleet.ts` core (a self-contained `connectors/` module), so it is
|
|
||||||
independent of the in-flight fleet-config PRs.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# F4 — Orchestrator chat connector + Matrix (#616)
|
|
||||||
|
|
||||||
- **Issue:** #616 · **Branch:** `feat/f4-matrix-connector` (off main; independent of #615) · **Doctrine:** north-star #613.
|
|
||||||
|
|
||||||
## Phase 1 (this PR) — abstraction + scaffold
|
|
||||||
|
|
||||||
- `src/fleet/connectors/types.ts`: `OrchestratorConnector` (send/subscribe/health) + message/config types; thread-aware via optional `threadId`; `DEFAULT_CONNECTOR_KIND=tmux`.
|
|
||||||
- `src/fleet/connectors/registry.ts`: extensible factory registry; `resolveConnectorKind` (defaults tmux, back-compat); `createConnector` throws `ConnectorNotImplementedError` until Phase 2 registers factories.
|
|
||||||
- `roster.schema.json`: optional `connector` block (tmux|discord|matrix; matrix homeserver/user/room; secrets via env, never roster).
|
|
||||||
- Design doc `docs/fleet/f4-matrix-connector.md`: interface, config, Matrix CS-API mapping, Conduit-default infra, phasing.
|
|
||||||
- **No fleet.ts changes** → self-contained, zero conflict with stacked #615.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- 7 connector tests green; tsc/eslint/prettier/sanitize clean; schema valid JSON.
|
|
||||||
|
|
||||||
## Phase 2+ (follow-ups, in the doc)
|
|
||||||
|
|
||||||
Matrix CS-API client (fetch send/sync/health) + factory; init/configure connector-selection UX + roster-parse wiring; systemd launch wiring; Conduit deploy guide; first-party Mosaic Discord (threads) as a connector.
|
|
||||||
|
|
||||||
## Phase 2a (feat/f4-matrix-client, stacked on #617) — Matrix CS-API client
|
|
||||||
|
|
||||||
- `src/fleet/connectors/matrix.ts`: `MatrixConnector implements OrchestratorConnector` over the Matrix
|
|
||||||
client-server API (injectable fetch, no SDK). `send` → PUT m.room.message (thread-aware); `subscribe`
|
|
||||||
→ /sync long-poll loop using the pure `parseSyncResponse`; `health` → /versions + /whoami.
|
|
||||||
`registerMatrixConnector(env)` registers the factory (token from MATRIX_ACCESS_TOKEN, never roster).
|
|
||||||
- Pure helpers `buildMessageBody` + `parseSyncResponse` make send/receive unit-testable.
|
|
||||||
- 13 Matrix tests + 7 registry = 20 connector tests green; tsc/eslint/prettier clean.
|
|
||||||
- Remaining Phase 2: init/configure connector-selection UX + roster-parse wiring (touches fleet.ts —
|
|
||||||
after #615); systemd launch wiring; Conduit deploy guide.
|
|
||||||
@@ -113,35 +113,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"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" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
|
||||||
import {
|
|
||||||
MatrixConnector,
|
|
||||||
buildMessageBody,
|
|
||||||
parseSyncResponse,
|
|
||||||
registerMatrixConnector,
|
|
||||||
type FetchLike,
|
|
||||||
} from './matrix.js';
|
|
||||||
import { createConnector, _resetConnectorRegistry } from './registry.js';
|
|
||||||
import type { MatrixConnectorConfig } from './types.js';
|
|
||||||
|
|
||||||
const CONFIG: MatrixConnectorConfig = {
|
|
||||||
homeserverUrl: 'https://matrix.internal/',
|
|
||||||
userId: '@mos:internal',
|
|
||||||
roomId: '!room:internal',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** A fetch mock that returns queued responses and records calls. */
|
|
||||||
function mockFetch(responses: Array<{ ok?: boolean; status?: number; body?: unknown }>): {
|
|
||||||
fetchImpl: FetchLike;
|
|
||||||
calls: Array<{ url: string; method?: string; body?: string }>;
|
|
||||||
} {
|
|
||||||
const calls: Array<{ url: string; method?: string; body?: string }> = [];
|
|
||||||
let i = 0;
|
|
||||||
const fetchImpl: FetchLike = async (url, init) => {
|
|
||||||
calls.push({ url, method: init?.method, body: init?.body });
|
|
||||||
const r = responses[Math.min(i, responses.length - 1)] ?? {};
|
|
||||||
i += 1;
|
|
||||||
return {
|
|
||||||
ok: r.ok ?? true,
|
|
||||||
status: r.status ?? 200,
|
|
||||||
json: async () => r.body ?? {},
|
|
||||||
text: async () => JSON.stringify(r.body ?? {}),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
return { fetchImpl, calls };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('buildMessageBody', () => {
|
|
||||||
it('builds an m.text event', () => {
|
|
||||||
expect(buildMessageBody({ text: 'hi' })).toEqual({ msgtype: 'm.text', body: 'hi' });
|
|
||||||
});
|
|
||||||
it('adds an m.thread relation when threadId is set', () => {
|
|
||||||
expect(buildMessageBody({ text: 'hi', threadId: '$evt' })).toEqual({
|
|
||||||
msgtype: 'm.text',
|
|
||||||
body: 'hi',
|
|
||||||
'm.relates_to': { rel_type: 'm.thread', event_id: '$evt' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseSyncResponse', () => {
|
|
||||||
it('extracts operator messages and skips the orchestrator’s own echoes', () => {
|
|
||||||
const data = {
|
|
||||||
next_batch: 's2',
|
|
||||||
rooms: {
|
|
||||||
join: {
|
|
||||||
'!room:internal': {
|
|
||||||
timeline: {
|
|
||||||
events: [
|
|
||||||
{
|
|
||||||
type: 'm.room.message',
|
|
||||||
sender: '@jason:internal',
|
|
||||||
origin_server_ts: 1_700_000_000_000,
|
|
||||||
content: { body: 'status?' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'm.room.message',
|
|
||||||
sender: '@mos:internal', // self — skipped
|
|
||||||
origin_server_ts: 1_700_000_001_000,
|
|
||||||
content: { body: 'working on it' },
|
|
||||||
},
|
|
||||||
{ type: 'm.reaction', sender: '@jason:internal', content: {} }, // non-message
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const msgs = parseSyncResponse(data, '!room:internal', '@mos:internal');
|
|
||||||
expect(msgs).toHaveLength(1);
|
|
||||||
expect(msgs[0]).toMatchObject({ text: 'status?', sender: '@jason:internal' });
|
|
||||||
expect(msgs[0]!.ts).toBe(new Date(1_700_000_000_000).toISOString());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('carries threadId through thread-relments', () => {
|
|
||||||
const data = {
|
|
||||||
rooms: {
|
|
||||||
join: {
|
|
||||||
'!room:internal': {
|
|
||||||
timeline: {
|
|
||||||
events: [
|
|
||||||
{
|
|
||||||
type: 'm.room.message',
|
|
||||||
sender: '@jason:internal',
|
|
||||||
origin_server_ts: 1,
|
|
||||||
content: {
|
|
||||||
body: 'in thread',
|
|
||||||
'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
expect(parseSyncResponse(data, '!room:internal', '@mos:internal')[0]!.threadId).toBe('$root');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns [] for an empty/foreign sync', () => {
|
|
||||||
expect(parseSyncResponse({}, '!room:internal', '@mos:internal')).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('MatrixConnector', () => {
|
|
||||||
it('throws without an access token', () => {
|
|
||||||
expect(() => new MatrixConnector(CONFIG, { accessToken: '' })).toThrow(/access token/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('send PUTs an m.text event and returns the event id', async () => {
|
|
||||||
const { fetchImpl, calls } = mockFetch([{ body: { event_id: '$abc' } }]);
|
|
||||||
const c = new MatrixConnector(CONFIG, { accessToken: 'tok', fetchImpl });
|
|
||||||
const res = await c.send({ text: 'pong' }, 1234);
|
|
||||||
expect(res).toEqual({ delivered: true, messageId: '$abc' });
|
|
||||||
expect(calls[0]!.method).toBe('PUT');
|
|
||||||
expect(calls[0]!.url).toContain(
|
|
||||||
'/_matrix/client/v3/rooms/!room%3Ainternal/send/m.room.message/mosaic-1234-1',
|
|
||||||
);
|
|
||||||
expect(JSON.parse(calls[0]!.body!)).toEqual({ msgtype: 'm.text', body: 'pong' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('send reports not-delivered on a non-2xx', async () => {
|
|
||||||
const { fetchImpl } = mockFetch([{ ok: false, status: 403 }]);
|
|
||||||
const c = new MatrixConnector(CONFIG, { accessToken: 'tok', fetchImpl });
|
|
||||||
const res = await c.send({ text: 'x' });
|
|
||||||
expect(res.delivered).toBe(false);
|
|
||||||
expect(res.error).toContain('403');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('health reports reachable + authenticated when whoami matches', async () => {
|
|
||||||
const { fetchImpl } = mockFetch([
|
|
||||||
{ body: { versions: ['v1.11'] } }, // /versions
|
|
||||||
{ body: { user_id: '@mos:internal' } }, // /whoami
|
|
||||||
]);
|
|
||||||
const c = new MatrixConnector(CONFIG, { accessToken: 'tok', fetchImpl });
|
|
||||||
const h = await c.health();
|
|
||||||
expect(h.reachable).toBe(true);
|
|
||||||
expect(h.authenticated).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('health flags auth mismatch', async () => {
|
|
||||||
const { fetchImpl } = mockFetch([
|
|
||||||
{ body: {} },
|
|
||||||
{ body: { user_id: '@someone-else:internal' } },
|
|
||||||
]);
|
|
||||||
const c = new MatrixConnector(CONFIG, { accessToken: 'tok', fetchImpl });
|
|
||||||
const h = await c.health();
|
|
||||||
expect(h.reachable).toBe(true);
|
|
||||||
expect(h.authenticated).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('health reports unreachable when /versions fails', async () => {
|
|
||||||
const { fetchImpl } = mockFetch([{ ok: false, status: 502 }]);
|
|
||||||
const c = new MatrixConnector(CONFIG, { accessToken: 'tok', fetchImpl });
|
|
||||||
const h = await c.health();
|
|
||||||
expect(h.reachable).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('registerMatrixConnector', () => {
|
|
||||||
beforeEach(() => _resetConnectorRegistry());
|
|
||||||
|
|
||||||
it('registers a matrix factory createConnector can build', () => {
|
|
||||||
registerMatrixConnector({ MATRIX_ACCESS_TOKEN: 'tok' } as NodeJS.ProcessEnv);
|
|
||||||
const c = createConnector({ kind: 'matrix', matrix: CONFIG });
|
|
||||||
expect(c.kind).toBe('matrix');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('the factory rejects config missing the matrix block', () => {
|
|
||||||
registerMatrixConnector({ MATRIX_ACCESS_TOKEN: 'tok' } as NodeJS.ProcessEnv);
|
|
||||||
expect(() => createConnector({ kind: 'matrix' })).toThrow(/missing the .matrix. block/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
/**
|
|
||||||
* Matrix connector (F4 Phase 2) — speaks the Matrix client-server API directly
|
|
||||||
* over HTTPS so it is homeserver-agnostic (Conduit default, Synapse alt). No
|
|
||||||
* SDK: a small injectable fetch keeps it dependency-light and unit-testable.
|
|
||||||
*
|
|
||||||
* The access token is supplied by the caller (from the environment —
|
|
||||||
* MATRIX_ACCESS_TOKEN — per the gateway secret pattern), never the roster.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
type OrchestratorConnector,
|
|
||||||
type OutboundMessage,
|
|
||||||
type InboundMessage,
|
|
||||||
type SendResult,
|
|
||||||
type ConnectorHealth,
|
|
||||||
type MatrixConnectorConfig,
|
|
||||||
type Unsubscribe,
|
|
||||||
} from './types.js';
|
|
||||||
import { registerConnector } from './registry.js';
|
|
||||||
|
|
||||||
/** Minimal fetch surface — avoids a lib.dom dependency and is trivial to mock. */
|
|
||||||
export interface FetchLike {
|
|
||||||
(
|
|
||||||
url: string,
|
|
||||||
init?: { method?: string; headers?: Record<string, string>; body?: string },
|
|
||||||
): Promise<{
|
|
||||||
ok: boolean;
|
|
||||||
status: number;
|
|
||||||
json: () => Promise<unknown>;
|
|
||||||
text: () => Promise<string>;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MatrixConnectorOptions {
|
|
||||||
accessToken: string;
|
|
||||||
/** Injectable fetch (defaults to global fetch). */
|
|
||||||
fetchImpl?: FetchLike;
|
|
||||||
/** Long-poll timeout for /sync, ms. */
|
|
||||||
syncTimeoutMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build the `m.room.message` event content, threading when a threadId is set. */
|
|
||||||
export function buildMessageBody(message: OutboundMessage): Record<string, unknown> {
|
|
||||||
const content: Record<string, unknown> = {
|
|
||||||
msgtype: 'm.text',
|
|
||||||
body: message.text,
|
|
||||||
};
|
|
||||||
if (message.threadId) {
|
|
||||||
content['m.relates_to'] = { rel_type: 'm.thread', event_id: message.threadId };
|
|
||||||
}
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shape of the bits of a /sync response we consume. */
|
|
||||||
interface SyncResponse {
|
|
||||||
next_batch?: string;
|
|
||||||
rooms?: {
|
|
||||||
join?: Record<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
timeline?: {
|
|
||||||
events?: Array<{
|
|
||||||
type?: string;
|
|
||||||
sender?: string;
|
|
||||||
origin_server_ts?: number;
|
|
||||||
content?: {
|
|
||||||
body?: string;
|
|
||||||
['m.relates_to']?: { rel_type?: string; event_id?: string };
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract inbound operator messages from a /sync response for one room,
|
|
||||||
* skipping the orchestrator's own echoes. Pure — the testable core of receive.
|
|
||||||
*/
|
|
||||||
export function parseSyncResponse(
|
|
||||||
data: unknown,
|
|
||||||
roomId: string,
|
|
||||||
selfUserId: string,
|
|
||||||
): InboundMessage[] {
|
|
||||||
const sync = data as SyncResponse;
|
|
||||||
const events = sync.rooms?.join?.[roomId]?.timeline?.events ?? [];
|
|
||||||
const out: InboundMessage[] = [];
|
|
||||||
for (const ev of events) {
|
|
||||||
if (ev.type !== 'm.room.message') continue;
|
|
||||||
if (!ev.sender || ev.sender === selfUserId) continue; // skip our own messages
|
|
||||||
const body = ev.content?.body;
|
|
||||||
if (typeof body !== 'string') continue;
|
|
||||||
const rel = ev.content?.['m.relates_to'];
|
|
||||||
out.push({
|
|
||||||
text: body,
|
|
||||||
sender: ev.sender,
|
|
||||||
ts: new Date(ev.origin_server_ts ?? 0).toISOString(),
|
|
||||||
...(rel?.rel_type === 'm.thread' && rel.event_id ? { threadId: rel.event_id } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MatrixConnector implements OrchestratorConnector {
|
|
||||||
readonly kind = 'matrix' as const;
|
|
||||||
private readonly fetchImpl: FetchLike;
|
|
||||||
private readonly token: string;
|
|
||||||
private readonly syncTimeoutMs: number;
|
|
||||||
private txnCounter = 0;
|
|
||||||
private stopped = false;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly config: MatrixConnectorConfig,
|
|
||||||
opts: MatrixConnectorOptions,
|
|
||||||
) {
|
|
||||||
this.token = opts.accessToken;
|
|
||||||
this.fetchImpl = opts.fetchImpl ?? (globalThis.fetch as unknown as FetchLike);
|
|
||||||
this.syncTimeoutMs = opts.syncTimeoutMs ?? 30_000;
|
|
||||||
if (!this.token) {
|
|
||||||
throw new Error('MatrixConnector requires an access token (set MATRIX_ACCESS_TOKEN).');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private url(path: string): string {
|
|
||||||
return `${this.config.homeserverUrl.replace(/\/$/, '')}${path}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private authHeaders(): Record<string, string> {
|
|
||||||
return { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json' };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Monotonic, unique-per-instance transaction id for idempotent sends. */
|
|
||||||
private nextTxnId(nowMs: number): string {
|
|
||||||
this.txnCounter += 1;
|
|
||||||
return `mosaic-${nowMs}-${this.txnCounter}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async send(message: OutboundMessage, nowMs = Date.now()): Promise<SendResult> {
|
|
||||||
const txnId = this.nextTxnId(nowMs);
|
|
||||||
const path = `/_matrix/client/v3/rooms/${encodeURIComponent(
|
|
||||||
this.config.roomId,
|
|
||||||
)}/send/m.room.message/${encodeURIComponent(txnId)}`;
|
|
||||||
try {
|
|
||||||
const res = await this.fetchImpl(this.url(path), {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: this.authHeaders(),
|
|
||||||
body: JSON.stringify(buildMessageBody(message)),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
return { delivered: false, error: `Matrix send failed: HTTP ${res.status}` };
|
|
||||||
}
|
|
||||||
const json = (await res.json()) as { event_id?: string };
|
|
||||||
return { delivered: true, ...(json.event_id ? { messageId: json.event_id } : {}) };
|
|
||||||
} catch (err) {
|
|
||||||
return { delivered: false, error: err instanceof Error ? err.message : String(err) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribe(handler: (message: InboundMessage) => void): Unsubscribe {
|
|
||||||
this.stopped = false;
|
|
||||||
let since: string | undefined;
|
|
||||||
const loop = async (): Promise<void> => {
|
|
||||||
while (!this.stopped) {
|
|
||||||
try {
|
|
||||||
const q = new URLSearchParams({ timeout: String(this.syncTimeoutMs) });
|
|
||||||
if (since) q.set('since', since);
|
|
||||||
const res = await this.fetchImpl(this.url(`/_matrix/client/v3/sync?${q.toString()}`), {
|
|
||||||
method: 'GET',
|
|
||||||
headers: this.authHeaders(),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
await this.backoff();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
since = (data as SyncResponse).next_batch ?? since;
|
|
||||||
for (const msg of parseSyncResponse(data, this.config.roomId, this.config.userId)) {
|
|
||||||
handler(msg);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
await this.backoff();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
void loop();
|
|
||||||
return () => {
|
|
||||||
this.stopped = true;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private backoff(): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, 2_000));
|
|
||||||
}
|
|
||||||
|
|
||||||
async health(): Promise<ConnectorHealth> {
|
|
||||||
try {
|
|
||||||
const versions = await this.fetchImpl(this.url('/_matrix/client/versions'), {
|
|
||||||
method: 'GET',
|
|
||||||
});
|
|
||||||
if (!versions.ok) {
|
|
||||||
return {
|
|
||||||
reachable: false,
|
|
||||||
authenticated: false,
|
|
||||||
detail: `versions HTTP ${versions.status}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const who = await this.fetchImpl(this.url('/_matrix/client/v3/account/whoami'), {
|
|
||||||
method: 'GET',
|
|
||||||
headers: this.authHeaders(),
|
|
||||||
});
|
|
||||||
if (!who.ok) {
|
|
||||||
return { reachable: true, authenticated: false, detail: `whoami HTTP ${who.status}` };
|
|
||||||
}
|
|
||||||
const json = (await who.json()) as { user_id?: string };
|
|
||||||
const authenticated = json.user_id === this.config.userId;
|
|
||||||
return {
|
|
||||||
reachable: true,
|
|
||||||
authenticated,
|
|
||||||
lastSeen: new Date().toISOString(),
|
|
||||||
...(authenticated
|
|
||||||
? {}
|
|
||||||
: { detail: `whoami user ${json.user_id} != ${this.config.userId}` }),
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
reachable: false,
|
|
||||||
authenticated: false,
|
|
||||||
detail: err instanceof Error ? err.message : String(err),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register the Matrix connector factory. The token is read from the environment
|
|
||||||
* (MATRIX_ACCESS_TOKEN) at build time, never the roster.
|
|
||||||
*/
|
|
||||||
export function registerMatrixConnector(env: NodeJS.ProcessEnv = process.env): void {
|
|
||||||
registerConnector('matrix', (config) => {
|
|
||||||
if (!config.matrix) {
|
|
||||||
throw new Error('Matrix connector config missing the `matrix` block (homeserver/user/room).');
|
|
||||||
}
|
|
||||||
return new MatrixConnector(config.matrix, { accessToken: env['MATRIX_ACCESS_TOKEN'] ?? '' });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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();
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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