Kind: Service
Source: atloria-monorepo/apps/api/src/tours/tours-healing.service.ts
ToursHealingService (D2 self-healing).
Live tours rot when the customer ships UI changes: selectors stop resolving and the player degrades to "element not found". This service closes the loop:
- NIGHTLY CRON — for every live, self-heal-OPTED-IN tour experience
(selfHealEnabled defaults to false), ask the screenshot-worker to replay
the flow's selectors against the live app (
POST screenshot/flow/validate). - WRITE-BACK — healed selectors are CAS-written into CapturedFlow.steps: the update only lands if steps are UNCHANGED since our read (a Prisma Json-equality guard), so an owner editing steps concurrently always wins.
- FLAGGING — un-healable steps (element truly gone) are recorded on
TourExperience.healing and emitted as a
tour_healingevent on the interaction_events spine. - SPIKES — the public events endpoint counts live
step_element_missingreports per experience/hour; crossing the threshold fires an immediate out-of-band validation (debounced, opt-in only) instead of waiting for the nightly sweep.
The worker call mirrors CaptureService.callWorker (fire-and-forget friendly, no auth header, SCREENSHOT_WORKER_URL env).
ToursHealingService keeps live, opted-in tour experiences healthy when UI changes cause captured selectors to stop resolving. It runs nightly validation through the screenshot worker, CAS-writes healed selectors only when flow steps have not changed, records unhealable steps, and triggers debounced validation when missing-element events spike.
Methods
| Method | Signature | Returns | Description |
|---|---|---|---|
nightlySweep | nightlySweep() | Promise<void> | Validate every opted-in live tour once a night. |
noteElementMissing | noteElementMissing(experienceId: string) | Promise<void> | Count a live step_element_missing report; when one experience crosses N misses within the hour (TOUR_MISS_SPIKE_THRESHOLD, default 10), fire an out-of-band v… |
validateById | validateById(projectId: string, experienceId: string, trigger: 'manual') | Promise<Record<string, unknown>> | Owner-triggered validation (POST /validate). |
validateExperience | `validateExperience(exp: TourExperienceRow, trigger: 'cron' | 'spike' | 'manual')` |
Dependencies
PrismaServiceRedisServiceEventsService
Where it refuses work
ToursHealingServicestops the work with an early return whenacquired !== 'OK'.ToursHealingServicestops the work with an early return whenexperiences.length === 0.ToursHealingServicestops the work with an early return when!client.ToursHealingServicestops the work with an early return whencount < this.spikeThreshold().ToursHealingServicestops the work with an early return whendebounced !== 'OK'.ToursHealingServicestops the work with an early return when!exp.
When something fails
ToursHealingServicehandles failure in 3 places: it logs it and continues in 1, turns it into a return value in 1, and discards it silently in 1. A failure discarded silently leaves no trace for whoever debugs this later.
Diagram
mermaidsequenceDiagram participant Cron as Nightly Cron / Event Endpoint participant Service as ToursHealingService participant DB as Prisma Database participant Worker as Screenshot Worker participant Events as interaction_events Cron->>Service: nightlySweep() / noteElementMissing() Service->>DB: Find live experiences with selfHealEnabled=true alt Nightly sweep Service->>Worker: POST screenshot/flow/validate Worker-->>Service: Validation result / healed selectors else Missing-element spike Service->>DB: Count step_element_missing events by hour Service->>Worker: POST screenshot/flow/validate (debounced) Worker-->>Service: Validation result / healed selectors end Service->>DB: CAS update CapturedFlow.steps<br/>(only if original JSON still matches) alt Steps cannot be healed Service->>DB: Update TourExperience.healing Service->>Events: Emit tour_healing event end
Usage
tsimport { Injectable } from '@nestjs/common';
import { ToursHealingService } from './tours-healing.service';
@Injectable()
export class ToursMaintenanceJob {
constructor(
private readonly toursHealingService: ToursHealingService,
) {}
// Invoke from the scheduled nightly job.
async runNightlyHealing(): Promise<void> {
await this.toursHealingService.nightlySweep();
}
// Invoke after receiving a public step_element_missing event.
async handleMissingElement(experienceId: string): Promise<void> {
await this.toursHealingService.noteElementMissing(experienceId);
}
// Manually validate a specific tour experience.
async validateExperience(experienceId: string) {
return this.toursHealingService.validateExperience(experienceId);
}
}
AI Coding Instructions
- Only process live experiences where
selfHealEnabledis explicitly enabled; the default behavior must remain opt-out. - Preserve the compare-and-swap JSON equality guard when writing
CapturedFlow.steps; never overwrite steps edited concurrently by an owner. - Route validation through the screenshot worker using
SCREENSHOT_WORKER_URLand thePOST screenshot/flow/validatecontract, consistent withCaptureService.callWorker. - Record genuinely unhealable selectors in
TourExperience.healingand emit atour_healingevent on theinteraction_eventsspine. - Keep spike-triggered validation debounced and scoped to per-experience, per-hour
step_element_missingcounts to avoid repeated worker calls.
Relationships
- DEPENDS_ON →
PrismaService - DEPENDS_ON →
RedisService - DEPENDS_ON →
EventsService
Referenced By
PublicToursController(DEPENDS_ON)ToursController(DEPENDS_ON)ToursModule(MODULE_PROVIDES)
Was this page helpful?