@pellux/goodvibes-tui 0.24.1 → 0.25.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 (56) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/daemon/calendar/caldav-client.ts +657 -0
  5. package/src/daemon/calendar/ics.ts +556 -0
  6. package/src/daemon/calendar/index.ts +52 -0
  7. package/src/daemon/calendar/register.ts +527 -0
  8. package/src/daemon/channels/drafts/draft-store.ts +363 -0
  9. package/src/daemon/channels/drafts/index.ts +22 -0
  10. package/src/daemon/channels/drafts/register.ts +449 -0
  11. package/src/daemon/channels/inbox/cursor-store.ts +298 -0
  12. package/src/daemon/channels/inbox/index.ts +58 -0
  13. package/src/daemon/channels/inbox/mapping.ts +190 -0
  14. package/src/daemon/channels/inbox/poller.ts +155 -0
  15. package/src/daemon/channels/inbox/provider-adapter.ts +152 -0
  16. package/src/daemon/channels/inbox/providers/discord.ts +253 -0
  17. package/src/daemon/channels/inbox/providers/email.ts +151 -0
  18. package/src/daemon/channels/inbox/providers/imap-client.ts +300 -0
  19. package/src/daemon/channels/inbox/providers/route-util.ts +23 -0
  20. package/src/daemon/channels/inbox/providers/slack.ts +264 -0
  21. package/src/daemon/channels/inbox/register.ts +247 -0
  22. package/src/daemon/channels/routing/inbox-bridge.ts +58 -0
  23. package/src/daemon/channels/routing/index.ts +39 -0
  24. package/src/daemon/channels/routing/register.ts +296 -0
  25. package/src/daemon/channels/routing/route-store.ts +278 -0
  26. package/src/daemon/channels/routing/routing-resolver.ts +75 -0
  27. package/src/daemon/email/imap-connector.ts +441 -0
  28. package/src/daemon/email/imap-parsing.ts +499 -0
  29. package/src/daemon/email/index.ts +68 -0
  30. package/src/daemon/email/register.ts +715 -0
  31. package/src/daemon/email/smtp-connector.ts +557 -0
  32. package/src/daemon/operator/credential-store.ts +129 -0
  33. package/src/daemon/operator/index.ts +43 -0
  34. package/src/daemon/operator/register-helper.ts +150 -0
  35. package/src/daemon/operator/sqlite-store.ts +124 -0
  36. package/src/daemon/operator/surfaces.ts +137 -0
  37. package/src/daemon/operator/types.ts +207 -0
  38. package/src/daemon/remote/backends/cloud-terminal.ts +137 -0
  39. package/src/daemon/remote/backends/docker.ts +80 -0
  40. package/src/daemon/remote/backends/index.ts +34 -0
  41. package/src/daemon/remote/backends/local-process.ts +113 -0
  42. package/src/daemon/remote/backends/process-runner.ts +151 -0
  43. package/src/daemon/remote/backends/ssh.ts +120 -0
  44. package/src/daemon/remote/backends/types.ts +71 -0
  45. package/src/daemon/remote/dispatcher.ts +160 -0
  46. package/src/daemon/remote/index.ts +74 -0
  47. package/src/daemon/remote/peer-registry.ts +321 -0
  48. package/src/daemon/remote/register.ts +411 -0
  49. package/src/daemon/triage/index.ts +59 -0
  50. package/src/daemon/triage/integration.ts +179 -0
  51. package/src/daemon/triage/pipeline.ts +285 -0
  52. package/src/daemon/triage/register.ts +231 -0
  53. package/src/daemon/triage/scorer.ts +287 -0
  54. package/src/daemon/triage/tagger.ts +777 -0
  55. package/src/runtime/services.ts +35 -0
  56. package/src/version.ts +1 -1
@@ -0,0 +1,296 @@
1
+ import {
2
+ declareOperatorMethods,
3
+ OperatorError,
4
+ type OperatorContext,
5
+ type OperatorHandler,
6
+ type OperatorMethodDescriptor,
7
+ type SurfaceRegister,
8
+ type Unregister,
9
+ } from '../../operator/index.ts';
10
+ import { RouteStore, type RoutingChannelRoute } from './route-store.ts';
11
+ import { createRoutingResolver, type RoutingResolver } from './routing-resolver.ts';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Wire contracts (request/response bodies) for the channels.routing.* methods.
15
+ //
16
+ // These interfaces and the JSON Schemas below mirror the handoff's literal
17
+ // Input/Output blocks exactly. Per the handoff's "Confirmation / Effect
18
+ // Semantics" note, `channels.routing.assign` and `channels.routing.delete` are
19
+ // control-plane mutations that additionally require confirmation. That
20
+ // confirmation is NOT part of the documented Input body shape: it is a
21
+ // framework confirm-guard envelope flag (`confirm: true` on the raw request
22
+ // body, plus `explicitUserRequest` in the call metadata) enforced by
23
+ // `assertConfirmed` inside `declareOperatorMethods` when `descriptor.confirm`
24
+ // is set. We therefore keep `confirm` out of the advertised contract here and
25
+ // rely on the framework guard, so the wire Input stays a faithful match for the
26
+ // handoff rather than a strict superset.
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export interface RoutingListInput {
30
+ profileId?: string;
31
+ surfaceKind?: string;
32
+ }
33
+ export interface RoutingListOutput {
34
+ routes: RoutingChannelRoute[];
35
+ }
36
+
37
+ export interface RoutingAssignInput {
38
+ channelId: string;
39
+ profileId: string;
40
+ label?: string;
41
+ }
42
+ export interface RoutingAssignOutput {
43
+ assignmentId: string;
44
+ channelId: string;
45
+ profileId: string;
46
+ created: boolean;
47
+ }
48
+
49
+ export interface RoutingDeleteInput {
50
+ assignmentId: string;
51
+ }
52
+ export interface RoutingDeleteOutput {
53
+ deleted: boolean;
54
+ }
55
+
56
+ function asObject(body: unknown): Record<string, unknown> {
57
+ return typeof body === 'object' && body !== null ? (body as Record<string, unknown>) : {};
58
+ }
59
+
60
+ function optionalString(value: unknown): string | undefined {
61
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // JSON Schemas (advertised on the catalog descriptors).
66
+ // ---------------------------------------------------------------------------
67
+
68
+ const CHANNEL_ROUTE_SCHEMA: Record<string, unknown> = {
69
+ type: 'object',
70
+ required: ['assignmentId', 'channelId', 'surfaceKind', 'profileId', 'createdAt', 'updatedAt'],
71
+ properties: {
72
+ assignmentId: { type: 'string' },
73
+ channelId: { type: 'string' },
74
+ surfaceKind: { type: 'string' },
75
+ routeId: { type: 'string' },
76
+ profileId: { type: 'string' },
77
+ label: { type: 'string' },
78
+ createdAt: { type: 'string' },
79
+ updatedAt: { type: 'string' },
80
+ },
81
+ };
82
+
83
+ const LIST_INPUT_SCHEMA: Record<string, unknown> = {
84
+ type: 'object',
85
+ properties: {
86
+ profileId: { type: 'string' },
87
+ surfaceKind: { type: 'string' },
88
+ },
89
+ };
90
+
91
+ const LIST_OUTPUT_SCHEMA: Record<string, unknown> = {
92
+ type: 'object',
93
+ required: ['routes'],
94
+ properties: {
95
+ routes: { type: 'array', items: CHANNEL_ROUTE_SCHEMA },
96
+ },
97
+ };
98
+
99
+ const ASSIGN_INPUT_SCHEMA: Record<string, unknown> = {
100
+ type: 'object',
101
+ required: ['channelId', 'profileId'],
102
+ properties: {
103
+ channelId: { type: 'string' },
104
+ profileId: { type: 'string' },
105
+ label: { type: 'string' },
106
+ },
107
+ };
108
+
109
+ const ASSIGN_OUTPUT_SCHEMA: Record<string, unknown> = {
110
+ type: 'object',
111
+ required: ['assignmentId', 'channelId', 'profileId', 'created'],
112
+ properties: {
113
+ assignmentId: { type: 'string' },
114
+ channelId: { type: 'string' },
115
+ profileId: { type: 'string' },
116
+ created: { type: 'boolean' },
117
+ },
118
+ };
119
+
120
+ const DELETE_INPUT_SCHEMA: Record<string, unknown> = {
121
+ type: 'object',
122
+ required: ['assignmentId'],
123
+ properties: {
124
+ assignmentId: { type: 'string' },
125
+ },
126
+ };
127
+
128
+ const DELETE_OUTPUT_SCHEMA: Record<string, unknown> = {
129
+ type: 'object',
130
+ required: ['deleted'],
131
+ properties: {
132
+ deleted: { type: 'boolean' },
133
+ },
134
+ };
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Registration
138
+ // ---------------------------------------------------------------------------
139
+
140
+ /**
141
+ * Handle returned by {@link registerRoutingMethods}: tears down the catalog
142
+ * methods and closes the underlying SQLite store. Also exposes the resolver so
143
+ * integration / the inbox surface can reuse routing resolution without going
144
+ * through the catalog.
145
+ */
146
+ export interface RoutingRegistration extends Unregister {
147
+ (): void;
148
+ readonly store: RouteStore;
149
+ readonly resolver: RoutingResolver;
150
+ }
151
+
152
+ /**
153
+ * Register the channel-to-profile routing control-plane methods against the
154
+ * operator catalog:
155
+ * - channels.routing.list (read-only)
156
+ * - channels.routing.assign (confirmed local-state mutation)
157
+ * - channels.routing.delete (confirmed local-state mutation)
158
+ *
159
+ * The store is lazily initialized on first method invocation (the SurfaceRegister
160
+ * contract is synchronous), so registration never blocks on disk I/O.
161
+ */
162
+ export function registerRoutingMethods(ctx: OperatorContext): RoutingRegistration {
163
+ const store = new RouteStore({ workingDirectory: ctx.workingDirectory });
164
+ const resolver = createRoutingResolver(store);
165
+
166
+ let initPromise: Promise<void> | null = null;
167
+ const ensureInit = async (): Promise<void> => {
168
+ if (!initPromise) {
169
+ initPromise = store.init().catch((error) => {
170
+ // Allow a later invocation to retry initialization.
171
+ initPromise = null;
172
+ throw error;
173
+ });
174
+ }
175
+ await initPromise;
176
+ };
177
+
178
+ const listHandler: OperatorHandler<RoutingListInput, RoutingListOutput> = async (input) => {
179
+ await ensureInit();
180
+ const body = asObject(input.body);
181
+ const routes = store.list({
182
+ profileId: optionalString(body.profileId),
183
+ surfaceKind: optionalString(body.surfaceKind),
184
+ });
185
+ return { routes };
186
+ };
187
+
188
+ const assignHandler: OperatorHandler<RoutingAssignInput, RoutingAssignOutput> = async (input) => {
189
+ await ensureInit();
190
+ const body = asObject(input.body);
191
+ const channelId = optionalString(body.channelId);
192
+ const profileId = optionalString(body.profileId);
193
+ if (channelId === undefined) {
194
+ throw new OperatorError('channelId is required', 'ROUTING_INVALID_CHANNEL_ID', 400);
195
+ }
196
+ if (profileId === undefined) {
197
+ throw new OperatorError('profileId is required', 'ROUTING_INVALID_PROFILE_ID', 400);
198
+ }
199
+ const { route, created } = await store.upsert({
200
+ channelId,
201
+ profileId,
202
+ label: optionalString(body.label),
203
+ });
204
+ ctx.logger.info('channels.routing.assign', {
205
+ assignmentId: route.assignmentId,
206
+ channelId: route.channelId,
207
+ profileId: route.profileId,
208
+ created,
209
+ principalId: input.context.principalId,
210
+ });
211
+ return {
212
+ assignmentId: route.assignmentId,
213
+ channelId: route.channelId,
214
+ profileId: route.profileId,
215
+ created,
216
+ };
217
+ };
218
+
219
+ const deleteHandler: OperatorHandler<RoutingDeleteInput, RoutingDeleteOutput> = async (input) => {
220
+ await ensureInit();
221
+ const body = asObject(input.body);
222
+ const assignmentId = optionalString(body.assignmentId);
223
+ if (assignmentId === undefined) {
224
+ throw new OperatorError('assignmentId is required', 'ROUTING_INVALID_ASSIGNMENT_ID', 400);
225
+ }
226
+ const deleted = await store.delete(assignmentId);
227
+ ctx.logger.info('channels.routing.delete', {
228
+ assignmentId,
229
+ deleted,
230
+ principalId: input.context.principalId,
231
+ });
232
+ return { deleted };
233
+ };
234
+
235
+ const listDescriptor: OperatorMethodDescriptor = {
236
+ id: 'channels.routing.list',
237
+ title: 'List channel routes',
238
+ description: 'List channel-to-profile routing assignments, optionally filtered by profile or surface.',
239
+ category: 'channels',
240
+ source: 'daemon',
241
+ access: 'authenticated',
242
+ transport: ['ws', 'internal'],
243
+ scopes: ['channels:routing:read'],
244
+ effect: 'read-only',
245
+ inputSchema: LIST_INPUT_SCHEMA,
246
+ outputSchema: LIST_OUTPUT_SCHEMA,
247
+ };
248
+
249
+ const assignDescriptor: OperatorMethodDescriptor = {
250
+ id: 'channels.routing.assign',
251
+ title: 'Assign channel to profile',
252
+ description: 'Create or update the profile that handles inbound messages on a channel.',
253
+ category: 'channels',
254
+ source: 'daemon',
255
+ access: 'authenticated',
256
+ transport: ['ws', 'internal'],
257
+ scopes: ['channels:routing:write'],
258
+ effect: 'local-state-mutation',
259
+ confirm: true,
260
+ inputSchema: ASSIGN_INPUT_SCHEMA,
261
+ outputSchema: ASSIGN_OUTPUT_SCHEMA,
262
+ };
263
+
264
+ const deleteDescriptor: OperatorMethodDescriptor = {
265
+ id: 'channels.routing.delete',
266
+ title: 'Delete channel route',
267
+ description: 'Remove a channel-to-profile routing assignment by assignmentId.',
268
+ category: 'channels',
269
+ source: 'daemon',
270
+ access: 'authenticated',
271
+ transport: ['ws', 'internal'],
272
+ scopes: ['channels:routing:write'],
273
+ effect: 'local-state-mutation',
274
+ confirm: true,
275
+ inputSchema: DELETE_INPUT_SCHEMA,
276
+ outputSchema: DELETE_OUTPUT_SCHEMA,
277
+ };
278
+
279
+ const unregister = declareOperatorMethods(ctx, [
280
+ { descriptor: listDescriptor, handler: listHandler as OperatorHandler<unknown, unknown> },
281
+ { descriptor: assignDescriptor, handler: assignHandler as OperatorHandler<unknown, unknown> },
282
+ { descriptor: deleteDescriptor, handler: deleteHandler as OperatorHandler<unknown, unknown> },
283
+ ]);
284
+
285
+ // Build the callable teardown handle, then attach the store/resolver as
286
+ // read-only properties so callers (integration, the inbox surface) can reuse
287
+ // routing resolution without going through the catalog.
288
+ const teardown = (): void => {
289
+ unregister();
290
+ store.close();
291
+ };
292
+ return Object.assign(teardown, { store, resolver }) as RoutingRegistration;
293
+ }
294
+
295
+ /** SurfaceRegister contract entry point (integration calls this). */
296
+ export const register: SurfaceRegister = (ctx) => registerRoutingMethods(ctx);
@@ -0,0 +1,278 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { OperatorSqliteStore } from '../../operator/sqlite-store.ts';
3
+ import { OperatorError } from '../../operator/types.ts';
4
+
5
+ /**
6
+ * A persisted channel-to-profile routing assignment, in the exact shape the
7
+ * `channels.routing.*` operator methods return (see daemon handoff doc,
8
+ * Responsibility 2). This is intentionally distinct from the foundation's
9
+ * generic `ChannelRoute` type — the routing control-plane has its own contract
10
+ * keyed on (surfaceKind, routeId) composite channel IDs.
11
+ */
12
+ export interface RoutingChannelRoute {
13
+ assignmentId: string;
14
+ /** Composite key: `surfaceKind` or `surfaceKind:routeId`. */
15
+ channelId: string;
16
+ surfaceKind: string;
17
+ routeId?: string;
18
+ profileId: string;
19
+ label?: string;
20
+ createdAt: string; // ISO-8601
21
+ updatedAt: string; // ISO-8601
22
+ }
23
+
24
+ /** Parsed components of a composite channelId. */
25
+ export interface ParsedChannelId {
26
+ surfaceKind: string;
27
+ routeId?: string;
28
+ }
29
+
30
+ export interface RouteUpsertInput {
31
+ channelId: string;
32
+ profileId: string;
33
+ label?: string;
34
+ }
35
+
36
+ export interface RouteUpsertResult {
37
+ route: RoutingChannelRoute;
38
+ created: boolean;
39
+ }
40
+
41
+ export interface RouteListFilter {
42
+ profileId?: string;
43
+ surfaceKind?: string;
44
+ }
45
+
46
+ const SCHEMA: string[] = [
47
+ `CREATE TABLE IF NOT EXISTS routes (
48
+ assignmentId TEXT PRIMARY KEY,
49
+ channelId TEXT NOT NULL,
50
+ surfaceKind TEXT NOT NULL,
51
+ routeId TEXT,
52
+ profileId TEXT NOT NULL,
53
+ label TEXT,
54
+ createdAt TEXT NOT NULL,
55
+ updatedAt TEXT NOT NULL
56
+ )`,
57
+ `CREATE INDEX IF NOT EXISTS idx_routes_surface_route ON routes (surfaceKind, routeId)`,
58
+ // Enforce one assignment per channel — the upsert path relies on this.
59
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_routes_channel ON routes (channelId)`,
60
+ ];
61
+
62
+ interface RouteRow {
63
+ assignmentId: string;
64
+ channelId: string;
65
+ surfaceKind: string;
66
+ routeId: string | null;
67
+ profileId: string;
68
+ label: string | null;
69
+ createdAt: string;
70
+ updatedAt: string;
71
+ }
72
+
73
+ /**
74
+ * Parse a composite channelId into its `surfaceKind` and optional `routeId`.
75
+ *
76
+ * The channelId format is `surfaceKind` (surface-only / wildcard) or
77
+ * `surfaceKind:routeId`. Everything after the FIRST colon is the routeId, so
78
+ * route IDs may themselves contain colons (e.g. `slack:C123:thread`).
79
+ */
80
+ export function parseChannelId(channelId: string): ParsedChannelId {
81
+ const raw = typeof channelId === 'string' ? channelId.trim() : '';
82
+ if (raw.length === 0) {
83
+ throw new OperatorError('channelId is required', 'ROUTING_INVALID_CHANNEL_ID', 400);
84
+ }
85
+ const separator = raw.indexOf(':');
86
+ if (separator === -1) {
87
+ return { surfaceKind: raw };
88
+ }
89
+ const surfaceKind = raw.slice(0, separator).trim();
90
+ const routeId = raw.slice(separator + 1).trim();
91
+ if (surfaceKind.length === 0) {
92
+ throw new OperatorError('channelId is missing a surfaceKind', 'ROUTING_INVALID_CHANNEL_ID', 400);
93
+ }
94
+ // A trailing colon with no routeId collapses to a surface-only assignment.
95
+ return routeId.length === 0 ? { surfaceKind } : { surfaceKind, routeId };
96
+ }
97
+
98
+ /** Build a canonical composite channelId from its parts. */
99
+ export function buildChannelId(surfaceKind: string, routeId?: string): string {
100
+ return routeId === undefined || routeId === '' ? surfaceKind : `${surfaceKind}:${routeId}`;
101
+ }
102
+
103
+ function rowToRoute(row: RouteRow): RoutingChannelRoute {
104
+ const route: RoutingChannelRoute = {
105
+ assignmentId: row.assignmentId,
106
+ channelId: row.channelId,
107
+ surfaceKind: row.surfaceKind,
108
+ profileId: row.profileId,
109
+ createdAt: row.createdAt,
110
+ updatedAt: row.updatedAt,
111
+ };
112
+ if (row.routeId !== null && row.routeId !== '') route.routeId = row.routeId;
113
+ if (row.label !== null && row.label !== '') route.label = row.label;
114
+ return route;
115
+ }
116
+
117
+ /**
118
+ * SQLite-backed store for channel-to-profile routing assignments.
119
+ *
120
+ * Wraps {@link OperatorSqliteStore} (file `channel-routes.sqlite`) and persists
121
+ * after every mutation. The store owns the composite-key parsing so callers only
122
+ * ever deal with `channelId` strings.
123
+ */
124
+ export class RouteStore {
125
+ private readonly store: OperatorSqliteStore;
126
+ private initialized = false;
127
+
128
+ constructor(options: { workingDirectory: string }) {
129
+ this.store = new OperatorSqliteStore({
130
+ workingDirectory: options.workingDirectory,
131
+ fileName: 'channel-routes.sqlite',
132
+ schema: SCHEMA,
133
+ });
134
+ }
135
+
136
+ get dbPath(): string {
137
+ return this.store.dbPath;
138
+ }
139
+
140
+ async init(): Promise<void> {
141
+ if (this.initialized) return;
142
+ await this.store.init();
143
+ this.initialized = true;
144
+ }
145
+
146
+ close(): void {
147
+ this.store.close();
148
+ this.initialized = false;
149
+ }
150
+
151
+ private requireInit(): void {
152
+ if (!this.initialized) {
153
+ throw new OperatorError('RouteStore not initialized', 'ROUTING_STORE_UNINITIALIZED', 500);
154
+ }
155
+ }
156
+
157
+ /** All assignments, ordered most-recently-updated first. */
158
+ listAll(): RoutingChannelRoute[] {
159
+ this.requireInit();
160
+ const rows = this.store.all<RouteRow>(
161
+ `SELECT assignmentId, channelId, surfaceKind, routeId, profileId, label, createdAt, updatedAt
162
+ FROM routes ORDER BY updatedAt DESC, assignmentId ASC`,
163
+ );
164
+ return rows.map(rowToRoute);
165
+ }
166
+
167
+ /** Assignments filtered by optional profileId and/or surfaceKind. */
168
+ list(filter: RouteListFilter = {}): RoutingChannelRoute[] {
169
+ this.requireInit();
170
+ const clauses: string[] = [];
171
+ const params: (string | number)[] = [];
172
+ if (filter.profileId !== undefined && filter.profileId !== '') {
173
+ clauses.push('profileId = ?');
174
+ params.push(filter.profileId);
175
+ }
176
+ if (filter.surfaceKind !== undefined && filter.surfaceKind !== '') {
177
+ clauses.push('surfaceKind = ?');
178
+ params.push(filter.surfaceKind);
179
+ }
180
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
181
+ const rows = this.store.all<RouteRow>(
182
+ `SELECT assignmentId, channelId, surfaceKind, routeId, profileId, label, createdAt, updatedAt
183
+ FROM routes ${where} ORDER BY updatedAt DESC, assignmentId ASC`,
184
+ params,
185
+ );
186
+ return rows.map(rowToRoute);
187
+ }
188
+
189
+ /** Lookup a single assignment by its composite channelId. */
190
+ findByChannelId(channelId: string): RoutingChannelRoute | null {
191
+ this.requireInit();
192
+ const row = this.store.get<RouteRow>(
193
+ `SELECT assignmentId, channelId, surfaceKind, routeId, profileId, label, createdAt, updatedAt
194
+ FROM routes WHERE channelId = ?`,
195
+ [channelId],
196
+ );
197
+ return row ? rowToRoute(row) : null;
198
+ }
199
+
200
+ /** Lookup a single assignment by its assignmentId. */
201
+ findById(assignmentId: string): RoutingChannelRoute | null {
202
+ this.requireInit();
203
+ const row = this.store.get<RouteRow>(
204
+ `SELECT assignmentId, channelId, surfaceKind, routeId, profileId, label, createdAt, updatedAt
205
+ FROM routes WHERE assignmentId = ?`,
206
+ [assignmentId],
207
+ );
208
+ return row ? rowToRoute(row) : null;
209
+ }
210
+
211
+ /**
212
+ * Create or update the assignment for a channel.
213
+ *
214
+ * `created` is true when no prior assignment existed for the channelId, false
215
+ * when an existing row was updated. The assignmentId is stable across updates
216
+ * (it is preserved from the existing row). Persists to disk after the write.
217
+ */
218
+ async upsert(input: RouteUpsertInput): Promise<RouteUpsertResult> {
219
+ this.requireInit();
220
+ const profileId = typeof input.profileId === 'string' ? input.profileId.trim() : '';
221
+ if (profileId.length === 0) {
222
+ throw new OperatorError('profileId is required', 'ROUTING_INVALID_PROFILE_ID', 400);
223
+ }
224
+ const { surfaceKind, routeId } = parseChannelId(input.channelId);
225
+ const channelId = buildChannelId(surfaceKind, routeId);
226
+ const label = typeof input.label === 'string' && input.label.trim().length > 0
227
+ ? input.label.trim()
228
+ : null;
229
+ const now = new Date().toISOString();
230
+
231
+ const existing = this.findByChannelId(channelId);
232
+ if (existing) {
233
+ this.store.run(
234
+ `UPDATE routes
235
+ SET profileId = ?, label = ?, surfaceKind = ?, routeId = ?, updatedAt = ?
236
+ WHERE assignmentId = ?`,
237
+ [profileId, label, surfaceKind, routeId ?? null, now, existing.assignmentId],
238
+ );
239
+ await this.store.save();
240
+ const route = this.findById(existing.assignmentId);
241
+ if (!route) {
242
+ throw new OperatorError('Failed to read back updated route', 'ROUTING_WRITE_FAILED', 500);
243
+ }
244
+ return { route, created: false };
245
+ }
246
+
247
+ const assignmentId = randomUUID();
248
+ this.store.run(
249
+ `INSERT INTO routes
250
+ (assignmentId, channelId, surfaceKind, routeId, profileId, label, createdAt, updatedAt)
251
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
252
+ [assignmentId, channelId, surfaceKind, routeId ?? null, profileId, label, now, now],
253
+ );
254
+ await this.store.save();
255
+ const route = this.findById(assignmentId);
256
+ if (!route) {
257
+ throw new OperatorError('Failed to read back inserted route', 'ROUTING_WRITE_FAILED', 500);
258
+ }
259
+ return { route, created: true };
260
+ }
261
+
262
+ /**
263
+ * Delete an assignment by assignmentId. Returns true when a row was removed,
264
+ * false when no matching assignment existed. Persists to disk after a delete.
265
+ */
266
+ async delete(assignmentId: string): Promise<boolean> {
267
+ this.requireInit();
268
+ const id = typeof assignmentId === 'string' ? assignmentId.trim() : '';
269
+ if (id.length === 0) {
270
+ throw new OperatorError('assignmentId is required', 'ROUTING_INVALID_ASSIGNMENT_ID', 400);
271
+ }
272
+ const existing = this.findById(id);
273
+ if (!existing) return false;
274
+ this.store.run('DELETE FROM routes WHERE assignmentId = ?', [id]);
275
+ await this.store.save();
276
+ return true;
277
+ }
278
+ }
@@ -0,0 +1,75 @@
1
+ import type { RoutingChannelRoute } from './route-store.ts';
2
+ import { RouteStore } from './route-store.ts';
3
+
4
+ /** Wildcard surfaceKind that matches any surface as a last resort. */
5
+ export const WILDCARD_SURFACE = 'any';
6
+
7
+ /**
8
+ * Pure resolution of a profileId from a set of routes, applying the EXACT same
9
+ * order as the agent-side `getProfileForChannel()` so offline and online
10
+ * routing produce identical results:
11
+ *
12
+ * 1. exact match — surfaceKind AND routeId both match
13
+ * 2. surface-only — surfaceKind matches, route has no routeId
14
+ * 3. wildcard — a route with surfaceKind === 'any' (no routeId)
15
+ *
16
+ * Returns the matching profileId, or `null` when nothing matches.
17
+ */
18
+ export function resolveProfile(
19
+ routes: readonly RoutingChannelRoute[],
20
+ surfaceKind: string,
21
+ routeId?: string,
22
+ ): string | null {
23
+ const surface = typeof surfaceKind === 'string' ? surfaceKind.trim() : '';
24
+ if (surface.length === 0) return null;
25
+ const route = typeof routeId === 'string' && routeId.trim().length > 0 ? routeId.trim() : undefined;
26
+
27
+ // 1. Exact match (surfaceKind + routeId). Only attempted when a routeId is
28
+ // supplied by the caller.
29
+ if (route !== undefined) {
30
+ const exact = routes.find(
31
+ (entry) => entry.surfaceKind === surface && entry.routeId === route,
32
+ );
33
+ if (exact) return exact.profileId;
34
+ }
35
+
36
+ // 2. Surface-only match (surfaceKind, no routeId on the route).
37
+ const surfaceOnly = routes.find(
38
+ (entry) => entry.surfaceKind === surface && (entry.routeId === undefined || entry.routeId === ''),
39
+ );
40
+ if (surfaceOnly) return surfaceOnly.profileId;
41
+
42
+ // 3. Wildcard match (surfaceKind === 'any', no routeId).
43
+ const wildcard = routes.find(
44
+ (entry) => entry.surfaceKind === WILDCARD_SURFACE && (entry.routeId === undefined || entry.routeId === ''),
45
+ );
46
+ if (wildcard) return wildcard.profileId;
47
+
48
+ return null;
49
+ }
50
+
51
+ /** Resolver bound to a live {@link RouteStore}. */
52
+ export interface RoutingResolver {
53
+ /**
54
+ * Resolve the profileId that should handle an inbound message on the given
55
+ * surface/route, applying the agent resolution order. Returns null when no
56
+ * assignment matches (caller falls back to the default session).
57
+ */
58
+ getProfileForChannel(surfaceKind: string, routeId?: string): string | null;
59
+ /** Alias of {@link getProfileForChannel}, exported for inbox surface reuse. */
60
+ resolveProfile(surfaceKind: string, routeId?: string): string | null;
61
+ }
62
+
63
+ /**
64
+ * Create a resolver backed by a {@link RouteStore}. The store must already be
65
+ * initialized; each call reads the current route set so assignments take effect
66
+ * immediately after a mutation.
67
+ */
68
+ export function createRoutingResolver(store: RouteStore): RoutingResolver {
69
+ const resolve = (surfaceKind: string, routeId?: string): string | null =>
70
+ resolveProfile(store.listAll(), surfaceKind, routeId);
71
+ return {
72
+ getProfileForChannel: resolve,
73
+ resolveProfile: resolve,
74
+ };
75
+ }