@pellux/goodvibes-tui 0.24.1 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +2419 -1040
- package/package.json +2 -2
- package/src/daemon/handlers/calendar/caldav-client.ts +661 -0
- package/src/daemon/handlers/calendar/ics.ts +556 -0
- package/src/daemon/handlers/calendar/index.ts +316 -0
- package/src/daemon/handlers/context.ts +29 -0
- package/src/daemon/handlers/contracts.ts +77 -0
- package/src/daemon/handlers/credentials.ts +129 -0
- package/src/daemon/handlers/drafts/draft-store.ts +427 -0
- package/src/daemon/handlers/drafts/index.ts +17 -0
- package/src/daemon/handlers/drafts/register.ts +331 -0
- package/src/daemon/handlers/email/config.ts +164 -0
- package/src/daemon/handlers/email/imap-connector.ts +441 -0
- package/src/daemon/handlers/email/imap-parsing.ts +499 -0
- package/src/daemon/handlers/email/index.ts +43 -0
- package/src/daemon/handlers/email/read-handlers.ts +80 -0
- package/src/daemon/handlers/email/runtime.ts +140 -0
- package/src/daemon/handlers/email/smtp-connector.ts +557 -0
- package/src/daemon/handlers/email/validation.ts +147 -0
- package/src/daemon/handlers/email/write-handlers.ts +133 -0
- package/src/daemon/handlers/errors.ts +18 -0
- package/src/daemon/handlers/inbox/cursor-store.ts +290 -0
- package/src/daemon/handlers/inbox/index.ts +210 -0
- package/src/daemon/handlers/inbox/mapping.ts +192 -0
- package/src/daemon/handlers/inbox/poller.ts +155 -0
- package/src/daemon/handlers/inbox/provider-adapter.ts +156 -0
- package/src/daemon/handlers/inbox/providers/discord.ts +251 -0
- package/src/daemon/handlers/inbox/providers/email.ts +151 -0
- package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/handlers/inbox/providers/route-util.ts +23 -0
- package/src/daemon/handlers/inbox/providers/slack.ts +262 -0
- package/src/daemon/handlers/index.ts +107 -0
- package/src/daemon/handlers/register.ts +161 -0
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +142 -0
- package/src/daemon/handlers/remote/backends/docker.ts +79 -0
- package/src/daemon/handlers/remote/backends/index.ts +40 -0
- package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
- package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
- package/src/daemon/handlers/remote/backends/ssh.ts +125 -0
- package/src/daemon/handlers/remote/backends/types.ts +97 -0
- package/src/daemon/handlers/remote/dispatcher.ts +181 -0
- package/src/daemon/handlers/remote/index.ts +119 -0
- package/src/daemon/handlers/remote/peer-registry.ts +357 -0
- package/src/daemon/handlers/remote/service.ts +191 -0
- package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
- package/src/daemon/handlers/routing/index.ts +261 -0
- package/src/daemon/handlers/routing/route-store.ts +319 -0
- package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
- package/src/daemon/handlers/sqlite-store.ts +124 -0
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +212 -0
- package/src/daemon/handlers/triage/pipeline.ts +273 -0
- package/src/daemon/handlers/triage/scorer.ts +287 -0
- package/src/daemon/handlers/triage/tagger/discord.ts +186 -0
- package/src/daemon/handlers/triage/tagger/imap.ts +383 -0
- package/src/daemon/handlers/triage/tagger/index.ts +184 -0
- package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
- package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
- package/src/daemon/handlers/triage/types.ts +50 -0
- package/src/runtime/services.ts +48 -0
- package/src/version.ts +1 -1
|
@@ -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);
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { HandlerSqliteStore } from '../sqlite-store.ts';
|
|
3
|
+
import { HandlerError } from '../errors.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A persisted channel-to-profile routing assignment, in the exact shape the
|
|
7
|
+
* `channels.routing.*` methods serve. Keyed on a composite `channelId`
|
|
8
|
+
* (`surfaceKind` or `surfaceKind:routeId`). The SDK output schemas require
|
|
9
|
+
* `surfaceKind`, `profileId`, `createdAt`, and `updatedAt`; `channelId`,
|
|
10
|
+
* `routeId`, and `label` are carried through as optional refinements.
|
|
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
|
+
/**
|
|
25
|
+
* Per-route item shape emitted by `channels.routing.list`, matching the SDK
|
|
26
|
+
* `CHANNEL_ROUTING_RULE_SCHEMA` exactly: properties
|
|
27
|
+
* {id, createdAt, updatedAt, surfaceKind, routeId, profileId, label},
|
|
28
|
+
* required [id, createdAt, updatedAt, surfaceKind, profileId], and
|
|
29
|
+
* additionalProperties:false. The store's `assignmentId` is projected to `id`,
|
|
30
|
+
* and the daemon-internal `channelId` is dropped (it is forbidden by the
|
|
31
|
+
* schema's additionalProperties:false).
|
|
32
|
+
*/
|
|
33
|
+
export interface RoutingRouteListItem {
|
|
34
|
+
id: string;
|
|
35
|
+
createdAt: string; // ISO-8601
|
|
36
|
+
updatedAt: string; // ISO-8601
|
|
37
|
+
surfaceKind: string;
|
|
38
|
+
routeId?: string;
|
|
39
|
+
profileId: string;
|
|
40
|
+
label?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Project a stored route to the SDK `channels.routing.list` item shape. Pure;
|
|
45
|
+
* only includes optional `routeId`/`label` when present so the payload never
|
|
46
|
+
* carries `undefined`-valued or schema-forbidden keys.
|
|
47
|
+
*/
|
|
48
|
+
export function toRouteListItem(route: RoutingChannelRoute): RoutingRouteListItem {
|
|
49
|
+
const item: RoutingRouteListItem = {
|
|
50
|
+
id: route.assignmentId,
|
|
51
|
+
createdAt: route.createdAt,
|
|
52
|
+
updatedAt: route.updatedAt,
|
|
53
|
+
surfaceKind: route.surfaceKind,
|
|
54
|
+
profileId: route.profileId,
|
|
55
|
+
};
|
|
56
|
+
if (route.routeId !== undefined) {
|
|
57
|
+
item.routeId = route.routeId;
|
|
58
|
+
}
|
|
59
|
+
if (route.label !== undefined) {
|
|
60
|
+
item.label = route.label;
|
|
61
|
+
}
|
|
62
|
+
return item;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Parsed components of a composite channelId. */
|
|
66
|
+
export interface ParsedChannelId {
|
|
67
|
+
surfaceKind: string;
|
|
68
|
+
routeId?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface RouteUpsertInput {
|
|
72
|
+
channelId: string;
|
|
73
|
+
profileId: string;
|
|
74
|
+
label?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface RouteUpsertResult {
|
|
78
|
+
route: RoutingChannelRoute;
|
|
79
|
+
created: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface RouteListFilter {
|
|
83
|
+
profileId?: string;
|
|
84
|
+
surfaceKind?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const SCHEMA: string[] = [
|
|
88
|
+
`CREATE TABLE IF NOT EXISTS routes (
|
|
89
|
+
assignmentId TEXT PRIMARY KEY,
|
|
90
|
+
channelId TEXT NOT NULL,
|
|
91
|
+
surfaceKind TEXT NOT NULL,
|
|
92
|
+
routeId TEXT,
|
|
93
|
+
profileId TEXT NOT NULL,
|
|
94
|
+
label TEXT,
|
|
95
|
+
createdAt TEXT NOT NULL,
|
|
96
|
+
updatedAt TEXT NOT NULL
|
|
97
|
+
)`,
|
|
98
|
+
`CREATE INDEX IF NOT EXISTS idx_routes_surface_route ON routes (surfaceKind, routeId)`,
|
|
99
|
+
// Enforce one assignment per channel — the upsert path relies on this.
|
|
100
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_routes_channel ON routes (channelId)`,
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
interface RouteRow {
|
|
104
|
+
assignmentId: string;
|
|
105
|
+
channelId: string;
|
|
106
|
+
surfaceKind: string;
|
|
107
|
+
routeId: string | null;
|
|
108
|
+
profileId: string;
|
|
109
|
+
label: string | null;
|
|
110
|
+
createdAt: string;
|
|
111
|
+
updatedAt: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Parse a composite channelId into its `surfaceKind` and optional `routeId`.
|
|
116
|
+
*
|
|
117
|
+
* The channelId format is `surfaceKind` (surface-only / wildcard) or
|
|
118
|
+
* `surfaceKind:routeId`. Everything after the FIRST colon is the routeId, so
|
|
119
|
+
* route IDs may themselves contain colons (e.g. `slack:C123:thread`).
|
|
120
|
+
*/
|
|
121
|
+
export function parseChannelId(channelId: string): ParsedChannelId {
|
|
122
|
+
const raw = typeof channelId === 'string' ? channelId.trim() : '';
|
|
123
|
+
if (raw.length === 0) {
|
|
124
|
+
throw new HandlerError('channelId is required', 'ROUTING_INVALID_CHANNEL_ID', 400);
|
|
125
|
+
}
|
|
126
|
+
const separator = raw.indexOf(':');
|
|
127
|
+
if (separator === -1) {
|
|
128
|
+
return { surfaceKind: raw };
|
|
129
|
+
}
|
|
130
|
+
const surfaceKind = raw.slice(0, separator).trim();
|
|
131
|
+
const routeId = raw.slice(separator + 1).trim();
|
|
132
|
+
if (surfaceKind.length === 0) {
|
|
133
|
+
throw new HandlerError('channelId is missing a surfaceKind', 'ROUTING_INVALID_CHANNEL_ID', 400);
|
|
134
|
+
}
|
|
135
|
+
// A trailing colon with no routeId collapses to a surface-only assignment.
|
|
136
|
+
return routeId.length === 0 ? { surfaceKind } : { surfaceKind, routeId };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Build a canonical composite channelId from its parts. */
|
|
140
|
+
export function buildChannelId(surfaceKind: string, routeId?: string): string {
|
|
141
|
+
return routeId === undefined || routeId === '' ? surfaceKind : `${surfaceKind}:${routeId}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function rowToRoute(row: RouteRow): RoutingChannelRoute {
|
|
145
|
+
const route: RoutingChannelRoute = {
|
|
146
|
+
assignmentId: row.assignmentId,
|
|
147
|
+
channelId: row.channelId,
|
|
148
|
+
surfaceKind: row.surfaceKind,
|
|
149
|
+
profileId: row.profileId,
|
|
150
|
+
createdAt: row.createdAt,
|
|
151
|
+
updatedAt: row.updatedAt,
|
|
152
|
+
};
|
|
153
|
+
if (row.routeId !== null && row.routeId !== '') route.routeId = row.routeId;
|
|
154
|
+
if (row.label !== null && row.label !== '') route.label = row.label;
|
|
155
|
+
return route;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* SQLite-backed store for channel-to-profile routing assignments.
|
|
160
|
+
*
|
|
161
|
+
* Wraps {@link HandlerSqliteStore} (file `channel-routes.sqlite`) and persists
|
|
162
|
+
* after every mutation. The store owns the composite-key parsing so callers only
|
|
163
|
+
* ever deal with `channelId` strings.
|
|
164
|
+
*/
|
|
165
|
+
export class RouteStore {
|
|
166
|
+
private readonly store: HandlerSqliteStore;
|
|
167
|
+
private initialized = false;
|
|
168
|
+
|
|
169
|
+
constructor(options: { workingDirectory: string }) {
|
|
170
|
+
this.store = new HandlerSqliteStore({
|
|
171
|
+
workingDirectory: options.workingDirectory,
|
|
172
|
+
fileName: 'channel-routes.sqlite',
|
|
173
|
+
schema: SCHEMA,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
get dbPath(): string {
|
|
178
|
+
return this.store.dbPath;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async init(): Promise<void> {
|
|
182
|
+
if (this.initialized) return;
|
|
183
|
+
await this.store.init();
|
|
184
|
+
this.initialized = true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
close(): void {
|
|
188
|
+
this.store.close();
|
|
189
|
+
this.initialized = false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private requireInit(): void {
|
|
193
|
+
if (!this.initialized) {
|
|
194
|
+
throw new HandlerError('RouteStore not initialized', 'ROUTING_STORE_UNINITIALIZED', 500);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** All assignments, ordered most-recently-updated first. */
|
|
199
|
+
listAll(): RoutingChannelRoute[] {
|
|
200
|
+
this.requireInit();
|
|
201
|
+
const rows = this.store.all<RouteRow>(
|
|
202
|
+
`SELECT assignmentId, channelId, surfaceKind, routeId, profileId, label, createdAt, updatedAt
|
|
203
|
+
FROM routes ORDER BY updatedAt DESC, assignmentId ASC`,
|
|
204
|
+
);
|
|
205
|
+
return rows.map(rowToRoute);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Assignments filtered by optional profileId and/or surfaceKind. */
|
|
209
|
+
list(filter: RouteListFilter = {}): RoutingChannelRoute[] {
|
|
210
|
+
this.requireInit();
|
|
211
|
+
const clauses: string[] = [];
|
|
212
|
+
const params: (string | number)[] = [];
|
|
213
|
+
if (filter.profileId !== undefined && filter.profileId !== '') {
|
|
214
|
+
clauses.push('profileId = ?');
|
|
215
|
+
params.push(filter.profileId);
|
|
216
|
+
}
|
|
217
|
+
if (filter.surfaceKind !== undefined && filter.surfaceKind !== '') {
|
|
218
|
+
clauses.push('surfaceKind = ?');
|
|
219
|
+
params.push(filter.surfaceKind);
|
|
220
|
+
}
|
|
221
|
+
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
|
|
222
|
+
const rows = this.store.all<RouteRow>(
|
|
223
|
+
`SELECT assignmentId, channelId, surfaceKind, routeId, profileId, label, createdAt, updatedAt
|
|
224
|
+
FROM routes ${where} ORDER BY updatedAt DESC, assignmentId ASC`,
|
|
225
|
+
params,
|
|
226
|
+
);
|
|
227
|
+
return rows.map(rowToRoute);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Lookup a single assignment by its composite channelId. */
|
|
231
|
+
findByChannelId(channelId: string): RoutingChannelRoute | null {
|
|
232
|
+
this.requireInit();
|
|
233
|
+
const row = this.store.get<RouteRow>(
|
|
234
|
+
`SELECT assignmentId, channelId, surfaceKind, routeId, profileId, label, createdAt, updatedAt
|
|
235
|
+
FROM routes WHERE channelId = ?`,
|
|
236
|
+
[channelId],
|
|
237
|
+
);
|
|
238
|
+
return row ? rowToRoute(row) : null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Lookup a single assignment by its assignmentId. */
|
|
242
|
+
findById(assignmentId: string): RoutingChannelRoute | null {
|
|
243
|
+
this.requireInit();
|
|
244
|
+
const row = this.store.get<RouteRow>(
|
|
245
|
+
`SELECT assignmentId, channelId, surfaceKind, routeId, profileId, label, createdAt, updatedAt
|
|
246
|
+
FROM routes WHERE assignmentId = ?`,
|
|
247
|
+
[assignmentId],
|
|
248
|
+
);
|
|
249
|
+
return row ? rowToRoute(row) : null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Create or update the assignment for a channel.
|
|
254
|
+
*
|
|
255
|
+
* `created` is true when no prior assignment existed for the channelId, false
|
|
256
|
+
* when an existing row was updated. The assignmentId is stable across updates
|
|
257
|
+
* (it is preserved from the existing row). Persists to disk after the write.
|
|
258
|
+
*/
|
|
259
|
+
async upsert(input: RouteUpsertInput): Promise<RouteUpsertResult> {
|
|
260
|
+
this.requireInit();
|
|
261
|
+
const profileId = typeof input.profileId === 'string' ? input.profileId.trim() : '';
|
|
262
|
+
if (profileId.length === 0) {
|
|
263
|
+
throw new HandlerError('profileId is required', 'ROUTING_INVALID_PROFILE_ID', 400);
|
|
264
|
+
}
|
|
265
|
+
const { surfaceKind, routeId } = parseChannelId(input.channelId);
|
|
266
|
+
const channelId = buildChannelId(surfaceKind, routeId);
|
|
267
|
+
const label = typeof input.label === 'string' && input.label.trim().length > 0
|
|
268
|
+
? input.label.trim()
|
|
269
|
+
: null;
|
|
270
|
+
const now = new Date().toISOString();
|
|
271
|
+
|
|
272
|
+
const existing = this.findByChannelId(channelId);
|
|
273
|
+
if (existing) {
|
|
274
|
+
this.store.run(
|
|
275
|
+
`UPDATE routes
|
|
276
|
+
SET profileId = ?, label = ?, surfaceKind = ?, routeId = ?, updatedAt = ?
|
|
277
|
+
WHERE assignmentId = ?`,
|
|
278
|
+
[profileId, label, surfaceKind, routeId ?? null, now, existing.assignmentId],
|
|
279
|
+
);
|
|
280
|
+
await this.store.save();
|
|
281
|
+
const route = this.findById(existing.assignmentId);
|
|
282
|
+
if (!route) {
|
|
283
|
+
throw new HandlerError('Failed to read back updated route', 'ROUTING_WRITE_FAILED', 500);
|
|
284
|
+
}
|
|
285
|
+
return { route, created: false };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const assignmentId = randomUUID();
|
|
289
|
+
this.store.run(
|
|
290
|
+
`INSERT INTO routes
|
|
291
|
+
(assignmentId, channelId, surfaceKind, routeId, profileId, label, createdAt, updatedAt)
|
|
292
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
293
|
+
[assignmentId, channelId, surfaceKind, routeId ?? null, profileId, label, now, now],
|
|
294
|
+
);
|
|
295
|
+
await this.store.save();
|
|
296
|
+
const route = this.findById(assignmentId);
|
|
297
|
+
if (!route) {
|
|
298
|
+
throw new HandlerError('Failed to read back inserted route', 'ROUTING_WRITE_FAILED', 500);
|
|
299
|
+
}
|
|
300
|
+
return { route, created: true };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Delete an assignment by assignmentId. Returns true when a row was removed,
|
|
305
|
+
* false when no matching assignment existed. Persists to disk after a delete.
|
|
306
|
+
*/
|
|
307
|
+
async delete(assignmentId: string): Promise<boolean> {
|
|
308
|
+
this.requireInit();
|
|
309
|
+
const id = typeof assignmentId === 'string' ? assignmentId.trim() : '';
|
|
310
|
+
if (id.length === 0) {
|
|
311
|
+
throw new HandlerError('assignmentId is required', 'ROUTING_INVALID_ASSIGNMENT_ID', 400);
|
|
312
|
+
}
|
|
313
|
+
const existing = this.findById(id);
|
|
314
|
+
if (!existing) return false;
|
|
315
|
+
this.store.run('DELETE FROM routes WHERE assignmentId = ?', [id]);
|
|
316
|
+
await this.store.save();
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { RoutingChannelRoute } from './route-store.ts';
|
|
2
|
+
import type { 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
|
+
}
|