@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.
Files changed (84) hide show
  1. package/CHANGELOG.md +5 -3
  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/{calendar → handlers/calendar}/caldav-client.ts +28 -24
  6. package/src/daemon/handlers/calendar/index.ts +316 -0
  7. package/src/daemon/handlers/context.ts +29 -0
  8. package/src/daemon/handlers/contracts.ts +77 -0
  9. package/src/daemon/{channels → handlers}/drafts/draft-store.ts +114 -50
  10. package/src/daemon/handlers/drafts/index.ts +17 -0
  11. package/src/daemon/handlers/drafts/register.ts +331 -0
  12. package/src/daemon/handlers/email/config.ts +164 -0
  13. package/src/daemon/handlers/email/index.ts +43 -0
  14. package/src/daemon/handlers/email/read-handlers.ts +80 -0
  15. package/src/daemon/handlers/email/runtime.ts +140 -0
  16. package/src/daemon/handlers/email/validation.ts +147 -0
  17. package/src/daemon/handlers/email/write-handlers.ts +133 -0
  18. package/src/daemon/handlers/errors.ts +18 -0
  19. package/src/daemon/{channels → handlers}/inbox/cursor-store.ts +58 -66
  20. package/src/daemon/handlers/inbox/index.ts +210 -0
  21. package/src/daemon/{channels → handlers}/inbox/mapping.ts +8 -6
  22. package/src/daemon/{channels → handlers}/inbox/poller.ts +4 -4
  23. package/src/daemon/{channels → handlers}/inbox/provider-adapter.ts +14 -10
  24. package/src/daemon/{channels → handlers}/inbox/providers/discord.ts +8 -10
  25. package/src/daemon/{channels → handlers}/inbox/providers/imap-client.ts +1 -1
  26. package/src/daemon/{channels → handlers}/inbox/providers/route-util.ts +2 -2
  27. package/src/daemon/{channels → handlers}/inbox/providers/slack.ts +9 -11
  28. package/src/daemon/handlers/index.ts +107 -0
  29. package/src/daemon/handlers/register.ts +161 -0
  30. package/src/daemon/{remote → handlers/remote}/backends/cloud-terminal.ts +8 -3
  31. package/src/daemon/{remote → handlers/remote}/backends/docker.ts +2 -3
  32. package/src/daemon/handlers/remote/backends/index.ts +40 -0
  33. package/src/daemon/{remote → handlers/remote}/backends/process-runner.ts +16 -40
  34. package/src/daemon/{remote → handlers/remote}/backends/ssh.ts +8 -3
  35. package/src/daemon/{remote → handlers/remote}/backends/types.ts +29 -3
  36. package/src/daemon/{remote → handlers/remote}/dispatcher.ts +27 -6
  37. package/src/daemon/handlers/remote/index.ts +119 -0
  38. package/src/daemon/{remote → handlers/remote}/peer-registry.ts +47 -11
  39. package/src/daemon/handlers/remote/service.ts +191 -0
  40. package/src/daemon/{channels → handlers}/routing/inbox-bridge.ts +33 -20
  41. package/src/daemon/handlers/routing/index.ts +261 -0
  42. package/src/daemon/{channels → handlers}/routing/route-store.ts +57 -16
  43. package/src/daemon/{channels → handlers}/routing/routing-resolver.ts +1 -1
  44. package/src/daemon/{operator → handlers}/sqlite-store.ts +5 -5
  45. package/src/daemon/handlers/triage/index.ts +57 -0
  46. package/src/daemon/handlers/triage/integration.ts +212 -0
  47. package/src/daemon/{triage → handlers/triage}/pipeline.ts +58 -70
  48. package/src/daemon/{triage → handlers/triage}/scorer.ts +21 -21
  49. package/src/daemon/handlers/triage/tagger/discord.ts +186 -0
  50. package/src/daemon/handlers/triage/tagger/imap.ts +383 -0
  51. package/src/daemon/handlers/triage/tagger/index.ts +184 -0
  52. package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
  53. package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
  54. package/src/daemon/handlers/triage/types.ts +50 -0
  55. package/src/runtime/services.ts +48 -35
  56. package/src/version.ts +1 -1
  57. package/src/daemon/calendar/index.ts +0 -52
  58. package/src/daemon/calendar/register.ts +0 -527
  59. package/src/daemon/channels/drafts/index.ts +0 -22
  60. package/src/daemon/channels/drafts/register.ts +0 -449
  61. package/src/daemon/channels/inbox/index.ts +0 -58
  62. package/src/daemon/channels/inbox/register.ts +0 -247
  63. package/src/daemon/channels/routing/index.ts +0 -39
  64. package/src/daemon/channels/routing/register.ts +0 -296
  65. package/src/daemon/email/index.ts +0 -68
  66. package/src/daemon/email/register.ts +0 -715
  67. package/src/daemon/operator/index.ts +0 -43
  68. package/src/daemon/operator/register-helper.ts +0 -150
  69. package/src/daemon/operator/surfaces.ts +0 -137
  70. package/src/daemon/operator/types.ts +0 -207
  71. package/src/daemon/remote/backends/index.ts +0 -34
  72. package/src/daemon/remote/index.ts +0 -74
  73. package/src/daemon/remote/register.ts +0 -411
  74. package/src/daemon/triage/index.ts +0 -59
  75. package/src/daemon/triage/integration.ts +0 -179
  76. package/src/daemon/triage/register.ts +0 -231
  77. package/src/daemon/triage/tagger.ts +0 -777
  78. /package/src/daemon/{calendar → handlers/calendar}/ics.ts +0 -0
  79. /package/src/daemon/{operator/credential-store.ts → handlers/credentials.ts} +0 -0
  80. /package/src/daemon/{email → handlers/email}/imap-connector.ts +0 -0
  81. /package/src/daemon/{email → handlers/email}/imap-parsing.ts +0 -0
  82. /package/src/daemon/{email → handlers/email}/smtp-connector.ts +0 -0
  83. /package/src/daemon/{channels → handlers}/inbox/providers/email.ts +0 -0
  84. /package/src/daemon/{remote → handlers/remote}/backends/local-process.ts +0 -0
@@ -1,5 +1,8 @@
1
- import { OperatorSqliteStore } from '../operator/index.ts';
2
- import { isSecretReferenceValue } from '../../config/secret-config.ts';
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 assertSecretRefIfPresent(value: string | undefined, field: string): void {
109
- // A dockerHost may legitimately be a non-credential socket/tcp address, but if
110
- // it looks like it embeds credentials (userinfo segment) it must be a ref.
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
- if (value.includes('@') && !isSecretReferenceValue(value)) {
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
- assertSecretRefIfPresent(dockerHost, 'dockerHost');
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 OperatorSqliteStore
229
+ // Peer registry — persisted via HandlerSqliteStore (peer-registry.sqlite)
194
230
  // ---------------------------------------------------------------------------
195
231
 
196
- const PEER_REGISTRY_FILE = 'remote-peers.sqlite';
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: OperatorSqliteStore;
269
+ private readonly store: HandlerSqliteStore;
234
270
  private initialized = false;
235
271
 
236
272
  constructor(workingDirectory: string) {
237
- this.store = new OperatorSqliteStore({
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 `RouteResolver` seam
5
- // (provider-adapter.ts): given an inbound item's `{ provider, fromDigest, kind }`
6
- // they ask "which daemon route binding handles this item?" and stamp the answer
7
- // onto `item.routeId` when one resolves. The routing control plane owns the
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 resolution
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: route-util.ts already swallows resolver errors, and the
24
- // underlying resolver is a pure in-memory lookup.
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
- * Optional input fields the bridge consults for routeId refinement. The
32
- * published {@link RouteResolver} input does not currently carry a routeId, so
33
- * surface-only / wildcard resolution is the effective path today; should the
34
- * inbox seam grow a routeId (e.g. a Slack channel id or email mailbox tag) the
35
- * bridge will pass it through to enable exact (surfaceKind+routeId) matches
36
- * without any further change here.
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 RouteResolverInput = Parameters<RouteResolver>[0] & { routeId?: unknown };
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
- * `getProfileForChannel`. Returns the resolved profileId (the daemon route
46
- * binding) or `undefined` when nothing matches.
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 ? input.routeId : undefined;
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);