@pellux/goodvibes-tui 0.25.0 → 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.
- package/CHANGELOG.md +5 -3
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +2419 -1040
- package/package.json +2 -2
- package/src/daemon/{calendar → handlers/calendar}/caldav-client.ts +28 -24
- package/src/daemon/handlers/calendar/index.ts +316 -0
- package/src/daemon/handlers/context.ts +29 -0
- package/src/daemon/handlers/contracts.ts +77 -0
- package/src/daemon/{channels → handlers}/drafts/draft-store.ts +114 -50
- package/src/daemon/handlers/drafts/index.ts +17 -0
- package/src/daemon/handlers/drafts/register.ts +331 -0
- package/src/daemon/handlers/email/config.ts +164 -0
- package/src/daemon/handlers/email/index.ts +43 -0
- package/src/daemon/handlers/email/read-handlers.ts +80 -0
- package/src/daemon/handlers/email/runtime.ts +140 -0
- package/src/daemon/handlers/email/validation.ts +147 -0
- package/src/daemon/handlers/email/write-handlers.ts +133 -0
- package/src/daemon/handlers/errors.ts +18 -0
- package/src/daemon/{channels → handlers}/inbox/cursor-store.ts +58 -66
- package/src/daemon/handlers/inbox/index.ts +210 -0
- package/src/daemon/{channels → handlers}/inbox/mapping.ts +8 -6
- package/src/daemon/{channels → handlers}/inbox/poller.ts +4 -4
- package/src/daemon/{channels → handlers}/inbox/provider-adapter.ts +14 -10
- package/src/daemon/{channels → handlers}/inbox/providers/discord.ts +8 -10
- package/src/daemon/{channels → handlers}/inbox/providers/imap-client.ts +1 -1
- package/src/daemon/{channels → handlers}/inbox/providers/route-util.ts +2 -2
- package/src/daemon/{channels → handlers}/inbox/providers/slack.ts +9 -11
- package/src/daemon/handlers/index.ts +107 -0
- package/src/daemon/handlers/register.ts +161 -0
- package/src/daemon/{remote → handlers/remote}/backends/cloud-terminal.ts +8 -3
- package/src/daemon/{remote → handlers/remote}/backends/docker.ts +2 -3
- package/src/daemon/handlers/remote/backends/index.ts +40 -0
- package/src/daemon/{remote → handlers/remote}/backends/process-runner.ts +16 -40
- package/src/daemon/{remote → handlers/remote}/backends/ssh.ts +8 -3
- package/src/daemon/{remote → handlers/remote}/backends/types.ts +29 -3
- package/src/daemon/{remote → handlers/remote}/dispatcher.ts +27 -6
- package/src/daemon/handlers/remote/index.ts +119 -0
- package/src/daemon/{remote → handlers/remote}/peer-registry.ts +47 -11
- package/src/daemon/handlers/remote/service.ts +191 -0
- package/src/daemon/{channels → handlers}/routing/inbox-bridge.ts +33 -20
- package/src/daemon/handlers/routing/index.ts +261 -0
- package/src/daemon/{channels → handlers}/routing/route-store.ts +57 -16
- package/src/daemon/{channels → handlers}/routing/routing-resolver.ts +1 -1
- package/src/daemon/{operator → handlers}/sqlite-store.ts +5 -5
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +212 -0
- package/src/daemon/{triage → handlers/triage}/pipeline.ts +58 -70
- package/src/daemon/{triage → handlers/triage}/scorer.ts +21 -21
- package/src/daemon/handlers/triage/tagger/discord.ts +186 -0
- package/src/daemon/handlers/triage/tagger/imap.ts +383 -0
- package/src/daemon/handlers/triage/tagger/index.ts +184 -0
- package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
- package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
- package/src/daemon/handlers/triage/types.ts +50 -0
- package/src/runtime/services.ts +48 -35
- package/src/version.ts +1 -1
- package/src/daemon/calendar/index.ts +0 -52
- package/src/daemon/calendar/register.ts +0 -527
- package/src/daemon/channels/drafts/index.ts +0 -22
- package/src/daemon/channels/drafts/register.ts +0 -449
- package/src/daemon/channels/inbox/index.ts +0 -58
- package/src/daemon/channels/inbox/register.ts +0 -247
- package/src/daemon/channels/routing/index.ts +0 -39
- package/src/daemon/channels/routing/register.ts +0 -296
- package/src/daemon/email/index.ts +0 -68
- package/src/daemon/email/register.ts +0 -715
- package/src/daemon/operator/index.ts +0 -43
- package/src/daemon/operator/register-helper.ts +0 -150
- package/src/daemon/operator/surfaces.ts +0 -137
- package/src/daemon/operator/types.ts +0 -207
- package/src/daemon/remote/backends/index.ts +0 -34
- package/src/daemon/remote/index.ts +0 -74
- package/src/daemon/remote/register.ts +0 -411
- package/src/daemon/triage/index.ts +0 -59
- package/src/daemon/triage/integration.ts +0 -179
- package/src/daemon/triage/register.ts +0 -231
- package/src/daemon/triage/tagger.ts +0 -777
- /package/src/daemon/{calendar → handlers/calendar}/ics.ts +0 -0
- /package/src/daemon/{operator/credential-store.ts → handlers/credentials.ts} +0 -0
- /package/src/daemon/{email → handlers/email}/imap-connector.ts +0 -0
- /package/src/daemon/{email → handlers/email}/imap-parsing.ts +0 -0
- /package/src/daemon/{email → handlers/email}/smtp-connector.ts +0 -0
- /package/src/daemon/{channels → handlers}/inbox/providers/email.ts +0 -0
- /package/src/daemon/{remote → handlers/remote}/backends/local-process.ts +0 -0
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { HandlerSqliteStore } from '../sqlite-store.ts';
|
|
2
|
+
import {
|
|
3
|
+
isSecretReferenceValue,
|
|
4
|
+
isMalformedGoodVibesSecretReferenceValue,
|
|
5
|
+
} from '../../../config/secret-config.ts';
|
|
3
6
|
|
|
4
7
|
// ---------------------------------------------------------------------------
|
|
5
8
|
// Backend vocabulary + per-backend config shapes
|
|
@@ -105,15 +108,48 @@ function requireSecretRef(value: unknown, field: string): string {
|
|
|
105
108
|
return ref;
|
|
106
109
|
}
|
|
107
110
|
|
|
108
|
-
function
|
|
109
|
-
// A dockerHost
|
|
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.
|
|
111
119
|
if (value === undefined) return;
|
|
112
|
-
|
|
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('@')) {
|
|
113
137
|
throw new PeerRegistryValidationError(
|
|
114
138
|
`Field '${field}' appears to embed credentials; pass a goodvibes://secrets/ reference instead.`,
|
|
115
139
|
);
|
|
116
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
|
+
}
|
|
117
153
|
}
|
|
118
154
|
|
|
119
155
|
/** Normalize + validate raw backendConfig into a typed, ref-only BackendConfig. */
|
|
@@ -124,7 +160,7 @@ export function normalizeBackendConfig(
|
|
|
124
160
|
switch (backendKind) {
|
|
125
161
|
case 'docker': {
|
|
126
162
|
const dockerHost = optionalString(raw.dockerHost, 'dockerHost');
|
|
127
|
-
|
|
163
|
+
assertDockerHostSafe(dockerHost, 'dockerHost');
|
|
128
164
|
return {
|
|
129
165
|
kind: 'docker',
|
|
130
166
|
containerName: requireString(raw.containerName, 'containerName'),
|
|
@@ -190,10 +226,10 @@ export function normalizeBackendConfig(
|
|
|
190
226
|
}
|
|
191
227
|
|
|
192
228
|
// ---------------------------------------------------------------------------
|
|
193
|
-
// Peer registry — persisted via
|
|
229
|
+
// Peer registry — persisted via HandlerSqliteStore (peer-registry.sqlite)
|
|
194
230
|
// ---------------------------------------------------------------------------
|
|
195
231
|
|
|
196
|
-
const PEER_REGISTRY_FILE = '
|
|
232
|
+
const PEER_REGISTRY_FILE = 'peer-registry.sqlite';
|
|
197
233
|
|
|
198
234
|
const SCHEMA = [
|
|
199
235
|
`CREATE TABLE IF NOT EXISTS peers (
|
|
@@ -230,11 +266,11 @@ function rowToRecord(row: PeerRow): PeerRecord {
|
|
|
230
266
|
}
|
|
231
267
|
|
|
232
268
|
export class PeerRegistry {
|
|
233
|
-
private readonly store:
|
|
269
|
+
private readonly store: HandlerSqliteStore;
|
|
234
270
|
private initialized = false;
|
|
235
271
|
|
|
236
272
|
constructor(workingDirectory: string) {
|
|
237
|
-
this.store = new
|
|
273
|
+
this.store = new HandlerSqliteStore({
|
|
238
274
|
workingDirectory,
|
|
239
275
|
fileName: PEER_REGISTRY_FILE,
|
|
240
276
|
schema: SCHEMA,
|
|
@@ -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
|
+
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
2
|
// Inbox <-> Routing resolver bridge.
|
|
3
3
|
//
|
|
4
|
-
// The inbox provider adapters expose a best-effort
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
8
|
// channel<->profile bindings and exposes a `RoutingResolver` whose
|
|
9
|
-
// `getProfileForChannel(surfaceKind, routeId?)` applies the canonical
|
|
10
|
-
// order:
|
|
9
|
+
// `getProfileForChannel(surfaceKind, routeId?)` applies the canonical
|
|
10
|
+
// resolution order:
|
|
11
11
|
//
|
|
12
12
|
// 1. exact — surfaceKind AND routeId both match
|
|
13
13
|
// 2. surface — surfaceKind matches, route has no routeId
|
|
@@ -20,36 +20,49 @@
|
|
|
20
20
|
// or message kind — see RoutingChannelRoute). When no binding matches the
|
|
21
21
|
// resolver returns null and the bridge yields `undefined`, so the adapter falls
|
|
22
22
|
// back to leaving the item unrouted (offline/default behaviour intact). The
|
|
23
|
-
// bridge NEVER throws:
|
|
24
|
-
//
|
|
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.
|
|
25
29
|
// ---------------------------------------------------------------------------
|
|
26
30
|
|
|
27
|
-
import type { RouteResolver } from '../inbox/provider-adapter.ts';
|
|
28
31
|
import type { RoutingResolver } from './routing-resolver.ts';
|
|
29
32
|
|
|
30
33
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
|
|
36
|
-
|
|
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.
|
|
37
48
|
*/
|
|
38
|
-
type
|
|
49
|
+
export type RouteResolver = (input: RouteResolverInput) => string | undefined;
|
|
39
50
|
|
|
40
51
|
/**
|
|
41
52
|
* Build a {@link RouteResolver} backed by the routing surface's
|
|
42
53
|
* {@link RoutingResolver}. Maps `provider -> surfaceKind` and forwards an
|
|
43
54
|
* optional `routeId` refinement, then applies the canonical
|
|
44
|
-
* exact > surface-only > wildcard resolution order via
|
|
45
|
-
*
|
|
46
|
-
*
|
|
55
|
+
* exact > surface-only > wildcard resolution order via `getProfileForChannel`.
|
|
56
|
+
* Returns the resolved profileId (the daemon route binding) or `undefined` when
|
|
57
|
+
* nothing matches.
|
|
47
58
|
*/
|
|
48
59
|
export function createInboxRouteResolver(resolver: RoutingResolver): RouteResolver {
|
|
49
60
|
return (input: RouteResolverInput): string | undefined => {
|
|
50
61
|
const surfaceKind = typeof input.provider === 'string' ? input.provider : '';
|
|
51
62
|
if (surfaceKind.length === 0) return undefined;
|
|
52
|
-
const routeId = typeof input.routeId === 'string' && input.routeId.length > 0
|
|
63
|
+
const routeId = typeof input.routeId === 'string' && input.routeId.length > 0
|
|
64
|
+
? input.routeId
|
|
65
|
+
: undefined;
|
|
53
66
|
// getProfileForChannel applies exact (surfaceKind+routeId) > surface-only >
|
|
54
67
|
// wildcard ('any') and returns null when no assignment matches.
|
|
55
68
|
const profileId = resolver.getProfileForChannel(surfaceKind, routeId);
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Channel routing surface — HANDLERS for the SDK-defined methods
|
|
3
|
+
// - channels.routing.list (read-only)
|
|
4
|
+
// - channels.routing.assign (admin, confirmation-gated)
|
|
5
|
+
// - channels.routing.delete (admin, dangerous, confirmation-gated)
|
|
6
|
+
//
|
|
7
|
+
// The SDK's GatewayMethodCatalog already registered the canonical descriptors
|
|
8
|
+
// (id, input/output schema, access, scopes, dangerous) for these three method
|
|
9
|
+
// ids with `handler: undefined`. This module NEVER re-declares an id, schema,
|
|
10
|
+
// or descriptor: it looks each one up by id and attaches a typed handler via
|
|
11
|
+
// `registerCatalogHandlers`, flipping the builtin method from HTTP-fallback to
|
|
12
|
+
// in-process execution. Handler outputs match the SDK output schemas exactly.
|
|
13
|
+
//
|
|
14
|
+
// Confirmation posture (per the daemon handoff, Responsibility 2): assign and
|
|
15
|
+
// delete are control-plane mutations that require explicit user confirmation
|
|
16
|
+
// (the SDK marks assign access:admin + 'Requires explicit confirmation', and
|
|
17
|
+
// delete dangerous:true). Both pass `{ confirm: true }` so the register wrapper
|
|
18
|
+
// enforces `body.confirm === true` AND `context.explicitUserRequest === true`.
|
|
19
|
+
// list is read-only and ungated.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
import type { HandlerContext } from '../context.ts';
|
|
23
|
+
import type { RoutingRegistration as FoundationRoutingRegistration } from '../index.ts';
|
|
24
|
+
import {
|
|
25
|
+
registerCatalogHandlers,
|
|
26
|
+
type CatalogHandlerEntry,
|
|
27
|
+
type TypedHandler,
|
|
28
|
+
type Unregister,
|
|
29
|
+
} from '../register.ts';
|
|
30
|
+
import {
|
|
31
|
+
buildChannelId,
|
|
32
|
+
parseChannelId,
|
|
33
|
+
RouteStore,
|
|
34
|
+
toRouteListItem,
|
|
35
|
+
type RoutingChannelRoute,
|
|
36
|
+
type RoutingRouteListItem,
|
|
37
|
+
} from './route-store.ts';
|
|
38
|
+
import { createRoutingResolver, type RoutingResolver } from './routing-resolver.ts';
|
|
39
|
+
|
|
40
|
+
export { createInboxRouteResolver, type RouteResolver, type RouteResolverInput } from './inbox-bridge.ts';
|
|
41
|
+
export {
|
|
42
|
+
buildChannelId,
|
|
43
|
+
parseChannelId,
|
|
44
|
+
RouteStore,
|
|
45
|
+
toRouteListItem,
|
|
46
|
+
type RoutingChannelRoute,
|
|
47
|
+
type RoutingRouteListItem,
|
|
48
|
+
} from './route-store.ts';
|
|
49
|
+
export {
|
|
50
|
+
createRoutingResolver,
|
|
51
|
+
resolveProfile,
|
|
52
|
+
WILDCARD_SURFACE,
|
|
53
|
+
type RoutingResolver,
|
|
54
|
+
} from './routing-resolver.ts';
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Wire shapes (derived from the SDK contracts; NOT re-declared schemas).
|
|
58
|
+
//
|
|
59
|
+
// list in : { profileId?, surfaceKind?, limit? }
|
|
60
|
+
// out: { routes: [{ id, createdAt, updatedAt, surfaceKind, routeId?,
|
|
61
|
+
// profileId, label? }], total } (SDK route-item shape)
|
|
62
|
+
// assign in : { channelId?, surfaceKind, routeId?, profileId, label? }
|
|
63
|
+
// out: { assignmentId, channelId?, surfaceKind, routeId?, profileId, label?, createdAt, updatedAt }
|
|
64
|
+
// delete in : { assignmentId } out: { deleted, assignmentId }
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
interface RoutingListBody {
|
|
68
|
+
profileId?: unknown;
|
|
69
|
+
surfaceKind?: unknown;
|
|
70
|
+
limit?: unknown;
|
|
71
|
+
}
|
|
72
|
+
interface RoutingListResult {
|
|
73
|
+
routes: RoutingRouteListItem[];
|
|
74
|
+
total: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface RoutingAssignBody {
|
|
78
|
+
channelId?: unknown;
|
|
79
|
+
surfaceKind?: unknown;
|
|
80
|
+
routeId?: unknown;
|
|
81
|
+
profileId?: unknown;
|
|
82
|
+
label?: unknown;
|
|
83
|
+
}
|
|
84
|
+
interface RoutingAssignResult {
|
|
85
|
+
assignmentId: string;
|
|
86
|
+
channelId: string;
|
|
87
|
+
surfaceKind: string;
|
|
88
|
+
routeId?: string;
|
|
89
|
+
profileId: string;
|
|
90
|
+
label?: string;
|
|
91
|
+
createdAt: string;
|
|
92
|
+
updatedAt: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface RoutingDeleteBody {
|
|
96
|
+
assignmentId?: unknown;
|
|
97
|
+
}
|
|
98
|
+
interface RoutingDeleteResult {
|
|
99
|
+
deleted: boolean;
|
|
100
|
+
assignmentId: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const LIST_METHOD = 'channels.routing.list';
|
|
104
|
+
const ASSIGN_METHOD = 'channels.routing.assign';
|
|
105
|
+
const DELETE_METHOD = 'channels.routing.delete';
|
|
106
|
+
|
|
107
|
+
function optionalString(value: unknown): string | undefined {
|
|
108
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function clampLimit(value: unknown): number | undefined {
|
|
112
|
+
if (value === undefined || value === null || value === '') return undefined;
|
|
113
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
114
|
+
if (!Number.isFinite(n)) return undefined;
|
|
115
|
+
return Math.max(1, Math.floor(n));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Routing surface handle. Satisfies the foundation `RoutingRegistration`
|
|
120
|
+
* (`{ unregister, resolveProfileId }`) and additionally exposes the live
|
|
121
|
+
* {@link RoutingResolver} and {@link RouteStore} so the inbox surface and tests
|
|
122
|
+
* can reuse routing resolution without going through the catalog.
|
|
123
|
+
*/
|
|
124
|
+
export interface RoutingRegistration extends FoundationRoutingRegistration {
|
|
125
|
+
readonly resolver: RoutingResolver;
|
|
126
|
+
readonly store: RouteStore;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Attach the channel-routing handlers to the SDK gateway catalog held by `ctx`.
|
|
131
|
+
*
|
|
132
|
+
* The {@link RouteStore} is lazily initialized on first invocation (the
|
|
133
|
+
* register contract is synchronous) so registration never blocks on disk I/O.
|
|
134
|
+
* Returns the teardown plus the resolver/store handles.
|
|
135
|
+
*/
|
|
136
|
+
export function registerRoutingMethods(ctx: HandlerContext): RoutingRegistration {
|
|
137
|
+
const store = new RouteStore({ workingDirectory: ctx.workingDirectory });
|
|
138
|
+
const resolver = createRoutingResolver(store);
|
|
139
|
+
|
|
140
|
+
let initPromise: Promise<void> | null = null;
|
|
141
|
+
const ensureInit = async (): Promise<void> => {
|
|
142
|
+
if (!initPromise) {
|
|
143
|
+
initPromise = store.init().catch((error) => {
|
|
144
|
+
// Allow a later invocation to retry initialization.
|
|
145
|
+
initPromise = null;
|
|
146
|
+
throw error;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
await initPromise;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const listHandler: TypedHandler<RoutingListBody, RoutingListResult> = async ({ body }) => {
|
|
153
|
+
await ensureInit();
|
|
154
|
+
const routes = store.list({
|
|
155
|
+
profileId: optionalString(body.profileId),
|
|
156
|
+
surfaceKind: optionalString(body.surfaceKind),
|
|
157
|
+
});
|
|
158
|
+
const limit = clampLimit(body.limit);
|
|
159
|
+
const limited = limit !== undefined ? routes.slice(0, limit) : routes;
|
|
160
|
+
// Project to the SDK `channels.routing.list` item shape: emit `id`
|
|
161
|
+
// (= assignmentId) and drop the daemon-internal `channelId`/`assignmentId`,
|
|
162
|
+
// which additionalProperties:false forbids.
|
|
163
|
+
const items = limited.map(toRouteListItem);
|
|
164
|
+
return { routes: items, total: items.length };
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const assignHandler: TypedHandler<RoutingAssignBody, RoutingAssignResult> = async ({ body, context }) => {
|
|
168
|
+
await ensureInit();
|
|
169
|
+
const profileId = optionalString(body.profileId);
|
|
170
|
+
if (profileId === undefined) {
|
|
171
|
+
throw new Error('profileId is required');
|
|
172
|
+
}
|
|
173
|
+
// The SDK contract accepts either a composite `channelId` or the
|
|
174
|
+
// `surfaceKind` (+ optional `routeId`) parts. Prefer an explicit
|
|
175
|
+
// channelId; otherwise build one from the parts.
|
|
176
|
+
const explicitChannelId = optionalString(body.channelId);
|
|
177
|
+
const surfaceKind = optionalString(body.surfaceKind);
|
|
178
|
+
const routeId = optionalString(body.routeId);
|
|
179
|
+
const channelId = explicitChannelId ?? (
|
|
180
|
+
surfaceKind !== undefined ? buildChannelId(surfaceKind, routeId) : undefined
|
|
181
|
+
);
|
|
182
|
+
if (channelId === undefined) {
|
|
183
|
+
throw new Error('surfaceKind (or channelId) is required');
|
|
184
|
+
}
|
|
185
|
+
const { route, created } = await store.upsert({
|
|
186
|
+
channelId,
|
|
187
|
+
profileId,
|
|
188
|
+
label: optionalString(body.label),
|
|
189
|
+
});
|
|
190
|
+
ctx.logger.info('channels.routing.assign', {
|
|
191
|
+
assignmentId: route.assignmentId,
|
|
192
|
+
channelId: route.channelId,
|
|
193
|
+
surfaceKind: route.surfaceKind,
|
|
194
|
+
profileId: route.profileId,
|
|
195
|
+
created,
|
|
196
|
+
principalId: context.principalId,
|
|
197
|
+
});
|
|
198
|
+
const result: RoutingAssignResult = {
|
|
199
|
+
assignmentId: route.assignmentId,
|
|
200
|
+
channelId: route.channelId,
|
|
201
|
+
surfaceKind: route.surfaceKind,
|
|
202
|
+
profileId: route.profileId,
|
|
203
|
+
createdAt: route.createdAt,
|
|
204
|
+
updatedAt: route.updatedAt,
|
|
205
|
+
};
|
|
206
|
+
if (route.routeId !== undefined) result.routeId = route.routeId;
|
|
207
|
+
if (route.label !== undefined) result.label = route.label;
|
|
208
|
+
return result;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const deleteHandler: TypedHandler<RoutingDeleteBody, RoutingDeleteResult> = async ({ body, context }) => {
|
|
212
|
+
await ensureInit();
|
|
213
|
+
const assignmentId = optionalString(body.assignmentId);
|
|
214
|
+
if (assignmentId === undefined) {
|
|
215
|
+
throw new Error('assignmentId is required');
|
|
216
|
+
}
|
|
217
|
+
const deleted = await store.delete(assignmentId);
|
|
218
|
+
ctx.logger.info('channels.routing.delete', {
|
|
219
|
+
assignmentId,
|
|
220
|
+
deleted,
|
|
221
|
+
principalId: context.principalId,
|
|
222
|
+
});
|
|
223
|
+
return { deleted, assignmentId };
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const entries: CatalogHandlerEntry[] = [
|
|
227
|
+
{ id: LIST_METHOD, handler: listHandler as TypedHandler<unknown, unknown> },
|
|
228
|
+
{
|
|
229
|
+
id: ASSIGN_METHOD,
|
|
230
|
+
handler: assignHandler as TypedHandler<unknown, unknown>,
|
|
231
|
+
options: { confirm: true },
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
id: DELETE_METHOD,
|
|
235
|
+
handler: deleteHandler as TypedHandler<unknown, unknown>,
|
|
236
|
+
options: { confirm: true },
|
|
237
|
+
},
|
|
238
|
+
];
|
|
239
|
+
|
|
240
|
+
const teardownHandlers = registerCatalogHandlers(ctx.catalog, entries);
|
|
241
|
+
|
|
242
|
+
const unregister: Unregister = () => {
|
|
243
|
+
teardownHandlers();
|
|
244
|
+
store.close();
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
unregister,
|
|
249
|
+
resolveProfileId: (surfaceKind: string, routeId?: string) =>
|
|
250
|
+
resolver.getProfileForChannel(surfaceKind, routeId),
|
|
251
|
+
resolver,
|
|
252
|
+
store,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Provider entry point consumed by `registerDaemonHandlers`
|
|
258
|
+
* (`DaemonHandlerSurfaceProviders.registerRouting`).
|
|
259
|
+
*/
|
|
260
|
+
export const registerRouting = (ctx: HandlerContext): RoutingRegistration =>
|
|
261
|
+
registerRoutingMethods(ctx);
|