@vellumai/assistant 0.10.3-staging.2 → 0.10.4-staging.1
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/openapi.yaml +73 -56
- package/package.json +1 -1
- package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +83 -31
- package/src/__tests__/assistant-stream-state.test.ts +3 -76
- package/src/__tests__/background-workers-disk-pressure.test.ts +4 -2
- package/src/__tests__/channel-approval-routes.test.ts +21 -26
- package/src/__tests__/channel-delivery-store.test.ts +28 -0
- package/src/__tests__/channel-guardian.test.ts +82 -32
- package/src/__tests__/channel-inbound-disk-pressure.test.ts +11 -19
- package/src/__tests__/channel-reply-delivery.test.ts +6 -2
- package/src/__tests__/compaction-ledger-store.test.ts +128 -0
- package/src/__tests__/config-loader-backfill.test.ts +148 -0
- package/src/__tests__/consult-deadline.test.ts +60 -0
- package/src/__tests__/contact-store-interaction-info.test.ts +156 -0
- package/src/__tests__/contact-store-user-file.test.ts +7 -10
- package/src/__tests__/contacts-relay-reads.test.ts +6 -9
- package/src/__tests__/contacts-write.test.ts +0 -2
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +4 -2
- package/src/__tests__/conversation-agent-loop.test.ts +98 -7
- package/src/__tests__/conversation-attention-telegram.test.ts +9 -11
- package/src/__tests__/conversation-error.test.ts +18 -0
- package/src/__tests__/conversation-fork-crud.test.ts +354 -24
- package/src/__tests__/conversation-title-service.test.ts +222 -201
- package/src/__tests__/db-compaction-events-migration.test.ts +129 -0
- package/src/__tests__/delete-propagation.test.ts +5 -3
- package/src/__tests__/dm-backfill.test.ts +6 -4
- package/src/__tests__/emit-signal-routing-intent.test.ts +2 -6
- package/src/__tests__/guardian-binding-drift-heal.test.ts +43 -23
- package/src/__tests__/guardian-dispatch.test.ts +50 -5
- package/src/__tests__/guardian-routing-state.test.ts +6 -10
- package/src/__tests__/helpers/channel-test-adapter.ts +45 -12
- package/src/__tests__/helpers/create-guardian-binding.ts +15 -23
- package/src/__tests__/helpers/mock-logger.ts +1 -0
- package/src/__tests__/helpers/seed-contact-channel.ts +96 -0
- package/src/__tests__/inbound-invite-redemption.test.ts +87 -10
- package/src/__tests__/invite-redemption-service.test.ts +273 -53
- package/src/__tests__/invite-routes-http.test.ts +34 -0
- package/src/__tests__/invite-service-ipc.test.ts +65 -2
- package/src/__tests__/list-messages-page-latest.test.ts +173 -4
- package/src/__tests__/mcp-config-secret-boundary.test.ts +3 -0
- package/src/__tests__/non-member-access-request.test.ts +15 -13
- package/src/__tests__/onboarding-persona-write.test.ts +52 -22
- package/src/__tests__/persist-onboarding-artifacts.test.ts +1 -0
- package/src/__tests__/persona-resolver.test.ts +75 -45
- package/src/__tests__/plugin-bootstrap.test.ts +13 -5
- package/src/__tests__/plugin-disabled-state.test.ts +190 -0
- package/src/__tests__/provider-usage-tracking.test.ts +1 -1
- package/src/__tests__/reaction-intercept-cold-cache-warm.test.ts +135 -0
- package/src/__tests__/reaction-intercept-member-verdict-warm.test.ts +158 -0
- package/src/__tests__/reaction-persistence.test.ts +51 -4
- package/src/__tests__/relay-server.test.ts +88 -31
- package/src/__tests__/runtime-attachment-metadata.test.ts +9 -11
- package/src/__tests__/settings-routes.test.ts +32 -0
- package/src/__tests__/slack-block-formatting.test.ts +1 -38
- package/src/__tests__/sse-actor-principal-guardian-source.test.ts +13 -36
- package/src/__tests__/stt-hints.test.ts +6 -3
- package/src/__tests__/subagent-fork-prompt-role.test.ts +195 -0
- package/src/__tests__/subagent-fork-spawn.test.ts +6 -7
- package/src/__tests__/subagent-role-registry.test.ts +17 -4
- package/src/__tests__/subagent-spawn-and-await.test.ts +546 -0
- package/src/__tests__/subagent-tools.test.ts +398 -3
- package/src/__tests__/thread-backfill.test.ts +3 -3
- package/src/__tests__/tool-preview-lifecycle.test.ts +26 -10
- package/src/__tests__/tool-start-timestamp.test.ts +4 -3
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +37 -51
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +2 -2
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +9 -7
- package/src/__tests__/trusted-contact-multichannel.test.ts +16 -7
- package/src/__tests__/trusted-contact-verification.test.ts +79 -54
- package/src/__tests__/voice-guardian-cold-cache-warm.test.ts +137 -0
- package/src/__tests__/voice-invite-redemption.test.ts +183 -20
- package/src/__tests__/workspace-migration-102-preserve-heartbeat-enabled-for-existing-workspaces.test.ts +3 -3
- package/src/__tests__/workspace-migration-111-prune-seeded-callsite-defaults.test.ts +2 -2
- package/src/__tests__/workspace-migration-112-remove-advisor-callsite-override.test.ts +170 -0
- package/src/__tests__/workspace-migration-drop-user-md.test.ts +196 -238
- package/src/a2a/__tests__/e2e-a2a-channel.test.ts +35 -47
- package/src/agent/loop-exclusive-tool.test.ts +19 -15
- package/src/agent/loop-native-web-search.test.ts +200 -0
- package/src/agent/loop.ts +108 -1
- package/src/api/responses/conversation-message.ts +9 -0
- package/src/approvals/guardian-request-resolvers.ts +16 -4
- package/src/calls/__tests__/relay-setup-router.test.ts +10 -18
- package/src/calls/guardian-dispatch.ts +14 -11
- package/src/calls/inbound-trust-reader.ts +7 -1
- package/src/calls/relay-access-wait.ts +6 -6
- package/src/calls/relay-server.ts +22 -2
- package/src/calls/relay-setup-router.ts +10 -10
- package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -0
- package/src/cli/commands/contacts.ts +10 -7
- package/src/cli/commands/memory/__tests__/worker.test.ts +147 -17
- package/src/cli/commands/memory/worker.ts +97 -30
- package/src/cli/commands/plugins.ts +3 -146
- package/src/cli/lib/__tests__/list-installed-plugins.test.ts +17 -17
- package/src/cli/lib/__tests__/publish-plugin.test.ts +98 -0
- package/src/cli/lib/publish-plugin.ts +231 -1
- package/src/config/__tests__/sync-gated-profiles.test.ts +5 -7
- package/src/config/bundled-skills/subagent/SKILL.md +16 -1
- package/src/config/bundled-skills/subagent/TOOLS.json +5 -4
- package/src/config/call-site-defaults.ts +0 -6
- package/src/config/llm-resolver.ts +0 -3
- package/src/config/schemas/call-site-catalog.ts +0 -7
- package/src/config/schemas/heartbeat.ts +2 -5
- package/src/config/schemas/llm.ts +3 -12
- package/src/config/schemas/memory-lifecycle.ts +1 -1
- package/src/config/seed-inference-profiles.ts +76 -35
- package/src/config/sync-gated-profiles.ts +0 -3
- package/src/contacts/__tests__/contacts-write-revoke-relay.test.ts +7 -8
- package/src/contacts/__tests__/member-write-relay.test.ts +35 -11
- package/src/contacts/contact-store.ts +27 -237
- package/src/contacts/contacts-write.ts +18 -58
- package/src/contacts/gateway-channel-read.ts +51 -0
- package/src/contacts/member-write-relay.ts +25 -31
- package/src/contacts/types.ts +3 -15
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +0 -44
- package/src/daemon/conversation-agent-loop-handlers.ts +29 -10
- package/src/daemon/conversation-agent-loop.ts +68 -61
- package/src/daemon/conversation-error.ts +7 -10
- package/src/daemon/conversation-tool-setup.ts +0 -10
- package/src/daemon/conversation.ts +10 -0
- package/src/daemon/external-plugins-bootstrap.ts +8 -2
- package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +0 -1
- package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +0 -2
- package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +0 -2
- package/src/daemon/handlers/__tests__/config-channels.test.ts +9 -14
- package/src/daemon/handlers/config-channels.ts +14 -29
- package/src/daemon/lifecycle.ts +16 -4
- package/src/daemon/message-types/surfaces.ts +2 -0
- package/src/heartbeat/heartbeat-service.ts +5 -0
- package/src/home/relationship-state-writer.ts +5 -0
- package/src/memory/__tests__/embedding-cache.test.ts +136 -0
- package/src/memory/compaction-ledger-store.ts +107 -0
- package/src/memory/conversation-crud.ts +136 -61
- package/src/memory/conversation-title-service.ts +173 -24
- package/src/memory/embedding-backend.ts +8 -1
- package/src/memory/embedding-cache.ts +139 -0
- package/src/memory/jobs-worker.ts +75 -29
- package/src/memory/memory-retrospective-job.ts +5 -0
- package/src/memory/migrations/209-strip-thinking-from-consolidated.ts +27 -5
- package/src/memory/migrations/302-create-compaction-events.ts +107 -0
- package/src/memory/migrations/303-add-conversation-creation-seq.ts +33 -0
- package/src/memory/migrations/__tests__/209-strip-thinking-from-consolidated.test.ts +79 -6
- package/src/memory/schema/contacts.ts +6 -2
- package/src/memory/schema/conversations.ts +39 -0
- package/src/memory/steps.ts +1090 -367
- package/src/memory/worker-control.ts +104 -18
- package/src/memory/worker-process.ts +17 -0
- package/src/messaging/channel-binding-metadata.ts +31 -0
- package/src/messaging/channel-binding-schema.ts +51 -0
- package/src/messaging/providers/__tests__/callback-routing.test.ts +45 -0
- package/src/messaging/providers/__tests__/transport-dispatch.test.ts +195 -0
- package/src/messaging/providers/a2a/__tests__/deliver.test.ts +11 -0
- package/src/messaging/providers/a2a/deliver.ts +5 -1
- package/src/messaging/providers/a2a/transport.ts +10 -0
- package/src/messaging/providers/callback-routing.ts +48 -0
- package/src/messaging/providers/channel-transport.ts +55 -0
- package/src/messaging/providers/index.ts +65 -241
- package/src/messaging/providers/slack/binding-metadata.ts +62 -0
- package/src/messaging/providers/slack/transport.ts +92 -0
- package/src/messaging/providers/telegram-bot/transport.ts +51 -0
- package/src/messaging/providers/whatsapp/transport.ts +38 -0
- package/src/notifications/__tests__/broadcaster.test.ts +0 -8
- package/src/notifications/__tests__/connected-channels.test.ts +8 -36
- package/src/notifications/__tests__/destination-resolver.test.ts +12 -117
- package/src/notifications/destination-resolver.ts +7 -23
- package/src/notifications/emit-signal.ts +5 -11
- package/src/plugins/defaults/index.ts +0 -35
- package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +11 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +243 -2
- package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +167 -14
- package/src/plugins/disabled-state.ts +31 -0
- package/src/plugins/registry.ts +55 -12
- package/src/prompts/persona-resolver.ts +43 -11
- package/src/providers/call-site-routing.ts +41 -0
- package/src/providers/provider-send-message.ts +6 -0
- package/src/providers/ratelimit.ts +6 -0
- package/src/providers/registry.ts +1 -1
- package/src/providers/retry.ts +6 -0
- package/src/providers/types.ts +13 -0
- package/src/providers/usage-tracking.ts +6 -0
- package/src/runtime/__tests__/guardian-vellum-migration.test.ts +30 -27
- package/src/runtime/__tests__/local-principal-trust.test.ts +16 -18
- package/src/runtime/__tests__/member-verdict-cache.test.ts +119 -0
- package/src/runtime/__tests__/trust-verdict-consumer.test.ts +115 -168
- package/src/runtime/access-request-helper.ts +1 -2
- package/src/runtime/actor-trust-resolver.ts +44 -17
- package/src/runtime/anchored-guardian.test.ts +7 -54
- package/src/runtime/anchored-guardian.ts +4 -53
- package/src/runtime/assistant-stream-state.ts +12 -74
- package/src/runtime/channel-reply-delivery.ts +3 -8
- package/src/runtime/guardian-vellum-migration.ts +18 -16
- package/src/runtime/invite-redemption-service.ts +25 -10
- package/src/runtime/local-actor-identity.test.ts +108 -0
- package/src/runtime/local-actor-identity.ts +27 -20
- package/src/runtime/member-verdict-cache.ts +0 -0
- package/src/runtime/routes/__tests__/contact-routes.test.ts +100 -7
- package/src/runtime/routes/__tests__/global-search-routes.test.ts +1 -2
- package/src/runtime/routes/__tests__/surface-action-routes.test.ts +2 -1
- package/src/runtime/routes/contact-routes.ts +40 -25
- package/src/runtime/routes/conversation-list-routes.ts +1 -29
- package/src/runtime/routes/conversation-routes.ts +27 -7
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -10
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -8
- package/src/runtime/routes/inbound-stages/reaction-intercept.ts +19 -0
- package/src/runtime/routes/settings-routes.ts +8 -3
- package/src/runtime/services/conversation-serializer.ts +6 -49
- package/src/runtime/slack-block-formatting.ts +0 -15
- package/src/runtime/trust-verdict-consumer.ts +36 -41
- package/src/subagent/__tests__/consult-prompt.test.ts +35 -0
- package/src/{plugins/defaults/advisor/__tests__/transcript.test.ts → subagent/__tests__/consult-transcript.test.ts} +47 -10
- package/src/{plugins/defaults/advisor/steering.ts → subagent/consult-prompt.ts} +17 -39
- package/src/{plugins/defaults/advisor/transcript.ts → subagent/consult-transcript.ts} +18 -8
- package/src/subagent/index.ts +1 -1
- package/src/subagent/manager.ts +245 -33
- package/src/subagent/types.ts +8 -1
- package/src/tools/registry.ts +10 -3
- package/src/tools/subagent/consult-deadline.ts +49 -0
- package/src/tools/subagent/spawn.ts +234 -5
- package/src/util/logger.ts +9 -0
- package/src/util/platform.ts +14 -0
- package/src/workspace/migrations/031-drop-user-md.ts +232 -148
- package/src/workspace/migrations/112-remove-advisor-callsite-override.ts +64 -0
- package/src/workspace/migrations/registry.ts +2 -0
- package/src/plugins/defaults/advisor/__tests__/advisor-gate.test.ts +0 -56
- package/src/plugins/defaults/advisor/__tests__/advisor-state-store.test.ts +0 -43
- package/src/plugins/defaults/advisor/__tests__/agent-loop-integration.test.ts +0 -137
- package/src/plugins/defaults/advisor/__tests__/consult.test.ts +0 -314
- package/src/plugins/defaults/advisor/__tests__/context-pack-gating.test.ts +0 -106
- package/src/plugins/defaults/advisor/__tests__/context-pack.test.ts +0 -60
- package/src/plugins/defaults/advisor/__tests__/hooks.test.ts +0 -138
- package/src/plugins/defaults/advisor/advisor-gate.ts +0 -29
- package/src/plugins/defaults/advisor/advisor-state-store.ts +0 -94
- package/src/plugins/defaults/advisor/config.ts +0 -21
- package/src/plugins/defaults/advisor/consult.ts +0 -197
- package/src/plugins/defaults/advisor/context-pack.ts +0 -288
- package/src/plugins/defaults/advisor/hooks/post-model-call.ts +0 -34
- package/src/plugins/defaults/advisor/hooks/pre-model-call.ts +0 -30
- package/src/plugins/defaults/advisor/hooks/user-prompt-submit.ts +0 -19
- package/src/plugins/defaults/advisor/package.json +0 -14
- package/src/plugins/defaults/advisor/tools/advisor.ts +0 -92
|
@@ -7,25 +7,48 @@
|
|
|
7
7
|
* so the PID-file bookkeeping lives here in one place.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
closeSync,
|
|
12
|
+
existsSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
openSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
unlinkSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from "node:fs";
|
|
19
|
+
import { dirname } from "node:path";
|
|
11
20
|
|
|
12
|
-
import {
|
|
21
|
+
import { getCurrentLogFilePath } from "../util/logger.js";
|
|
22
|
+
import {
|
|
23
|
+
getMemorySyncRunnerMarkerPath,
|
|
24
|
+
getMemoryWorkerPidPath,
|
|
25
|
+
} from "../util/platform.js";
|
|
13
26
|
|
|
14
27
|
export interface MemoryWorkerStatus {
|
|
15
28
|
status: "running" | "not_running";
|
|
16
29
|
pid?: number;
|
|
17
30
|
}
|
|
18
31
|
|
|
32
|
+
/** True when `err` is a Node ESRCH error ("no such process"). */
|
|
33
|
+
function isEsrchError(err: unknown): boolean {
|
|
34
|
+
return (
|
|
35
|
+
!!err &&
|
|
36
|
+
typeof err === "object" &&
|
|
37
|
+
"code" in err &&
|
|
38
|
+
(err as { code?: unknown }).code === "ESRCH"
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
19
42
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
43
|
+
* Read a PID file and report liveness. A missing or malformed file reports
|
|
44
|
+
* not_running; a file pointing at a dead process is cleaned up and reported as
|
|
45
|
+
* not_running. Shared by the worker-process PID file and the sync-runner
|
|
46
|
+
* marker so both probe identically.
|
|
23
47
|
*/
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (!existsSync(pidPath)) return { status: "not_running" };
|
|
48
|
+
function probePidFile(path: string): MemoryWorkerStatus {
|
|
49
|
+
if (!existsSync(path)) return { status: "not_running" };
|
|
27
50
|
|
|
28
|
-
const raw = readFileSync(
|
|
51
|
+
const raw = readFileSync(path, "utf-8").trim();
|
|
29
52
|
const pid = parseInt(raw, 10);
|
|
30
53
|
if (!Number.isFinite(pid) || pid <= 0) return { status: "not_running" };
|
|
31
54
|
|
|
@@ -33,15 +56,10 @@ export function probeMemoryWorker(): MemoryWorkerStatus {
|
|
|
33
56
|
process.kill(pid, 0);
|
|
34
57
|
return { status: "running", pid };
|
|
35
58
|
} catch (err: unknown) {
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
typeof err === "object" &&
|
|
39
|
-
"code" in err &&
|
|
40
|
-
err.code === "ESRCH"
|
|
41
|
-
) {
|
|
42
|
-
// Stale PID file — clean it up.
|
|
59
|
+
if (isEsrchError(err)) {
|
|
60
|
+
// Stale file — clean it up.
|
|
43
61
|
try {
|
|
44
|
-
unlinkSync(
|
|
62
|
+
unlinkSync(path);
|
|
45
63
|
} catch {
|
|
46
64
|
// best-effort
|
|
47
65
|
}
|
|
@@ -51,6 +69,53 @@ export function probeMemoryWorker(): MemoryWorkerStatus {
|
|
|
51
69
|
}
|
|
52
70
|
}
|
|
53
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Inspect the PID file to determine whether the worker process is alive.
|
|
74
|
+
* A stale PID file (pointing at a dead process) is cleaned up and reported
|
|
75
|
+
* as not_running.
|
|
76
|
+
*/
|
|
77
|
+
export function probeMemoryWorker(): MemoryWorkerStatus {
|
|
78
|
+
return probePidFile(getMemoryWorkerPidPath());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Inspect the sync-runner marker to determine whether the daemon's in-process
|
|
83
|
+
* synchronous runner is currently draining the memory-job queue. The daemon's
|
|
84
|
+
* worker supervisor writes the marker (with its own PID) only while it owns
|
|
85
|
+
* processing, so a live marker means the synchronous runner is going. A stale
|
|
86
|
+
* marker (daemon gone) is cleaned up and reported as not_running.
|
|
87
|
+
*/
|
|
88
|
+
export function probeSyncRunner(): MemoryWorkerStatus {
|
|
89
|
+
return probePidFile(getMemorySyncRunnerMarkerPath());
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Publish the sync-runner marker recording `pid` (the daemon process). Called
|
|
94
|
+
* by the worker supervisor when its in-process synchronous runner takes over
|
|
95
|
+
* processing. Best-effort: a write failure only affects status reporting, not
|
|
96
|
+
* job processing.
|
|
97
|
+
*/
|
|
98
|
+
export function writeSyncRunnerMarker(pid: number): void {
|
|
99
|
+
try {
|
|
100
|
+
writeFileSync(getMemorySyncRunnerMarkerPath(), String(pid), { flag: "w" });
|
|
101
|
+
} catch {
|
|
102
|
+
// best-effort — the marker is a status hint, not a correctness invariant
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Remove the sync-runner marker. Called by the worker supervisor when it stands
|
|
108
|
+
* down for an out-of-process worker, and on daemon shutdown. Best-effort.
|
|
109
|
+
*/
|
|
110
|
+
export function removeSyncRunnerMarker(): void {
|
|
111
|
+
try {
|
|
112
|
+
const path = getMemorySyncRunnerMarkerPath();
|
|
113
|
+
if (existsSync(path)) unlinkSync(path);
|
|
114
|
+
} catch {
|
|
115
|
+
// best-effort
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
54
119
|
export class MemoryWorkerSpawnError extends Error {}
|
|
55
120
|
|
|
56
121
|
/**
|
|
@@ -72,13 +137,34 @@ export async function spawnMemoryWorkerProcess(): Promise<{
|
|
|
72
137
|
const pidPath = getMemoryWorkerPidPath();
|
|
73
138
|
const entry = new URL("./worker-process.ts", import.meta.url);
|
|
74
139
|
|
|
140
|
+
// Pipe the worker's stderr into the same daily log file the daemon
|
|
141
|
+
// writes to. The worker's pino logger already writes there directly,
|
|
142
|
+
// but stderr captures crash traces (uncaught exceptions that bypass
|
|
143
|
+
// the catch handler) and pino's fallback output if the file logger
|
|
144
|
+
// fails to initialize. Without this, any such output is lost to
|
|
145
|
+
// /dev/null and the worker dies silently.
|
|
146
|
+
let stderrFd: number | "inherit" = "inherit";
|
|
147
|
+
try {
|
|
148
|
+
const logPath = getCurrentLogFilePath();
|
|
149
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
150
|
+
stderrFd = openSync(logPath, "a", 0o600);
|
|
151
|
+
} catch {
|
|
152
|
+
// If the log file can't be opened, inherit the parent's stderr so
|
|
153
|
+
// crash output is at least visible to the spawning process.
|
|
154
|
+
}
|
|
155
|
+
|
|
75
156
|
// Spawn detached so the worker survives the spawning process exiting.
|
|
76
157
|
const child = Bun.spawn({
|
|
77
158
|
cmd: ["bun", "run", entry.pathname],
|
|
78
|
-
stdio: ["ignore", "ignore",
|
|
159
|
+
stdio: ["ignore", "ignore", stderrFd],
|
|
79
160
|
detached: true,
|
|
80
161
|
});
|
|
81
162
|
|
|
163
|
+
// Close our copy of the log fd — the child has its own.
|
|
164
|
+
if (typeof stderrFd === "number") {
|
|
165
|
+
closeSync(stderrFd);
|
|
166
|
+
}
|
|
167
|
+
|
|
82
168
|
// Unreference so the spawning process doesn't wait for the child.
|
|
83
169
|
child.unref();
|
|
84
170
|
|
|
@@ -58,6 +58,23 @@ async function main(): Promise<void> {
|
|
|
58
58
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
59
59
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
60
60
|
|
|
61
|
+
// Catch stray exceptions that escape the worker loop so they produce a
|
|
62
|
+
// clean pino-formatted log entry (and PID-file cleanup) instead of a raw
|
|
63
|
+
// stack trace on stderr. The stderr fd is already piped to the log file
|
|
64
|
+
// by the spawner, so even without these handlers the trace would be
|
|
65
|
+
// captured — but this gives us structured logging and graceful shutdown.
|
|
66
|
+
process.on("uncaughtException", (err) => {
|
|
67
|
+
log.error({ err }, "Uncaught exception in memory worker process");
|
|
68
|
+
cleanupPidFile();
|
|
69
|
+
process.exit(1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
process.on("unhandledRejection", (reason) => {
|
|
73
|
+
log.error({ reason }, "Unhandled rejection in memory worker process");
|
|
74
|
+
cleanupPidFile();
|
|
75
|
+
process.exit(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
61
78
|
// Clean up if the process exits unexpectedly through any other path.
|
|
62
79
|
process.on("exit", () => {
|
|
63
80
|
worker.stop();
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ExternalConversationBinding } from "../memory/external-conversation-store.js";
|
|
2
|
+
import type { ChannelBindingMetadata } from "./channel-binding-schema.js";
|
|
3
|
+
import { buildSlackBindingMetadata } from "./providers/slack/binding-metadata.js";
|
|
4
|
+
|
|
5
|
+
type BindingMetadataBuilder = (
|
|
6
|
+
binding: ExternalConversationBinding,
|
|
7
|
+
) => ChannelBindingMetadata | undefined;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Per-channel binding-metadata builders, keyed by source-channel id. A channel
|
|
11
|
+
* that can enrich a serialized conversation binding (e.g. with deep links back
|
|
12
|
+
* to the source message) registers its builder here; channels without an entry
|
|
13
|
+
* contribute nothing.
|
|
14
|
+
*
|
|
15
|
+
* Wired statically on purpose: conversation serialization runs in contexts that
|
|
16
|
+
* never boot the daemon (unit tests, CLI tooling), so this must not depend on
|
|
17
|
+
* the lifecycle-registered messaging-provider registry.
|
|
18
|
+
*/
|
|
19
|
+
const BINDING_METADATA_BUILDERS: Record<string, BindingMetadataBuilder> = {
|
|
20
|
+
slack: buildSlackBindingMetadata,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Channel-specific fields to merge onto a serialized channel binding, or
|
|
25
|
+
* `undefined` when the source channel contributes none.
|
|
26
|
+
*/
|
|
27
|
+
export function buildChannelBindingMetadata(
|
|
28
|
+
binding: ExternalConversationBinding,
|
|
29
|
+
): ChannelBindingMetadata | undefined {
|
|
30
|
+
return BINDING_METADATA_BUILDERS[binding.sourceChannel]?.(binding);
|
|
31
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const slackThreadSchema = z.object({
|
|
4
|
+
channelId: z.string(),
|
|
5
|
+
threadTs: z.string(),
|
|
6
|
+
link: z
|
|
7
|
+
.object({
|
|
8
|
+
appUrl: z.string().optional(),
|
|
9
|
+
webUrl: z.string().optional(),
|
|
10
|
+
})
|
|
11
|
+
.optional(),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const slackChannelSchema = z.object({
|
|
15
|
+
channelId: z.string(),
|
|
16
|
+
name: z.string().optional(),
|
|
17
|
+
link: z.object({ webUrl: z.string() }).optional(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Wire shape of a serialized conversation channel binding — the single source
|
|
22
|
+
* of truth for this contract.
|
|
23
|
+
*
|
|
24
|
+
* Consumed as a route `responseBody` (which drives `openapi.yaml` generation
|
|
25
|
+
* and, in turn, the web client's generated daemon types), and the server-side
|
|
26
|
+
* builders derive their TypeScript types from it via `z.infer`. The shape is
|
|
27
|
+
* therefore declared exactly once.
|
|
28
|
+
*/
|
|
29
|
+
export const channelBindingSchema = z.object({
|
|
30
|
+
sourceChannel: z.string(),
|
|
31
|
+
externalChatId: z.string(),
|
|
32
|
+
externalChatName: z.string().optional(),
|
|
33
|
+
externalThreadId: z.string().optional(),
|
|
34
|
+
externalUserId: z.string().nullable(),
|
|
35
|
+
displayName: z.string().nullable(),
|
|
36
|
+
username: z.string().nullable(),
|
|
37
|
+
slackThread: slackThreadSchema.optional(),
|
|
38
|
+
slackChannel: slackChannelSchema.optional(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
type ChannelBinding = z.infer<typeof channelBindingSchema>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The channel-specific fields a per-channel builder contributes to a binding
|
|
45
|
+
* (everything beyond the channel-neutral base). Picked from the schema above
|
|
46
|
+
* so a builder's output can never drift from the wire contract.
|
|
47
|
+
*/
|
|
48
|
+
export type ChannelBindingMetadata = Pick<
|
|
49
|
+
ChannelBinding,
|
|
50
|
+
"externalChatName" | "slackThread" | "slackChannel"
|
|
51
|
+
>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { channelForCallback } from "../callback-routing.js";
|
|
4
|
+
|
|
5
|
+
describe("channelForCallback", () => {
|
|
6
|
+
test("resolves each direct-delivery channel from its callback URL", () => {
|
|
7
|
+
expect(channelForCallback("http://gw/deliver/slack?threadTs=1")).toBe(
|
|
8
|
+
"slack",
|
|
9
|
+
);
|
|
10
|
+
expect(channelForCallback("http://gw/deliver/telegram")).toBe("telegram");
|
|
11
|
+
expect(channelForCallback("http://gw/deliver/whatsapp")).toBe("whatsapp");
|
|
12
|
+
expect(channelForCallback("http://gw/deliver/a2a?taskId=t1")).toBe("a2a");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("returns undefined for channels not delivered directly", () => {
|
|
16
|
+
expect(channelForCallback("http://gw/deliver/discord")).toBeUndefined();
|
|
17
|
+
expect(channelForCallback("http://gw/deliver/phone")).toBeUndefined();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("returns undefined for non-delivery paths", () => {
|
|
21
|
+
expect(channelForCallback("http://gw/v1/messages")).toBeUndefined();
|
|
22
|
+
expect(
|
|
23
|
+
channelForCallback(
|
|
24
|
+
"http://gw/v1/internal/managed-gateway/outbound-send/?route_id=r1",
|
|
25
|
+
),
|
|
26
|
+
).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("returns undefined for unparseable input", () => {
|
|
30
|
+
expect(channelForCallback("not-a-url")).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("resolves base-less callback paths", () => {
|
|
34
|
+
expect(channelForCallback("/deliver/slack?threadTs=1")).toBe("slack");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("resolves relative guardian-style /deliver/<channel> callbacks", () => {
|
|
38
|
+
// resolveDeliverCallbackUrlForChannel() emits these relative URLs for
|
|
39
|
+
// off-channel guardian approvals/denials and timer-driven expiry notices;
|
|
40
|
+
// they must route as direct delivery, not fall through to the HTTP proxy.
|
|
41
|
+
expect(channelForCallback("/deliver/slack")).toBe("slack");
|
|
42
|
+
expect(channelForCallback("/deliver/telegram")).toBe("telegram");
|
|
43
|
+
expect(channelForCallback("/deliver/whatsapp")).toBe("whatsapp");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type { ChannelReplyPayload } from "@vellumai/gateway-client";
|
|
4
|
+
|
|
5
|
+
// Replace each channel's provider-API send layer with spies so the dispatcher's
|
|
6
|
+
// routing and sub-operation selection can be asserted without network calls.
|
|
7
|
+
const slack = {
|
|
8
|
+
sendSlackReply: mock((..._args: unknown[]) =>
|
|
9
|
+
Promise.resolve({ ts: "slack-ts" }),
|
|
10
|
+
),
|
|
11
|
+
sendSlackReaction: mock((..._args: unknown[]) => Promise.resolve()),
|
|
12
|
+
sendSlackTypingIndicator: mock((..._args: unknown[]) =>
|
|
13
|
+
Promise.resolve("typing-ts"),
|
|
14
|
+
),
|
|
15
|
+
sendSlackAssistantThreadStatus: mock((..._args: unknown[]) =>
|
|
16
|
+
Promise.resolve(),
|
|
17
|
+
),
|
|
18
|
+
sendSlackAttachments: mock((..._args: unknown[]) =>
|
|
19
|
+
Promise.resolve({ allFailed: false, failureCount: 0 }),
|
|
20
|
+
),
|
|
21
|
+
};
|
|
22
|
+
const telegram = {
|
|
23
|
+
sendTelegramReply: mock((..._args: unknown[]) => Promise.resolve()),
|
|
24
|
+
sendTelegramTypingIndicator: mock((..._args: unknown[]) => Promise.resolve()),
|
|
25
|
+
sendTelegramAttachments: mock((..._args: unknown[]) =>
|
|
26
|
+
Promise.resolve({ allFailed: false, failureCount: 0 }),
|
|
27
|
+
),
|
|
28
|
+
};
|
|
29
|
+
const whatsapp = {
|
|
30
|
+
sendWhatsAppReply: mock((..._args: unknown[]) => Promise.resolve()),
|
|
31
|
+
sendWhatsAppAttachments: mock((..._args: unknown[]) =>
|
|
32
|
+
Promise.resolve({ allFailed: false, failureCount: 0 }),
|
|
33
|
+
),
|
|
34
|
+
};
|
|
35
|
+
const a2a = {
|
|
36
|
+
deliverA2AReply: mock((..._args: unknown[]) => Promise.resolve({ ok: true })),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
mock.module("../slack/send.js", () => slack);
|
|
40
|
+
mock.module("../telegram-bot/send.js", () => telegram);
|
|
41
|
+
mock.module("../whatsapp/send.js", () => whatsapp);
|
|
42
|
+
mock.module("../a2a/deliver.js", () => a2a);
|
|
43
|
+
mock.module("../../../util/logger.js", () => ({
|
|
44
|
+
getLogger: () => ({ debug() {}, info() {}, warn() {}, error() {} }),
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
const { deliverDirect, isDirectDelivery, getTransportForCallback } =
|
|
48
|
+
await import("../index.js");
|
|
49
|
+
|
|
50
|
+
const BASE = "https://gateway.internal";
|
|
51
|
+
|
|
52
|
+
function payload(
|
|
53
|
+
overrides: Partial<ChannelReplyPayload> = {},
|
|
54
|
+
): ChannelReplyPayload {
|
|
55
|
+
return { chatId: "C1", ...overrides };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
for (const group of [slack, telegram, whatsapp, a2a]) {
|
|
60
|
+
for (const spy of Object.values(group)) spy.mockClear();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("routing", () => {
|
|
65
|
+
test("resolves each channel's callback path to its transport", () => {
|
|
66
|
+
expect(
|
|
67
|
+
getTransportForCallback(`${BASE}/deliver/slack?threadTs=1`)?.channel,
|
|
68
|
+
).toBe("slack");
|
|
69
|
+
expect(getTransportForCallback(`${BASE}/deliver/telegram`)?.channel).toBe(
|
|
70
|
+
"telegram",
|
|
71
|
+
);
|
|
72
|
+
expect(getTransportForCallback(`${BASE}/deliver/whatsapp`)?.channel).toBe(
|
|
73
|
+
"whatsapp",
|
|
74
|
+
);
|
|
75
|
+
expect(
|
|
76
|
+
getTransportForCallback(`${BASE}/deliver/a2a?taskId=t1`)?.channel,
|
|
77
|
+
).toBe("a2a");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("isDirectDelivery is true for owned paths, false otherwise", () => {
|
|
81
|
+
expect(isDirectDelivery(`${BASE}/deliver/slack`)).toBe(true);
|
|
82
|
+
expect(isDirectDelivery(`${BASE}/deliver/a2a?taskId=t1`)).toBe(true);
|
|
83
|
+
expect(isDirectDelivery(`${BASE}/deliver/discord`)).toBe(false);
|
|
84
|
+
expect(isDirectDelivery(`${BASE}/v1/messages`)).toBe(false);
|
|
85
|
+
expect(
|
|
86
|
+
isDirectDelivery(
|
|
87
|
+
`${BASE}/v1/internal/managed-gateway/outbound-send/?route_id=r1`,
|
|
88
|
+
),
|
|
89
|
+
).toBe(false);
|
|
90
|
+
expect(getTransportForCallback(`${BASE}/deliver/discord`)).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("Slack sub-operation selection", () => {
|
|
95
|
+
test("text routes to sendSlackReply, threading the callback URL's threadTs", async () => {
|
|
96
|
+
await deliverDirect(
|
|
97
|
+
`${BASE}/deliver/slack?threadTs=1700.5`,
|
|
98
|
+
payload({ text: "hi" }),
|
|
99
|
+
);
|
|
100
|
+
expect(slack.sendSlackReply).toHaveBeenCalledTimes(1);
|
|
101
|
+
const opts = slack.sendSlackReply.mock.calls[0][2] as { threadTs?: string };
|
|
102
|
+
expect(opts.threadTs).toBe("1700.5");
|
|
103
|
+
expect(slack.sendSlackReaction).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("threads a base-less callback URL's threadTs", async () => {
|
|
107
|
+
await deliverDirect(
|
|
108
|
+
`/deliver/slack?threadTs=1700.9`,
|
|
109
|
+
payload({ text: "hi" }),
|
|
110
|
+
);
|
|
111
|
+
expect(slack.sendSlackReply).toHaveBeenCalledTimes(1);
|
|
112
|
+
const opts = slack.sendSlackReply.mock.calls[0][2] as { threadTs?: string };
|
|
113
|
+
expect(opts.threadTs).toBe("1700.9");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("reaction routes to sendSlackReaction, not the text path", async () => {
|
|
117
|
+
await deliverDirect(
|
|
118
|
+
`${BASE}/deliver/slack`,
|
|
119
|
+
payload({
|
|
120
|
+
reaction: {
|
|
121
|
+
action: "add",
|
|
122
|
+
name: "white_check_mark",
|
|
123
|
+
messageTs: "1700.5",
|
|
124
|
+
},
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
expect(slack.sendSlackReaction).toHaveBeenCalledTimes(1);
|
|
128
|
+
expect(slack.sendSlackReply).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("assistantThreadStatus routes to sendSlackAssistantThreadStatus", async () => {
|
|
132
|
+
await deliverDirect(
|
|
133
|
+
`${BASE}/deliver/slack`,
|
|
134
|
+
payload({
|
|
135
|
+
assistantThreadStatus: {
|
|
136
|
+
channel: "C1",
|
|
137
|
+
threadTs: "1700.5",
|
|
138
|
+
status: "is thinking",
|
|
139
|
+
},
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
expect(slack.sendSlackAssistantThreadStatus).toHaveBeenCalledTimes(1);
|
|
143
|
+
expect(slack.sendSlackReply).not.toHaveBeenCalled();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("typing routes to sendSlackTypingIndicator", async () => {
|
|
147
|
+
await deliverDirect(
|
|
148
|
+
`${BASE}/deliver/slack`,
|
|
149
|
+
payload({ chatAction: "typing" }),
|
|
150
|
+
);
|
|
151
|
+
expect(slack.sendSlackTypingIndicator).toHaveBeenCalledTimes(1);
|
|
152
|
+
expect(slack.sendSlackReply).not.toHaveBeenCalled();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("capability gating across channels", () => {
|
|
157
|
+
test("a reaction payload to Telegram falls through to deliver (no sendReaction)", async () => {
|
|
158
|
+
await deliverDirect(
|
|
159
|
+
`${BASE}/deliver/telegram`,
|
|
160
|
+
payload({
|
|
161
|
+
text: "hi",
|
|
162
|
+
reaction: { action: "add", name: "x", messageTs: "1" },
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
expect(telegram.sendTelegramReply).toHaveBeenCalledTimes(1);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("typing to Telegram routes to its typing indicator", async () => {
|
|
169
|
+
await deliverDirect(
|
|
170
|
+
`${BASE}/deliver/telegram`,
|
|
171
|
+
payload({ chatAction: "typing" }),
|
|
172
|
+
);
|
|
173
|
+
expect(telegram.sendTelegramTypingIndicator).toHaveBeenCalledTimes(1);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("WhatsApp text routes to sendWhatsAppReply", async () => {
|
|
177
|
+
await deliverDirect(`${BASE}/deliver/whatsapp`, payload({ text: "hi" }));
|
|
178
|
+
expect(whatsapp.sendWhatsAppReply).toHaveBeenCalledTimes(1);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("A2A routes to deliverA2AReply with the callback URL", async () => {
|
|
182
|
+
const url = `${BASE}/deliver/a2a?taskId=t1`;
|
|
183
|
+
await deliverDirect(url, payload({ text: "hi" }));
|
|
184
|
+
expect(a2a.deliverA2AReply).toHaveBeenCalledTimes(1);
|
|
185
|
+
expect(a2a.deliverA2AReply.mock.calls[0][0]).toBe(url);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("unsupported callback", () => {
|
|
190
|
+
test("throws when no transport owns the callback", async () => {
|
|
191
|
+
await expect(
|
|
192
|
+
deliverDirect(`${BASE}/deliver/discord`, payload({ text: "hi" })),
|
|
193
|
+
).rejects.toThrow(/unsupported callback/);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -167,6 +167,17 @@ describe("deliverA2AReply", () => {
|
|
|
167
167
|
expect(completeWithArtifactsCalls).toHaveLength(0);
|
|
168
168
|
});
|
|
169
169
|
|
|
170
|
+
test("completes task for a base-less (relative) callback URL", async () => {
|
|
171
|
+
const result = await deliverA2AReply("/deliver/a2a?taskId=task-123", {
|
|
172
|
+
chatId: "chat-1",
|
|
173
|
+
text: "Hello",
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(result.ok).toBe(true);
|
|
177
|
+
expect(completeWithArtifactsCalls).toHaveLength(1);
|
|
178
|
+
expect(completeWithArtifactsCalls[0].taskId).toBe("task-123");
|
|
179
|
+
});
|
|
180
|
+
|
|
170
181
|
test("returns ok: true when payload has no content", async () => {
|
|
171
182
|
const result = await deliverA2AReply(baseCallbackUrl, {
|
|
172
183
|
chatId: "chat-1",
|
|
@@ -36,7 +36,11 @@ const PUSH_TIMEOUT_MS = 15_000;
|
|
|
36
36
|
/** Extract the `taskId` query parameter from a callback URL. */
|
|
37
37
|
function parseTaskId(callbackUrl: string): string | null {
|
|
38
38
|
try {
|
|
39
|
-
|
|
39
|
+
// Dummy base so base-less callbacks (e.g. `/deliver/a2a?taskId=…`) parse the
|
|
40
|
+
// same as absolute ones — consistent with `callbackContext` in the dispatcher.
|
|
41
|
+
return new URL(callbackUrl, "http://callback.invalid").searchParams.get(
|
|
42
|
+
"taskId",
|
|
43
|
+
);
|
|
40
44
|
} catch {
|
|
41
45
|
return null;
|
|
42
46
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ChannelTransport } from "../channel-transport.js";
|
|
2
|
+
import { deliverA2AReply } from "./deliver.js";
|
|
3
|
+
|
|
4
|
+
export const a2aTransport: ChannelTransport = {
|
|
5
|
+
channel: "a2a",
|
|
6
|
+
|
|
7
|
+
async deliver(ctx, payload) {
|
|
8
|
+
return deliverA2AReply(ctx.callbackUrl, payload);
|
|
9
|
+
},
|
|
10
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ChannelId } from "../../channels/types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Channels whose outbound replies the assistant delivers directly to the
|
|
5
|
+
* provider API, bypassing the gateway HTTP proxy. Each is reached at the gateway
|
|
6
|
+
* callback path `/deliver/<channel>`.
|
|
7
|
+
*
|
|
8
|
+
* This is the single source of truth for that set and that mapping — shared by
|
|
9
|
+
* the delivery transports (`messaging/providers`) and any caller that needs to
|
|
10
|
+
* resolve a callback URL back to its channel WITHOUT loading the transport
|
|
11
|
+
* implementations (and their provider-API send code). Keep it dependency-light.
|
|
12
|
+
*/
|
|
13
|
+
const DIRECT_DELIVERY_CHANNELS = [
|
|
14
|
+
"slack",
|
|
15
|
+
"telegram",
|
|
16
|
+
"whatsapp",
|
|
17
|
+
"a2a",
|
|
18
|
+
] as const satisfies readonly ChannelId[];
|
|
19
|
+
|
|
20
|
+
export type DirectDeliveryChannel = (typeof DIRECT_DELIVERY_CHANNELS)[number];
|
|
21
|
+
|
|
22
|
+
const CALLBACK_PREFIX = "/deliver/";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a gateway callback URL to the direct-delivery channel that owns it, or
|
|
26
|
+
* `undefined` when no channel delivers it directly.
|
|
27
|
+
*
|
|
28
|
+
* Handles both absolute callbacks (gateway webhook replies) and base-less
|
|
29
|
+
* relative ones like `/deliver/slack`, which off-channel guardian flows emit
|
|
30
|
+
* (`resolveDeliverCallbackUrlForChannel`, the guardian expiry sweep). The
|
|
31
|
+
* query-stripped fallback is required, not defensive: without it those relative
|
|
32
|
+
* notices fall through to the HTTP proxy, which cannot `fetch()` a base-less URL.
|
|
33
|
+
*/
|
|
34
|
+
export function channelForCallback(
|
|
35
|
+
callbackUrl: string,
|
|
36
|
+
): DirectDeliveryChannel | undefined {
|
|
37
|
+
let pathname: string;
|
|
38
|
+
try {
|
|
39
|
+
pathname = new URL(callbackUrl).pathname;
|
|
40
|
+
} catch {
|
|
41
|
+
pathname = callbackUrl.split("?", 1)[0];
|
|
42
|
+
}
|
|
43
|
+
if (!pathname.startsWith(CALLBACK_PREFIX)) return undefined;
|
|
44
|
+
const channel = pathname.slice(CALLBACK_PREFIX.length);
|
|
45
|
+
return (DIRECT_DELIVERY_CHANNELS as readonly string[]).includes(channel)
|
|
46
|
+
? (channel as DirectDeliveryChannel)
|
|
47
|
+
: undefined;
|
|
48
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChannelDeliveryResult,
|
|
3
|
+
ChannelReplyPayload,
|
|
4
|
+
} from "@vellumai/gateway-client";
|
|
5
|
+
|
|
6
|
+
import type { ChannelId } from "../../channels/types.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Per-channel state carried on the gateway callback URL (e.g. Slack `threadTs`,
|
|
10
|
+
* A2A `taskId`). The dispatcher parses the URL once; each transport reads only
|
|
11
|
+
* the params it needs.
|
|
12
|
+
*/
|
|
13
|
+
export interface CallbackContext {
|
|
14
|
+
readonly callbackUrl: string;
|
|
15
|
+
readonly params: Readonly<Record<string, string>>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Direct outbound delivery for one channel, wrapping the channel's provider-API
|
|
20
|
+
* send functions behind a uniform surface. Transports are registered statically
|
|
21
|
+
* (delivery runs in non-daemon contexts) and dispatched by channel, resolved
|
|
22
|
+
* from the gateway callback URL via `callback-routing.ts`.
|
|
23
|
+
*
|
|
24
|
+
* The dispatcher routes a payload to the optional sub-operation methods when the
|
|
25
|
+
* matching payload field is set and the method exists; otherwise it calls
|
|
26
|
+
* `deliver`. A transport only implements the sub-operations it supports.
|
|
27
|
+
*/
|
|
28
|
+
export interface ChannelTransport {
|
|
29
|
+
/** Canonical source channel id, e.g. `"slack"`. */
|
|
30
|
+
readonly channel: ChannelId;
|
|
31
|
+
|
|
32
|
+
/** Deliver a rendered reply (text / approval / attachments). */
|
|
33
|
+
deliver(
|
|
34
|
+
ctx: CallbackContext,
|
|
35
|
+
payload: ChannelReplyPayload,
|
|
36
|
+
): Promise<ChannelDeliveryResult>;
|
|
37
|
+
|
|
38
|
+
/** Send a typing indicator. Routed when `payload.chatAction === "typing"`. */
|
|
39
|
+
sendTyping?(
|
|
40
|
+
ctx: CallbackContext,
|
|
41
|
+
payload: ChannelReplyPayload,
|
|
42
|
+
): Promise<ChannelDeliveryResult>;
|
|
43
|
+
|
|
44
|
+
/** Add an emoji reaction. Routed when `payload.reaction` is set. */
|
|
45
|
+
sendReaction?(
|
|
46
|
+
ctx: CallbackContext,
|
|
47
|
+
payload: ChannelReplyPayload,
|
|
48
|
+
): Promise<ChannelDeliveryResult>;
|
|
49
|
+
|
|
50
|
+
/** Update an assistant-thread status surface. Routed when `payload.assistantThreadStatus` is set. */
|
|
51
|
+
setThreadStatus?(
|
|
52
|
+
ctx: CallbackContext,
|
|
53
|
+
payload: ChannelReplyPayload,
|
|
54
|
+
): Promise<ChannelDeliveryResult>;
|
|
55
|
+
}
|