@vellumai/assistant 0.3.26 → 0.3.28
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/ARCHITECTURE.md +48 -1
- package/Dockerfile +2 -2
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +6 -2
- package/src/__tests__/agent-loop.test.ts +119 -0
- package/src/__tests__/bundled-asset.test.ts +107 -0
- package/src/__tests__/canonical-guardian-store.test.ts +636 -0
- package/src/__tests__/channel-approval-routes.test.ts +174 -1
- package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
- package/src/__tests__/guardian-dispatch.test.ts +19 -19
- package/src/__tests__/guardian-routing-invariants.test.ts +954 -0
- package/src/__tests__/mcp-cli.test.ts +77 -0
- package/src/__tests__/non-member-access-request.test.ts +31 -29
- package/src/__tests__/notification-decision-fallback.test.ts +61 -3
- package/src/__tests__/notification-decision-strategy.test.ts +17 -0
- package/src/__tests__/notification-guardian-path.test.ts +13 -15
- package/src/__tests__/onboarding-template-contract.test.ts +116 -21
- package/src/__tests__/secret-scanner-executor.test.ts +59 -0
- package/src/__tests__/secret-scanner.test.ts +8 -0
- package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
- package/src/__tests__/session-runtime-assembly.test.ts +76 -47
- package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
- package/src/agent/loop.ts +46 -3
- package/src/approvals/guardian-decision-primitive.ts +285 -0
- package/src/approvals/guardian-request-resolvers.ts +539 -0
- package/src/calls/guardian-dispatch.ts +46 -40
- package/src/calls/relay-server.ts +147 -2
- package/src/calls/types.ts +1 -1
- package/src/config/system-prompt.ts +2 -1
- package/src/config/templates/BOOTSTRAP.md +47 -31
- package/src/config/templates/USER.md +5 -0
- package/src/config/update-bulletin-template-path.ts +4 -1
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +22 -17
- package/src/daemon/handlers/guardian-actions.ts +45 -66
- package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
- package/src/daemon/lifecycle.ts +3 -16
- package/src/daemon/server.ts +18 -0
- package/src/daemon/session-agent-loop-handlers.ts +5 -4
- package/src/daemon/session-agent-loop.ts +32 -5
- package/src/daemon/session-process.ts +68 -307
- package/src/daemon/session-runtime-assembly.ts +112 -24
- package/src/daemon/session-tool-setup.ts +1 -0
- package/src/daemon/session.ts +1 -0
- package/src/home-base/prebuilt/seed.ts +2 -1
- package/src/hooks/templates.ts +2 -1
- package/src/memory/canonical-guardian-store.ts +524 -0
- package/src/memory/channel-guardian-store.ts +1 -0
- package/src/memory/db-init.ts +16 -0
- package/src/memory/guardian-action-store.ts +7 -60
- package/src/memory/guardian-approvals.ts +9 -4
- package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
- package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
- package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
- package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
- package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
- package/src/memory/migrations/index.ts +4 -0
- package/src/memory/migrations/registry.ts +5 -0
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +52 -0
- package/src/notifications/copy-composer.ts +16 -4
- package/src/notifications/decision-engine.ts +57 -0
- package/src/permissions/defaults.ts +2 -0
- package/src/runtime/access-request-helper.ts +137 -0
- package/src/runtime/actor-trust-resolver.ts +225 -0
- package/src/runtime/channel-guardian-service.ts +12 -4
- package/src/runtime/guardian-context-resolver.ts +32 -7
- package/src/runtime/guardian-decision-types.ts +6 -0
- package/src/runtime/guardian-reply-router.ts +687 -0
- package/src/runtime/http-server.ts +8 -0
- package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
- package/src/runtime/routes/conversation-routes.ts +18 -0
- package/src/runtime/routes/guardian-action-routes.ts +100 -109
- package/src/runtime/routes/inbound-message-handler.ts +170 -525
- package/src/runtime/tool-grant-request-helper.ts +195 -0
- package/src/tools/executor.ts +13 -1
- package/src/tools/sensitive-output-placeholders.ts +203 -0
- package/src/tools/tool-approval-handler.ts +44 -1
- package/src/tools/types.ts +11 -0
- package/src/util/bundled-asset.ts +31 -0
- package/src/util/canonicalize-identity.ts +52 -0
package/src/hooks/templates.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { chmodSync, cpSync, type Dirent,readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
|
+
import { resolveBundledDir } from '../util/bundled-asset.js';
|
|
4
5
|
import { pathExists } from '../util/fs.js';
|
|
5
6
|
import { getLogger } from '../util/logger.js';
|
|
6
7
|
import { getHooksDir } from '../util/platform.js';
|
|
@@ -15,7 +16,7 @@ const log = getLogger('hooks-templates');
|
|
|
15
16
|
* - Newly installed hooks are disabled by default.
|
|
16
17
|
*/
|
|
17
18
|
export function installTemplates(): void {
|
|
18
|
-
const templatesDir =
|
|
19
|
+
const templatesDir = resolveBundledDir(import.meta.dirname ?? __dirname, '../../hook-templates', 'hook-templates');
|
|
19
20
|
if (!pathExists(templatesDir)) return;
|
|
20
21
|
|
|
21
22
|
const hooksDir = getHooksDir();
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store for canonical guardian requests and deliveries.
|
|
3
|
+
*
|
|
4
|
+
* Unifies voice guardian action requests/deliveries and channel guardian
|
|
5
|
+
* approval requests into a single persistence model. Resolution uses
|
|
6
|
+
* compare-and-swap (CAS) semantics: the first writer to transition a
|
|
7
|
+
* request from the expected status wins.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { and, eq } from 'drizzle-orm';
|
|
11
|
+
import { v4 as uuid } from 'uuid';
|
|
12
|
+
|
|
13
|
+
import { getDb, rawChanges } from './db.js';
|
|
14
|
+
import {
|
|
15
|
+
canonicalGuardianDeliveries,
|
|
16
|
+
canonicalGuardianRequests,
|
|
17
|
+
} from './schema.js';
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Types
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
export type CanonicalRequestStatus = 'pending' | 'approved' | 'denied' | 'expired' | 'cancelled';
|
|
24
|
+
|
|
25
|
+
export interface CanonicalGuardianRequest {
|
|
26
|
+
id: string;
|
|
27
|
+
kind: string;
|
|
28
|
+
sourceType: string;
|
|
29
|
+
sourceChannel: string | null;
|
|
30
|
+
conversationId: string | null;
|
|
31
|
+
requesterExternalUserId: string | null;
|
|
32
|
+
requesterChatId: string | null;
|
|
33
|
+
guardianExternalUserId: string | null;
|
|
34
|
+
callSessionId: string | null;
|
|
35
|
+
pendingQuestionId: string | null;
|
|
36
|
+
questionText: string | null;
|
|
37
|
+
requestCode: string | null;
|
|
38
|
+
toolName: string | null;
|
|
39
|
+
inputDigest: string | null;
|
|
40
|
+
status: CanonicalRequestStatus;
|
|
41
|
+
answerText: string | null;
|
|
42
|
+
decidedByExternalUserId: string | null;
|
|
43
|
+
followupState: string | null;
|
|
44
|
+
expiresAt: string | null;
|
|
45
|
+
createdAt: string;
|
|
46
|
+
updatedAt: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CanonicalGuardianDelivery {
|
|
50
|
+
id: string;
|
|
51
|
+
requestId: string;
|
|
52
|
+
destinationChannel: string;
|
|
53
|
+
destinationConversationId: string | null;
|
|
54
|
+
destinationChatId: string | null;
|
|
55
|
+
destinationMessageId: string | null;
|
|
56
|
+
status: string;
|
|
57
|
+
createdAt: string;
|
|
58
|
+
updatedAt: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Request code generation
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Generate a short human-readable request code (6 hex chars, uppercase).
|
|
67
|
+
*
|
|
68
|
+
* Checks for collisions against existing PENDING canonical requests and
|
|
69
|
+
* retries up to 5 times to avoid code reuse among active requests.
|
|
70
|
+
*/
|
|
71
|
+
export function generateCanonicalRequestCode(): string {
|
|
72
|
+
const MAX_RETRIES = 5;
|
|
73
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
74
|
+
const code = uuid().replace(/-/g, '').slice(0, 6).toUpperCase();
|
|
75
|
+
// Only check for collisions among pending requests — resolved requests
|
|
76
|
+
// with the same code are harmless since getCanonicalGuardianRequestByCode
|
|
77
|
+
// already filters by status='pending'.
|
|
78
|
+
const existing = getCanonicalGuardianRequestByCodeInternal(code);
|
|
79
|
+
if (!existing) return code;
|
|
80
|
+
}
|
|
81
|
+
// Last resort: return the code even if it collides (extremely unlikely
|
|
82
|
+
// with 16^6 = ~16.7M possible codes).
|
|
83
|
+
return uuid().replace(/-/g, '').slice(0, 6).toUpperCase();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Internal code lookup used by the collision checker. Avoids circular
|
|
88
|
+
* dependency with the public getCanonicalGuardianRequestByCode by
|
|
89
|
+
* inlining the same query logic.
|
|
90
|
+
*/
|
|
91
|
+
function getCanonicalGuardianRequestByCodeInternal(code: string): boolean {
|
|
92
|
+
const db = getDb();
|
|
93
|
+
const row = db
|
|
94
|
+
.select()
|
|
95
|
+
.from(canonicalGuardianRequests)
|
|
96
|
+
.where(
|
|
97
|
+
and(
|
|
98
|
+
eq(canonicalGuardianRequests.requestCode, code),
|
|
99
|
+
eq(canonicalGuardianRequests.status, 'pending'),
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
.get();
|
|
103
|
+
return !!row;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Helpers
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
function rowToRequest(row: typeof canonicalGuardianRequests.$inferSelect): CanonicalGuardianRequest {
|
|
111
|
+
return {
|
|
112
|
+
id: row.id,
|
|
113
|
+
kind: row.kind,
|
|
114
|
+
sourceType: row.sourceType,
|
|
115
|
+
sourceChannel: row.sourceChannel,
|
|
116
|
+
conversationId: row.conversationId,
|
|
117
|
+
requesterExternalUserId: row.requesterExternalUserId,
|
|
118
|
+
requesterChatId: row.requesterChatId,
|
|
119
|
+
guardianExternalUserId: row.guardianExternalUserId,
|
|
120
|
+
callSessionId: row.callSessionId,
|
|
121
|
+
pendingQuestionId: row.pendingQuestionId,
|
|
122
|
+
questionText: row.questionText,
|
|
123
|
+
requestCode: row.requestCode,
|
|
124
|
+
toolName: row.toolName,
|
|
125
|
+
inputDigest: row.inputDigest,
|
|
126
|
+
status: row.status as CanonicalRequestStatus,
|
|
127
|
+
answerText: row.answerText,
|
|
128
|
+
decidedByExternalUserId: row.decidedByExternalUserId,
|
|
129
|
+
followupState: row.followupState,
|
|
130
|
+
expiresAt: row.expiresAt,
|
|
131
|
+
createdAt: row.createdAt,
|
|
132
|
+
updatedAt: row.updatedAt,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function rowToDelivery(row: typeof canonicalGuardianDeliveries.$inferSelect): CanonicalGuardianDelivery {
|
|
137
|
+
return {
|
|
138
|
+
id: row.id,
|
|
139
|
+
requestId: row.requestId,
|
|
140
|
+
destinationChannel: row.destinationChannel,
|
|
141
|
+
destinationConversationId: row.destinationConversationId,
|
|
142
|
+
destinationChatId: row.destinationChatId,
|
|
143
|
+
destinationMessageId: row.destinationMessageId,
|
|
144
|
+
status: row.status,
|
|
145
|
+
createdAt: row.createdAt,
|
|
146
|
+
updatedAt: row.updatedAt,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Canonical Guardian Requests
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
export interface CreateCanonicalGuardianRequestParams {
|
|
155
|
+
id?: string;
|
|
156
|
+
kind: string;
|
|
157
|
+
sourceType: string;
|
|
158
|
+
sourceChannel?: string;
|
|
159
|
+
conversationId?: string;
|
|
160
|
+
requesterExternalUserId?: string;
|
|
161
|
+
requesterChatId?: string;
|
|
162
|
+
guardianExternalUserId?: string;
|
|
163
|
+
callSessionId?: string;
|
|
164
|
+
pendingQuestionId?: string;
|
|
165
|
+
questionText?: string;
|
|
166
|
+
requestCode?: string;
|
|
167
|
+
toolName?: string;
|
|
168
|
+
inputDigest?: string;
|
|
169
|
+
status?: CanonicalRequestStatus;
|
|
170
|
+
answerText?: string;
|
|
171
|
+
decidedByExternalUserId?: string;
|
|
172
|
+
followupState?: string;
|
|
173
|
+
expiresAt?: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function createCanonicalGuardianRequest(params: CreateCanonicalGuardianRequestParams): CanonicalGuardianRequest {
|
|
177
|
+
const db = getDb();
|
|
178
|
+
const now = new Date().toISOString();
|
|
179
|
+
const id = params.id ?? uuid();
|
|
180
|
+
|
|
181
|
+
const row = {
|
|
182
|
+
id,
|
|
183
|
+
kind: params.kind,
|
|
184
|
+
sourceType: params.sourceType,
|
|
185
|
+
sourceChannel: params.sourceChannel ?? null,
|
|
186
|
+
conversationId: params.conversationId ?? null,
|
|
187
|
+
requesterExternalUserId: params.requesterExternalUserId ?? null,
|
|
188
|
+
requesterChatId: params.requesterChatId ?? null,
|
|
189
|
+
guardianExternalUserId: params.guardianExternalUserId ?? null,
|
|
190
|
+
callSessionId: params.callSessionId ?? null,
|
|
191
|
+
pendingQuestionId: params.pendingQuestionId ?? null,
|
|
192
|
+
questionText: params.questionText ?? null,
|
|
193
|
+
requestCode: params.requestCode ?? generateCanonicalRequestCode(),
|
|
194
|
+
toolName: params.toolName ?? null,
|
|
195
|
+
inputDigest: params.inputDigest ?? null,
|
|
196
|
+
status: params.status ?? ('pending' as const),
|
|
197
|
+
answerText: params.answerText ?? null,
|
|
198
|
+
decidedByExternalUserId: params.decidedByExternalUserId ?? null,
|
|
199
|
+
followupState: params.followupState ?? null,
|
|
200
|
+
expiresAt: params.expiresAt ?? null,
|
|
201
|
+
createdAt: now,
|
|
202
|
+
updatedAt: now,
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
db.insert(canonicalGuardianRequests).values(row).run();
|
|
206
|
+
return rowToRequest(row);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function getCanonicalGuardianRequest(id: string): CanonicalGuardianRequest | null {
|
|
210
|
+
const db = getDb();
|
|
211
|
+
const row = db
|
|
212
|
+
.select()
|
|
213
|
+
.from(canonicalGuardianRequests)
|
|
214
|
+
.where(eq(canonicalGuardianRequests.id, id))
|
|
215
|
+
.get();
|
|
216
|
+
return row ? rowToRequest(row) : null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Look up a canonical guardian request by its short request code.
|
|
221
|
+
* Scoped to pending (unresolved) requests so that codes recycled by older,
|
|
222
|
+
* already-resolved requests do not collide with the active one.
|
|
223
|
+
*/
|
|
224
|
+
export function getCanonicalGuardianRequestByCode(code: string): CanonicalGuardianRequest | null {
|
|
225
|
+
const db = getDb();
|
|
226
|
+
const row = db
|
|
227
|
+
.select()
|
|
228
|
+
.from(canonicalGuardianRequests)
|
|
229
|
+
.where(
|
|
230
|
+
and(
|
|
231
|
+
eq(canonicalGuardianRequests.requestCode, code),
|
|
232
|
+
eq(canonicalGuardianRequests.status, 'pending'),
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
.get();
|
|
236
|
+
return row ? rowToRequest(row) : null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export interface ListCanonicalGuardianRequestsFilters {
|
|
240
|
+
status?: CanonicalRequestStatus;
|
|
241
|
+
guardianExternalUserId?: string;
|
|
242
|
+
requesterExternalUserId?: string;
|
|
243
|
+
conversationId?: string;
|
|
244
|
+
sourceType?: string;
|
|
245
|
+
sourceChannel?: string;
|
|
246
|
+
kind?: string;
|
|
247
|
+
toolName?: string;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function listCanonicalGuardianRequests(filters?: ListCanonicalGuardianRequestsFilters): CanonicalGuardianRequest[] {
|
|
251
|
+
const db = getDb();
|
|
252
|
+
|
|
253
|
+
const conditions = [];
|
|
254
|
+
if (filters?.status) {
|
|
255
|
+
conditions.push(eq(canonicalGuardianRequests.status, filters.status));
|
|
256
|
+
}
|
|
257
|
+
if (filters?.guardianExternalUserId) {
|
|
258
|
+
conditions.push(eq(canonicalGuardianRequests.guardianExternalUserId, filters.guardianExternalUserId));
|
|
259
|
+
}
|
|
260
|
+
if (filters?.conversationId) {
|
|
261
|
+
conditions.push(eq(canonicalGuardianRequests.conversationId, filters.conversationId));
|
|
262
|
+
}
|
|
263
|
+
if (filters?.requesterExternalUserId) {
|
|
264
|
+
conditions.push(eq(canonicalGuardianRequests.requesterExternalUserId, filters.requesterExternalUserId));
|
|
265
|
+
}
|
|
266
|
+
if (filters?.sourceType) {
|
|
267
|
+
conditions.push(eq(canonicalGuardianRequests.sourceType, filters.sourceType));
|
|
268
|
+
}
|
|
269
|
+
if (filters?.sourceChannel) {
|
|
270
|
+
conditions.push(eq(canonicalGuardianRequests.sourceChannel, filters.sourceChannel));
|
|
271
|
+
}
|
|
272
|
+
if (filters?.kind) {
|
|
273
|
+
conditions.push(eq(canonicalGuardianRequests.kind, filters.kind));
|
|
274
|
+
}
|
|
275
|
+
if (filters?.toolName) {
|
|
276
|
+
conditions.push(eq(canonicalGuardianRequests.toolName, filters.toolName));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (conditions.length === 0) {
|
|
280
|
+
return db.select().from(canonicalGuardianRequests).all().map(rowToRequest);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return db
|
|
284
|
+
.select()
|
|
285
|
+
.from(canonicalGuardianRequests)
|
|
286
|
+
.where(and(...conditions))
|
|
287
|
+
.all()
|
|
288
|
+
.map(rowToRequest);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export interface UpdateCanonicalGuardianRequestParams {
|
|
292
|
+
status?: CanonicalRequestStatus;
|
|
293
|
+
answerText?: string;
|
|
294
|
+
decidedByExternalUserId?: string;
|
|
295
|
+
followupState?: string;
|
|
296
|
+
expiresAt?: string;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function updateCanonicalGuardianRequest(
|
|
300
|
+
id: string,
|
|
301
|
+
updates: UpdateCanonicalGuardianRequestParams,
|
|
302
|
+
): CanonicalGuardianRequest | null {
|
|
303
|
+
const db = getDb();
|
|
304
|
+
const now = new Date().toISOString();
|
|
305
|
+
|
|
306
|
+
const setValues: Record<string, unknown> = { updatedAt: now };
|
|
307
|
+
if (updates.status !== undefined) setValues.status = updates.status;
|
|
308
|
+
if (updates.answerText !== undefined) setValues.answerText = updates.answerText;
|
|
309
|
+
if (updates.decidedByExternalUserId !== undefined) setValues.decidedByExternalUserId = updates.decidedByExternalUserId;
|
|
310
|
+
if (updates.followupState !== undefined) setValues.followupState = updates.followupState;
|
|
311
|
+
if (updates.expiresAt !== undefined) setValues.expiresAt = updates.expiresAt;
|
|
312
|
+
|
|
313
|
+
db.update(canonicalGuardianRequests)
|
|
314
|
+
.set(setValues)
|
|
315
|
+
.where(eq(canonicalGuardianRequests.id, id))
|
|
316
|
+
.run();
|
|
317
|
+
|
|
318
|
+
return getCanonicalGuardianRequest(id);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export interface ResolveDecision {
|
|
322
|
+
status: CanonicalRequestStatus;
|
|
323
|
+
answerText?: string;
|
|
324
|
+
decidedByExternalUserId?: string;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Compare-and-swap resolve: only transitions the request from `expectedStatus`
|
|
329
|
+
* to the new status atomically. Returns the updated request on success, or
|
|
330
|
+
* null if the current status did not match `expectedStatus` (first-writer-wins).
|
|
331
|
+
*/
|
|
332
|
+
export function resolveCanonicalGuardianRequest(
|
|
333
|
+
id: string,
|
|
334
|
+
expectedStatus: CanonicalRequestStatus,
|
|
335
|
+
decision: ResolveDecision,
|
|
336
|
+
): CanonicalGuardianRequest | null {
|
|
337
|
+
const db = getDb();
|
|
338
|
+
const now = new Date().toISOString();
|
|
339
|
+
|
|
340
|
+
const setValues: Record<string, unknown> = {
|
|
341
|
+
status: decision.status,
|
|
342
|
+
updatedAt: now,
|
|
343
|
+
};
|
|
344
|
+
if (decision.answerText !== undefined) setValues.answerText = decision.answerText;
|
|
345
|
+
if (decision.decidedByExternalUserId !== undefined) setValues.decidedByExternalUserId = decision.decidedByExternalUserId;
|
|
346
|
+
|
|
347
|
+
db.update(canonicalGuardianRequests)
|
|
348
|
+
.set(setValues)
|
|
349
|
+
.where(
|
|
350
|
+
and(
|
|
351
|
+
eq(canonicalGuardianRequests.id, id),
|
|
352
|
+
eq(canonicalGuardianRequests.status, expectedStatus),
|
|
353
|
+
),
|
|
354
|
+
)
|
|
355
|
+
.run();
|
|
356
|
+
|
|
357
|
+
if (rawChanges() === 0) return null;
|
|
358
|
+
|
|
359
|
+
return getCanonicalGuardianRequest(id);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
// Canonical Guardian Deliveries
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
export interface CreateCanonicalGuardianDeliveryParams {
|
|
367
|
+
id?: string;
|
|
368
|
+
requestId: string;
|
|
369
|
+
destinationChannel: string;
|
|
370
|
+
destinationConversationId?: string;
|
|
371
|
+
destinationChatId?: string;
|
|
372
|
+
destinationMessageId?: string;
|
|
373
|
+
status?: string;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function createCanonicalGuardianDelivery(params: CreateCanonicalGuardianDeliveryParams): CanonicalGuardianDelivery {
|
|
377
|
+
const db = getDb();
|
|
378
|
+
const now = new Date().toISOString();
|
|
379
|
+
const id = params.id ?? uuid();
|
|
380
|
+
|
|
381
|
+
const row = {
|
|
382
|
+
id,
|
|
383
|
+
requestId: params.requestId,
|
|
384
|
+
destinationChannel: params.destinationChannel,
|
|
385
|
+
destinationConversationId: params.destinationConversationId ?? null,
|
|
386
|
+
destinationChatId: params.destinationChatId ?? null,
|
|
387
|
+
destinationMessageId: params.destinationMessageId ?? null,
|
|
388
|
+
status: params.status ?? ('pending' as const),
|
|
389
|
+
createdAt: now,
|
|
390
|
+
updatedAt: now,
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
db.insert(canonicalGuardianDeliveries).values(row).run();
|
|
394
|
+
return rowToDelivery(row);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function listCanonicalGuardianDeliveries(requestId: string): CanonicalGuardianDelivery[] {
|
|
398
|
+
const db = getDb();
|
|
399
|
+
return db
|
|
400
|
+
.select()
|
|
401
|
+
.from(canonicalGuardianDeliveries)
|
|
402
|
+
.where(eq(canonicalGuardianDeliveries.requestId, requestId))
|
|
403
|
+
.all()
|
|
404
|
+
.map(rowToDelivery);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* List pending canonical requests that were delivered to a specific
|
|
409
|
+
* destination conversation.
|
|
410
|
+
*
|
|
411
|
+
* This bridges inbound guardian replies (which arrive on the destination
|
|
412
|
+
* conversation) back to their canonical request records. The caller can
|
|
413
|
+
* optionally scope by destination channel when the same conversation ID
|
|
414
|
+
* namespace could exist across channels.
|
|
415
|
+
*/
|
|
416
|
+
export function listPendingCanonicalGuardianRequestsByDestinationConversation(
|
|
417
|
+
destinationConversationId: string,
|
|
418
|
+
destinationChannel?: string,
|
|
419
|
+
): CanonicalGuardianRequest[] {
|
|
420
|
+
const db = getDb();
|
|
421
|
+
|
|
422
|
+
const deliveryConditions = [eq(canonicalGuardianDeliveries.destinationConversationId, destinationConversationId)];
|
|
423
|
+
if (destinationChannel) {
|
|
424
|
+
deliveryConditions.push(eq(canonicalGuardianDeliveries.destinationChannel, destinationChannel));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const deliveries = db
|
|
428
|
+
.select()
|
|
429
|
+
.from(canonicalGuardianDeliveries)
|
|
430
|
+
.where(and(...deliveryConditions))
|
|
431
|
+
.all();
|
|
432
|
+
|
|
433
|
+
if (deliveries.length === 0) return [];
|
|
434
|
+
|
|
435
|
+
const seenRequestIds = new Set<string>();
|
|
436
|
+
const pendingRequests: CanonicalGuardianRequest[] = [];
|
|
437
|
+
|
|
438
|
+
for (const delivery of deliveries) {
|
|
439
|
+
if (seenRequestIds.has(delivery.requestId)) continue;
|
|
440
|
+
seenRequestIds.add(delivery.requestId);
|
|
441
|
+
|
|
442
|
+
const request = getCanonicalGuardianRequest(delivery.requestId);
|
|
443
|
+
if (request && request.status === 'pending') {
|
|
444
|
+
pendingRequests.push(request);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return pendingRequests;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* List pending canonical requests that were delivered to a specific
|
|
453
|
+
* destination chat (channel + chatId pair).
|
|
454
|
+
*
|
|
455
|
+
* This bridges inbound guardian replies (which arrive on a specific chat)
|
|
456
|
+
* back to their canonical request records. Unlike the conversation-based
|
|
457
|
+
* variant, this uses the chat-level addressing that channel transports
|
|
458
|
+
* (Telegram, SMS) natively provide — critical for voice-originated
|
|
459
|
+
* `pending_question` requests that lack `guardianExternalUserId`.
|
|
460
|
+
*/
|
|
461
|
+
export function listPendingCanonicalGuardianRequestsByDestinationChat(
|
|
462
|
+
destinationChannel: string,
|
|
463
|
+
destinationChatId: string,
|
|
464
|
+
): CanonicalGuardianRequest[] {
|
|
465
|
+
const db = getDb();
|
|
466
|
+
|
|
467
|
+
const deliveries = db
|
|
468
|
+
.select()
|
|
469
|
+
.from(canonicalGuardianDeliveries)
|
|
470
|
+
.where(
|
|
471
|
+
and(
|
|
472
|
+
eq(canonicalGuardianDeliveries.destinationChannel, destinationChannel),
|
|
473
|
+
eq(canonicalGuardianDeliveries.destinationChatId, destinationChatId),
|
|
474
|
+
),
|
|
475
|
+
)
|
|
476
|
+
.all();
|
|
477
|
+
|
|
478
|
+
if (deliveries.length === 0) return [];
|
|
479
|
+
|
|
480
|
+
const seenRequestIds = new Set<string>();
|
|
481
|
+
const pendingRequests: CanonicalGuardianRequest[] = [];
|
|
482
|
+
|
|
483
|
+
for (const delivery of deliveries) {
|
|
484
|
+
if (seenRequestIds.has(delivery.requestId)) continue;
|
|
485
|
+
seenRequestIds.add(delivery.requestId);
|
|
486
|
+
|
|
487
|
+
const request = getCanonicalGuardianRequest(delivery.requestId);
|
|
488
|
+
if (request && request.status === 'pending') {
|
|
489
|
+
pendingRequests.push(request);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return pendingRequests;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export interface UpdateCanonicalGuardianDeliveryParams {
|
|
497
|
+
status?: string;
|
|
498
|
+
destinationMessageId?: string;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export function updateCanonicalGuardianDelivery(
|
|
502
|
+
id: string,
|
|
503
|
+
updates: UpdateCanonicalGuardianDeliveryParams,
|
|
504
|
+
): CanonicalGuardianDelivery | null {
|
|
505
|
+
const db = getDb();
|
|
506
|
+
const now = new Date().toISOString();
|
|
507
|
+
|
|
508
|
+
const setValues: Record<string, unknown> = { updatedAt: now };
|
|
509
|
+
if (updates.status !== undefined) setValues.status = updates.status;
|
|
510
|
+
if (updates.destinationMessageId !== undefined) setValues.destinationMessageId = updates.destinationMessageId;
|
|
511
|
+
|
|
512
|
+
db.update(canonicalGuardianDeliveries)
|
|
513
|
+
.set(setValues)
|
|
514
|
+
.where(eq(canonicalGuardianDeliveries.id, id))
|
|
515
|
+
.run();
|
|
516
|
+
|
|
517
|
+
const row = db
|
|
518
|
+
.select()
|
|
519
|
+
.from(canonicalGuardianDeliveries)
|
|
520
|
+
.where(eq(canonicalGuardianDeliveries.id, id))
|
|
521
|
+
.get();
|
|
522
|
+
|
|
523
|
+
return row ? rowToDelivery(row) : null;
|
|
524
|
+
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
export {
|
|
14
14
|
type ApprovalRequestStatus,
|
|
15
15
|
countPendingByConversation,
|
|
16
|
+
// @internal — test-only helpers; production code uses canonical-guardian-store
|
|
16
17
|
createApprovalRequest,
|
|
17
18
|
findPendingAccessRequestForRequester,
|
|
18
19
|
getAllPendingApprovalsByGuardianChat,
|
package/src/memory/db-init.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
addCoreColumns,
|
|
4
4
|
createAssistantInboxTables,
|
|
5
5
|
createCallSessionsTables,
|
|
6
|
+
createCanonicalGuardianTables,
|
|
6
7
|
createChannelGuardianTables,
|
|
7
8
|
createContactsAndTriageTables,
|
|
8
9
|
createConversationAttentionTables,
|
|
@@ -18,6 +19,8 @@ import {
|
|
|
18
19
|
createTasksAndWorkItemsTables,
|
|
19
20
|
createWatchersAndLogsTables,
|
|
20
21
|
migrateCallSessionMode,
|
|
22
|
+
migrateCanonicalGuardianDeliveriesDestinationIndex,
|
|
23
|
+
migrateCanonicalGuardianRequesterChatId,
|
|
21
24
|
migrateChannelInboundDeliveredSegments,
|
|
22
25
|
migrateConversationsThreadTypeIndex,
|
|
23
26
|
migrateFkCascadeRebuilds,
|
|
@@ -29,6 +32,7 @@ import {
|
|
|
29
32
|
migrateGuardianVerificationPurpose,
|
|
30
33
|
migrateGuardianVerificationSessions,
|
|
31
34
|
migrateMessagesFtsBackfill,
|
|
35
|
+
migrateNormalizePhoneIdentities,
|
|
32
36
|
migrateNotificationDeliveryThreadDecision,
|
|
33
37
|
migrateReminderRoutingIntent,
|
|
34
38
|
migrateSchemaIndexesAndColumns,
|
|
@@ -145,5 +149,17 @@ export function initializeDb(): void {
|
|
|
145
149
|
// 23. Thread decision audit columns on notification_deliveries
|
|
146
150
|
migrateNotificationDeliveryThreadDecision(database);
|
|
147
151
|
|
|
152
|
+
// 24. Canonical guardian requests and deliveries (unified cross-source guardian domain)
|
|
153
|
+
createCanonicalGuardianTables(database);
|
|
154
|
+
|
|
155
|
+
// 24b. Add requester_chat_id to canonical_guardian_requests (chat ID != user ID on some channels)
|
|
156
|
+
migrateCanonicalGuardianRequesterChatId(database);
|
|
157
|
+
|
|
158
|
+
// 24c. Composite index on canonical_guardian_deliveries(destination_channel, destination_chat_id) for chat-based lookups
|
|
159
|
+
migrateCanonicalGuardianDeliveriesDestinationIndex(database);
|
|
160
|
+
|
|
161
|
+
// 25. Normalize phone-like identity fields to E.164 across guardian and ingress tables
|
|
162
|
+
migrateNormalizePhoneIdentities(database);
|
|
163
|
+
|
|
148
164
|
validateMigrationState(database);
|
|
149
165
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* answer resolves the request and all other deliveries are marked answered.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { and,
|
|
10
|
+
import { and, desc, eq, inArray, lt } from 'drizzle-orm';
|
|
11
11
|
import { v4 as uuid } from 'uuid';
|
|
12
12
|
|
|
13
13
|
import { getLogger } from '../util/logger.js';
|
|
@@ -136,6 +136,12 @@ function generateRequestCode(): string {
|
|
|
136
136
|
// Guardian Action Requests
|
|
137
137
|
// ---------------------------------------------------------------------------
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* @internal Test-only helper. Production code should create guardian requests
|
|
141
|
+
* via `createCanonicalGuardianRequest` in canonical-guardian-store.ts.
|
|
142
|
+
* This function is retained solely so that existing test fixtures that seed
|
|
143
|
+
* legacy guardian action rows continue to compile.
|
|
144
|
+
*/
|
|
139
145
|
export function createGuardianActionRequest(params: {
|
|
140
146
|
assistantId?: string;
|
|
141
147
|
kind: string;
|
|
@@ -226,65 +232,6 @@ export function getPendingRequestByCallSessionId(callSessionId: string): Guardia
|
|
|
226
232
|
return row ? rowToRequest(row) : null;
|
|
227
233
|
}
|
|
228
234
|
|
|
229
|
-
/**
|
|
230
|
-
* Count pending guardian action requests for a given call session.
|
|
231
|
-
* Used as a candidate-affinity hint so the decision engine knows how many
|
|
232
|
-
* active guardian requests already exist for the current call.
|
|
233
|
-
*/
|
|
234
|
-
export function countPendingRequestsByCallSessionId(callSessionId: string): number {
|
|
235
|
-
const db = getDb();
|
|
236
|
-
const row = db
|
|
237
|
-
.select({ count: count() })
|
|
238
|
-
.from(guardianActionRequests)
|
|
239
|
-
.where(
|
|
240
|
-
and(
|
|
241
|
-
eq(guardianActionRequests.callSessionId, callSessionId),
|
|
242
|
-
eq(guardianActionRequests.status, 'pending'),
|
|
243
|
-
),
|
|
244
|
-
)
|
|
245
|
-
.get();
|
|
246
|
-
return row?.count ?? 0;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* Look up the vellum conversation ID used for the first guardian question
|
|
251
|
-
* delivery in a given call session. Returns the conversation ID when one
|
|
252
|
-
* exists, or null if no vellum delivery has been recorded yet.
|
|
253
|
-
*
|
|
254
|
-
* Used by guardian-dispatch to enforce deterministic thread affinity:
|
|
255
|
-
* all guardian questions within the same call session should route to
|
|
256
|
-
* the same vellum conversation.
|
|
257
|
-
*/
|
|
258
|
-
export function getGuardianConversationIdForCallSession(callSessionId: string): string | null {
|
|
259
|
-
try {
|
|
260
|
-
const db = getDb();
|
|
261
|
-
const row = db
|
|
262
|
-
.select({ conversationId: guardianActionDeliveries.destinationConversationId })
|
|
263
|
-
.from(guardianActionDeliveries)
|
|
264
|
-
.innerJoin(
|
|
265
|
-
guardianActionRequests,
|
|
266
|
-
eq(guardianActionDeliveries.requestId, guardianActionRequests.id),
|
|
267
|
-
)
|
|
268
|
-
.where(
|
|
269
|
-
and(
|
|
270
|
-
eq(guardianActionRequests.callSessionId, callSessionId),
|
|
271
|
-
eq(guardianActionDeliveries.destinationChannel, 'vellum'),
|
|
272
|
-
isNotNull(guardianActionDeliveries.destinationConversationId),
|
|
273
|
-
),
|
|
274
|
-
)
|
|
275
|
-
.orderBy(guardianActionDeliveries.createdAt)
|
|
276
|
-
.limit(1)
|
|
277
|
-
.get();
|
|
278
|
-
return row?.conversationId ?? null;
|
|
279
|
-
} catch (err) {
|
|
280
|
-
if (err instanceof Error && err.message.includes('no such table')) {
|
|
281
|
-
log.warn({ err }, 'guardian tables not yet created');
|
|
282
|
-
return null;
|
|
283
|
-
}
|
|
284
|
-
throw err;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
235
|
/**
|
|
289
236
|
* First-response-wins resolution. Checks that the request is still
|
|
290
237
|
* 'pending' before updating; returns the updated request on success
|
|
@@ -70,6 +70,12 @@ function rowToApprovalRequest(row: typeof channelGuardianApprovalRequests.$infer
|
|
|
70
70
|
// Operations
|
|
71
71
|
// ---------------------------------------------------------------------------
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* @internal Test-only helper. Production code should create guardian requests
|
|
75
|
+
* via `createCanonicalGuardianRequest` in canonical-guardian-store.ts.
|
|
76
|
+
* This function is retained solely so that existing test fixtures that seed
|
|
77
|
+
* legacy approval rows continue to compile.
|
|
78
|
+
*/
|
|
73
79
|
export function createApprovalRequest(params: {
|
|
74
80
|
runId: string;
|
|
75
81
|
requestId?: string;
|
|
@@ -535,10 +541,9 @@ export function countPendingByConversation(
|
|
|
535
541
|
}
|
|
536
542
|
|
|
537
543
|
/**
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
* requests while one is already pending.
|
|
544
|
+
* @internal Test-only helper. Production code should query canonical guardian
|
|
545
|
+
* requests via `listCanonicalGuardianRequests` in canonical-guardian-store.ts.
|
|
546
|
+
* Retained for existing test fixtures that check legacy approval dedup.
|
|
542
547
|
*/
|
|
543
548
|
export function findPendingAccessRequestForRequester(
|
|
544
549
|
assistantId: string,
|