@pellux/goodvibes-tui 0.24.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +2419 -1040
  4. package/package.json +2 -2
  5. package/src/daemon/handlers/calendar/caldav-client.ts +661 -0
  6. package/src/daemon/handlers/calendar/ics.ts +556 -0
  7. package/src/daemon/handlers/calendar/index.ts +316 -0
  8. package/src/daemon/handlers/context.ts +29 -0
  9. package/src/daemon/handlers/contracts.ts +77 -0
  10. package/src/daemon/handlers/credentials.ts +129 -0
  11. package/src/daemon/handlers/drafts/draft-store.ts +427 -0
  12. package/src/daemon/handlers/drafts/index.ts +17 -0
  13. package/src/daemon/handlers/drafts/register.ts +331 -0
  14. package/src/daemon/handlers/email/config.ts +164 -0
  15. package/src/daemon/handlers/email/imap-connector.ts +441 -0
  16. package/src/daemon/handlers/email/imap-parsing.ts +499 -0
  17. package/src/daemon/handlers/email/index.ts +43 -0
  18. package/src/daemon/handlers/email/read-handlers.ts +80 -0
  19. package/src/daemon/handlers/email/runtime.ts +140 -0
  20. package/src/daemon/handlers/email/smtp-connector.ts +557 -0
  21. package/src/daemon/handlers/email/validation.ts +147 -0
  22. package/src/daemon/handlers/email/write-handlers.ts +133 -0
  23. package/src/daemon/handlers/errors.ts +18 -0
  24. package/src/daemon/handlers/inbox/cursor-store.ts +290 -0
  25. package/src/daemon/handlers/inbox/index.ts +210 -0
  26. package/src/daemon/handlers/inbox/mapping.ts +192 -0
  27. package/src/daemon/handlers/inbox/poller.ts +155 -0
  28. package/src/daemon/handlers/inbox/provider-adapter.ts +156 -0
  29. package/src/daemon/handlers/inbox/providers/discord.ts +251 -0
  30. package/src/daemon/handlers/inbox/providers/email.ts +151 -0
  31. package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
  32. package/src/daemon/handlers/inbox/providers/route-util.ts +23 -0
  33. package/src/daemon/handlers/inbox/providers/slack.ts +262 -0
  34. package/src/daemon/handlers/index.ts +107 -0
  35. package/src/daemon/handlers/register.ts +161 -0
  36. package/src/daemon/handlers/remote/backends/cloud-terminal.ts +142 -0
  37. package/src/daemon/handlers/remote/backends/docker.ts +79 -0
  38. package/src/daemon/handlers/remote/backends/index.ts +40 -0
  39. package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
  40. package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
  41. package/src/daemon/handlers/remote/backends/ssh.ts +125 -0
  42. package/src/daemon/handlers/remote/backends/types.ts +97 -0
  43. package/src/daemon/handlers/remote/dispatcher.ts +181 -0
  44. package/src/daemon/handlers/remote/index.ts +119 -0
  45. package/src/daemon/handlers/remote/peer-registry.ts +357 -0
  46. package/src/daemon/handlers/remote/service.ts +191 -0
  47. package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
  48. package/src/daemon/handlers/routing/index.ts +261 -0
  49. package/src/daemon/handlers/routing/route-store.ts +319 -0
  50. package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
  51. package/src/daemon/handlers/sqlite-store.ts +124 -0
  52. package/src/daemon/handlers/triage/index.ts +57 -0
  53. package/src/daemon/handlers/triage/integration.ts +212 -0
  54. package/src/daemon/handlers/triage/pipeline.ts +273 -0
  55. package/src/daemon/handlers/triage/scorer.ts +287 -0
  56. package/src/daemon/handlers/triage/tagger/discord.ts +186 -0
  57. package/src/daemon/handlers/triage/tagger/imap.ts +383 -0
  58. package/src/daemon/handlers/triage/tagger/index.ts +184 -0
  59. package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
  60. package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
  61. package/src/daemon/handlers/triage/types.ts +50 -0
  62. package/src/runtime/services.ts +48 -0
  63. package/src/version.ts +1 -1
@@ -0,0 +1,357 @@
1
+ import { HandlerSqliteStore } from '../sqlite-store.ts';
2
+ import {
3
+ isSecretReferenceValue,
4
+ isMalformedGoodVibesSecretReferenceValue,
5
+ } from '../../../config/secret-config.ts';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Backend vocabulary + per-backend config shapes
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export type BackendKind = 'docker' | 'ssh' | 'cloud-terminal' | 'local-process';
12
+
13
+ export type CloudProvider = 'gcp' | 'aws' | 'azure';
14
+
15
+ export interface DockerBackendConfig {
16
+ containerName: string;
17
+ /**
18
+ * Optional Docker host. When it points at a remote daemon over TLS this MUST
19
+ * be a goodvibes://secrets/ reference (never a raw URL with embedded creds).
20
+ * A bare local socket path (unix://...) or tcp host without credentials is
21
+ * also accepted.
22
+ */
23
+ dockerHost?: string;
24
+ }
25
+
26
+ export interface SshBackendConfig {
27
+ sshHost: string;
28
+ sshPort?: number;
29
+ sshUser: string;
30
+ /** goodvibes://secrets/ reference to the private key — never the raw key. */
31
+ identityRef: string;
32
+ }
33
+
34
+ export interface CloudTerminalBackendConfig {
35
+ provider: CloudProvider;
36
+ projectId?: string;
37
+ /** goodvibes://secrets/ reference to the provider credential. */
38
+ credentialRef: string;
39
+ /** Optional zone/region/location passed to the provider CLI. */
40
+ location?: string;
41
+ /** For gcp: the Cloud Shell / VM instance to target. */
42
+ instance?: string;
43
+ }
44
+
45
+ export interface LocalProcessBackendConfig {
46
+ /** Optional working directory for spawned processes. */
47
+ cwd?: string;
48
+ /** Optional allowlist of executables; when set, only these may be invoked. */
49
+ allowedCommands?: string[];
50
+ }
51
+
52
+ export type BackendConfig =
53
+ | ({ kind: 'docker' } & DockerBackendConfig)
54
+ | ({ kind: 'ssh' } & SshBackendConfig)
55
+ | ({ kind: 'cloud-terminal' } & CloudTerminalBackendConfig)
56
+ | ({ kind: 'local-process' } & LocalProcessBackendConfig);
57
+
58
+ export interface PeerRecord {
59
+ peerId: string;
60
+ displayName: string;
61
+ backendKind: BackendKind;
62
+ backendConfig: BackendConfig;
63
+ }
64
+
65
+ export interface PeerRegistrationInput {
66
+ peerId: string;
67
+ displayName: string;
68
+ backendKind: BackendKind;
69
+ // Raw, untrusted config from the operator method body. Normalized + validated.
70
+ backendConfig: Record<string, unknown>;
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Validation helpers — backendConfig must hold ONLY secret refs for any
75
+ // credential-bearing field. Raw secrets are rejected outright.
76
+ // ---------------------------------------------------------------------------
77
+
78
+ class PeerRegistryValidationError extends Error {
79
+ constructor(message: string) {
80
+ super(message);
81
+ this.name = 'PeerRegistryValidationError';
82
+ }
83
+ }
84
+
85
+ function requireString(value: unknown, field: string): string {
86
+ if (typeof value !== 'string' || value.trim().length === 0) {
87
+ throw new PeerRegistryValidationError(`Field '${field}' is required and must be a non-empty string.`);
88
+ }
89
+ return value.trim();
90
+ }
91
+
92
+ function optionalString(value: unknown, field: string): string | undefined {
93
+ if (value === undefined || value === null) return undefined;
94
+ if (typeof value !== 'string') {
95
+ throw new PeerRegistryValidationError(`Field '${field}' must be a string when provided.`);
96
+ }
97
+ const trimmed = value.trim();
98
+ return trimmed.length === 0 ? undefined : trimmed;
99
+ }
100
+
101
+ function requireSecretRef(value: unknown, field: string): string {
102
+ const ref = requireString(value, field);
103
+ if (!isSecretReferenceValue(ref)) {
104
+ throw new PeerRegistryValidationError(
105
+ `Field '${field}' must be a goodvibes://secrets/ reference, not a raw credential.`,
106
+ );
107
+ }
108
+ return ref;
109
+ }
110
+
111
+ function assertDockerHostSafe(value: string | undefined, field: string): void {
112
+ // A dockerHost is accepted in exactly two shapes, mirroring how docker.ts
113
+ // resolves it (docker.ts: `startsWith('goodvibes://') ? resolveRef(...) : raw`):
114
+ // 1. A goodvibes://secrets/ reference — resolved from the credential store.
115
+ // 2. A credential-free local/plain address (unix:// socket, or a bare
116
+ // tcp/host with no embedded userinfo) — used verbatim.
117
+ // Enforcement here must match that resolution so no credential-bearing or
118
+ // unresolvable value slips through to docker.ts.
119
+ if (value === undefined) return;
120
+
121
+ // A valid secret ref is always allowed: docker.ts resolves it from the store.
122
+ if (isSecretReferenceValue(value)) return;
123
+
124
+ // A `goodvibes://` value that is NOT a well-formed secret ref would be handed
125
+ // to credentials.resolveRef() and fail opaquely (REMOTE_BACKEND_CREDENTIAL_MISSING)
126
+ // — or, worse, a near-miss could be treated as a literal host. Reject it at
127
+ // registration so the misconfiguration surfaces immediately.
128
+ if (isMalformedGoodVibesSecretReferenceValue(value)) {
129
+ throw new PeerRegistryValidationError(
130
+ `Field '${field}' looks like a goodvibes:// reference but is malformed; use a valid goodvibes://secrets/ reference.`,
131
+ );
132
+ }
133
+
134
+ // Embedded userinfo credentials (e.g. tcp://user:pass@host) must never be
135
+ // stored raw — docker.ts would pass them verbatim as DOCKER_HOST.
136
+ if (value.includes('@')) {
137
+ throw new PeerRegistryValidationError(
138
+ `Field '${field}' appears to embed credentials; pass a goodvibes://secrets/ reference instead.`,
139
+ );
140
+ }
141
+
142
+ // A remote daemon reached over TLS carries its credentials out-of-band and
143
+ // MUST be referenced through the credential store, never pinned as a raw
144
+ // host string the daemon would use unauthenticated. docker.ts only treats a
145
+ // goodvibes:// value as a secret, so a raw `https://`/`tcp+tls://` endpoint
146
+ // here would bypass credential resolution entirely.
147
+ const lowered = value.toLowerCase();
148
+ if (lowered.startsWith('https://') || lowered.startsWith('tcp+tls://')) {
149
+ throw new PeerRegistryValidationError(
150
+ `Field '${field}' points at a TLS Docker daemon; pass a goodvibes://secrets/ reference instead of a raw URL.`,
151
+ );
152
+ }
153
+ }
154
+
155
+ /** Normalize + validate raw backendConfig into a typed, ref-only BackendConfig. */
156
+ export function normalizeBackendConfig(
157
+ backendKind: BackendKind,
158
+ raw: Record<string, unknown>,
159
+ ): BackendConfig {
160
+ switch (backendKind) {
161
+ case 'docker': {
162
+ const dockerHost = optionalString(raw.dockerHost, 'dockerHost');
163
+ assertDockerHostSafe(dockerHost, 'dockerHost');
164
+ return {
165
+ kind: 'docker',
166
+ containerName: requireString(raw.containerName, 'containerName'),
167
+ ...(dockerHost !== undefined ? { dockerHost } : {}),
168
+ };
169
+ }
170
+ case 'ssh': {
171
+ const portValue = raw.sshPort;
172
+ let sshPort: number | undefined;
173
+ if (portValue !== undefined && portValue !== null) {
174
+ const parsed = typeof portValue === 'number' ? portValue : Number(portValue);
175
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
176
+ throw new PeerRegistryValidationError("Field 'sshPort' must be an integer between 1 and 65535.");
177
+ }
178
+ sshPort = parsed;
179
+ }
180
+ return {
181
+ kind: 'ssh',
182
+ sshHost: requireString(raw.sshHost, 'sshHost'),
183
+ sshUser: requireString(raw.sshUser, 'sshUser'),
184
+ identityRef: requireSecretRef(raw.identityRef, 'identityRef'),
185
+ ...(sshPort !== undefined ? { sshPort } : {}),
186
+ };
187
+ }
188
+ case 'cloud-terminal': {
189
+ const provider = requireString(raw.provider, 'provider');
190
+ if (provider !== 'gcp' && provider !== 'aws' && provider !== 'azure') {
191
+ throw new PeerRegistryValidationError("Field 'provider' must be one of 'gcp' | 'aws' | 'azure'.");
192
+ }
193
+ const projectId = optionalString(raw.projectId, 'projectId');
194
+ const location = optionalString(raw.location, 'location');
195
+ const instance = optionalString(raw.instance, 'instance');
196
+ return {
197
+ kind: 'cloud-terminal',
198
+ provider,
199
+ credentialRef: requireSecretRef(raw.credentialRef, 'credentialRef'),
200
+ ...(projectId !== undefined ? { projectId } : {}),
201
+ ...(location !== undefined ? { location } : {}),
202
+ ...(instance !== undefined ? { instance } : {}),
203
+ };
204
+ }
205
+ case 'local-process': {
206
+ const cwd = optionalString(raw.cwd, 'cwd');
207
+ let allowedCommands: string[] | undefined;
208
+ if (raw.allowedCommands !== undefined && raw.allowedCommands !== null) {
209
+ if (!Array.isArray(raw.allowedCommands)) {
210
+ throw new PeerRegistryValidationError("Field 'allowedCommands' must be an array of strings.");
211
+ }
212
+ const list = raw.allowedCommands
213
+ .map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
214
+ .filter((entry) => entry.length > 0);
215
+ allowedCommands = list;
216
+ }
217
+ return {
218
+ kind: 'local-process',
219
+ ...(cwd !== undefined ? { cwd } : {}),
220
+ ...(allowedCommands !== undefined ? { allowedCommands } : {}),
221
+ };
222
+ }
223
+ default:
224
+ throw new PeerRegistryValidationError(`Unknown backendKind: ${String(backendKind)}`);
225
+ }
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Peer registry — persisted via HandlerSqliteStore (peer-registry.sqlite)
230
+ // ---------------------------------------------------------------------------
231
+
232
+ const PEER_REGISTRY_FILE = 'peer-registry.sqlite';
233
+
234
+ const SCHEMA = [
235
+ `CREATE TABLE IF NOT EXISTS peers (
236
+ peerId TEXT PRIMARY KEY,
237
+ displayName TEXT NOT NULL,
238
+ backendKind TEXT NOT NULL,
239
+ backendConfig TEXT NOT NULL
240
+ )`,
241
+ ];
242
+
243
+ interface PeerRow {
244
+ peerId: string;
245
+ displayName: string;
246
+ backendKind: string;
247
+ backendConfig: string;
248
+ }
249
+
250
+ const VALID_BACKEND_KINDS: ReadonlySet<BackendKind> = new Set([
251
+ 'docker',
252
+ 'ssh',
253
+ 'cloud-terminal',
254
+ 'local-process',
255
+ ]);
256
+
257
+ function rowToRecord(row: PeerRow): PeerRecord {
258
+ const backendKind = row.backendKind as BackendKind;
259
+ const parsed = JSON.parse(row.backendConfig) as BackendConfig;
260
+ return {
261
+ peerId: row.peerId,
262
+ displayName: row.displayName,
263
+ backendKind,
264
+ backendConfig: parsed,
265
+ };
266
+ }
267
+
268
+ export class PeerRegistry {
269
+ private readonly store: HandlerSqliteStore;
270
+ private initialized = false;
271
+
272
+ constructor(workingDirectory: string) {
273
+ this.store = new HandlerSqliteStore({
274
+ workingDirectory,
275
+ fileName: PEER_REGISTRY_FILE,
276
+ schema: SCHEMA,
277
+ });
278
+ }
279
+
280
+ get dbPath(): string {
281
+ return this.store.dbPath;
282
+ }
283
+
284
+ async init(): Promise<void> {
285
+ if (this.initialized) return;
286
+ await this.store.init();
287
+ this.initialized = true;
288
+ }
289
+
290
+ private requireInit(): void {
291
+ if (!this.initialized) {
292
+ throw new Error('PeerRegistry not initialized: call init() first.');
293
+ }
294
+ }
295
+
296
+ /** Register (upsert) a peer. Validates + normalizes backendConfig to refs-only. */
297
+ async register(input: PeerRegistrationInput): Promise<PeerRecord> {
298
+ this.requireInit();
299
+ const peerId = requireString(input.peerId, 'peerId');
300
+ const displayName = requireString(input.displayName, 'displayName');
301
+ if (!VALID_BACKEND_KINDS.has(input.backendKind)) {
302
+ throw new PeerRegistryValidationError(`Unknown backendKind: ${String(input.backendKind)}`);
303
+ }
304
+ const backendConfig = normalizeBackendConfig(input.backendKind, input.backendConfig ?? {});
305
+ const serialized = JSON.stringify(backendConfig);
306
+ this.store.run(
307
+ `INSERT INTO peers (peerId, displayName, backendKind, backendConfig)
308
+ VALUES (?, ?, ?, ?)
309
+ ON CONFLICT(peerId) DO UPDATE SET
310
+ displayName = excluded.displayName,
311
+ backendKind = excluded.backendKind,
312
+ backendConfig = excluded.backendConfig`,
313
+ [peerId, displayName, input.backendKind, serialized],
314
+ );
315
+ await this.store.save();
316
+ return { peerId, displayName, backendKind: input.backendKind, backendConfig };
317
+ }
318
+
319
+ /** Look up a peer by id. Returns null when not registered. */
320
+ get(peerId: string): PeerRecord | null {
321
+ this.requireInit();
322
+ const row = this.store.get<PeerRow>(
323
+ 'SELECT peerId, displayName, backendKind, backendConfig FROM peers WHERE peerId = ?',
324
+ [peerId],
325
+ );
326
+ return row ? rowToRecord(row) : null;
327
+ }
328
+
329
+ /** List all registered peers (config included; contains only secret refs). */
330
+ list(): PeerRecord[] {
331
+ this.requireInit();
332
+ const rows = this.store.all<PeerRow>(
333
+ 'SELECT peerId, displayName, backendKind, backendConfig FROM peers ORDER BY peerId ASC',
334
+ );
335
+ return rows.map(rowToRecord);
336
+ }
337
+
338
+ /** Remove a peer. Returns true when a row was deleted. */
339
+ async remove(peerId: string): Promise<boolean> {
340
+ this.requireInit();
341
+ const existed = this.get(peerId) !== null;
342
+ if (existed) {
343
+ this.store.run('DELETE FROM peers WHERE peerId = ?', [peerId]);
344
+ await this.store.save();
345
+ }
346
+ return existed;
347
+ }
348
+
349
+ close(): void {
350
+ if (this.initialized) {
351
+ this.store.close();
352
+ this.initialized = false;
353
+ }
354
+ }
355
+ }
356
+
357
+ export { PeerRegistryValidationError };
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Host implementation of the daemon-sdk `DistributedRuntimeRouteService` (17
3
+ * methods). The SDK ships NO docker/ssh/cloud backend, so the host owns it:
4
+ *
5
+ * - `invokePeer` executes against the host's own remote backends through the
6
+ * `RemoteDispatcher` (docker / ssh / cloud-terminal / local-process). This is
7
+ * the only method that performs real command execution.
8
+ * - The 16 peer / pairing / work-queue methods are backed by the SDK's
9
+ * `DistributedRuntimeManager` (constructed by the surface), which owns peer
10
+ * records, pairing challenges, token rotation, and the work queue.
11
+ *
12
+ * The SDK injects this instance into `DaemonRemoteRouteContext.distributedRuntime`
13
+ * so the published `remote.peers.*` HTTP routes dispatch to it. No catalog
14
+ * descriptor or schema is authored here.
15
+ */
16
+ import { operations } from '@pellux/goodvibes-sdk/platform/runtime';
17
+ import type {
18
+ DistributedRuntimeRouteService,
19
+ RemotePeerAuth,
20
+ } from '../contracts.ts';
21
+ import { RemoteDispatcher } from './dispatcher.ts';
22
+ import type { DispatchPayload } from './backends/index.ts';
23
+
24
+ type DistributedRuntimeManager = operations.DistributedRuntimeManager;
25
+ type DistributedPeerAuth = operations.DistributedPeerAuth;
26
+
27
+ /** Coerce an injected `RemotePeerAuth` (typed `unknown` by the SDK) to the manager's auth shape. */
28
+ function asPeerAuth(auth: RemotePeerAuth): DistributedPeerAuth {
29
+ return auth as DistributedPeerAuth;
30
+ }
31
+
32
+ /** Narrow an arbitrary route-supplied input bag to a typed manager input. */
33
+ function asInput<T>(input: Record<string, unknown>): T {
34
+ return input as unknown as T;
35
+ }
36
+
37
+ function readString(input: Record<string, unknown>, key: string): string {
38
+ const value = input[key];
39
+ return typeof value === 'string' ? value : '';
40
+ }
41
+
42
+ function readPrincipal(input: Record<string, unknown>): string {
43
+ const actor = input.actor ?? input.principalId ?? input.queuedBy;
44
+ return typeof actor === 'string' && actor.length > 0 ? actor : 'remote';
45
+ }
46
+
47
+ function readPayload(input: Record<string, unknown>): DispatchPayload | undefined {
48
+ const payload = input.payload;
49
+ if (payload && typeof payload === 'object') {
50
+ return payload as DispatchPayload;
51
+ }
52
+ return undefined;
53
+ }
54
+
55
+ export class HostDistributedRuntime implements DistributedRuntimeRouteService {
56
+ private readonly manager: DistributedRuntimeManager;
57
+ private readonly dispatcher: RemoteDispatcher;
58
+
59
+ constructor(manager: DistributedRuntimeManager, dispatcher: RemoteDispatcher) {
60
+ this.manager = manager;
61
+ this.dispatcher = dispatcher;
62
+ }
63
+
64
+ // --- Pairing -------------------------------------------------------------
65
+
66
+ listPairRequests(): unknown {
67
+ return this.manager.listPairRequests();
68
+ }
69
+
70
+ requestPairing(input: Record<string, unknown>): Promise<unknown> {
71
+ return this.manager.requestPairing(
72
+ asInput<Parameters<DistributedRuntimeManager['requestPairing']>[0]>(input),
73
+ );
74
+ }
75
+
76
+ approvePairRequest(requestId: string, input: Record<string, unknown>): Promise<unknown | null> {
77
+ return this.manager.approvePairRequest(
78
+ requestId,
79
+ asInput<Parameters<DistributedRuntimeManager['approvePairRequest']>[1]>(input),
80
+ );
81
+ }
82
+
83
+ rejectPairRequest(requestId: string, input: Record<string, unknown>): Promise<unknown | null> {
84
+ return this.manager.rejectPairRequest(
85
+ requestId,
86
+ asInput<Parameters<DistributedRuntimeManager['rejectPairRequest']>[1]>(input),
87
+ );
88
+ }
89
+
90
+ verifyPairRequest(
91
+ requestId: string,
92
+ challenge: string,
93
+ input: Record<string, unknown>,
94
+ ): Promise<unknown | null> {
95
+ return this.manager.verifyPairRequest(
96
+ requestId,
97
+ challenge,
98
+ asInput<Parameters<DistributedRuntimeManager['verifyPairRequest']>[2]>(input),
99
+ );
100
+ }
101
+
102
+ // --- Peer management -----------------------------------------------------
103
+
104
+ listPeers(): unknown {
105
+ return this.manager.listPeers();
106
+ }
107
+
108
+ rotatePeerToken(peerId: string, input: Record<string, unknown>): Promise<unknown | null> {
109
+ return this.manager.rotatePeerToken(
110
+ peerId,
111
+ asInput<Parameters<DistributedRuntimeManager['rotatePeerToken']>[1]>(input),
112
+ );
113
+ }
114
+
115
+ revokePeerToken(peerId: string, input: Record<string, unknown>): Promise<unknown | null> {
116
+ return this.manager.revokePeerToken(
117
+ peerId,
118
+ asInput<Parameters<DistributedRuntimeManager['revokePeerToken']>[1]>(input),
119
+ );
120
+ }
121
+
122
+ disconnectPeer(peerId: string, input: Record<string, unknown>): Promise<unknown | null> {
123
+ return this.manager.disconnectPeer(
124
+ peerId,
125
+ asInput<Parameters<DistributedRuntimeManager['disconnectPeer']>[1]>(input),
126
+ );
127
+ }
128
+
129
+ getNodeHostContract(): unknown {
130
+ return this.manager.getNodeHostContract();
131
+ }
132
+
133
+ // --- Peer-authenticated heartbeat + work claim ---------------------------
134
+
135
+ heartbeatPeer(auth: RemotePeerAuth, input: Record<string, unknown>): Promise<unknown> {
136
+ return this.manager.heartbeatPeer(
137
+ asPeerAuth(auth),
138
+ asInput<Parameters<DistributedRuntimeManager['heartbeatPeer']>[1]>(input),
139
+ );
140
+ }
141
+
142
+ claimWork(auth: RemotePeerAuth, input: Record<string, unknown>): Promise<unknown> {
143
+ return this.manager.claimWork(
144
+ asPeerAuth(auth),
145
+ asInput<Parameters<DistributedRuntimeManager['claimWork']>[1]>(input),
146
+ );
147
+ }
148
+
149
+ completeWork(
150
+ auth: RemotePeerAuth,
151
+ workId: string,
152
+ input: Record<string, unknown>,
153
+ ): Promise<unknown | null> {
154
+ return this.manager.completeWork(
155
+ asPeerAuth(auth),
156
+ workId,
157
+ asInput<Parameters<DistributedRuntimeManager['completeWork']>[2]>(input),
158
+ );
159
+ }
160
+
161
+ // --- Work queue ----------------------------------------------------------
162
+
163
+ listWork(): unknown {
164
+ return this.manager.listWork();
165
+ }
166
+
167
+ cancelWork(workId: string, input: Record<string, unknown>): Promise<unknown | null> {
168
+ return this.manager.cancelWork(
169
+ workId,
170
+ asInput<Parameters<DistributedRuntimeManager['cancelWork']>[1]>(input),
171
+ );
172
+ }
173
+
174
+ // --- Command execution (host-owned backends) -----------------------------
175
+
176
+ /**
177
+ * Execute a command on a registered peer through the host backends. Unlike the
178
+ * manager's enqueue-only `invokePeer`, this performs real docker/ssh/cloud/
179
+ * local-process execution and returns a receipt with a full-stdout digest.
180
+ */
181
+ async invokePeer(input: Record<string, unknown>): Promise<unknown> {
182
+ const payload = readPayload(input);
183
+ return this.dispatcher.dispatch({
184
+ peerId: readString(input, 'peerId'),
185
+ command: readString(input, 'command'),
186
+ principalId: readPrincipal(input),
187
+ async: input.async === true,
188
+ ...(payload !== undefined ? { payload } : {}),
189
+ });
190
+ }
191
+ }
@@ -0,0 +1,71 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Inbox <-> Routing resolver bridge.
3
+ //
4
+ // The inbox provider adapters expose a best-effort route-resolution seam: given
5
+ // an inbound item's `{ provider, fromDigest, kind, routeId? }` they ask "which
6
+ // daemon route binding handles this item?" and stamp the answer onto
7
+ // `item.routeId` when one resolves. The routing control plane owns the
8
+ // channel<->profile bindings and exposes a `RoutingResolver` whose
9
+ // `getProfileForChannel(surfaceKind, routeId?)` applies the canonical
10
+ // resolution order:
11
+ //
12
+ // 1. exact — surfaceKind AND routeId both match
13
+ // 2. surface — surfaceKind matches, route has no routeId
14
+ // 3. wildcard — a route with surfaceKind === 'any' (no routeId)
15
+ //
16
+ // This module bridges the two shapes. The bridge maps an inbound item's
17
+ // `provider` to the routing `surfaceKind` and forwards an optional `routeId`
18
+ // refinement; `fromDigest`/`kind` are NOT used as routing keys (the routing
19
+ // store is keyed by surfaceKind + optional routeId only, never by sender digest
20
+ // or message kind — see RoutingChannelRoute). When no binding matches the
21
+ // resolver returns null and the bridge yields `undefined`, so the adapter falls
22
+ // back to leaving the item unrouted (offline/default behaviour intact). The
23
+ // bridge NEVER throws: the underlying resolver is a pure in-memory lookup.
24
+ //
25
+ // The inbox surface is a separate follow-up module, so this bridge declares the
26
+ // minimal `RouteResolver` seam locally rather than importing a sibling that the
27
+ // routing slice does not own — keeping the routing handler layer free of any
28
+ // cross-surface import edge.
29
+ // ---------------------------------------------------------------------------
30
+
31
+ import type { RoutingResolver } from './routing-resolver.ts';
32
+
33
+ /**
34
+ * Fields the bridge consults on an inbound item. `provider` maps to the routing
35
+ * `surfaceKind`; an optional `routeId` (e.g. a Slack channel id or email
36
+ * mailbox tag) enables exact (surfaceKind+routeId) matches. Additional inbound
37
+ * fields are ignored for routing purposes.
38
+ */
39
+ export interface RouteResolverInput {
40
+ readonly provider?: unknown;
41
+ readonly routeId?: unknown;
42
+ readonly [key: string]: unknown;
43
+ }
44
+
45
+ /**
46
+ * Best-effort seam the inbox provider adapters invoke per inbound item. Returns
47
+ * the resolved profile-route binding id, or `undefined` when nothing matches.
48
+ */
49
+ export type RouteResolver = (input: RouteResolverInput) => string | undefined;
50
+
51
+ /**
52
+ * Build a {@link RouteResolver} backed by the routing surface's
53
+ * {@link RoutingResolver}. Maps `provider -> surfaceKind` and forwards an
54
+ * optional `routeId` refinement, then applies the canonical
55
+ * exact > surface-only > wildcard resolution order via `getProfileForChannel`.
56
+ * Returns the resolved profileId (the daemon route binding) or `undefined` when
57
+ * nothing matches.
58
+ */
59
+ export function createInboxRouteResolver(resolver: RoutingResolver): RouteResolver {
60
+ return (input: RouteResolverInput): string | undefined => {
61
+ const surfaceKind = typeof input.provider === 'string' ? input.provider : '';
62
+ if (surfaceKind.length === 0) return undefined;
63
+ const routeId = typeof input.routeId === 'string' && input.routeId.length > 0
64
+ ? input.routeId
65
+ : undefined;
66
+ // getProfileForChannel applies exact (surfaceKind+routeId) > surface-only >
67
+ // wildcard ('any') and returns null when no assignment matches.
68
+ const profileId = resolver.getProfileForChannel(surfaceKind, routeId);
69
+ return profileId ?? undefined;
70
+ };
71
+ }