vellum 0.2.13 → 0.2.14
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/README.md +32 -0
- package/bun.lock +2 -2
- package/docs/skills.md +4 -4
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
- package/src/__tests__/app-git-history.test.ts +176 -0
- package/src/__tests__/app-git-service.test.ts +169 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
- package/src/__tests__/browser-skill-endstate.test.ts +6 -6
- package/src/__tests__/call-bridge.test.ts +105 -13
- package/src/__tests__/call-domain.test.ts +163 -0
- package/src/__tests__/call-orchestrator.test.ts +113 -0
- package/src/__tests__/call-routes-http.test.ts +246 -6
- package/src/__tests__/channel-approval-routes.test.ts +438 -0
- package/src/__tests__/channel-approval.test.ts +266 -0
- package/src/__tests__/channel-approvals.test.ts +393 -0
- package/src/__tests__/channel-delivery-store.test.ts +447 -0
- package/src/__tests__/checker.test.ts +607 -1048
- package/src/__tests__/cli.test.ts +1 -56
- package/src/__tests__/config-schema.test.ts +137 -18
- package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
- package/src/__tests__/conflict-policy.test.ts +121 -0
- package/src/__tests__/conflict-store.test.ts +2 -0
- package/src/__tests__/contacts-tools.test.ts +3 -3
- package/src/__tests__/contradiction-checker.test.ts +99 -1
- package/src/__tests__/credential-security-invariants.test.ts +22 -6
- package/src/__tests__/credential-vault-unit.test.ts +780 -0
- package/src/__tests__/elevenlabs-client.test.ts +62 -0
- package/src/__tests__/ephemeral-permissions.test.ts +73 -23
- package/src/__tests__/filesystem-tools.test.ts +579 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
- package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
- package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
- package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
- package/src/__tests__/handlers-slack-config.test.ts +2 -1
- package/src/__tests__/handlers-telegram-config.test.ts +855 -0
- package/src/__tests__/handlers-twitter-config.test.ts +141 -1
- package/src/__tests__/hooks-runner.test.ts +6 -2
- package/src/__tests__/host-file-edit-tool.test.ts +124 -0
- package/src/__tests__/host-file-read-tool.test.ts +62 -0
- package/src/__tests__/host-file-write-tool.test.ts +59 -0
- package/src/__tests__/host-shell-tool.test.ts +251 -0
- package/src/__tests__/ingress-reconcile.test.ts +581 -0
- package/src/__tests__/ipc-snapshot.test.ts +100 -41
- package/src/__tests__/ipc-validate.test.ts +50 -0
- package/src/__tests__/key-migration.test.ts +23 -0
- package/src/__tests__/memory-regressions.test.ts +99 -0
- package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
- package/src/__tests__/oauth-callback-registry.test.ts +11 -4
- package/src/__tests__/playbook-execution.test.ts +502 -0
- package/src/__tests__/playbook-tools.test.ts +4 -6
- package/src/__tests__/public-ingress-urls.test.ts +34 -0
- package/src/__tests__/qdrant-manager.test.ts +267 -0
- package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
- package/src/__tests__/recurrence-engine.test.ts +9 -0
- package/src/__tests__/recurrence-types.test.ts +8 -0
- package/src/__tests__/registry.test.ts +1 -1
- package/src/__tests__/runtime-runs.test.ts +1 -25
- package/src/__tests__/schedule-store.test.ts +16 -14
- package/src/__tests__/schedule-tools.test.ts +83 -0
- package/src/__tests__/scheduler-recurrence.test.ts +111 -10
- package/src/__tests__/secret-allowlist.test.ts +18 -17
- package/src/__tests__/secret-ingress-handler.test.ts +11 -0
- package/src/__tests__/secret-scanner.test.ts +43 -0
- package/src/__tests__/session-conflict-gate.test.ts +442 -6
- package/src/__tests__/session-init.benchmark.test.ts +3 -0
- package/src/__tests__/session-process-bridge.test.ts +242 -0
- package/src/__tests__/session-skill-tools.test.ts +1 -1
- package/src/__tests__/shell-identity.test.ts +256 -0
- package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
- package/src/__tests__/subagent-tools.test.ts +637 -54
- package/src/__tests__/task-management-tools.test.ts +936 -0
- package/src/__tests__/task-runner.test.ts +2 -2
- package/src/__tests__/terminal-tools.test.ts +840 -0
- package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
- package/src/__tests__/tool-executor.test.ts +85 -151
- package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
- package/src/__tests__/trust-store.test.ts +27 -453
- package/src/__tests__/twilio-provider.test.ts +153 -3
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
- package/src/__tests__/twilio-routes.test.ts +17 -262
- package/src/__tests__/twitter-auth-handler.test.ts +2 -1
- package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
- package/src/__tests__/twitter-cli-routing.test.ts +252 -0
- package/src/__tests__/twitter-oauth-client.test.ts +209 -0
- package/src/__tests__/workspace-policy.test.ts +213 -0
- package/src/calls/call-bridge.ts +92 -19
- package/src/calls/call-domain.ts +157 -5
- package/src/calls/call-orchestrator.ts +93 -7
- package/src/calls/call-store.ts +6 -0
- package/src/calls/elevenlabs-client.ts +8 -0
- package/src/calls/elevenlabs-config.ts +7 -5
- package/src/calls/twilio-provider.ts +91 -0
- package/src/calls/twilio-routes.ts +32 -37
- package/src/calls/types.ts +3 -1
- package/src/calls/voice-quality.ts +29 -7
- package/src/cli/twitter.ts +200 -21
- package/src/cli.ts +1 -20
- package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
- package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
- package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
- package/src/config/bundled-skills/messaging/SKILL.md +17 -2
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
- package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
- package/src/config/bundled-skills/twitter/SKILL.md +103 -17
- package/src/config/defaults.ts +10 -4
- package/src/config/schema.ts +80 -21
- package/src/config/types.ts +1 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
- package/src/daemon/assistant-attachments.ts +4 -2
- package/src/daemon/handlers/apps.ts +69 -0
- package/src/daemon/handlers/config.ts +543 -24
- package/src/daemon/handlers/index.ts +1 -0
- package/src/daemon/handlers/sessions.ts +22 -6
- package/src/daemon/handlers/shared.ts +2 -1
- package/src/daemon/handlers/skills.ts +5 -20
- package/src/daemon/ipc-contract-inventory.json +28 -0
- package/src/daemon/ipc-contract.ts +168 -10
- package/src/daemon/ipc-validate.ts +17 -0
- package/src/daemon/lifecycle.ts +2 -0
- package/src/daemon/server.ts +78 -72
- package/src/daemon/session-attachments.ts +1 -1
- package/src/daemon/session-conflict-gate.ts +62 -6
- package/src/daemon/session-notifiers.ts +1 -1
- package/src/daemon/session-process.ts +62 -3
- package/src/daemon/session-tool-setup.ts +1 -2
- package/src/daemon/tls-certs.ts +189 -0
- package/src/daemon/video-thumbnail.ts +5 -3
- package/src/hooks/manager.ts +5 -9
- package/src/memory/app-git-service.ts +295 -0
- package/src/memory/app-store.ts +21 -0
- package/src/memory/conflict-intent.ts +47 -4
- package/src/memory/conflict-policy.ts +73 -0
- package/src/memory/conflict-store.ts +9 -1
- package/src/memory/contradiction-checker.ts +28 -0
- package/src/memory/conversation-key-store.ts +15 -0
- package/src/memory/db.ts +81 -0
- package/src/memory/embedding-local.ts +3 -13
- package/src/memory/external-conversation-store.ts +234 -0
- package/src/memory/job-handlers/conflict.ts +22 -2
- package/src/memory/jobs-worker.ts +67 -28
- package/src/memory/runs-store.ts +54 -7
- package/src/memory/schema.ts +20 -0
- package/src/messaging/provider.ts +9 -0
- package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
- package/src/messaging/providers/telegram-bot/client.ts +104 -0
- package/src/messaging/providers/telegram-bot/types.ts +15 -0
- package/src/messaging/registry.ts +1 -0
- package/src/permissions/checker.ts +48 -44
- package/src/permissions/prompter.ts +0 -4
- package/src/permissions/shell-identity.ts +227 -0
- package/src/permissions/trust-store.ts +76 -53
- package/src/permissions/types.ts +0 -19
- package/src/permissions/workspace-policy.ts +114 -0
- package/src/providers/retry.ts +12 -37
- package/src/runtime/assistant-event-hub.ts +41 -4
- package/src/runtime/channel-approval-parser.ts +60 -0
- package/src/runtime/channel-approval-types.ts +71 -0
- package/src/runtime/channel-approvals.ts +145 -0
- package/src/runtime/gateway-client.ts +16 -0
- package/src/runtime/http-server.ts +29 -9
- package/src/runtime/routes/call-routes.ts +52 -2
- package/src/runtime/routes/channel-routes.ts +296 -16
- package/src/runtime/routes/events-routes.ts +97 -28
- package/src/runtime/routes/run-routes.ts +2 -7
- package/src/runtime/run-orchestrator.ts +0 -3
- package/src/schedule/recurrence-engine.ts +26 -2
- package/src/schedule/recurrence-types.ts +1 -1
- package/src/schedule/schedule-store.ts +12 -3
- package/src/security/secret-scanner.ts +7 -0
- package/src/tasks/ephemeral-permissions.ts +0 -2
- package/src/tasks/task-scheduler.ts +2 -1
- package/src/tools/calls/call-start.ts +8 -0
- package/src/tools/execution-target.ts +21 -0
- package/src/tools/execution-timeout.ts +49 -0
- package/src/tools/executor.ts +6 -135
- package/src/tools/network/web-search.ts +9 -32
- package/src/tools/policy-context.ts +29 -0
- package/src/tools/schedule/update.ts +8 -1
- package/src/tools/terminal/parser.ts +16 -18
- package/src/tools/types.ts +4 -11
- package/src/twitter/oauth-client.ts +102 -0
- package/src/twitter/router.ts +101 -0
- package/src/util/debounce.ts +88 -0
- package/src/util/network-info.ts +47 -0
- package/src/util/platform.ts +29 -4
- package/src/util/promise-guard.ts +37 -0
- package/src/util/retry.ts +98 -0
- package/src/util/truncate.ts +1 -1
- package/src/workspace/git-service.ts +129 -112
- package/src/tools/contacts/contact-merge.ts +0 -55
- package/src/tools/contacts/contact-search.ts +0 -58
- package/src/tools/contacts/contact-upsert.ts +0 -64
- package/src/tools/playbooks/index.ts +0 -4
- package/src/tools/playbooks/playbook-create.ts +0 -96
- package/src/tools/playbooks/playbook-delete.ts +0 -52
- package/src/tools/playbooks/playbook-list.ts +0 -74
- package/src/tools/playbooks/playbook-update.ts +0 -111
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const testDir = mkdtempSync(join(tmpdir(), 'channel-delivery-store-test-'));
|
|
7
|
+
|
|
8
|
+
mock.module('../util/platform.js', () => ({
|
|
9
|
+
getDataDir: () => testDir,
|
|
10
|
+
isMacOS: () => process.platform === 'darwin',
|
|
11
|
+
isLinux: () => process.platform === 'linux',
|
|
12
|
+
isWindows: () => process.platform === 'win32',
|
|
13
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
14
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
15
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
16
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
17
|
+
ensureDataDir: () => {},
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
mock.module('../util/logger.js', () => ({
|
|
21
|
+
getLogger: () => new Proxy({} as Record<string, unknown>, {
|
|
22
|
+
get: () => () => {},
|
|
23
|
+
}),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
import { initializeDb, getDb, resetDb } from '../memory/db.js';
|
|
27
|
+
import { channelInboundEvents, messages } from '../memory/schema.js';
|
|
28
|
+
import {
|
|
29
|
+
recordInbound,
|
|
30
|
+
linkMessage,
|
|
31
|
+
findMessageBySourceId,
|
|
32
|
+
acknowledgeDelivery,
|
|
33
|
+
storePayload,
|
|
34
|
+
clearPayload,
|
|
35
|
+
markProcessed,
|
|
36
|
+
recordProcessingFailure,
|
|
37
|
+
getRetryableEvents,
|
|
38
|
+
getDeadLetterEvents,
|
|
39
|
+
replayDeadLetters,
|
|
40
|
+
} from '../memory/channel-delivery-store.js';
|
|
41
|
+
import { RETRY_MAX_ATTEMPTS } from '../memory/job-utils.js';
|
|
42
|
+
import { eq } from 'drizzle-orm';
|
|
43
|
+
|
|
44
|
+
initializeDb();
|
|
45
|
+
|
|
46
|
+
afterAll(() => {
|
|
47
|
+
resetDb();
|
|
48
|
+
try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
function resetTables() {
|
|
52
|
+
const db = getDb();
|
|
53
|
+
db.run('DELETE FROM channel_inbound_events');
|
|
54
|
+
db.run('DELETE FROM messages');
|
|
55
|
+
db.run('DELETE FROM conversation_keys');
|
|
56
|
+
db.run('DELETE FROM conversations');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Insert a message row so FK constraints on channel_inbound_events.message_id pass. */
|
|
60
|
+
function insertMessage(id: string, conversationId: string): void {
|
|
61
|
+
const db = getDb();
|
|
62
|
+
db.insert(messages).values({
|
|
63
|
+
id,
|
|
64
|
+
conversationId,
|
|
65
|
+
role: 'user',
|
|
66
|
+
content: 'test message',
|
|
67
|
+
createdAt: Date.now(),
|
|
68
|
+
}).run();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('channel-delivery-store', () => {
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
resetTables();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ── Recording inbound events ──────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
test('records an inbound event and creates a conversation', () => {
|
|
79
|
+
const result = recordInbound('telegram', 'chat-1', 'msg-1');
|
|
80
|
+
|
|
81
|
+
expect(result.accepted).toBe(true);
|
|
82
|
+
expect(result.duplicate).toBe(false);
|
|
83
|
+
expect(result.eventId).toBeDefined();
|
|
84
|
+
expect(result.conversationId).toBeDefined();
|
|
85
|
+
|
|
86
|
+
const db = getDb();
|
|
87
|
+
const row = db
|
|
88
|
+
.select()
|
|
89
|
+
.from(channelInboundEvents)
|
|
90
|
+
.where(eq(channelInboundEvents.id, result.eventId))
|
|
91
|
+
.get();
|
|
92
|
+
|
|
93
|
+
expect(row).toBeDefined();
|
|
94
|
+
expect(row!.sourceChannel).toBe('telegram');
|
|
95
|
+
expect(row!.externalChatId).toBe('chat-1');
|
|
96
|
+
expect(row!.externalMessageId).toBe('msg-1');
|
|
97
|
+
expect(row!.deliveryStatus).toBe('pending');
|
|
98
|
+
expect(row!.processingStatus).toBe('pending');
|
|
99
|
+
expect(row!.processingAttempts).toBe(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('records inbound with sourceMessageId option', () => {
|
|
103
|
+
const result = recordInbound('telegram', 'chat-1', 'msg-1', {
|
|
104
|
+
sourceMessageId: 'src-42',
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const db = getDb();
|
|
108
|
+
const row = db
|
|
109
|
+
.select()
|
|
110
|
+
.from(channelInboundEvents)
|
|
111
|
+
.where(eq(channelInboundEvents.id, result.eventId))
|
|
112
|
+
.get();
|
|
113
|
+
|
|
114
|
+
expect(row!.sourceMessageId).toBe('src-42');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('same chat on same channel reuses the same conversation', () => {
|
|
118
|
+
const r1 = recordInbound('telegram', 'chat-1', 'msg-1');
|
|
119
|
+
const r2 = recordInbound('telegram', 'chat-1', 'msg-2');
|
|
120
|
+
|
|
121
|
+
expect(r1.conversationId).toBe(r2.conversationId);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('different chats get different conversations', () => {
|
|
125
|
+
const r1 = recordInbound('telegram', 'chat-1', 'msg-1');
|
|
126
|
+
const r2 = recordInbound('telegram', 'chat-2', 'msg-1');
|
|
127
|
+
|
|
128
|
+
expect(r1.conversationId).not.toBe(r2.conversationId);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('different channels get different conversations', () => {
|
|
132
|
+
const r1 = recordInbound('telegram', 'chat-1', 'msg-1');
|
|
133
|
+
const r2 = recordInbound('slack', 'chat-1', 'msg-1');
|
|
134
|
+
|
|
135
|
+
expect(r1.conversationId).not.toBe(r2.conversationId);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ── Deduplication ─────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
test('duplicate inbound returns duplicate: true with same eventId', () => {
|
|
141
|
+
const first = recordInbound('telegram', 'chat-1', 'msg-1');
|
|
142
|
+
const second = recordInbound('telegram', 'chat-1', 'msg-1');
|
|
143
|
+
|
|
144
|
+
expect(second.duplicate).toBe(true);
|
|
145
|
+
expect(second.accepted).toBe(true);
|
|
146
|
+
expect(second.eventId).toBe(first.eventId);
|
|
147
|
+
expect(second.conversationId).toBe(first.conversationId);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('same message ID on different chats is not a duplicate', () => {
|
|
151
|
+
const r1 = recordInbound('telegram', 'chat-1', 'msg-1');
|
|
152
|
+
const r2 = recordInbound('telegram', 'chat-2', 'msg-1');
|
|
153
|
+
|
|
154
|
+
expect(r1.duplicate).toBe(false);
|
|
155
|
+
expect(r2.duplicate).toBe(false);
|
|
156
|
+
expect(r1.eventId).not.toBe(r2.eventId);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// ── linkMessage + findMessageBySourceId ───────────────────────────
|
|
160
|
+
|
|
161
|
+
test('linkMessage sets messageId and findMessageBySourceId retrieves it', () => {
|
|
162
|
+
const result = recordInbound('telegram', 'chat-1', 'msg-1', {
|
|
163
|
+
sourceMessageId: 'src-100',
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const msgId = 'internal-msg-abc';
|
|
167
|
+
insertMessage(msgId, result.conversationId);
|
|
168
|
+
linkMessage(result.eventId, msgId);
|
|
169
|
+
|
|
170
|
+
const found = findMessageBySourceId('telegram', 'chat-1', 'src-100');
|
|
171
|
+
expect(found).not.toBeNull();
|
|
172
|
+
expect(found!.messageId).toBe(msgId);
|
|
173
|
+
expect(found!.conversationId).toBe(result.conversationId);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('findMessageBySourceId returns null when no match', () => {
|
|
177
|
+
const found = findMessageBySourceId('telegram', 'chat-1', 'nonexistent');
|
|
178
|
+
expect(found).toBeNull();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('findMessageBySourceId returns null when messageId is not linked', () => {
|
|
182
|
+
recordInbound('telegram', 'chat-1', 'msg-1', {
|
|
183
|
+
sourceMessageId: 'src-200',
|
|
184
|
+
});
|
|
185
|
+
// Not calling linkMessage — messageId stays null
|
|
186
|
+
const found = findMessageBySourceId('telegram', 'chat-1', 'src-200');
|
|
187
|
+
expect(found).toBeNull();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ── Delivery status transitions ───────────────────────────────────
|
|
191
|
+
|
|
192
|
+
test('acknowledgeDelivery transitions from pending to delivered', () => {
|
|
193
|
+
recordInbound('telegram', 'chat-1', 'msg-1');
|
|
194
|
+
|
|
195
|
+
const ack = acknowledgeDelivery('telegram', 'chat-1', 'msg-1');
|
|
196
|
+
expect(ack).toBe(true);
|
|
197
|
+
|
|
198
|
+
const db = getDb();
|
|
199
|
+
const row = db
|
|
200
|
+
.select()
|
|
201
|
+
.from(channelInboundEvents)
|
|
202
|
+
.where(eq(channelInboundEvents.externalMessageId, 'msg-1'))
|
|
203
|
+
.get();
|
|
204
|
+
expect(row!.deliveryStatus).toBe('delivered');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('acknowledgeDelivery returns false for unknown event', () => {
|
|
208
|
+
const ack = acknowledgeDelivery('telegram', 'chat-1', 'nonexistent');
|
|
209
|
+
expect(ack).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ── Processing status transitions ─────────────────────────────────
|
|
213
|
+
|
|
214
|
+
test('markProcessed sets processingStatus to processed', () => {
|
|
215
|
+
const result = recordInbound('telegram', 'chat-1', 'msg-1');
|
|
216
|
+
markProcessed(result.eventId);
|
|
217
|
+
|
|
218
|
+
const db = getDb();
|
|
219
|
+
const row = db
|
|
220
|
+
.select()
|
|
221
|
+
.from(channelInboundEvents)
|
|
222
|
+
.where(eq(channelInboundEvents.id, result.eventId))
|
|
223
|
+
.get();
|
|
224
|
+
expect(row!.processingStatus).toBe('processed');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('recordProcessingFailure with retryable error sets status to failed', () => {
|
|
228
|
+
const result = recordInbound('telegram', 'chat-1', 'msg-1');
|
|
229
|
+
|
|
230
|
+
// A timeout error is classified as retryable
|
|
231
|
+
const err = new Error('request timeout');
|
|
232
|
+
recordProcessingFailure(result.eventId, err);
|
|
233
|
+
|
|
234
|
+
const db = getDb();
|
|
235
|
+
const row = db
|
|
236
|
+
.select()
|
|
237
|
+
.from(channelInboundEvents)
|
|
238
|
+
.where(eq(channelInboundEvents.id, result.eventId))
|
|
239
|
+
.get();
|
|
240
|
+
|
|
241
|
+
expect(row!.processingStatus).toBe('failed');
|
|
242
|
+
expect(row!.processingAttempts).toBe(1);
|
|
243
|
+
expect(row!.lastProcessingError).toBe('request timeout');
|
|
244
|
+
expect(row!.retryAfter).toBeGreaterThan(0);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('recordProcessingFailure with fatal error sets status to dead_letter', () => {
|
|
248
|
+
const result = recordInbound('telegram', 'chat-1', 'msg-1');
|
|
249
|
+
|
|
250
|
+
// A 400-status error is classified as fatal
|
|
251
|
+
const err = { status: 400, message: 'Bad Request' };
|
|
252
|
+
recordProcessingFailure(result.eventId, err);
|
|
253
|
+
|
|
254
|
+
const db = getDb();
|
|
255
|
+
const row = db
|
|
256
|
+
.select()
|
|
257
|
+
.from(channelInboundEvents)
|
|
258
|
+
.where(eq(channelInboundEvents.id, result.eventId))
|
|
259
|
+
.get();
|
|
260
|
+
|
|
261
|
+
expect(row!.processingStatus).toBe('dead_letter');
|
|
262
|
+
expect(row!.processingAttempts).toBe(1);
|
|
263
|
+
expect(row!.retryAfter).toBeNull();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('recordProcessingFailure dead-letters after max attempts', () => {
|
|
267
|
+
const result = recordInbound('telegram', 'chat-1', 'msg-1');
|
|
268
|
+
|
|
269
|
+
// Exhaust all retry attempts with retryable errors
|
|
270
|
+
const err = new Error('request timeout');
|
|
271
|
+
for (let i = 0; i < RETRY_MAX_ATTEMPTS; i++) {
|
|
272
|
+
recordProcessingFailure(result.eventId, err);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const db = getDb();
|
|
276
|
+
const row = db
|
|
277
|
+
.select()
|
|
278
|
+
.from(channelInboundEvents)
|
|
279
|
+
.where(eq(channelInboundEvents.id, result.eventId))
|
|
280
|
+
.get();
|
|
281
|
+
|
|
282
|
+
expect(row!.processingStatus).toBe('dead_letter');
|
|
283
|
+
expect(row!.processingAttempts).toBe(RETRY_MAX_ATTEMPTS);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ── Payload storage ───────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
test('storePayload persists raw payload and clearPayload removes it', () => {
|
|
289
|
+
const result = recordInbound('telegram', 'chat-1', 'msg-1');
|
|
290
|
+
const payload = { update_id: 123, message: { text: 'hello' } };
|
|
291
|
+
|
|
292
|
+
storePayload(result.eventId, payload);
|
|
293
|
+
|
|
294
|
+
const db = getDb();
|
|
295
|
+
let row = db
|
|
296
|
+
.select()
|
|
297
|
+
.from(channelInboundEvents)
|
|
298
|
+
.where(eq(channelInboundEvents.id, result.eventId))
|
|
299
|
+
.get();
|
|
300
|
+
expect(row!.rawPayload).toBe(JSON.stringify(payload));
|
|
301
|
+
|
|
302
|
+
clearPayload(result.eventId);
|
|
303
|
+
|
|
304
|
+
row = db
|
|
305
|
+
.select()
|
|
306
|
+
.from(channelInboundEvents)
|
|
307
|
+
.where(eq(channelInboundEvents.id, result.eventId))
|
|
308
|
+
.get();
|
|
309
|
+
expect(row!.rawPayload).toBeNull();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// ── Retryable events query ────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
test('getRetryableEvents returns failed events past their backoff', () => {
|
|
315
|
+
const r1 = recordInbound('telegram', 'chat-1', 'msg-1');
|
|
316
|
+
const r2 = recordInbound('telegram', 'chat-1', 'msg-2');
|
|
317
|
+
const _r3 = recordInbound('telegram', 'chat-1', 'msg-3');
|
|
318
|
+
|
|
319
|
+
// r1: failed with past retry_after
|
|
320
|
+
const err = new Error('request timeout');
|
|
321
|
+
recordProcessingFailure(r1.eventId, err);
|
|
322
|
+
// Force retry_after to be in the past
|
|
323
|
+
const db = getDb();
|
|
324
|
+
db.update(channelInboundEvents)
|
|
325
|
+
.set({ retryAfter: Date.now() - 10_000 })
|
|
326
|
+
.where(eq(channelInboundEvents.id, r1.eventId))
|
|
327
|
+
.run();
|
|
328
|
+
|
|
329
|
+
// r2: failed but retry_after is in the future
|
|
330
|
+
recordProcessingFailure(r2.eventId, err);
|
|
331
|
+
db.update(channelInboundEvents)
|
|
332
|
+
.set({ retryAfter: Date.now() + 60_000 })
|
|
333
|
+
.where(eq(channelInboundEvents.id, r2.eventId))
|
|
334
|
+
.run();
|
|
335
|
+
|
|
336
|
+
// r3: still pending (not failed) — should not appear
|
|
337
|
+
const retryable = getRetryableEvents();
|
|
338
|
+
expect(retryable).toHaveLength(1);
|
|
339
|
+
expect(retryable[0].id).toBe(r1.eventId);
|
|
340
|
+
expect(retryable[0].conversationId).toBe(r1.conversationId);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('getRetryableEvents respects limit parameter', () => {
|
|
344
|
+
const db = getDb();
|
|
345
|
+
const err = new Error('request timeout');
|
|
346
|
+
const ids: string[] = [];
|
|
347
|
+
|
|
348
|
+
for (let i = 0; i < 5; i++) {
|
|
349
|
+
const r = recordInbound('telegram', 'chat-1', `msg-${i}`);
|
|
350
|
+
ids.push(r.eventId);
|
|
351
|
+
recordProcessingFailure(r.eventId, err);
|
|
352
|
+
db.update(channelInboundEvents)
|
|
353
|
+
.set({ retryAfter: Date.now() - 10_000 })
|
|
354
|
+
.where(eq(channelInboundEvents.id, r.eventId))
|
|
355
|
+
.run();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const retryable = getRetryableEvents(2);
|
|
359
|
+
expect(retryable).toHaveLength(2);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// ── Dead-letter queue ─────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
test('getDeadLetterEvents returns dead-lettered events', () => {
|
|
365
|
+
const r1 = recordInbound('telegram', 'chat-1', 'msg-1');
|
|
366
|
+
const _r2 = recordInbound('telegram', 'chat-1', 'msg-2');
|
|
367
|
+
|
|
368
|
+
// r1: dead-letter via fatal error
|
|
369
|
+
recordProcessingFailure(r1.eventId, { status: 400, message: 'invalid' });
|
|
370
|
+
|
|
371
|
+
// r2: still pending
|
|
372
|
+
const deadLetters = getDeadLetterEvents();
|
|
373
|
+
expect(deadLetters).toHaveLength(1);
|
|
374
|
+
expect(deadLetters[0].id).toBe(r1.eventId);
|
|
375
|
+
expect(deadLetters[0].sourceChannel).toBe('telegram');
|
|
376
|
+
expect(deadLetters[0].externalChatId).toBe('chat-1');
|
|
377
|
+
expect(deadLetters[0].externalMessageId).toBe('msg-1');
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test('replayDeadLetters resets dead-lettered events to failed for retry', () => {
|
|
381
|
+
const r1 = recordInbound('telegram', 'chat-1', 'msg-1');
|
|
382
|
+
const r2 = recordInbound('telegram', 'chat-1', 'msg-2');
|
|
383
|
+
|
|
384
|
+
// Dead-letter both
|
|
385
|
+
recordProcessingFailure(r1.eventId, { status: 400, message: 'bad' });
|
|
386
|
+
recordProcessingFailure(r2.eventId, { status: 401, message: 'auth' });
|
|
387
|
+
|
|
388
|
+
const count = replayDeadLetters([r1.eventId, r2.eventId]);
|
|
389
|
+
expect(count).toBe(2);
|
|
390
|
+
|
|
391
|
+
const db = getDb();
|
|
392
|
+
const row1 = db.select().from(channelInboundEvents)
|
|
393
|
+
.where(eq(channelInboundEvents.id, r1.eventId)).get();
|
|
394
|
+
const row2 = db.select().from(channelInboundEvents)
|
|
395
|
+
.where(eq(channelInboundEvents.id, r2.eventId)).get();
|
|
396
|
+
|
|
397
|
+
expect(row1!.processingStatus).toBe('failed');
|
|
398
|
+
expect(row1!.processingAttempts).toBe(0);
|
|
399
|
+
expect(row1!.lastProcessingError).toBeNull();
|
|
400
|
+
expect(row1!.retryAfter).toBeGreaterThan(0);
|
|
401
|
+
|
|
402
|
+
expect(row2!.processingStatus).toBe('failed');
|
|
403
|
+
expect(row2!.processingAttempts).toBe(0);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test('replayDeadLetters skips non-dead-lettered events', () => {
|
|
407
|
+
const r1 = recordInbound('telegram', 'chat-1', 'msg-1');
|
|
408
|
+
|
|
409
|
+
// r1 is still pending, not dead-lettered
|
|
410
|
+
const count = replayDeadLetters([r1.eventId]);
|
|
411
|
+
expect(count).toBe(0);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('replayDeadLetters skips nonexistent IDs', () => {
|
|
415
|
+
const count = replayDeadLetters(['nonexistent-id']);
|
|
416
|
+
expect(count).toBe(0);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// ── Full lifecycle ────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
test('full lifecycle: inbound -> link -> acknowledge -> processed', () => {
|
|
422
|
+
const result = recordInbound('telegram', 'chat-1', 'msg-1', {
|
|
423
|
+
sourceMessageId: 'src-1',
|
|
424
|
+
});
|
|
425
|
+
expect(result.duplicate).toBe(false);
|
|
426
|
+
|
|
427
|
+
const msgId = 'internal-msg-1';
|
|
428
|
+
insertMessage(msgId, result.conversationId);
|
|
429
|
+
linkMessage(result.eventId, msgId);
|
|
430
|
+
acknowledgeDelivery('telegram', 'chat-1', 'msg-1');
|
|
431
|
+
markProcessed(result.eventId);
|
|
432
|
+
|
|
433
|
+
const db = getDb();
|
|
434
|
+
const row = db
|
|
435
|
+
.select()
|
|
436
|
+
.from(channelInboundEvents)
|
|
437
|
+
.where(eq(channelInboundEvents.id, result.eventId))
|
|
438
|
+
.get();
|
|
439
|
+
|
|
440
|
+
expect(row!.messageId).toBe(msgId);
|
|
441
|
+
expect(row!.deliveryStatus).toBe('delivered');
|
|
442
|
+
expect(row!.processingStatus).toBe('processed');
|
|
443
|
+
|
|
444
|
+
const found = findMessageBySourceId('telegram', 'chat-1', 'src-1');
|
|
445
|
+
expect(found!.messageId).toBe(msgId);
|
|
446
|
+
});
|
|
447
|
+
});
|