@vellumai/assistant 0.3.7 → 0.3.9
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/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +20 -0
- package/src/__tests__/approval-routes-http.test.ts +704 -0
- package/src/__tests__/call-controller.test.ts +835 -0
- package/src/__tests__/call-state.test.ts +24 -24
- package/src/__tests__/ipc-snapshot.test.ts +14 -0
- package/src/__tests__/relay-server.test.ts +9 -9
- package/src/__tests__/run-orchestrator.test.ts +399 -3
- package/src/__tests__/runtime-runs.test.ts +12 -4
- package/src/__tests__/send-endpoint-busy.test.ts +284 -0
- package/src/__tests__/session-init.benchmark.test.ts +3 -3
- package/src/__tests__/subagent-manager-notify.test.ts +3 -3
- package/src/__tests__/voice-session-bridge.test.ts +869 -0
- package/src/calls/{call-orchestrator.ts → call-controller.ts} +156 -257
- package/src/calls/call-domain.ts +21 -21
- package/src/calls/call-state.ts +12 -12
- package/src/calls/guardian-dispatch.ts +43 -3
- package/src/calls/relay-server.ts +34 -39
- package/src/calls/twilio-routes.ts +3 -3
- package/src/calls/voice-session-bridge.ts +244 -0
- package/src/config/bundled-skills/media-processing/SKILL.md +81 -14
- package/src/config/bundled-skills/media-processing/TOOLS.json +3 -3
- package/src/config/bundled-skills/media-processing/services/preprocess.ts +3 -3
- package/src/config/defaults.ts +5 -0
- package/src/config/notifications-schema.ts +15 -0
- package/src/config/schema.ts +13 -0
- package/src/config/types.ts +1 -0
- package/src/daemon/daemon-control.ts +13 -12
- package/src/daemon/handlers/subagents.ts +10 -3
- package/src/daemon/ipc-contract/notifications.ts +9 -0
- package/src/daemon/ipc-contract-inventory.json +2 -0
- package/src/daemon/ipc-contract.ts +4 -1
- package/src/daemon/lifecycle.ts +100 -1
- package/src/daemon/server.ts +8 -0
- package/src/daemon/session-agent-loop.ts +4 -0
- package/src/daemon/session-process.ts +51 -0
- package/src/daemon/session-runtime-assembly.ts +32 -0
- package/src/daemon/session.ts +5 -0
- package/src/memory/db-init.ts +80 -0
- package/src/memory/guardian-action-store.ts +2 -2
- package/src/memory/migrations/016-memory-segments-indexes.ts +1 -0
- package/src/memory/migrations/019-notification-tables-schema-migration.ts +70 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/migrations/registry.ts +5 -0
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +59 -1
- package/src/notifications/README.md +134 -0
- package/src/notifications/adapters/macos.ts +55 -0
- package/src/notifications/adapters/telegram.ts +65 -0
- package/src/notifications/broadcaster.ts +175 -0
- package/src/notifications/copy-composer.ts +118 -0
- package/src/notifications/decision-engine.ts +391 -0
- package/src/notifications/decisions-store.ts +158 -0
- package/src/notifications/deliveries-store.ts +130 -0
- package/src/notifications/destination-resolver.ts +54 -0
- package/src/notifications/deterministic-checks.ts +187 -0
- package/src/notifications/emit-signal.ts +191 -0
- package/src/notifications/events-store.ts +145 -0
- package/src/notifications/preference-extractor.ts +223 -0
- package/src/notifications/preference-summary.ts +110 -0
- package/src/notifications/preferences-store.ts +142 -0
- package/src/notifications/runtime-dispatch.ts +100 -0
- package/src/notifications/signal.ts +24 -0
- package/src/notifications/types.ts +75 -0
- package/src/runtime/http-server.ts +15 -0
- package/src/runtime/http-types.ts +22 -0
- package/src/runtime/pending-interactions.ts +73 -0
- package/src/runtime/routes/approval-routes.ts +179 -0
- package/src/runtime/routes/channel-inbound-routes.ts +39 -4
- package/src/runtime/routes/conversation-routes.ts +107 -1
- package/src/runtime/routes/run-routes.ts +1 -1
- package/src/runtime/run-orchestrator.ts +157 -2
- package/src/subagent/manager.ts +6 -6
- package/src/tools/browser/browser-manager.ts +1 -1
- package/src/tools/subagent/message.ts +9 -2
- package/src/__tests__/call-orchestrator.test.ts +0 -1496
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRUD operations for notification decisions.
|
|
3
|
+
*
|
|
4
|
+
* Each row records the routing decision made by the decision engine for
|
|
5
|
+
* a given notification event: whether to notify, which channels, and the
|
|
6
|
+
* reasoning behind it. This provides a full audit trail of how signals
|
|
7
|
+
* were routed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { and, desc, eq } from 'drizzle-orm';
|
|
11
|
+
import { getDb } from '../memory/db.js';
|
|
12
|
+
import { notificationDecisions, notificationEvents } from '../memory/schema.js';
|
|
13
|
+
|
|
14
|
+
export interface NotificationDecisionRow {
|
|
15
|
+
id: string;
|
|
16
|
+
notificationEventId: string;
|
|
17
|
+
shouldNotify: boolean;
|
|
18
|
+
selectedChannels: string; // JSON array
|
|
19
|
+
reasoningSummary: string;
|
|
20
|
+
confidence: number;
|
|
21
|
+
fallbackUsed: boolean;
|
|
22
|
+
promptVersion: string | null;
|
|
23
|
+
validationResults: string | null; // JSON
|
|
24
|
+
createdAt: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function rowToDecision(row: typeof notificationDecisions.$inferSelect): NotificationDecisionRow {
|
|
28
|
+
return {
|
|
29
|
+
id: row.id,
|
|
30
|
+
notificationEventId: row.notificationEventId,
|
|
31
|
+
shouldNotify: row.shouldNotify === 1,
|
|
32
|
+
selectedChannels: row.selectedChannels,
|
|
33
|
+
reasoningSummary: row.reasoningSummary,
|
|
34
|
+
confidence: row.confidence,
|
|
35
|
+
fallbackUsed: row.fallbackUsed === 1,
|
|
36
|
+
promptVersion: row.promptVersion,
|
|
37
|
+
validationResults: row.validationResults,
|
|
38
|
+
createdAt: row.createdAt,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CreateDecisionParams {
|
|
43
|
+
id: string;
|
|
44
|
+
notificationEventId: string;
|
|
45
|
+
shouldNotify: boolean;
|
|
46
|
+
selectedChannels: string[]; // will be serialised to JSON
|
|
47
|
+
reasoningSummary: string;
|
|
48
|
+
confidence: number;
|
|
49
|
+
fallbackUsed: boolean;
|
|
50
|
+
promptVersion?: string;
|
|
51
|
+
validationResults?: Record<string, unknown>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Insert a new decision record. */
|
|
55
|
+
export function createDecision(params: CreateDecisionParams): NotificationDecisionRow {
|
|
56
|
+
const db = getDb();
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
|
|
59
|
+
const row = {
|
|
60
|
+
id: params.id,
|
|
61
|
+
notificationEventId: params.notificationEventId,
|
|
62
|
+
shouldNotify: params.shouldNotify ? 1 : 0,
|
|
63
|
+
selectedChannels: JSON.stringify(params.selectedChannels),
|
|
64
|
+
reasoningSummary: params.reasoningSummary,
|
|
65
|
+
confidence: params.confidence,
|
|
66
|
+
fallbackUsed: params.fallbackUsed ? 1 : 0,
|
|
67
|
+
promptVersion: params.promptVersion ?? null,
|
|
68
|
+
validationResults: params.validationResults ? JSON.stringify(params.validationResults) : null,
|
|
69
|
+
createdAt: now,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
db.insert(notificationDecisions).values(row).run();
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
...row,
|
|
76
|
+
shouldNotify: params.shouldNotify,
|
|
77
|
+
fallbackUsed: params.fallbackUsed,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Fetch a single decision by ID. */
|
|
82
|
+
export function getDecisionById(id: string): NotificationDecisionRow | null {
|
|
83
|
+
const db = getDb();
|
|
84
|
+
const row = db
|
|
85
|
+
.select()
|
|
86
|
+
.from(notificationDecisions)
|
|
87
|
+
.where(eq(notificationDecisions.id, id))
|
|
88
|
+
.get();
|
|
89
|
+
if (!row) return null;
|
|
90
|
+
return rowToDecision(row);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Fetch a decision by its parent event ID. */
|
|
94
|
+
export function getDecisionByEventId(eventId: string): NotificationDecisionRow | null {
|
|
95
|
+
const db = getDb();
|
|
96
|
+
const row = db
|
|
97
|
+
.select()
|
|
98
|
+
.from(notificationDecisions)
|
|
99
|
+
.where(eq(notificationDecisions.notificationEventId, eventId))
|
|
100
|
+
.get();
|
|
101
|
+
if (!row) return null;
|
|
102
|
+
return rowToDecision(row);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ListDecisionsFilters {
|
|
106
|
+
shouldNotify?: boolean;
|
|
107
|
+
limit?: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** List decisions for an assistant with optional filters. */
|
|
111
|
+
export function listDecisions(
|
|
112
|
+
assistantId: string,
|
|
113
|
+
filters?: ListDecisionsFilters,
|
|
114
|
+
): NotificationDecisionRow[] {
|
|
115
|
+
const db = getDb();
|
|
116
|
+
|
|
117
|
+
// Join through notificationEvents to filter by assistantId
|
|
118
|
+
const conditions = [eq(notificationEvents.assistantId, assistantId)];
|
|
119
|
+
|
|
120
|
+
if (filters?.shouldNotify !== undefined) {
|
|
121
|
+
conditions.push(eq(notificationDecisions.shouldNotify, filters.shouldNotify ? 1 : 0));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const limit = filters?.limit ?? 50;
|
|
125
|
+
|
|
126
|
+
const rows = db
|
|
127
|
+
.select({
|
|
128
|
+
id: notificationDecisions.id,
|
|
129
|
+
notificationEventId: notificationDecisions.notificationEventId,
|
|
130
|
+
shouldNotify: notificationDecisions.shouldNotify,
|
|
131
|
+
selectedChannels: notificationDecisions.selectedChannels,
|
|
132
|
+
reasoningSummary: notificationDecisions.reasoningSummary,
|
|
133
|
+
confidence: notificationDecisions.confidence,
|
|
134
|
+
fallbackUsed: notificationDecisions.fallbackUsed,
|
|
135
|
+
promptVersion: notificationDecisions.promptVersion,
|
|
136
|
+
validationResults: notificationDecisions.validationResults,
|
|
137
|
+
createdAt: notificationDecisions.createdAt,
|
|
138
|
+
})
|
|
139
|
+
.from(notificationDecisions)
|
|
140
|
+
.innerJoin(notificationEvents, eq(notificationDecisions.notificationEventId, notificationEvents.id))
|
|
141
|
+
.where(and(...conditions))
|
|
142
|
+
.orderBy(desc(notificationDecisions.createdAt))
|
|
143
|
+
.limit(limit)
|
|
144
|
+
.all();
|
|
145
|
+
|
|
146
|
+
return rows.map((row) => ({
|
|
147
|
+
id: row.id,
|
|
148
|
+
notificationEventId: row.notificationEventId,
|
|
149
|
+
shouldNotify: row.shouldNotify === 1,
|
|
150
|
+
selectedChannels: row.selectedChannels,
|
|
151
|
+
reasoningSummary: row.reasoningSummary,
|
|
152
|
+
confidence: row.confidence,
|
|
153
|
+
fallbackUsed: row.fallbackUsed === 1,
|
|
154
|
+
promptVersion: row.promptVersion,
|
|
155
|
+
validationResults: row.validationResults,
|
|
156
|
+
createdAt: row.createdAt,
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delivery audit records for notifications.
|
|
3
|
+
*
|
|
4
|
+
* Each row represents a single attempt to deliver a notification decision
|
|
5
|
+
* to a specific channel and destination. Multiple attempts for the same
|
|
6
|
+
* (decision, channel, destination) are tracked via the `attempt` counter.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { and, eq } from 'drizzle-orm';
|
|
10
|
+
import { getDb } from '../memory/db.js';
|
|
11
|
+
import { notificationDeliveries } from '../memory/schema.js';
|
|
12
|
+
import type { NotificationChannel, NotificationDeliveryStatus } from './types.js';
|
|
13
|
+
|
|
14
|
+
export interface NotificationDeliveryRow {
|
|
15
|
+
id: string;
|
|
16
|
+
notificationDecisionId: string;
|
|
17
|
+
assistantId: string;
|
|
18
|
+
channel: string;
|
|
19
|
+
destination: string;
|
|
20
|
+
status: string;
|
|
21
|
+
attempt: number;
|
|
22
|
+
renderedTitle: string | null;
|
|
23
|
+
renderedBody: string | null;
|
|
24
|
+
errorCode: string | null;
|
|
25
|
+
errorMessage: string | null;
|
|
26
|
+
sentAt: number | null;
|
|
27
|
+
createdAt: number;
|
|
28
|
+
updatedAt: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function rowToDelivery(row: typeof notificationDeliveries.$inferSelect): NotificationDeliveryRow {
|
|
32
|
+
return {
|
|
33
|
+
id: row.id,
|
|
34
|
+
notificationDecisionId: row.notificationDecisionId,
|
|
35
|
+
assistantId: row.assistantId,
|
|
36
|
+
channel: row.channel,
|
|
37
|
+
destination: row.destination,
|
|
38
|
+
status: row.status,
|
|
39
|
+
attempt: row.attempt,
|
|
40
|
+
renderedTitle: row.renderedTitle,
|
|
41
|
+
renderedBody: row.renderedBody,
|
|
42
|
+
errorCode: row.errorCode,
|
|
43
|
+
errorMessage: row.errorMessage,
|
|
44
|
+
sentAt: row.sentAt,
|
|
45
|
+
createdAt: row.createdAt,
|
|
46
|
+
updatedAt: row.updatedAt,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CreateDeliveryParams {
|
|
51
|
+
id: string;
|
|
52
|
+
notificationDecisionId: string;
|
|
53
|
+
assistantId: string;
|
|
54
|
+
channel: NotificationChannel;
|
|
55
|
+
destination: string;
|
|
56
|
+
status: NotificationDeliveryStatus;
|
|
57
|
+
attempt: number;
|
|
58
|
+
renderedTitle?: string;
|
|
59
|
+
renderedBody?: string;
|
|
60
|
+
errorCode?: string;
|
|
61
|
+
errorMessage?: string;
|
|
62
|
+
sentAt?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Create a new delivery audit record. */
|
|
66
|
+
export function createDelivery(params: CreateDeliveryParams): NotificationDeliveryRow {
|
|
67
|
+
const db = getDb();
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
|
|
70
|
+
const row = {
|
|
71
|
+
id: params.id,
|
|
72
|
+
notificationDecisionId: params.notificationDecisionId,
|
|
73
|
+
assistantId: params.assistantId,
|
|
74
|
+
channel: params.channel,
|
|
75
|
+
destination: params.destination,
|
|
76
|
+
status: params.status,
|
|
77
|
+
attempt: params.attempt,
|
|
78
|
+
renderedTitle: params.renderedTitle ?? null,
|
|
79
|
+
renderedBody: params.renderedBody ?? null,
|
|
80
|
+
errorCode: params.errorCode ?? null,
|
|
81
|
+
errorMessage: params.errorMessage ?? null,
|
|
82
|
+
sentAt: params.sentAt ?? null,
|
|
83
|
+
createdAt: now,
|
|
84
|
+
updatedAt: now,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
db.insert(notificationDeliveries).values(row).run();
|
|
88
|
+
|
|
89
|
+
return row;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Update the status of an existing delivery record. */
|
|
93
|
+
export function updateDeliveryStatus(
|
|
94
|
+
id: string,
|
|
95
|
+
status: NotificationDeliveryStatus,
|
|
96
|
+
error?: { code?: string; message?: string },
|
|
97
|
+
): boolean {
|
|
98
|
+
const db = getDb();
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
|
|
101
|
+
const updates: Record<string, unknown> = { status, updatedAt: now };
|
|
102
|
+
if (status === 'sent') {
|
|
103
|
+
updates.sentAt = now;
|
|
104
|
+
}
|
|
105
|
+
if (error?.code) {
|
|
106
|
+
updates.errorCode = error.code;
|
|
107
|
+
}
|
|
108
|
+
if (error?.message) {
|
|
109
|
+
updates.errorMessage = error.message;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const result = db
|
|
113
|
+
.update(notificationDeliveries)
|
|
114
|
+
.set(updates)
|
|
115
|
+
.where(eq(notificationDeliveries.id, id))
|
|
116
|
+
.run() as unknown as { changes?: number };
|
|
117
|
+
|
|
118
|
+
return (result.changes ?? 0) > 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** List all delivery records for a given notification decision. */
|
|
122
|
+
export function listDeliveries(decisionId: string): NotificationDeliveryRow[] {
|
|
123
|
+
const db = getDb();
|
|
124
|
+
const rows = db
|
|
125
|
+
.select()
|
|
126
|
+
.from(notificationDeliveries)
|
|
127
|
+
.where(eq(notificationDeliveries.notificationDecisionId, decisionId))
|
|
128
|
+
.all();
|
|
129
|
+
return rows.map(rowToDelivery);
|
|
130
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves per-channel destination endpoints for notification delivery.
|
|
3
|
+
*
|
|
4
|
+
* - macOS: no external endpoint needed — delivery goes through the IPC
|
|
5
|
+
* broadcast mechanism to connected desktop clients.
|
|
6
|
+
* - Telegram: requires a chat ID sourced from the guardian binding for the
|
|
7
|
+
* assistant.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getActiveBinding } from '../memory/channel-guardian-store.js';
|
|
11
|
+
import type { NotificationChannel, ChannelDestination } from './types.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve destination information for each requested channel.
|
|
15
|
+
*
|
|
16
|
+
* Returns a map keyed by channel name. Channels that cannot be resolved
|
|
17
|
+
* (e.g. no Telegram binding configured) are omitted from the result.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveDestinations(
|
|
20
|
+
assistantId: string,
|
|
21
|
+
channels: NotificationChannel[],
|
|
22
|
+
): Map<NotificationChannel, ChannelDestination> {
|
|
23
|
+
const result = new Map<NotificationChannel, ChannelDestination>();
|
|
24
|
+
|
|
25
|
+
for (const channel of channels) {
|
|
26
|
+
switch (channel) {
|
|
27
|
+
case 'macos': {
|
|
28
|
+
// macOS delivery is local IPC — no external endpoint required.
|
|
29
|
+
result.set('macos', { channel: 'macos' });
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
case 'telegram': {
|
|
33
|
+
const binding = getActiveBinding(assistantId, 'telegram');
|
|
34
|
+
if (binding) {
|
|
35
|
+
result.set('telegram', {
|
|
36
|
+
channel: 'telegram',
|
|
37
|
+
endpoint: binding.guardianDeliveryChatId,
|
|
38
|
+
metadata: {
|
|
39
|
+
externalUserId: binding.guardianExternalUserId,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
// If no binding exists, skip — the channel is not configured.
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
default: {
|
|
47
|
+
// Unknown channel — skip silently.
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic pre-send gate checks for notification decisions.
|
|
3
|
+
*
|
|
4
|
+
* These checks run after the decision engine produces a NotificationDecision
|
|
5
|
+
* and before the broadcaster dispatches. They enforce hard invariants that
|
|
6
|
+
* the LLM cannot override: channel availability, source-active suppression,
|
|
7
|
+
* deduplication, and schema validity.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { and, eq } from 'drizzle-orm';
|
|
11
|
+
import { getDb } from '../memory/db.js';
|
|
12
|
+
import { notificationEvents } from '../memory/schema.js';
|
|
13
|
+
import { getLogger } from '../util/logger.js';
|
|
14
|
+
import type { NotificationSignal } from './signal.js';
|
|
15
|
+
import type { NotificationChannel, NotificationDecision } from './types.js';
|
|
16
|
+
|
|
17
|
+
const log = getLogger('notification-deterministic-checks');
|
|
18
|
+
|
|
19
|
+
export interface CheckResult {
|
|
20
|
+
passed: boolean;
|
|
21
|
+
reason?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DeterministicCheckContext {
|
|
25
|
+
/** Channels that are currently connected and available for delivery. */
|
|
26
|
+
connectedChannels: NotificationChannel[];
|
|
27
|
+
/** Dedupe window in milliseconds. Events with the same dedupeKey within this window are suppressed. */
|
|
28
|
+
dedupeWindowMs?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DEFAULT_DEDUPE_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Run all deterministic pre-send checks against a decision.
|
|
35
|
+
* Returns passed=false if any check fails, with a reason describing
|
|
36
|
+
* which check blocked the notification.
|
|
37
|
+
*/
|
|
38
|
+
export async function runDeterministicChecks(
|
|
39
|
+
signal: NotificationSignal,
|
|
40
|
+
decision: NotificationDecision,
|
|
41
|
+
context: DeterministicCheckContext,
|
|
42
|
+
): Promise<CheckResult> {
|
|
43
|
+
// Check 1: Decision schema validity (fail-closed)
|
|
44
|
+
const schemaCheck = checkDecisionSchema(decision);
|
|
45
|
+
if (!schemaCheck.passed) {
|
|
46
|
+
log.info({ signalId: signal.signalId, reason: schemaCheck.reason }, 'Deterministic check failed: schema');
|
|
47
|
+
return schemaCheck;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Check 2: Source-active suppression
|
|
51
|
+
const sourceActiveCheck = checkSourceActiveSuppression(signal);
|
|
52
|
+
if (!sourceActiveCheck.passed) {
|
|
53
|
+
log.info({ signalId: signal.signalId, reason: sourceActiveCheck.reason }, 'Deterministic check failed: source active');
|
|
54
|
+
return sourceActiveCheck;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Check 3: Channel availability
|
|
58
|
+
const channelCheck = checkChannelAvailability(decision, context.connectedChannels);
|
|
59
|
+
if (!channelCheck.passed) {
|
|
60
|
+
log.info({ signalId: signal.signalId, reason: channelCheck.reason }, 'Deterministic check failed: channel availability');
|
|
61
|
+
return channelCheck;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check 4: Dedupe
|
|
65
|
+
const dedupeCheck = checkDedupe(signal, decision, context.dedupeWindowMs ?? DEFAULT_DEDUPE_WINDOW_MS);
|
|
66
|
+
if (!dedupeCheck.passed) {
|
|
67
|
+
log.info({ signalId: signal.signalId, reason: dedupeCheck.reason }, 'Deterministic check failed: dedupe');
|
|
68
|
+
return dedupeCheck;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { passed: true };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Individual checks ──────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Fail-closed schema validation. If the decision is missing required
|
|
78
|
+
* fields or has invalid types, block the notification.
|
|
79
|
+
*/
|
|
80
|
+
function checkDecisionSchema(decision: NotificationDecision): CheckResult {
|
|
81
|
+
if (typeof decision.shouldNotify !== 'boolean') {
|
|
82
|
+
return { passed: false, reason: 'Invalid decision: shouldNotify is not a boolean' };
|
|
83
|
+
}
|
|
84
|
+
if (!Array.isArray(decision.selectedChannels)) {
|
|
85
|
+
return { passed: false, reason: 'Invalid decision: selectedChannels is not an array' };
|
|
86
|
+
}
|
|
87
|
+
if (typeof decision.reasoningSummary !== 'string') {
|
|
88
|
+
return { passed: false, reason: 'Invalid decision: reasoningSummary is not a string' };
|
|
89
|
+
}
|
|
90
|
+
if (typeof decision.dedupeKey !== 'string' || decision.dedupeKey.length === 0) {
|
|
91
|
+
return { passed: false, reason: 'Invalid decision: dedupeKey is missing or empty' };
|
|
92
|
+
}
|
|
93
|
+
if (typeof decision.confidence !== 'number' || !Number.isFinite(decision.confidence)) {
|
|
94
|
+
return { passed: false, reason: 'Invalid decision: confidence is not a finite number' };
|
|
95
|
+
}
|
|
96
|
+
return { passed: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* If the user is already looking at the source context (visibleInSourceNow),
|
|
101
|
+
* suppress the notification to avoid redundant alerts.
|
|
102
|
+
*/
|
|
103
|
+
function checkSourceActiveSuppression(signal: NotificationSignal): CheckResult {
|
|
104
|
+
if (signal.attentionHints.visibleInSourceNow) {
|
|
105
|
+
return {
|
|
106
|
+
passed: false,
|
|
107
|
+
reason: 'Source-active suppression: user is already viewing the source context',
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return { passed: true };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Verify that at least one of the selected channels is actually
|
|
115
|
+
* connected and available for delivery.
|
|
116
|
+
*/
|
|
117
|
+
function checkChannelAvailability(
|
|
118
|
+
decision: NotificationDecision,
|
|
119
|
+
connectedChannels: NotificationChannel[],
|
|
120
|
+
): CheckResult {
|
|
121
|
+
if (!decision.shouldNotify) {
|
|
122
|
+
// Not notifying — channel availability is irrelevant
|
|
123
|
+
return { passed: true };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const connectedSet = new Set(connectedChannels);
|
|
127
|
+
const availableSelected = decision.selectedChannels.filter((ch) => connectedSet.has(ch));
|
|
128
|
+
|
|
129
|
+
if (availableSelected.length === 0) {
|
|
130
|
+
return {
|
|
131
|
+
passed: false,
|
|
132
|
+
reason: `Channel availability: none of the selected channels (${decision.selectedChannels.join(', ')}) are connected`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { passed: true };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Check if a signal with the same dedupeKey was already processed
|
|
141
|
+
* within the dedupe window. Uses the events-store table directly.
|
|
142
|
+
*/
|
|
143
|
+
function checkDedupe(
|
|
144
|
+
signal: NotificationSignal,
|
|
145
|
+
decision: NotificationDecision,
|
|
146
|
+
windowMs: number,
|
|
147
|
+
): CheckResult {
|
|
148
|
+
if (!decision.dedupeKey) {
|
|
149
|
+
return { passed: true };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const db = getDb();
|
|
154
|
+
const cutoff = Date.now() - windowMs;
|
|
155
|
+
|
|
156
|
+
const existing = db
|
|
157
|
+
.select({ id: notificationEvents.id, createdAt: notificationEvents.createdAt })
|
|
158
|
+
.from(notificationEvents)
|
|
159
|
+
.where(
|
|
160
|
+
and(
|
|
161
|
+
eq(notificationEvents.assistantId, signal.assistantId),
|
|
162
|
+
eq(notificationEvents.dedupeKey, decision.dedupeKey),
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
.all();
|
|
166
|
+
|
|
167
|
+
// Filter by created_at > cutoff (the events store already checked
|
|
168
|
+
// dedupe on insert, but this catches cases where the engine is
|
|
169
|
+
// re-evaluating a signal that was previously stored).
|
|
170
|
+
for (const row of existing) {
|
|
171
|
+
// The current signal's own event row should not count as a duplicate
|
|
172
|
+
if (row.id === signal.signalId) continue;
|
|
173
|
+
// Only consider events within the dedupe window
|
|
174
|
+
if (row.createdAt < cutoff) continue;
|
|
175
|
+
// If any other event with the same dedupeKey exists within the window, suppress
|
|
176
|
+
return {
|
|
177
|
+
passed: false,
|
|
178
|
+
reason: `Dedupe: signal with dedupeKey "${decision.dedupeKey}" was already processed`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
} catch (err) {
|
|
182
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
183
|
+
log.warn({ err: errMsg }, 'Dedupe check failed, allowing notification through');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { passed: true };
|
|
187
|
+
}
|