@pellux/goodvibes-tui 0.24.0 → 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.
- package/CHANGELOG.md +15 -0
- package/README.md +4 -5
- package/docs/foundation-artifacts/operator-contract.json +304 -230
- package/package.json +2 -2
- package/src/daemon/calendar/caldav-client.ts +657 -0
- package/src/daemon/calendar/ics.ts +556 -0
- package/src/daemon/calendar/index.ts +52 -0
- package/src/daemon/calendar/register.ts +527 -0
- package/src/daemon/channels/drafts/draft-store.ts +363 -0
- package/src/daemon/channels/drafts/index.ts +22 -0
- package/src/daemon/channels/drafts/register.ts +449 -0
- package/src/daemon/channels/inbox/cursor-store.ts +298 -0
- package/src/daemon/channels/inbox/index.ts +58 -0
- package/src/daemon/channels/inbox/mapping.ts +190 -0
- package/src/daemon/channels/inbox/poller.ts +155 -0
- package/src/daemon/channels/inbox/provider-adapter.ts +152 -0
- package/src/daemon/channels/inbox/providers/discord.ts +253 -0
- package/src/daemon/channels/inbox/providers/email.ts +151 -0
- package/src/daemon/channels/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/channels/inbox/providers/route-util.ts +23 -0
- package/src/daemon/channels/inbox/providers/slack.ts +264 -0
- package/src/daemon/channels/inbox/register.ts +247 -0
- package/src/daemon/channels/routing/inbox-bridge.ts +58 -0
- package/src/daemon/channels/routing/index.ts +39 -0
- package/src/daemon/channels/routing/register.ts +296 -0
- package/src/daemon/channels/routing/route-store.ts +278 -0
- package/src/daemon/channels/routing/routing-resolver.ts +75 -0
- package/src/daemon/email/imap-connector.ts +441 -0
- package/src/daemon/email/imap-parsing.ts +499 -0
- package/src/daemon/email/index.ts +68 -0
- package/src/daemon/email/register.ts +715 -0
- package/src/daemon/email/smtp-connector.ts +557 -0
- package/src/daemon/operator/credential-store.ts +129 -0
- package/src/daemon/operator/index.ts +43 -0
- package/src/daemon/operator/register-helper.ts +150 -0
- package/src/daemon/operator/sqlite-store.ts +124 -0
- package/src/daemon/operator/surfaces.ts +137 -0
- package/src/daemon/operator/types.ts +207 -0
- package/src/daemon/remote/backends/cloud-terminal.ts +137 -0
- package/src/daemon/remote/backends/docker.ts +80 -0
- package/src/daemon/remote/backends/index.ts +34 -0
- package/src/daemon/remote/backends/local-process.ts +113 -0
- package/src/daemon/remote/backends/process-runner.ts +151 -0
- package/src/daemon/remote/backends/ssh.ts +120 -0
- package/src/daemon/remote/backends/types.ts +71 -0
- package/src/daemon/remote/dispatcher.ts +160 -0
- package/src/daemon/remote/index.ts +74 -0
- package/src/daemon/remote/peer-registry.ts +321 -0
- package/src/daemon/remote/register.ts +411 -0
- package/src/daemon/triage/index.ts +59 -0
- package/src/daemon/triage/integration.ts +179 -0
- package/src/daemon/triage/pipeline.ts +285 -0
- package/src/daemon/triage/register.ts +231 -0
- package/src/daemon/triage/scorer.ts +287 -0
- package/src/daemon/triage/tagger.ts +777 -0
- package/src/runtime/services.ts +35 -0
- 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
|
+
}
|