@holdyapp/sdk 0.2.0
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/LICENSE +21 -0
- package/README.md +165 -0
- package/dist/anchor.d.ts +8 -0
- package/dist/auto-capture.d.ts +21 -0
- package/dist/holdy.cjs.js +255 -0
- package/dist/holdy.cjs.js.map +7 -0
- package/dist/holdy.esm.js +255 -0
- package/dist/holdy.esm.js.map +7 -0
- package/dist/holdy.iife.js +255 -0
- package/dist/holdy.iife.js.map +7 -0
- package/dist/index.d.ts +46 -0
- package/dist/outcome.d.ts +19 -0
- package/dist/queue.d.ts +54 -0
- package/dist/render/kinds/banner.d.ts +3 -0
- package/dist/render/kinds/checklist.d.ts +19 -0
- package/dist/render/kinds/cta.d.ts +8 -0
- package/dist/render/kinds/modal.d.ts +16 -0
- package/dist/render/kinds/shared.d.ts +16 -0
- package/dist/render/kinds/spotlight.d.ts +17 -0
- package/dist/render/kinds/tooltip.d.ts +3 -0
- package/dist/render/root.d.ts +3 -0
- package/dist/render/styles.d.ts +5 -0
- package/dist/store.d.ts +36 -0
- package/dist/transport.d.ts +59 -0
- package/dist/types.d.ts +114 -0
- package/package.json +60 -0
- package/src/anchor.ts +72 -0
- package/src/auto-capture.ts +129 -0
- package/src/index.ts +427 -0
- package/src/outcome.ts +51 -0
- package/src/queue.ts +296 -0
- package/src/render/kinds/banner.ts +52 -0
- package/src/render/kinds/checklist.ts +175 -0
- package/src/render/kinds/cta.ts +53 -0
- package/src/render/kinds/modal.ts +129 -0
- package/src/render/kinds/shared.ts +67 -0
- package/src/render/kinds/spotlight.ts +176 -0
- package/src/render/kinds/tooltip.ts +82 -0
- package/src/render/root.ts +69 -0
- package/src/render/styles.ts +280 -0
- package/src/store.ts +106 -0
- package/src/transport.ts +182 -0
- package/src/types.ts +173 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AccountIdentity,
|
|
3
|
+
CanonicalPrimitive,
|
|
4
|
+
DecisionListener,
|
|
5
|
+
DecisionResult,
|
|
6
|
+
HoldyConfig,
|
|
7
|
+
IdentifyOptions,
|
|
8
|
+
InterventionPayload,
|
|
9
|
+
OutcomeValue,
|
|
10
|
+
RuntimeConfig,
|
|
11
|
+
ThemeConfig,
|
|
12
|
+
TrackOptions,
|
|
13
|
+
UserIdentity,
|
|
14
|
+
} from './types'
|
|
15
|
+
import { Transport } from './transport'
|
|
16
|
+
import { OutcomeTracker } from './outcome'
|
|
17
|
+
import { InterventionStore } from './store'
|
|
18
|
+
import { resolveAnchor } from './anchor'
|
|
19
|
+
import { installAutoCapture } from './auto-capture'
|
|
20
|
+
import { appendToRoot, ensureRoot } from './render/root'
|
|
21
|
+
import { renderTooltip } from './render/kinds/tooltip'
|
|
22
|
+
import { renderBanner } from './render/kinds/banner'
|
|
23
|
+
import { renderCta } from './render/kinds/cta'
|
|
24
|
+
import { renderModal } from './render/kinds/modal'
|
|
25
|
+
import { renderChecklist } from './render/kinds/checklist'
|
|
26
|
+
import { renderSpotlight } from './render/kinds/spotlight'
|
|
27
|
+
import type { RenderControl, RenderHandlers } from './render/kinds/shared'
|
|
28
|
+
|
|
29
|
+
const RING_BUFFER_LIMIT = 20
|
|
30
|
+
const CONFIG_CACHE_KEY = '__holdy_runtime_config_v1'
|
|
31
|
+
const CONFIG_CACHE_TTL_MS = 60 * 60 * 1000
|
|
32
|
+
const EVAL_THROTTLE_MS = 1500
|
|
33
|
+
|
|
34
|
+
interface RingEntry {
|
|
35
|
+
primitive: CanonicalPrimitive
|
|
36
|
+
payload: Record<string, unknown>
|
|
37
|
+
occurred_at: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class HoldySdk {
|
|
41
|
+
private config: HoldyConfig | null = null
|
|
42
|
+
private transport: Transport | null = null
|
|
43
|
+
private outcomeTracker: OutcomeTracker | null = null
|
|
44
|
+
private store: InterventionStore = new InterventionStore()
|
|
45
|
+
private runtime: RuntimeConfig | null = null
|
|
46
|
+
private activeWorkflow: string | null = null
|
|
47
|
+
private sentiment: 'positivo' | 'neutro' | 'negativo' | 'urgente' | null = null
|
|
48
|
+
private readonly ring: RingEntry[] = []
|
|
49
|
+
private listeners: DecisionListener[] = []
|
|
50
|
+
private lastEvalAt = 0
|
|
51
|
+
private initialized = false
|
|
52
|
+
private autoCaptureCleanup: (() => void) | null = null
|
|
53
|
+
|
|
54
|
+
async init(config: HoldyConfig): Promise<void> {
|
|
55
|
+
if (this.initialized) {
|
|
56
|
+
this.log('init called twice · ignoring')
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
if (!config.apiKey) {
|
|
60
|
+
throw new Error('[holdy] apiKey is required')
|
|
61
|
+
}
|
|
62
|
+
this.config = config
|
|
63
|
+
this.transport = new Transport(config)
|
|
64
|
+
this.outcomeTracker = new OutcomeTracker(this.transport)
|
|
65
|
+
this.initialized = true
|
|
66
|
+
|
|
67
|
+
const cached = this.readCachedConfig()
|
|
68
|
+
if (cached) this.runtime = cached
|
|
69
|
+
|
|
70
|
+
const fresh = await this.transport.fetchRuntimeConfig()
|
|
71
|
+
if (fresh) {
|
|
72
|
+
this.runtime = fresh
|
|
73
|
+
this.writeCachedConfig(fresh)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (this.shouldRender()) {
|
|
77
|
+
ensureRoot(this.themeFromRuntime())
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Stage 28 G_auto · auto-capture de page views.
|
|
81
|
+
// Habilita só quando customer pediu explicitamente · default off pra
|
|
82
|
+
// back-compat. Ring buffer + transport recebem session.activity igual
|
|
83
|
+
// se viesse de track() manual · decision engine consome no contexto.
|
|
84
|
+
if (config.autoCapture) {
|
|
85
|
+
this.autoCaptureCleanup = installAutoCapture((path, title, referrer, kind) => {
|
|
86
|
+
void this.track('session.activity', {
|
|
87
|
+
kind,
|
|
88
|
+
path,
|
|
89
|
+
...(title ? { title } : {}),
|
|
90
|
+
...(referrer ? { referrer } : {}),
|
|
91
|
+
}, { skipEval: kind !== 'route_change' })
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Identifica conta + usuário corrente.
|
|
98
|
+
*
|
|
99
|
+
* - `accountId/userId/sessionId` hidratam config local pra evaluate/track.
|
|
100
|
+
* - `account/user` (metadata rica) disparam upsert best-effort em
|
|
101
|
+
* /v1/accounts/upsert. Backend COALESCE → nunca sobrescreve campos do
|
|
102
|
+
* wizard manual. Em sucesso, account_id Holdy retornado hidrata config
|
|
103
|
+
* local automaticamente (cliente não precisa saber UUID).
|
|
104
|
+
*/
|
|
105
|
+
identify(opts: IdentifyOptions): void {
|
|
106
|
+
if (!this.config) return
|
|
107
|
+
if (opts.accountId !== undefined) this.config.accountId = opts.accountId
|
|
108
|
+
if (opts.userId !== undefined) this.config.userId = opts.userId
|
|
109
|
+
if (opts.sessionId !== undefined) this.config.sessionId = opts.sessionId
|
|
110
|
+
|
|
111
|
+
if (opts.account || opts.user) {
|
|
112
|
+
void this.upsertIdentityRich(opts.account, opts.user)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private async upsertIdentityRich(
|
|
117
|
+
account: AccountIdentity | undefined,
|
|
118
|
+
user: UserIdentity | undefined,
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
if (!this.transport || !account) return
|
|
121
|
+
const result = await this.transport.upsertAccount({ account, user })
|
|
122
|
+
if (!result || !this.config) return
|
|
123
|
+
if (!this.config.accountId) this.config.accountId = result.account_id
|
|
124
|
+
if (!this.config.userId && result.account_user_id) {
|
|
125
|
+
this.config.userId = result.account_user_id
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
setWorkflow(workflowKey: string | null | undefined): void {
|
|
130
|
+
this.activeWorkflow = workflowKey ?? null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
setSentiment(sentiment: 'positivo' | 'neutro' | 'negativo' | 'urgente' | null): void {
|
|
134
|
+
this.sentiment = sentiment
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async track(
|
|
138
|
+
primitive: CanonicalPrimitive,
|
|
139
|
+
payload: Record<string, unknown> = {},
|
|
140
|
+
opts: TrackOptions = {},
|
|
141
|
+
): Promise<void> {
|
|
142
|
+
if (!this.config || !this.transport) {
|
|
143
|
+
this.log('track called before init')
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
const occurred_at = new Date().toISOString()
|
|
147
|
+
const entry: RingEntry = { primitive, payload, occurred_at }
|
|
148
|
+
this.pushRing(entry)
|
|
149
|
+
|
|
150
|
+
// workflow tracking auto-sync
|
|
151
|
+
if (primitive === 'workflow.started' && typeof payload.workflow === 'string') {
|
|
152
|
+
this.activeWorkflow = payload.workflow
|
|
153
|
+
}
|
|
154
|
+
if (
|
|
155
|
+
(primitive === 'workflow.completed' || primitive === 'workflow.abandoned') &&
|
|
156
|
+
typeof payload.workflow === 'string' &&
|
|
157
|
+
this.activeWorkflow === payload.workflow
|
|
158
|
+
) {
|
|
159
|
+
this.activeWorkflow = null
|
|
160
|
+
}
|
|
161
|
+
if (opts.workflow !== undefined) this.activeWorkflow = opts.workflow
|
|
162
|
+
|
|
163
|
+
// ingest fire-and-forget
|
|
164
|
+
this.transport.ingestBatch([
|
|
165
|
+
{
|
|
166
|
+
primitive,
|
|
167
|
+
occurred_at,
|
|
168
|
+
account_id: this.config.accountId,
|
|
169
|
+
user_id: this.config.userId,
|
|
170
|
+
session_id: this.config.sessionId,
|
|
171
|
+
payload,
|
|
172
|
+
},
|
|
173
|
+
])
|
|
174
|
+
|
|
175
|
+
if (opts.skipEval) return
|
|
176
|
+
if (!this.shouldTriggerEval(primitive)) return
|
|
177
|
+
|
|
178
|
+
await this.evaluateInternal()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async evaluate(): Promise<DecisionResult | null> {
|
|
182
|
+
return this.evaluateInternal(true)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
onDecision(listener: DecisionListener): () => void {
|
|
186
|
+
this.listeners.push(listener)
|
|
187
|
+
return () => {
|
|
188
|
+
this.listeners = this.listeners.filter((l) => l !== listener)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
reportOutcome(
|
|
193
|
+
decisionId: string,
|
|
194
|
+
outcome: OutcomeValue,
|
|
195
|
+
metadata?: Record<string, unknown>,
|
|
196
|
+
): void {
|
|
197
|
+
this.outcomeTracker?.dispatch(decisionId, outcome, metadata)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
destroy(): void {
|
|
201
|
+
this.transport?.destroy()
|
|
202
|
+
if (this.autoCaptureCleanup) {
|
|
203
|
+
try { this.autoCaptureCleanup() } catch { /* ignore */ }
|
|
204
|
+
this.autoCaptureCleanup = null
|
|
205
|
+
}
|
|
206
|
+
this.config = null
|
|
207
|
+
this.transport = null
|
|
208
|
+
this.outcomeTracker = null
|
|
209
|
+
this.runtime = null
|
|
210
|
+
this.activeWorkflow = null
|
|
211
|
+
this.listeners = []
|
|
212
|
+
this.ring.length = 0
|
|
213
|
+
this.initialized = false
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/* ───────── internals ───────── */
|
|
217
|
+
|
|
218
|
+
private async evaluateInternal(force = false): Promise<DecisionResult | null> {
|
|
219
|
+
if (!this.transport || !this.config?.accountId) {
|
|
220
|
+
this.log('evaluate skipped · no accountId set · call identify first')
|
|
221
|
+
return null
|
|
222
|
+
}
|
|
223
|
+
const now = Date.now()
|
|
224
|
+
if (!force && now - this.lastEvalAt < EVAL_THROTTLE_MS) {
|
|
225
|
+
return null
|
|
226
|
+
}
|
|
227
|
+
this.lastEvalAt = now
|
|
228
|
+
|
|
229
|
+
const evalInput: {
|
|
230
|
+
account_id: string
|
|
231
|
+
current_workflow?: string
|
|
232
|
+
recent_primitives: Array<{
|
|
233
|
+
primitive: CanonicalPrimitive
|
|
234
|
+
payload: Record<string, unknown>
|
|
235
|
+
occurred_at: string
|
|
236
|
+
}>
|
|
237
|
+
consumer: 'ui' | 'csm' | 'agent'
|
|
238
|
+
recent_sentiment?: 'positivo' | 'neutro' | 'negativo' | 'urgente'
|
|
239
|
+
} = {
|
|
240
|
+
account_id: this.config.accountId,
|
|
241
|
+
recent_primitives: [...this.ring],
|
|
242
|
+
consumer: this.config.consumer ?? 'ui',
|
|
243
|
+
}
|
|
244
|
+
if (this.activeWorkflow) evalInput.current_workflow = this.activeWorkflow
|
|
245
|
+
if (this.sentiment) evalInput.recent_sentiment = this.sentiment
|
|
246
|
+
|
|
247
|
+
const result = await this.transport.evaluate(evalInput)
|
|
248
|
+
if (!result) return null
|
|
249
|
+
|
|
250
|
+
this.listeners.forEach((l) => {
|
|
251
|
+
try {
|
|
252
|
+
l(result)
|
|
253
|
+
} catch (e) {
|
|
254
|
+
this.log('listener error', e)
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
if (result.decision_type === 'intervention' && this.shouldRender()) {
|
|
259
|
+
void this.renderDecision(result)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return result
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private async renderDecision(decision: DecisionResult): Promise<void> {
|
|
266
|
+
const payload = decision.decision_payload as InterventionPayload
|
|
267
|
+
if (!payload || typeof payload.kind !== 'string' || typeof payload.text !== 'string') {
|
|
268
|
+
this.outcomeTracker?.dispatch(decision.decision_id, 'failed', { reason: 'invalid_payload' })
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const handlers: RenderHandlers = {
|
|
273
|
+
onExecuted: (meta) => this.outcomeTracker?.dispatch(decision.decision_id, 'executed', meta),
|
|
274
|
+
onDismissed: (meta) => this.outcomeTracker?.dispatch(decision.decision_id, 'dismissed', meta),
|
|
275
|
+
onFailed: (meta) => this.outcomeTracker?.dispatch(decision.decision_id, 'failed', meta),
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let control: RenderControl | null = null
|
|
279
|
+
try {
|
|
280
|
+
switch (payload.kind) {
|
|
281
|
+
case 'tooltip': {
|
|
282
|
+
const anchor = await resolveAnchor(payload.anchor_selector)
|
|
283
|
+
if (!anchor) {
|
|
284
|
+
this.outcomeTracker?.dispatch(decision.decision_id, 'no_show', {
|
|
285
|
+
reason: 'anchor_unresolved',
|
|
286
|
+
selector: payload.anchor_selector ?? null,
|
|
287
|
+
})
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
control = renderTooltip(payload, anchor, handlers)
|
|
291
|
+
break
|
|
292
|
+
}
|
|
293
|
+
case 'banner':
|
|
294
|
+
control = renderBanner(payload, handlers)
|
|
295
|
+
break
|
|
296
|
+
case 'cta':
|
|
297
|
+
control = renderCta(payload, handlers)
|
|
298
|
+
break
|
|
299
|
+
case 'modal':
|
|
300
|
+
control = renderModal(payload, handlers)
|
|
301
|
+
break
|
|
302
|
+
case 'checklist':
|
|
303
|
+
control = renderChecklist(payload, decision.decision_id, handlers)
|
|
304
|
+
break
|
|
305
|
+
case 'spotlight': {
|
|
306
|
+
const anchor = await resolveAnchor(payload.anchor_selector)
|
|
307
|
+
if (!anchor) {
|
|
308
|
+
this.outcomeTracker?.dispatch(decision.decision_id, 'no_show', {
|
|
309
|
+
reason: 'anchor_unresolved',
|
|
310
|
+
selector: payload.anchor_selector ?? null,
|
|
311
|
+
})
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
control = renderSpotlight(payload, anchor, handlers)
|
|
315
|
+
break
|
|
316
|
+
}
|
|
317
|
+
default:
|
|
318
|
+
this.outcomeTracker?.dispatch(decision.decision_id, 'no_show', {
|
|
319
|
+
reason: 'kind_not_supported',
|
|
320
|
+
kind: payload.kind,
|
|
321
|
+
})
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
} catch (e) {
|
|
325
|
+
this.log('render error', e)
|
|
326
|
+
handlers.onFailed({ error: String(e) })
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (!control) return
|
|
331
|
+
|
|
332
|
+
// Slot collision: store decide quem fica · sup ersedido é dismissado.
|
|
333
|
+
const superseded = this.store.claim(
|
|
334
|
+
payload.kind,
|
|
335
|
+
decision.decision_id,
|
|
336
|
+
decision.confidence,
|
|
337
|
+
payload.anchor_selector,
|
|
338
|
+
() => control!.dismiss('dismissed'),
|
|
339
|
+
)
|
|
340
|
+
if (superseded && superseded.decisionId !== decision.decision_id) {
|
|
341
|
+
superseded.dismiss()
|
|
342
|
+
this.outcomeTracker?.dispatch(superseded.decisionId, 'dismissed', {
|
|
343
|
+
reason: 'superseded',
|
|
344
|
+
by: decision.decision_id,
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
this.outcomeTracker?.registerPending(decision.decision_id, this.activeWorkflow ?? undefined)
|
|
349
|
+
appendToRoot(control.el)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private shouldRender(): boolean {
|
|
353
|
+
if (this.config?.renderInterventions === false) return false
|
|
354
|
+
if (this.runtime?.features && this.runtime.features.interventions_enabled === false) return false
|
|
355
|
+
return typeof document !== 'undefined'
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private shouldTriggerEval(primitive: CanonicalPrimitive): boolean {
|
|
359
|
+
// Eventos que tipicamente disparam decisão (state-change ou friction).
|
|
360
|
+
return (
|
|
361
|
+
primitive === 'workflow.started' ||
|
|
362
|
+
primitive === 'workflow.blocked' ||
|
|
363
|
+
primitive === 'workflow.completed' ||
|
|
364
|
+
primitive === 'workflow.abandoned' ||
|
|
365
|
+
primitive === 'friction.detected' ||
|
|
366
|
+
primitive === 'support.sentiment_negative' ||
|
|
367
|
+
primitive === 'expansion.intent_detected' ||
|
|
368
|
+
primitive === 'expansion.ceiling_hit'
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private pushRing(entry: RingEntry): void {
|
|
373
|
+
this.ring.push(entry)
|
|
374
|
+
while (this.ring.length > RING_BUFFER_LIMIT) this.ring.shift()
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private themeFromRuntime(): ThemeConfig | undefined {
|
|
378
|
+
return this.runtime?.theme
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private readCachedConfig(): RuntimeConfig | null {
|
|
382
|
+
if (typeof localStorage === 'undefined') return null
|
|
383
|
+
try {
|
|
384
|
+
const raw = localStorage.getItem(CONFIG_CACHE_KEY)
|
|
385
|
+
if (!raw) return null
|
|
386
|
+
const parsed = JSON.parse(raw) as { at: number; data: RuntimeConfig }
|
|
387
|
+
if (Date.now() - parsed.at > CONFIG_CACHE_TTL_MS) return null
|
|
388
|
+
return parsed.data
|
|
389
|
+
} catch {
|
|
390
|
+
return null
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private writeCachedConfig(config: RuntimeConfig): void {
|
|
395
|
+
if (typeof localStorage === 'undefined') return
|
|
396
|
+
try {
|
|
397
|
+
localStorage.setItem(CONFIG_CACHE_KEY, JSON.stringify({ at: Date.now(), data: config }))
|
|
398
|
+
} catch {
|
|
399
|
+
// storage quota · ignore
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private log(...args: unknown[]): void {
|
|
404
|
+
if (this.config?.debug && typeof console !== 'undefined') {
|
|
405
|
+
console.warn('[holdy]', ...args)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export const Holdy = new HoldySdk()
|
|
411
|
+
|
|
412
|
+
export type {
|
|
413
|
+
AccountIdentity,
|
|
414
|
+
CanonicalPrimitive,
|
|
415
|
+
DecisionResult,
|
|
416
|
+
DecisionListener,
|
|
417
|
+
HoldyConfig,
|
|
418
|
+
IdentifyOptions,
|
|
419
|
+
InterventionPayload,
|
|
420
|
+
OutcomeValue,
|
|
421
|
+
RuntimeConfig,
|
|
422
|
+
ThemeConfig,
|
|
423
|
+
TrackOptions,
|
|
424
|
+
UserIdentity,
|
|
425
|
+
} from './types'
|
|
426
|
+
|
|
427
|
+
export default Holdy
|
package/src/outcome.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { OutcomeValue } from './types'
|
|
2
|
+
import type { Transport } from './transport'
|
|
3
|
+
|
|
4
|
+
interface PendingDecision {
|
|
5
|
+
id: string
|
|
6
|
+
workflow?: string | undefined
|
|
7
|
+
startedAt: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Outcome tracker · explicit only.
|
|
12
|
+
*
|
|
13
|
+
* - executed → ack via UI handler.
|
|
14
|
+
* - dismissed → ack via UI handler.
|
|
15
|
+
* - no_show → SDK detecta anchor não resolvido em 5s.
|
|
16
|
+
* - failed → exception capturada pelo render boundary.
|
|
17
|
+
* - converted → conversion cron server-side faz join workflow.completed.
|
|
18
|
+
*/
|
|
19
|
+
export class OutcomeTracker {
|
|
20
|
+
private readonly transport: Transport
|
|
21
|
+
private readonly pending = new Map<string, PendingDecision>()
|
|
22
|
+
|
|
23
|
+
constructor(transport: Transport) {
|
|
24
|
+
this.transport = transport
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
registerPending(decisionId: string, workflow: string | undefined): void {
|
|
28
|
+
this.pending.set(decisionId, {
|
|
29
|
+
id: decisionId,
|
|
30
|
+
workflow,
|
|
31
|
+
startedAt: Date.now(),
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
dispatch(decisionId: string, outcome: OutcomeValue, metadata?: Record<string, unknown>): void {
|
|
36
|
+
if (!this.pending.has(decisionId) && outcome !== 'failed') {
|
|
37
|
+
// unknown decision · still report failed/dismissed since they're idempotent
|
|
38
|
+
this.transport.reportOutcome(decisionId, outcome, metadata)
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
this.pending.delete(decisionId)
|
|
42
|
+
this.transport.reportOutcome(decisionId, outcome, {
|
|
43
|
+
...metadata,
|
|
44
|
+
latency_ms: metadata?.latency_ms ?? Date.now() - (this.pending.get(decisionId)?.startedAt ?? Date.now()),
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
hasPending(decisionId: string): boolean {
|
|
49
|
+
return this.pending.has(decisionId)
|
|
50
|
+
}
|
|
51
|
+
}
|