@absolutejs/voice 0.0.22-beta.112 → 0.0.22-beta.114

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.
@@ -1,5 +1,6 @@
1
1
  import { Elysia } from 'elysia';
2
2
  import type { VoiceRedisTaskLeaseCoordinator } from './queue';
3
+ import type { VoiceTelephonyOutcomeDecision, VoiceTelephonyOutcomeProviderEvent, VoiceTelephonyWebhookDecision } from './telephonyOutcome';
3
4
  export type VoiceCampaignStatus = 'canceled' | 'completed' | 'draft' | 'paused' | 'running';
4
5
  export type VoiceCampaignRecipientStatus = 'canceled' | 'completed' | 'failed' | 'pending' | 'queued';
5
6
  export type VoiceCampaignAttemptStatus = 'canceled' | 'failed' | 'queued' | 'running' | 'succeeded';
@@ -154,6 +155,7 @@ export type VoiceCampaignRuntime = {
154
155
  export type VoiceCampaignRoutesOptions = VoiceCampaignRuntimeOptions & {
155
156
  headers?: HeadersInit;
156
157
  htmlPath?: false | string;
158
+ observability?: VoiceCampaignObservabilityOptions;
157
159
  name?: string;
158
160
  path?: string;
159
161
  title?: string;
@@ -192,15 +194,106 @@ export type VoiceCampaignWorkerLoop = {
192
194
  stop: () => void;
193
195
  tick: () => Promise<VoiceCampaignWorkerResult>;
194
196
  };
197
+ export type VoiceCampaignObservabilityOptions = {
198
+ leases?: VoiceRedisTaskLeaseCoordinator;
199
+ now?: number;
200
+ rateWindowMs?: number;
201
+ stuckAfterMs?: number;
202
+ };
203
+ export type VoiceCampaignObservabilityReport = {
204
+ attemptRate: {
205
+ failed: number;
206
+ started: number;
207
+ succeeded: number;
208
+ windowMs: number;
209
+ };
210
+ campaigns: Array<{
211
+ activeAttempts: number;
212
+ campaignId: string;
213
+ lease?: {
214
+ expiresAt: number;
215
+ workerId: string;
216
+ };
217
+ name: string;
218
+ queueDepth: number;
219
+ status: VoiceCampaignStatus;
220
+ stuckAttempts: number;
221
+ stuckRecipients: number;
222
+ updatedAt: number;
223
+ }>;
224
+ failureReasons: Array<{
225
+ count: number;
226
+ reason: string;
227
+ }>;
228
+ generatedAt: number;
229
+ leases: {
230
+ active: number;
231
+ known: boolean;
232
+ };
233
+ queue: {
234
+ activeAttempts: number;
235
+ queuedRecipients: number;
236
+ runningCampaigns: number;
237
+ };
238
+ stuck: {
239
+ attempts: Array<{
240
+ attemptId: string;
241
+ campaignId: string;
242
+ recipientId: string;
243
+ status: VoiceCampaignAttemptStatus;
244
+ updatedAt: number;
245
+ }>;
246
+ recipients: Array<{
247
+ campaignId: string;
248
+ recipientId: string;
249
+ status: VoiceCampaignRecipientStatus;
250
+ updatedAt: number;
251
+ }>;
252
+ };
253
+ summary: VoiceCampaignSummary;
254
+ };
255
+ export type VoiceCampaignTelephonyOutcomeInput<TResult = unknown> = {
256
+ campaignId?: string;
257
+ decision: VoiceTelephonyOutcomeDecision;
258
+ event?: VoiceTelephonyOutcomeProviderEvent;
259
+ externalCallId?: string;
260
+ routeResult?: TResult;
261
+ sessionId?: string;
262
+ attemptId?: string;
263
+ };
264
+ export type VoiceCampaignTelephonyOutcomeStatus = 'failed' | 'ignore' | 'succeeded';
265
+ export type VoiceCampaignTelephonyOutcomeOptions<TResult = unknown> = {
266
+ resolveCampaignId?: (input: VoiceCampaignTelephonyOutcomeInput<TResult>) => Promise<string | undefined> | string | undefined;
267
+ resolveExternalCallId?: (input: VoiceCampaignTelephonyOutcomeInput<TResult>) => Promise<string | undefined> | string | undefined;
268
+ resolveAttemptId?: (input: VoiceCampaignTelephonyOutcomeInput<TResult>) => Promise<string | undefined> | string | undefined;
269
+ runtime?: VoiceCampaignRuntime;
270
+ statusForDecision?: (input: VoiceCampaignTelephonyOutcomeInput<TResult>) => Promise<VoiceCampaignTelephonyOutcomeStatus> | VoiceCampaignTelephonyOutcomeStatus;
271
+ store?: VoiceCampaignStore;
272
+ };
273
+ export type VoiceCampaignTelephonyOutcomeResult = {
274
+ applied: boolean;
275
+ campaignId?: string;
276
+ error?: string;
277
+ externalCallId?: string;
278
+ reason?: 'ignored' | 'missing-attempt' | 'missing-campaign' | 'missing-runtime' | 'terminal-attempt';
279
+ status?: 'failed' | 'succeeded';
280
+ attemptId?: string;
281
+ };
195
282
  export declare const createVoiceMemoryCampaignStore: () => VoiceCampaignStore;
196
283
  export declare const summarizeVoiceCampaigns: (records: VoiceCampaignRecord[]) => VoiceCampaignSummary;
284
+ export declare const buildVoiceCampaignObservabilityReport: (records: VoiceCampaignRecord[], options?: VoiceCampaignObservabilityOptions) => Promise<VoiceCampaignObservabilityReport>;
197
285
  export declare const createVoiceCampaign: (options: VoiceCampaignRuntimeOptions) => VoiceCampaignRuntime;
198
286
  export declare const createVoiceCampaignWorker: (options: VoiceCampaignWorkerOptions) => VoiceCampaignWorker;
199
287
  export declare const createVoiceCampaignWorkerLoop: (options: VoiceCampaignWorkerLoopOptions) => VoiceCampaignWorkerLoop;
288
+ export declare const applyVoiceCampaignTelephonyOutcome: <TResult = unknown>(input: VoiceCampaignTelephonyOutcomeInput<TResult>, options?: VoiceCampaignTelephonyOutcomeOptions<TResult>) => Promise<VoiceCampaignTelephonyOutcomeResult>;
289
+ export declare const createVoiceCampaignTelephonyOutcomeHandler: <TResult = unknown>(options: VoiceCampaignTelephonyOutcomeOptions<TResult>) => (input: VoiceTelephonyWebhookDecision<TResult>) => Promise<VoiceCampaignTelephonyOutcomeResult>;
200
290
  export declare const runVoiceCampaignProof: (options?: VoiceCampaignProofOptions) => Promise<VoiceCampaignProofReport>;
201
291
  export declare const renderVoiceCampaignsHTML: (records: VoiceCampaignRecord[], options?: {
202
292
  title?: string;
203
293
  }) => string;
294
+ export declare const renderVoiceCampaignObservabilityHTML: (report: VoiceCampaignObservabilityReport, options?: {
295
+ title?: string;
296
+ }) => string;
204
297
  export declare const createVoiceCampaignRoutes: (options: VoiceCampaignRoutesOptions) => Elysia<"", {
205
298
  decorator: {};
206
299
  store: {};
@@ -231,6 +324,20 @@ export declare const createVoiceCampaignRoutes: (options: VoiceCampaignRoutesOpt
231
324
  };
232
325
  };
233
326
  };
327
+ } & {
328
+ [x: string]: {
329
+ observability: {
330
+ get: {
331
+ body: unknown;
332
+ params: {};
333
+ query: unknown;
334
+ headers: unknown;
335
+ response: {
336
+ 200: VoiceCampaignObservabilityReport;
337
+ };
338
+ };
339
+ };
340
+ };
234
341
  } & {
235
342
  [x: string]: {
236
343
  post: {
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { voice } from './plugin';
2
2
  export { createVoiceAppKit, createVoiceAppKitRoutes, summarizeVoiceAppKitStatus } from './appKit';
3
- export { createVoiceCampaign, createVoiceCampaignRoutes, createVoiceCampaignWorker, createVoiceCampaignWorkerLoop, createVoiceMemoryCampaignStore, renderVoiceCampaignsHTML, runVoiceCampaignProof, summarizeVoiceCampaigns } from './campaign';
3
+ export { applyVoiceCampaignTelephonyOutcome, buildVoiceCampaignObservabilityReport, createVoiceCampaignTelephonyOutcomeHandler, createVoiceCampaign, createVoiceCampaignRoutes, createVoiceCampaignWorker, createVoiceCampaignWorkerLoop, createVoiceMemoryCampaignStore, renderVoiceCampaignObservabilityHTML, renderVoiceCampaignsHTML, runVoiceCampaignProof, summarizeVoiceCampaigns } from './campaign';
4
4
  export { createVoiceAssistant, createVoiceExperiment, summarizeVoiceAssistantRuns } from './assistant';
5
5
  export { createVoiceAssistantHealthHTMLHandler, createVoiceAssistantHealthJSONHandler, createVoiceAssistantHealthRoutes, renderVoiceAssistantHealthHTML, summarizeVoiceAssistantHealth } from './assistantHealth';
6
6
  export { createVoiceBargeInRoutes, renderVoiceBargeInHTML, summarizeVoiceBargeIn } from './bargeInRoutes';
package/dist/index.js CHANGED
@@ -10628,6 +10628,112 @@ var summarizeVoiceCampaigns = (records) => {
10628
10628
  }
10629
10629
  return summary;
10630
10630
  };
10631
+ var buildVoiceCampaignObservabilityReport = async (records, options = {}) => {
10632
+ const now = options.now ?? Date.now();
10633
+ const stuckAfterMs = Math.max(1, options.stuckAfterMs ?? 15 * 60000);
10634
+ const rateWindowMs = Math.max(1, options.rateWindowMs ?? 60 * 60000);
10635
+ const rateWindowStart = now - rateWindowMs;
10636
+ const failureReasons = new Map;
10637
+ const report = {
10638
+ attemptRate: {
10639
+ failed: 0,
10640
+ started: 0,
10641
+ succeeded: 0,
10642
+ windowMs: rateWindowMs
10643
+ },
10644
+ campaigns: [],
10645
+ failureReasons: [],
10646
+ generatedAt: now,
10647
+ leases: {
10648
+ active: 0,
10649
+ known: Boolean(options.leases)
10650
+ },
10651
+ queue: {
10652
+ activeAttempts: 0,
10653
+ queuedRecipients: 0,
10654
+ runningCampaigns: 0
10655
+ },
10656
+ stuck: {
10657
+ attempts: [],
10658
+ recipients: []
10659
+ },
10660
+ summary: summarizeVoiceCampaigns(records)
10661
+ };
10662
+ for (const record of records) {
10663
+ const campaignId = record.campaign.id;
10664
+ const queuedRecipients = record.recipients.filter((recipient) => recipient.status === "queued");
10665
+ const activeAttempts = record.attempts.filter((attempt) => attempt.status === "queued" || attempt.status === "running");
10666
+ const campaignReport = {
10667
+ activeAttempts: activeAttempts.length,
10668
+ campaignId,
10669
+ name: record.campaign.name,
10670
+ queueDepth: queuedRecipients.length,
10671
+ status: record.campaign.status,
10672
+ stuckAttempts: 0,
10673
+ stuckRecipients: 0,
10674
+ updatedAt: record.campaign.updatedAt
10675
+ };
10676
+ if (record.campaign.status === "running") {
10677
+ report.queue.runningCampaigns += 1;
10678
+ }
10679
+ report.queue.queuedRecipients += queuedRecipients.length;
10680
+ report.queue.activeAttempts += activeAttempts.length;
10681
+ for (const recipient of record.recipients) {
10682
+ if ((recipient.status === "pending" || recipient.status === "queued") && now - recipient.updatedAt >= stuckAfterMs) {
10683
+ campaignReport.stuckRecipients += 1;
10684
+ report.stuck.recipients.push({
10685
+ campaignId,
10686
+ recipientId: recipient.id,
10687
+ status: recipient.status,
10688
+ updatedAt: recipient.updatedAt
10689
+ });
10690
+ }
10691
+ if (recipient.error) {
10692
+ failureReasons.set(recipient.error, (failureReasons.get(recipient.error) ?? 0) + 1);
10693
+ }
10694
+ }
10695
+ for (const attempt of record.attempts) {
10696
+ if ((attempt.startedAt ?? attempt.createdAt) >= rateWindowStart) {
10697
+ report.attemptRate.started += 1;
10698
+ }
10699
+ if (attempt.status === "failed" && attempt.updatedAt >= rateWindowStart) {
10700
+ report.attemptRate.failed += 1;
10701
+ }
10702
+ if (attempt.status === "succeeded" && attempt.updatedAt >= rateWindowStart) {
10703
+ report.attemptRate.succeeded += 1;
10704
+ }
10705
+ if (attempt.error) {
10706
+ failureReasons.set(attempt.error, (failureReasons.get(attempt.error) ?? 0) + 1);
10707
+ }
10708
+ if ((attempt.status === "queued" || attempt.status === "running") && now - attempt.updatedAt >= stuckAfterMs) {
10709
+ campaignReport.stuckAttempts += 1;
10710
+ report.stuck.attempts.push({
10711
+ attemptId: attempt.id,
10712
+ campaignId,
10713
+ recipientId: attempt.recipientId,
10714
+ status: attempt.status,
10715
+ updatedAt: attempt.updatedAt
10716
+ });
10717
+ }
10718
+ }
10719
+ if (options.leases) {
10720
+ const lease = await options.leases.get(getCampaignLeaseTaskId(campaignId));
10721
+ if (lease) {
10722
+ report.leases.active += 1;
10723
+ campaignReport.lease = {
10724
+ expiresAt: lease.expiresAt,
10725
+ workerId: lease.workerId
10726
+ };
10727
+ }
10728
+ }
10729
+ report.campaigns.push(campaignReport);
10730
+ }
10731
+ report.failureReasons = [...failureReasons.entries()].map(([reason, count]) => ({ count, reason })).sort((left, right) => right.count === left.count ? left.reason.localeCompare(right.reason) : right.count - left.count);
10732
+ report.campaigns.sort((left, right) => right.updatedAt - left.updatedAt);
10733
+ report.stuck.attempts.sort((left, right) => left.updatedAt - right.updatedAt);
10734
+ report.stuck.recipients.sort((left, right) => left.updatedAt - right.updatedAt);
10735
+ return report;
10736
+ };
10631
10737
  var createVoiceCampaign = (options) => {
10632
10738
  const { store } = options;
10633
10739
  return {
@@ -10878,6 +10984,155 @@ var createVoiceCampaignWorkerLoop = (options) => {
10878
10984
  tick
10879
10985
  };
10880
10986
  };
10987
+ var firstOutcomeString = (values) => {
10988
+ for (const value of values) {
10989
+ if (typeof value === "string" && value.trim()) {
10990
+ return value.trim();
10991
+ }
10992
+ if (typeof value === "number" && Number.isFinite(value)) {
10993
+ return String(value);
10994
+ }
10995
+ }
10996
+ };
10997
+ var resolveDefaultCampaignOutcomeIds = (input) => {
10998
+ const metadata = input.event?.metadata ?? {};
10999
+ const decisionMetadata = input.decision.metadata ?? {};
11000
+ const routeResult = typeof input.routeResult === "object" && input.routeResult !== null ? input.routeResult : {};
11001
+ return {
11002
+ campaignId: input.campaignId ?? firstOutcomeString([
11003
+ metadata.campaignId,
11004
+ metadata.voiceCampaignId,
11005
+ decisionMetadata.campaignId,
11006
+ decisionMetadata.voiceCampaignId,
11007
+ routeResult.campaignId,
11008
+ routeResult.voiceCampaignId
11009
+ ]),
11010
+ externalCallId: input.externalCallId ?? firstOutcomeString([
11011
+ metadata.externalCallId,
11012
+ metadata.callId,
11013
+ metadata.callSid,
11014
+ metadata.callUuid,
11015
+ decisionMetadata.externalCallId,
11016
+ decisionMetadata.callId,
11017
+ decisionMetadata.callSid,
11018
+ decisionMetadata.callUuid,
11019
+ routeResult.externalCallId,
11020
+ routeResult.callId,
11021
+ routeResult.callSid,
11022
+ routeResult.callUuid,
11023
+ input.sessionId
11024
+ ]),
11025
+ attemptId: input.attemptId ?? firstOutcomeString([
11026
+ metadata.attemptId,
11027
+ metadata.voiceCampaignAttemptId,
11028
+ decisionMetadata.attemptId,
11029
+ decisionMetadata.voiceCampaignAttemptId,
11030
+ routeResult.attemptId,
11031
+ routeResult.voiceCampaignAttemptId
11032
+ ])
11033
+ };
11034
+ };
11035
+ var defaultCampaignOutcomeStatus = (input) => {
11036
+ switch (input.decision.action) {
11037
+ case "complete":
11038
+ case "transfer":
11039
+ return "succeeded";
11040
+ case "escalate":
11041
+ case "no-answer":
11042
+ case "voicemail":
11043
+ return "failed";
11044
+ default:
11045
+ return "ignore";
11046
+ }
11047
+ };
11048
+ var findCampaignAttempt = async (input) => {
11049
+ const records = input.campaignId ? [await input.runtime.get(input.campaignId)].filter(Boolean) : await input.runtime.list();
11050
+ for (const record of records) {
11051
+ const attempt = record.attempts.find((item) => input.attemptId && item.id === input.attemptId || input.externalCallId && item.externalCallId === input.externalCallId);
11052
+ if (attempt) {
11053
+ return {
11054
+ attempt,
11055
+ record
11056
+ };
11057
+ }
11058
+ }
11059
+ };
11060
+ var applyVoiceCampaignTelephonyOutcome = async (input, options = {}) => {
11061
+ const runtime = options.runtime ?? (options.store ? createVoiceCampaign({
11062
+ store: options.store
11063
+ }) : undefined);
11064
+ if (!runtime) {
11065
+ return {
11066
+ applied: false,
11067
+ reason: "missing-runtime"
11068
+ };
11069
+ }
11070
+ const defaults = resolveDefaultCampaignOutcomeIds(input);
11071
+ const campaignId = await options.resolveCampaignId?.(input) ?? defaults.campaignId;
11072
+ const attemptId = await options.resolveAttemptId?.(input) ?? defaults.attemptId;
11073
+ const externalCallId = await options.resolveExternalCallId?.(input) ?? defaults.externalCallId;
11074
+ const status = await options.statusForDecision?.(input) ?? defaultCampaignOutcomeStatus(input);
11075
+ if (status === "ignore") {
11076
+ return {
11077
+ applied: false,
11078
+ campaignId,
11079
+ externalCallId,
11080
+ reason: "ignored",
11081
+ attemptId
11082
+ };
11083
+ }
11084
+ const match = await findCampaignAttempt({
11085
+ attemptId,
11086
+ campaignId,
11087
+ externalCallId,
11088
+ runtime
11089
+ });
11090
+ if (!match) {
11091
+ return {
11092
+ applied: false,
11093
+ campaignId,
11094
+ externalCallId,
11095
+ reason: campaignId ? "missing-attempt" : "missing-campaign",
11096
+ attemptId
11097
+ };
11098
+ }
11099
+ if (match.attempt.status === "failed" || match.attempt.status === "succeeded") {
11100
+ return {
11101
+ applied: false,
11102
+ campaignId: match.record.campaign.id,
11103
+ externalCallId: match.attempt.externalCallId ?? externalCallId,
11104
+ reason: "terminal-attempt",
11105
+ status: match.attempt.status,
11106
+ attemptId: match.attempt.id
11107
+ };
11108
+ }
11109
+ await runtime.completeAttempt(match.record.campaign.id, match.attempt.id, {
11110
+ error: status === "failed" ? input.decision.reason ?? input.decision.disposition ?? input.event?.reason ?? input.event?.status ?? input.decision.action : undefined,
11111
+ externalCallId: externalCallId ?? match.attempt.externalCallId,
11112
+ metadata: {
11113
+ telephonyAction: input.decision.action,
11114
+ telephonyConfidence: input.decision.confidence,
11115
+ telephonyDisposition: input.decision.disposition,
11116
+ telephonyProvider: input.event?.provider,
11117
+ telephonySource: input.decision.source,
11118
+ telephonyStatus: input.event?.status
11119
+ },
11120
+ status
11121
+ });
11122
+ return {
11123
+ applied: true,
11124
+ campaignId: match.record.campaign.id,
11125
+ externalCallId: externalCallId ?? match.attempt.externalCallId,
11126
+ status,
11127
+ attemptId: match.attempt.id
11128
+ };
11129
+ };
11130
+ var createVoiceCampaignTelephonyOutcomeHandler = (options) => (input) => applyVoiceCampaignTelephonyOutcome({
11131
+ decision: input.decision,
11132
+ event: input.event,
11133
+ routeResult: input.routeResult,
11134
+ sessionId: input.sessionId
11135
+ }, options);
10881
11136
  var defaultProofRecipients = () => [
10882
11137
  {
10883
11138
  id: "campaign-proof-recipient-1",
@@ -10953,6 +11208,12 @@ var renderVoiceCampaignsHTML = (records, options = {}) => {
10953
11208
  const summary = summarizeVoiceCampaigns(records);
10954
11209
  return `<!doctype html><html lang="en"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>${escapeHtml18(title)}</title><style>body{background:#111827;color:#f9fafb;font-family:ui-sans-serif,system-ui,sans-serif;margin:0}main{margin:auto;max-width:1080px;padding:32px}.hero{background:linear-gradient(135deg,rgba(251,146,60,.18),rgba(45,212,191,.12));border:1px solid #334155;border-radius:28px;margin-bottom:18px;padding:28px}.eyebrow{color:#fdba74;font-weight:900;letter-spacing:.12em;text-transform:uppercase}h1{font-size:clamp(2.4rem,6vw,5rem);line-height:.9;margin:.2rem 0 1rem}.grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));margin:18px 0}.grid article,table{background:#172033;border:1px solid #334155;border-radius:18px}.grid article{padding:16px}.grid span{color:#aab5c0}.grid strong{display:block;font-size:2rem;margin:.25rem 0}table{border-collapse:collapse;overflow:hidden;width:100%}td,th{border-bottom:1px solid #334155;padding:12px;text-align:left}</style></head><body><main><section class="hero"><p class="eyebrow">Self-hosted outbound</p><h1>${escapeHtml18(title)}</h1><p>Campaign orchestration, recipients, attempts, retries, and outcomes without a hosted dialer dashboard.</p><section class="grid"><article><span>Campaigns</span><strong>${String(summary.campaigns.total)}</strong></article><article><span>Recipients</span><strong>${String(summary.recipients.total)}</strong></article><article><span>Attempts</span><strong>${String(summary.attempts.total)}</strong></article><article><span>Running</span><strong>${String(summary.campaigns.running)}</strong></article></section></section><table><thead><tr><th>Name</th><th>Status</th><th>Recipients</th><th>Attempts</th><th>Updated</th></tr></thead><tbody>${rows || '<tr><td colspan="5">No campaigns yet.</td></tr>'}</tbody></table></main></body></html>`;
10955
11210
  };
11211
+ var renderVoiceCampaignObservabilityHTML = (report, options = {}) => {
11212
+ const title = options.title ?? "Voice Campaign Observability";
11213
+ const campaignRows = report.campaigns.map((campaign) => `<tr><td>${escapeHtml18(campaign.name)}</td><td>${escapeHtml18(campaign.status)}</td><td>${String(campaign.queueDepth)}</td><td>${String(campaign.activeAttempts)}</td><td>${String(campaign.stuckRecipients + campaign.stuckAttempts)}</td><td>${campaign.lease ? escapeHtml18(campaign.lease.workerId) : "none"}</td></tr>`).join("");
11214
+ const failureRows = report.failureReasons.map((failure) => `<tr><td>${escapeHtml18(failure.reason)}</td><td>${String(failure.count)}</td></tr>`).join("");
11215
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>${escapeHtml18(title)}</title><style>body{background:#0b1220;color:#e5edf7;font-family:ui-sans-serif,system-ui,sans-serif;margin:0}main{margin:auto;max-width:1120px;padding:32px}.hero{background:linear-gradient(135deg,rgba(20,184,166,.2),rgba(251,146,60,.14));border:1px solid #334155;border-radius:28px;margin-bottom:18px;padding:28px}.eyebrow{color:#5eead4;font-weight:900;letter-spacing:.12em;text-transform:uppercase}h1{font-size:clamp(2.2rem,5vw,4.6rem);line-height:.95;margin:.2rem 0 1rem}.grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));margin:18px 0}.card,table{background:#111c2f;border:1px solid #334155;border-radius:18px}.card{padding:16px}.card span{color:#9fb0c5}.card strong{display:block;font-size:2rem;margin:.25rem 0}table{border-collapse:collapse;margin-top:18px;overflow:hidden;width:100%}td,th{border-bottom:1px solid #334155;padding:12px;text-align:left}.warn{color:#fde68a}.bad{color:#fecaca}</style></head><body><main><section class="hero"><p class="eyebrow">Campaign ops</p><h1>${escapeHtml18(title)}</h1><p>Queue depth, active leases, attempt rates, failure reasons, and stuck work for self-hosted outbound voice.</p><section class="grid"><article class="card"><span>Queued recipients</span><strong>${String(report.queue.queuedRecipients)}</strong></article><article class="card"><span>Active attempts</span><strong>${String(report.queue.activeAttempts)}</strong></article><article class="card"><span>Running campaigns</span><strong>${String(report.queue.runningCampaigns)}</strong></article><article class="card"><span>Active leases</span><strong>${report.leases.known ? String(report.leases.active) : "n/a"}</strong></article><article class="card"><span>Attempts/window</span><strong>${String(report.attemptRate.started)}</strong></article><article class="card"><span>Stuck work</span><strong class="${report.stuck.attempts.length + report.stuck.recipients.length > 0 ? "bad" : ""}">${String(report.stuck.attempts.length + report.stuck.recipients.length)}</strong></article></section></section><h2>Campaigns</h2><table><thead><tr><th>Name</th><th>Status</th><th>Queued</th><th>Active</th><th>Stuck</th><th>Lease</th></tr></thead><tbody>${campaignRows || '<tr><td colspan="6">No campaigns yet.</td></tr>'}</tbody></table><h2>Failure Reasons</h2><table><thead><tr><th>Reason</th><th>Count</th></tr></thead><tbody>${failureRows || '<tr><td colspan="2">No failures recorded.</td></tr>'}</tbody></table></main></body></html>`;
11216
+ };
10956
11217
  var readJsonBody = async (request) => {
10957
11218
  const text = await request.text();
10958
11219
  return text.trim() ? JSON.parse(text) : {};
@@ -10964,7 +11225,7 @@ var createVoiceCampaignRoutes = (options) => {
10964
11225
  const app = new Elysia17({ name: options.name ?? "absolutejs-voice-campaigns" }).get(path, async () => ({
10965
11226
  campaigns: await runtime.list(),
10966
11227
  summary: await runtime.summarize()
10967
- })).post(path, async ({ request }) => runtime.create(await readJsonBody(request))).get(`${path}/:campaignId`, ({ params }) => runtime.get(params.campaignId)).delete(`${path}/:campaignId`, async ({ params }) => {
11228
+ })).get(`${path}/observability`, async () => buildVoiceCampaignObservabilityReport(await runtime.list(), options.observability)).post(path, async ({ request }) => runtime.create(await readJsonBody(request))).get(`${path}/:campaignId`, ({ params }) => runtime.get(params.campaignId)).delete(`${path}/:campaignId`, async ({ params }) => {
10968
11229
  await runtime.remove(params.campaignId);
10969
11230
  return { ok: true };
10970
11231
  }).post(`${path}/:campaignId/recipients`, async ({ params, request }) => {
@@ -10983,6 +11244,14 @@ var createVoiceCampaignRoutes = (options) => {
10983
11244
  ...options.headers
10984
11245
  }
10985
11246
  });
11247
+ }).get(`${htmlPath}/observability`, async () => {
11248
+ const report = await buildVoiceCampaignObservabilityReport(await runtime.list(), options.observability);
11249
+ return new Response(renderVoiceCampaignObservabilityHTML(report, options), {
11250
+ headers: {
11251
+ "content-type": "text/html; charset=utf-8",
11252
+ ...options.headers
11253
+ }
11254
+ });
10986
11255
  });
10987
11256
  }
10988
11257
  return app;
@@ -19033,6 +19302,7 @@ export {
19033
19302
  renderVoiceEvalHTML,
19034
19303
  renderVoiceEvalBaselineHTML,
19035
19304
  renderVoiceCampaignsHTML,
19305
+ renderVoiceCampaignObservabilityHTML,
19036
19306
  renderVoiceCallReviewMarkdown,
19037
19307
  renderVoiceCallReviewHTML,
19038
19308
  renderVoiceBargeInHTML,
@@ -19209,6 +19479,7 @@ export {
19209
19479
  createVoiceDiagnosticsRoutes,
19210
19480
  createVoiceCampaignWorkerLoop,
19211
19481
  createVoiceCampaignWorker,
19482
+ createVoiceCampaignTelephonyOutcomeHandler,
19212
19483
  createVoiceCampaignRoutes,
19213
19484
  createVoiceCampaign,
19214
19485
  createVoiceCallReviewRecorder,
@@ -19262,11 +19533,13 @@ export {
19262
19533
  buildVoiceOpsTaskFromReview,
19263
19534
  buildVoiceOpsConsoleReport,
19264
19535
  buildVoiceDiagnosticsMarkdown,
19536
+ buildVoiceCampaignObservabilityReport,
19265
19537
  assignVoiceOpsTask,
19266
19538
  applyVoiceTelephonyOutcome,
19267
19539
  applyVoiceOpsTaskPolicy,
19268
19540
  applyVoiceOpsTaskAssignmentRule,
19269
19541
  applyVoiceHandoffDeliveryResult,
19542
+ applyVoiceCampaignTelephonyOutcome,
19270
19543
  applyRiskTieredPhraseHintCorrections,
19271
19544
  applyPhraseHintCorrections,
19272
19545
  TURN_PROFILE_DEFAULTS
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@absolutejs/voice",
3
- "version": "0.0.22-beta.112",
3
+ "version": "0.0.22-beta.114",
4
4
  "description": "Voice primitives and Elysia plugin for AbsoluteJS",
5
5
  "repository": {
6
6
  "type": "git",