- RMM Overshell (migration 077): admin page, dispatch UI, executor/worker, target resolver, script registry (AD/DHCP/DNS/event-log/services/software/network/loglift) - LogLift evidence pipeline (migration 078): upload webhook, B2 storage client, receiver/matcher, EventLogCollector PowerShell script - IT Glue audit + write-back (migrations 075, 076): asset-audit runner, ticket xrefs, applications/configurations browse pages + apply/revert/audit endpoints - Link-aware analyzer bundles (migration 073) + provider toggle (migration 074): link-discovery service, OpenRouter LLM provider, related-tickets/itglue-suggestion panels, analyze-bundle endpoint - Endpoint data model + device-link reconciliation (migrations 079, 080): conflicts admin page, reconciler service, resolve endpoints - Dashboard overhaul: integration-health service + alerts, overview/health endpoints - Permissions: add itglue + rmm scopes; middleware: public /api/rmm/loglift route Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
999 lines
34 KiB
Markdown
999 lines
34 KiB
Markdown
# AI Ticket Analyzer — Operator Runbook
|
||
|
||
How to keep the analyzer healthy in production. Audience is whoever is on
|
||
call for Pulse. The feature spec is in
|
||
`wulf-pulse-ticket-analyzer-prompt.md`; the implementation log is in
|
||
`wulf-pulse-ticket-analyzer-build-notes.md`.
|
||
|
||
---
|
||
|
||
## At a glance
|
||
|
||
- **Pipeline**: Haiku triage → (optional) IT Glue retrieval → Sonnet deep
|
||
analysis → (optional) Opus deep reasoning → persistence.
|
||
- **Trigger**: user clicks "Analyze" on a ticket page; that POSTs to
|
||
`/api/analyzer/tickets/:ticketNumber/analyze`. The route preprocesses
|
||
inline, short-circuits to the cached row when content hasn't changed,
|
||
otherwise queues a job for the worker.
|
||
- **Worker**: `analyzerWorker` polls `analyzer_jobs` every 2 s in-process.
|
||
Auto-starts in production (`NODE_ENV=production`). Opt in for dev with
|
||
`ANALYZER_WORKER_AUTOSTART=1`.
|
||
- **Cost ceiling**: hard-coded `COST_CEILING_USD = 2.00` in
|
||
`lib/services/analyzer/pipeline.ts`. Trips before Opus only — Sonnet runs
|
||
unconditionally. When tripped, the analysis is flagged
|
||
`needs_human_review = true` with reason "cost ceiling reached".
|
||
|
||
---
|
||
|
||
## Required environment
|
||
|
||
Add to `~/projects_env/wulf-pulse.env` (do not commit values):
|
||
|
||
```
|
||
ANTHROPIC_API_KEY=
|
||
ITGLUE_API_KEY=
|
||
ITGLUE_API_BASE=https://api.itglue.com
|
||
ALLOWED_SHARE_DOMAINS=wulfconsulting.com
|
||
# Optional in dev:
|
||
ANALYZER_WORKER_AUTOSTART=1
|
||
```
|
||
|
||
`SMTP_*` and `BETTER_AUTH_URL` are already set elsewhere in Pulse — share
|
||
emails reuse the existing magic-link transport.
|
||
|
||
---
|
||
|
||
## First-time setup on an existing database
|
||
|
||
Migration `069_create_analyzer_tables.sql` creates `analyzer_analyses`,
|
||
`analyzer_shares`, and `analyzer_jobs`. Postgres only re-applies migrations
|
||
on a fresh data volume — for an existing DB, run the migration directly:
|
||
|
||
```bash
|
||
psql "$DATABASE_URL" -f migrations/069_create_analyzer_tables.sql
|
||
```
|
||
|
||
The script is idempotent (`IF NOT EXISTS` on every object).
|
||
|
||
---
|
||
|
||
## Monitoring cost
|
||
|
||
Every completed analysis stores `estimated_cost_usd`, `total_input_tokens`,
|
||
`total_output_tokens`, and per-stage tier flags
|
||
(`haiku_used` / `sonnet_used` / `opus_used`). The number is a forward
|
||
estimate from `lib/services/llm/pricing.ts` and will drift if Anthropic
|
||
changes published rates — re-check that file before believing big spend
|
||
numbers.
|
||
|
||
### Daily spend (last 14 days)
|
||
|
||
```sql
|
||
SELECT date_trunc('day', triggered_at) AS day,
|
||
count(*) FILTER (WHERE status = 'complete') AS analyses,
|
||
count(*) FILTER (WHERE opus_used) AS opus_runs,
|
||
count(*) FILTER (WHERE needs_human_review) AS needs_review,
|
||
round(sum(estimated_cost_usd)::numeric, 2) AS spend_usd
|
||
FROM analyzer_analyses
|
||
WHERE triggered_at > now() - interval '14 days'
|
||
GROUP BY 1
|
||
ORDER BY 1 DESC;
|
||
```
|
||
|
||
### Top 20 most expensive analyses (last 30 days)
|
||
|
||
```sql
|
||
SELECT id, ticket_number, analysis_version, estimated_cost_usd,
|
||
opus_used, needs_human_review, triggered_at
|
||
FROM analyzer_analyses
|
||
WHERE triggered_at > now() - interval '30 days'
|
||
ORDER BY estimated_cost_usd DESC
|
||
LIMIT 20;
|
||
```
|
||
|
||
### Heaviest tickets (multiple re-runs against the same ticket)
|
||
|
||
```sql
|
||
SELECT ticket_number,
|
||
count(*) AS analyses,
|
||
round(sum(estimated_cost_usd)::numeric, 2) AS spend_usd,
|
||
max(triggered_at) AS last_run
|
||
FROM analyzer_analyses
|
||
GROUP BY 1
|
||
HAVING count(*) > 1
|
||
ORDER BY spend_usd DESC
|
||
LIMIT 20;
|
||
```
|
||
|
||
### Cost ceiling — what to do when it trips
|
||
|
||
`needs_human_review = true` with `human_review_reasons` containing "cost
|
||
ceiling reached" means Sonnet ran but Opus was skipped. The Sonnet output
|
||
is still persisted and is usually fine. To re-run with Opus forced on,
|
||
trigger a new analysis (the route accepts `force=true`); the cost ceiling
|
||
will reapply, so check the prior `estimated_cost_usd` before committing.
|
||
To raise the ceiling, edit `COST_CEILING_USD` in
|
||
`lib/services/analyzer/pipeline.ts` and redeploy.
|
||
|
||
To reduce spend without code changes: discourage operators from clicking
|
||
Re-analyze unless content has changed (the idempotency short-circuit
|
||
already prevents charged re-runs against the same `content_hash`).
|
||
|
||
---
|
||
|
||
## Adding IT Glue org aliases
|
||
|
||
Stage 2 resolves an Autotask company name to an IT Glue organization in
|
||
two steps:
|
||
|
||
1. **Alias map** — `lib/services/analyzer/itglue-aliases.json`. Keys are
|
||
normalized company names (lowercase, single-spaced); values are IT Glue
|
||
organization IDs as **strings**. Exact-match wins immediately.
|
||
2. **Live IT Glue search** — fallback when the alias map misses.
|
||
|
||
When the live search misses or returns the wrong org, add an alias.
|
||
|
||
### Finding the IT Glue organization ID
|
||
|
||
In the IT Glue UI, navigate to the organization. The numeric segment of
|
||
the URL (`/organizations/12345`) is the ID. Or via API:
|
||
|
||
```bash
|
||
curl -s -H "x-api-key: $ITGLUE_API_KEY" \
|
||
"$ITGLUE_API_BASE/organizations?filter[name]=Seubert%20and%20Associates" \
|
||
| jq '.data[] | {id, name: .attributes.name}'
|
||
```
|
||
|
||
### Updating the alias file
|
||
|
||
Edit `lib/services/analyzer/itglue-aliases.json`. Keys must be **already
|
||
normalized** — lowercase, single-spaced — because the matcher applies the
|
||
same normalization to the Autotask company name and looks up an exact key.
|
||
|
||
```json
|
||
{
|
||
"seubert": "1234567",
|
||
"seubert and associates": "1234567",
|
||
"acme insurance group": "7654321"
|
||
}
|
||
```
|
||
|
||
Strip the `_comment` / `_example` keys when adding the first real entry,
|
||
or leave them — they're ignored at match time (no real org name normalizes
|
||
to a leading underscore).
|
||
|
||
Changes require a redeploy. The file is bundled into the build by Next's
|
||
`import` — there is no runtime reload.
|
||
|
||
### Verifying an alias works
|
||
|
||
After deploy, run an analysis on a ticket from that company. Check the
|
||
`model_traces.itglue` field on the resulting row:
|
||
|
||
```sql
|
||
SELECT model_traces->'itglue'
|
||
FROM analyzer_analyses
|
||
WHERE id = '<analysis-id>';
|
||
```
|
||
|
||
`alias_used: true` confirms the alias hit. `org_id` should be the value
|
||
you put in the JSON.
|
||
|
||
---
|
||
|
||
## Triaging failed analyses
|
||
|
||
Two failure surfaces to know about:
|
||
|
||
### 1. Failed jobs (`analyzer_jobs.status = 'failed'`)
|
||
|
||
The job hit an exception before the analysis row was written. The error
|
||
message is in `analyzer_jobs.error_message`. Common causes:
|
||
|
||
| Error message | Cause | Fix |
|
||
|---|---|---|
|
||
| `Ticket T... not found in local mirror — confirm sync is current.` | Autotask sync hasn't pulled the ticket yet. | Wait for the next sync, or trigger a manual sync from `/admin`. The webhook usually fills the gap; if not, see `scripts/backfill-ticket-notes-gap.ts` for the reconciliation pattern. |
|
||
| `LLM stage on claude-... failed twice` | Two Anthropic call attempts both failed (network / parse / 5xx). | Check the API key is valid (`curl` Anthropic), and check Anthropic status. Re-queue by clicking Re-analyze. |
|
||
| `Anthropic API error: 429 ...` | Rate limit. | Wait and re-queue. |
|
||
| `IT Glue search failed: ...` should NOT appear here — the pipeline tolerates IT Glue failures and continues without context. | If you see one, the failure was outside the catch block and is a bug. |
|
||
|
||
```sql
|
||
-- Recent failures
|
||
SELECT id, ticket_number, error_message, queued_at, finished_at
|
||
FROM analyzer_jobs
|
||
WHERE status = 'failed'
|
||
AND queued_at > now() - interval '7 days'
|
||
ORDER BY queued_at DESC;
|
||
```
|
||
|
||
To retry a failed job, queue a fresh one — there is no in-place retry.
|
||
Either click Re-analyze in the UI or:
|
||
|
||
```sql
|
||
INSERT INTO analyzer_jobs (ticket_number, queued_by_user_id)
|
||
SELECT ticket_number, queued_by_user_id
|
||
FROM analyzer_jobs
|
||
WHERE id = '<failed-job-id>';
|
||
```
|
||
|
||
### 2. Completed but flagged for human review (`needs_human_review = true`)
|
||
|
||
The pipeline finished and produced an analysis, but the model itself
|
||
reported low confidence or the cost circuit breaker tripped before Opus.
|
||
`human_review_reasons` is a JSONB array of short strings — read it.
|
||
|
||
The UI exposes this queue at `/analyzer/queue`. SQL equivalent:
|
||
|
||
```sql
|
||
SELECT id, ticket_number, analysis_version,
|
||
confidence_score, estimated_cost_usd,
|
||
human_review_reasons
|
||
FROM analyzer_analyses
|
||
WHERE needs_human_review = true
|
||
AND status = 'complete'
|
||
ORDER BY triggered_at DESC
|
||
LIMIT 50;
|
||
```
|
||
|
||
Reasons you'll see:
|
||
|
||
- `"cost ceiling reached"` — Opus skipped to save money. Sonnet output is
|
||
what you have. Re-run with `force=true` to override (and accept the
|
||
spend).
|
||
- `"low confidence: <score>"` — the model itself reports uncertainty. The
|
||
Sonnet output may still be useful as a starting point; treat as
|
||
guidance, not verdict.
|
||
- A model-emitted reason from Stage 3 or Stage 4. The full string is in
|
||
the JSONB — don't truncate before reading.
|
||
|
||
### 3. Worker not picking up jobs
|
||
|
||
If `analyzer_jobs` has rows in `status='queued'` for more than ~30s, the
|
||
worker is wedged or not running.
|
||
|
||
- Check the application log for `[ANALYZER-WORKER] starting` at boot. If
|
||
missing in production, `NODE_ENV` may not be set to `production` —
|
||
bring up `ANALYZER_WORKER_AUTOSTART=1` as a workaround.
|
||
- The worker is in-process; restarting the Pulse app restarts it.
|
||
- Concurrency: multiple replicas all run a worker, but `claimQueuedJob`
|
||
uses `FOR UPDATE SKIP LOCKED` so each job is processed exactly once.
|
||
|
||
---
|
||
|
||
## Manual ops
|
||
|
||
### Trigger an analysis from psql
|
||
|
||
```sql
|
||
INSERT INTO analyzer_jobs (ticket_number, queued_by_user_id)
|
||
VALUES ('T20260424.0045', NULL);
|
||
```
|
||
|
||
The worker will pick it up within 2 seconds. `queued_by_user_id` may be
|
||
null (`ON DELETE SET NULL`).
|
||
|
||
### Force a re-run despite a cached analysis
|
||
|
||
The `force=true` flag on `POST /api/analyzer/tickets/:ticketNumber/analyze`
|
||
bypasses the idempotency short-circuit. The Re-analyze button in the UI
|
||
sends this flag.
|
||
|
||
### Inspect raw stage outputs for a debugging session
|
||
|
||
```sql
|
||
SELECT model_traces
|
||
FROM analyzer_analyses
|
||
WHERE id = '<analysis-id>';
|
||
```
|
||
|
||
Per-stage payload includes input/output tokens, attempt count, full parsed
|
||
model responses, and IT Glue resolution metadata. Don't paste this into
|
||
external systems — it can include redacted-but-still-sensitive snippets
|
||
of customer data.
|
||
|
||
### Ad hoc: how much have we spent this month?
|
||
|
||
```sql
|
||
SELECT round(sum(estimated_cost_usd)::numeric, 2) AS spend_usd,
|
||
count(*) AS analyses
|
||
FROM analyzer_analyses
|
||
WHERE triggered_at >= date_trunc('month', now());
|
||
```
|
||
|
||
---
|
||
|
||
## Things that are not implemented yet
|
||
|
||
- No automated retries on failed jobs.
|
||
- No `viewed_at` tracking on share rows (column exists, nothing writes
|
||
to it).
|
||
- No `email_sent_at` column — share email send state lives only in the
|
||
HTTP response and the server log.
|
||
- No pre-Sonnet cost gate. The circuit breaker only protects against
|
||
Opus.
|
||
- No autocomplete on the share-recipient field.
|
||
|
||
If any of these become a real operational problem, file the work — the
|
||
stubs are intentional and called out in
|
||
`wulf-pulse-ticket-analyzer-build-notes.md`.
|
||
|
||
---
|
||
|
||
## Phase 2 additions
|
||
|
||
### Stage execution history (per-analysis forensics)
|
||
|
||
Migration 070 introduced `analyzer_stage_executions`. Every stage of the
|
||
pipeline (preprocess, triage, itglue, analyze, deep_review, fingerprint)
|
||
now writes a row including the input it saw and the output it produced.
|
||
Failed stages get a row with `error_message` populated.
|
||
|
||
Read it like this:
|
||
|
||
```sql
|
||
-- Full per-stage trace for one analysis
|
||
SELECT stage_order, stage, model_id,
|
||
input_tokens, output_tokens, latency_ms,
|
||
error_message
|
||
FROM analyzer_stage_executions
|
||
WHERE analysis_id = '<analysis-id>'
|
||
ORDER BY stage_order;
|
||
|
||
-- Inspect a single stage's full input/output
|
||
SELECT input_payload, output_payload
|
||
FROM analyzer_stage_executions
|
||
WHERE analysis_id = '<analysis-id>' AND stage = 'analyze';
|
||
```
|
||
|
||
The legacy `analyzer_analyses.model_traces` JSONB column is preserved for
|
||
back-compat. New analyses double-write to both. A future migration will
|
||
drop `model_traces` once aggregate reports have soaked.
|
||
|
||
### Fingerprint stage (Stage 6)
|
||
|
||
After persistence, the worker runs Haiku-tier fingerprint extraction and
|
||
writes the result to `analyzer_analyses.aggregate_fingerprint`. Failure
|
||
is non-fatal — the analysis row stays usable, fingerprint stays NULL.
|
||
|
||
Re-fingerprint on demand:
|
||
|
||
```sql
|
||
SELECT id, ticket_number, analysis_version
|
||
FROM analyzer_analyses
|
||
WHERE aggregate_fingerprint IS NULL
|
||
AND status = 'complete'
|
||
ORDER BY triggered_at ASC;
|
||
```
|
||
|
||
Or run the backfill script (idempotent — the SQL filter skips already-fingerprinted rows):
|
||
|
||
```bash
|
||
npx tsx scripts/backfill-fingerprints.ts # process all missing
|
||
npx tsx scripts/backfill-fingerprints.ts --limit=50 # cap work
|
||
npx tsx scripts/backfill-fingerprints.ts --dry-run # show what would run
|
||
```
|
||
|
||
The script reconstructs the Stage 6 input from
|
||
`analyzer_analyses.model_traces.{triage_response, sonnet_response, opus_response}`.
|
||
If a row's model_traces is missing those fields (very old format) the script
|
||
logs `[skip]` for it and moves on.
|
||
|
||
Cost: Haiku at ~$0.001 per fingerprint. 1000 analyses ≈ $1.
|
||
|
||
### Aggregate reports
|
||
|
||
Migration 071 added `analyzer_aggregate_reports` and relaxed
|
||
`analyzer_stage_executions.analysis_id` to be nullable; rows now carry
|
||
either an `analysis_id` or an `aggregate_report_id` (CHECK enforces
|
||
exactly one).
|
||
|
||
Workflow:
|
||
|
||
1. User selects tickets on `/analyzer/tickets` and clicks
|
||
**Generate aggregate report**.
|
||
2. POST `/api/analyzer/aggregate-reports` validates: ≤100 tickets, all
|
||
have a fingerprint, none are stale. Inserts a 'pending' row, fires
|
||
the runner via `void runAggregateReport(id)`, returns immediately.
|
||
3. Runner does:
|
||
- Loads fingerprints
|
||
- Computes SQL distributions (categories, clients, root cause,
|
||
resolution path) and writes them to the row immediately
|
||
- Fetches IT Glue doc titles for affected clients (if requested)
|
||
- Calls Sonnet (Opus opt-in) with a structured payload
|
||
- Writes the LLM-derived fields, marks `status='complete'`
|
||
4. UI polls `GET /api/analyzer/aggregate-reports/:id` every 3s while
|
||
pending/running.
|
||
|
||
Inspect a report's sub-stage trace (linked via `aggregate_report_id`):
|
||
|
||
```sql
|
||
SELECT stage_order, stage, model_id, latency_ms, error_message
|
||
FROM analyzer_stage_executions
|
||
WHERE aggregate_report_id = '<report-id>'
|
||
ORDER BY stage_order;
|
||
```
|
||
|
||
Daily spend on aggregate reports:
|
||
|
||
```sql
|
||
SELECT date_trunc('day', generated_at) AS day,
|
||
count(*) AS reports,
|
||
round(sum(estimated_cost_usd)::numeric, 2) AS spend_usd
|
||
FROM analyzer_aggregate_reports
|
||
WHERE generated_at > now() - interval '14 days'
|
||
GROUP BY 1
|
||
ORDER BY 1 DESC;
|
||
```
|
||
|
||
### Cost guards (Phase 2.7)
|
||
|
||
Three thresholds enforce per-user daily spend:
|
||
|
||
- **$5 per request** — a single aggregate report estimated above $5
|
||
triggers a confirmation modal. Frontend re-POSTs with
|
||
`confirmedCost: true` if the user clicks through.
|
||
- **$20 per user/day** — soft warn. Surfaced via `softWarn:true` in the
|
||
cost evaluation; no enforcement.
|
||
- **$50 per user/day** — hard block. POST returns 403 unless the user
|
||
is in `ANALYZER_DAILY_COST_OVERRIDE_USERS` (comma-separated list of
|
||
Better Auth user ids).
|
||
|
||
Daily spend is computed as the trailing 24h sum of
|
||
`analyzer_analyses.estimated_cost_usd` + `analyzer_aggregate_reports.estimated_cost_usd`
|
||
for the user.
|
||
|
||
Every gating decision writes a row to `analyzer_cost_audit`:
|
||
|
||
```sql
|
||
SELECT created_at, user_id, action, estimated_cost,
|
||
daily_spend_before, decision, decision_reason
|
||
FROM analyzer_cost_audit
|
||
ORDER BY created_at DESC
|
||
LIMIT 50;
|
||
```
|
||
|
||
To grant a user override capability:
|
||
|
||
```bash
|
||
# in ~/projects_env/wulf-pulse.env
|
||
ANALYZER_DAILY_COST_OVERRIDE_USERS=user_id_1,user_id_2
|
||
```
|
||
|
||
Restart `pulse-app` to pick up the change.
|
||
|
||
### Browse / filter ticket list
|
||
|
||
`/analyzer/tickets` rebuilt as the primary entry point in Phase 2.5:
|
||
|
||
- Multi-select for client, issue type, queue, status, priority, assignee
|
||
- Sticky filter bar with period chips (today/yesterday/this+last week,
|
||
30d/60d, custom range, all time)
|
||
- Active-filter chips below the bar; click to remove
|
||
- Bulk row selection persisted in localStorage
|
||
(`analyzer:ticket-selection:v1`) — survives pagination but is
|
||
session-scoped (no user id baked in)
|
||
- Bulk "Analyze N selected" sequentially queues jobs for each ticket
|
||
(forces re-analyze on stale; analyzes from scratch on un-analyzed)
|
||
- Bulk "Generate aggregate report" routes selected tickets to
|
||
`/analyzer/reports/new`; only enabled when all selected are
|
||
`analyzedState === 'current'`
|
||
|
||
**Staleness heuristic**: a ticket's `analyzedState` is computed as
|
||
`stale` when `tickets.last_activity_date > latest_analysis.completed_at`.
|
||
The Phase 2 spec calls for content-hash-based comparison; that requires
|
||
either caching the current hash on the tickets row (sync change) or
|
||
computing it on read for the visible page (slow). The date heuristic
|
||
gets ~95% of the value at zero compute cost; revisit when there's real
|
||
load signal.
|
||
|
||
---
|
||
|
||
## Link-aware bundles (Phase 3)
|
||
|
||
A "bundle" is an aggregate report launched from a single ticket page —
|
||
typically a master/problem ticket that names other tickets in its
|
||
description. Instead of forcing the user to analyze each constituent
|
||
manually and then visit `/analyzer/reports/new`, the bundle endpoint
|
||
fans out per-ticket analyses and chains them into an aggregate report
|
||
automatically.
|
||
|
||
### How it works end-to-end
|
||
|
||
1. User loads `/analyzer/ticket/<ticket-number>`.
|
||
2. `<RelatedTicketsPanel/>` calls `GET /api/analyzer/tickets/:tn/links`
|
||
(cheap, no LLM) — regex extraction over the description and retained
|
||
notes for `T\d{8}\.\d{4}` references, plus the structured
|
||
`RELATED TICKETS:` block detector and `problem_ticket_id` resolution.
|
||
The panel renders only when refs exist or the ticket looks like a
|
||
problem ticket.
|
||
3. (Optional) User flips the "AI-suggest more" Switch — this POSTs the
|
||
same endpoint with `includeSuggested: true`, runs one Haiku pass over
|
||
recent same-company tickets (±30 days, capped at 50 candidates), and
|
||
returns up to 5 suggestions with one-line reasons.
|
||
4. User clicks **"Analyze with N linked tickets"**. The panel POSTs to
|
||
`/api/analyzer/tickets/:tn/analyze-bundle` with
|
||
`linkedTicketNumbers: [...]`.
|
||
5. The bundle endpoint:
|
||
- Verifies every ticket exists locally (one SQL round-trip).
|
||
- Per ticket: idempotency-checks via content hash. If a complete
|
||
analysis exists, it's reused; otherwise a fresh `analyzer_jobs` row
|
||
is queued.
|
||
- Cost-guard runs against **new work only** plus the aggregate-reduce
|
||
step. $5 confirmation threshold and $50 daily hard block are the
|
||
same gates as standalone aggregate reports.
|
||
- Inserts an `analyzer_aggregate_reports` row in `'pending_analyses'`
|
||
state with `expected_ticket_numbers` populated (or straight to
|
||
`'pending'` and immediately fires the runner if everything was
|
||
already complete).
|
||
6. Worker polls and runs each queued job. After each successful
|
||
analysis, `chainTriggerForCompletedAnalysis()` looks up bundles
|
||
waiting on that ticket, appends the new analysis_id, and (if the full
|
||
set is now satisfied) flips status to `'pending'` and fires
|
||
`runAggregateReport()`.
|
||
7. Frontend polls `GET /api/analyzer/aggregate-reports/:id` every 3s and
|
||
navigates to `/analyzer/reports/:id` on completion.
|
||
|
||
### Status state machine
|
||
|
||
```
|
||
pending_analyses ──── all expected analyses complete ────► pending
|
||
│
|
||
▼
|
||
running
|
||
│
|
||
▼
|
||
complete | failed
|
||
```
|
||
|
||
`'pending_analyses'` is the new state Phase 3 introduces.
|
||
Manual-multi-select reports created via `/analyzer/reports/new` skip it
|
||
and start at `'pending'` (their analyses must already be complete to
|
||
even submit).
|
||
|
||
### Inspecting a bundle
|
||
|
||
```sql
|
||
SELECT id, status, ticket_count,
|
||
array_length(expected_ticket_numbers, 1) AS expected,
|
||
array_length(analysis_ids, 1) AS collected,
|
||
triggered_by_ticket_number,
|
||
generated_at
|
||
FROM analyzer_aggregate_reports
|
||
WHERE expected_ticket_numbers IS NOT NULL
|
||
ORDER BY generated_at DESC
|
||
LIMIT 20;
|
||
```
|
||
|
||
Find which expected tickets a stuck `pending_analyses` bundle is still
|
||
waiting on:
|
||
|
||
```sql
|
||
WITH r AS (
|
||
SELECT id, expected_ticket_numbers, analysis_ids
|
||
FROM analyzer_aggregate_reports
|
||
WHERE id = '<report-id>'
|
||
)
|
||
SELECT etn.ticket_number,
|
||
(SELECT bool_or(aa.id = ANY(r.analysis_ids))
|
||
FROM analyzer_analyses aa
|
||
WHERE aa.ticket_number = etn.ticket_number
|
||
AND aa.status = 'complete') AS has_collected_analysis
|
||
FROM r,
|
||
LATERAL UNNEST(r.expected_ticket_numbers) AS etn(ticket_number);
|
||
```
|
||
|
||
The `false` rows are the tickets we're still waiting on. Cross-reference
|
||
with `analyzer_jobs` filtered by those ticket numbers to see whether the
|
||
job is queued, in-flight, or failed.
|
||
|
||
### Cost shape
|
||
|
||
For a typical 4-ticket problem bundle on fresh tickets:
|
||
|
||
| Step | Model | Approx cost |
|
||
|---|---|---|
|
||
| Link discovery (explicit) | none | ~free |
|
||
| AI-suggested arm (if toggled) | Haiku | ~$0.005 |
|
||
| Per-ticket pipeline × 4 | Haiku → Sonnet (+ optional Opus) | $0.20 – $1.20 |
|
||
| Aggregate reduce | Opus | ~$0.50 |
|
||
| **Total** | | **~$1 – $2** |
|
||
|
||
The bundle endpoint's per-ticket estimate is a flat $0.15 (pessimistic
|
||
Sonnet) used purely for the cost guard. Real spend is captured per row
|
||
on `analyzer_analyses.estimated_cost_usd` once each pipeline run
|
||
completes.
|
||
|
||
### When the panel doesn't render
|
||
|
||
The panel is intentionally invisible on tickets that aren't candidates
|
||
for bundling:
|
||
|
||
- No `T<YYYYMMDD>.<####>` references found in description or retained
|
||
notes
|
||
- `tickets.problem_ticket_id` is null
|
||
- Title contains neither "master problem ticket" nor "problem ticket"
|
||
|
||
If a user expects to see the panel and doesn't, the most common reason
|
||
is that the referenced tickets aren't in our local mirror yet (sync
|
||
gap) — `discoverExplicitLinks` filters refs against
|
||
`tickets.ticket_number` to keep ghost links out of the UI. Run the
|
||
ticket sync and reload.
|
||
|
||
### Suggested-arm limits
|
||
|
||
The Haiku call drops any suggestion whose ticket_number isn't in the
|
||
candidate list it was given (hallucination guard). Suggestions are
|
||
capped at 5 and never auto-included — the user has to tick the
|
||
checkbox. If the LLM call throws, the failure is logged and the panel
|
||
still shows the explicit refs (the suggestion arm is opportunistic, not
|
||
load-bearing).
|
||
|
||
---
|
||
|
||
## IT Glue asset audits (Phase 4)
|
||
|
||
The asset-audit pipeline analyzes one IT Glue Application record at a time
|
||
against ticket history + IT Glue's own field schema, surfaces documentation
|
||
gaps and "promote-from-Notes" suggestions, and lets admins push approved
|
||
changes back to IT Glue. Every change is recorded in three audit layers
|
||
(see Phase 4 build notes).
|
||
|
||
Permissions:
|
||
|
||
- Read audit / run audit → any authenticated user (cheap, ~$0.01).
|
||
- Apply or Revert → `requirePermission('itglue', 'write')` — admin or
|
||
super-admin only.
|
||
- `/admin/itglue-writes` → admin-only.
|
||
|
||
### Inspecting audits
|
||
|
||
```sql
|
||
-- Latest audit per asset, lowest scoring first
|
||
SELECT a.asset_id,
|
||
fa.name AS application_name,
|
||
fa.organization_name,
|
||
a.overall_score,
|
||
a.ticket_count,
|
||
jsonb_array_length(a.field_gaps) AS gap_count,
|
||
jsonb_array_length(a.notes_promotions) AS promo_count,
|
||
a.provider, a.model_used,
|
||
a.estimated_cost_usd,
|
||
a.generated_at
|
||
FROM itglue_asset_audits a
|
||
JOIN itg_flexible_assets fa ON fa.id = a.asset_id::bigint
|
||
WHERE a.status = 'complete'
|
||
ORDER BY a.asset_id, a.generated_at DESC;
|
||
```
|
||
|
||
```sql
|
||
-- Failed audits (forensics)
|
||
SELECT id, asset_id, generated_at, provider, model_used,
|
||
LEFT(error_message, 200) AS error
|
||
FROM itglue_asset_audits
|
||
WHERE status = 'failed'
|
||
ORDER BY generated_at DESC
|
||
LIMIT 20;
|
||
```
|
||
|
||
### Inspecting writes
|
||
|
||
```sql
|
||
-- All committed writes in the last 7 days, with provenance
|
||
SELECT w.performed_at,
|
||
w.field_name,
|
||
w.before_value, w.after_value,
|
||
w.performed_by_user_id,
|
||
w.audit_id,
|
||
fa.name AS application_name,
|
||
fa.organization_name
|
||
FROM itglue_writes w
|
||
JOIN itg_flexible_assets fa ON fa.id = w.asset_id::bigint
|
||
WHERE w.status = 'committed'
|
||
AND w.performed_at >= NOW() - INTERVAL '7 days'
|
||
ORDER BY w.performed_at DESC;
|
||
```
|
||
|
||
```sql
|
||
-- Failed writes (admin should investigate)
|
||
SELECT id, asset_id, field_name, error_message, performed_at
|
||
FROM itglue_writes
|
||
WHERE status = 'failed'
|
||
ORDER BY performed_at DESC
|
||
LIMIT 20;
|
||
```
|
||
|
||
### How a revert works
|
||
|
||
Reverts produce a brand-new `itglue_writes` row whose `before_value` /
|
||
`after_value` are swapped from the original, and mark the original row
|
||
`status='reverted'`. The chain is always traceable:
|
||
|
||
```sql
|
||
-- Find every write + its revert (if any) for one asset
|
||
SELECT id, field_name, status,
|
||
before_value, after_value,
|
||
performed_at,
|
||
audit_id,
|
||
source_evidence ->> 'reverts_write_id' AS reverts_id
|
||
FROM itglue_writes
|
||
WHERE asset_id = '17096940'
|
||
ORDER BY performed_at;
|
||
```
|
||
|
||
### Cost-guard
|
||
|
||
Audit runs charge ~$0.01 (DeepSeek) or ~$0.10 (Claude) per call. Same
|
||
guard primitives as ticket analyses:
|
||
|
||
```sql
|
||
SELECT created_at, user_id, action, estimated_cost,
|
||
decision, decision_reason
|
||
FROM analyzer_cost_audit
|
||
WHERE action = 'itglue_audit'
|
||
ORDER BY created_at DESC
|
||
LIMIT 50;
|
||
```
|
||
|
||
### When the data-builder returns no ticket evidence
|
||
|
||
`buildAssetAuditContext` joins via `companies.company_name = itg_organizations.name`
|
||
(case-insensitive) to map IT Glue org → Autotask company id, then matches
|
||
ticket fingerprints whose summary or fingerprint mention the asset name. Two
|
||
common reasons for an empty `ticket_evidence` array:
|
||
|
||
1. The IT Glue org's `name` doesn't match any `companies.company_name`
|
||
exactly — fix by aligning the names, or extend the join.
|
||
2. The asset's `name` is too generic ("Office", "Email") and the ILIKE match
|
||
pulls nothing distinctive — accept the audit will rely on field schema +
|
||
peer exemplars only, no per-ticket grounding.
|
||
|
||
### Generic audit_log surfaces every change too
|
||
|
||
`audit.log()` is called after every successful Apply/Revert with action
|
||
`itglue.write` or `itglue.revert`. `/admin/audit-log` shows it. So admins
|
||
have two views: domain-specific at `/admin/itglue-writes` (with diffs +
|
||
revert button), and the generic admin feed at `/admin/audit-log`.
|
||
|
||
---
|
||
|
||
## Phase 4.1: ticket-first capture, Configurations, xrefs
|
||
|
||
### Inspecting the cross-reference index
|
||
|
||
```sql
|
||
-- Every ticket↔asset linkage in the last 7 days
|
||
SELECT created_at,
|
||
ticket_number,
|
||
relationship,
|
||
asset_type,
|
||
asset_id,
|
||
source,
|
||
confidence,
|
||
details
|
||
FROM itglue_ticket_xrefs
|
||
WHERE created_at >= NOW() - INTERVAL '7 days'
|
||
ORDER BY created_at DESC
|
||
LIMIT 100;
|
||
```
|
||
|
||
```sql
|
||
-- Most-referenced IT Glue assets across all tickets (heat-map for which
|
||
-- docs the analyzer leans on most)
|
||
SELECT asset_type,
|
||
asset_id,
|
||
COUNT(*) AS reference_count,
|
||
COUNT(DISTINCT ticket_number) AS distinct_tickets,
|
||
MAX(created_at) AS last_referenced
|
||
FROM itglue_ticket_xrefs
|
||
WHERE relationship = 'referenced'
|
||
GROUP BY asset_type, asset_id
|
||
ORDER BY reference_count DESC
|
||
LIMIT 30;
|
||
```
|
||
|
||
```sql
|
||
-- Assets that have been UPDATED via ticket-driven audits but never
|
||
-- REFERENCED — possibly newly-introduced docs that haven't proven their
|
||
-- worth yet, or docs the analyzer's retrieval stage isn't finding.
|
||
SELECT u.asset_type, u.asset_id, COUNT(*) AS update_count
|
||
FROM itglue_ticket_xrefs u
|
||
WHERE u.relationship = 'updated'
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM itglue_ticket_xrefs r
|
||
WHERE r.asset_type = u.asset_type
|
||
AND r.asset_id = u.asset_id
|
||
AND r.relationship = 'referenced'
|
||
)
|
||
GROUP BY u.asset_type, u.asset_id
|
||
ORDER BY update_count DESC;
|
||
```
|
||
|
||
### Ticket-scoped audits
|
||
|
||
```sql
|
||
-- All audits triggered from a specific ticket
|
||
SELECT a.id, a.asset_type, a.asset_id,
|
||
fa.name AS asset_name,
|
||
a.overall_score,
|
||
jsonb_array_length(a.field_gaps) AS gap_count,
|
||
a.provider, a.estimated_cost_usd,
|
||
a.generated_at
|
||
FROM itglue_asset_audits a
|
||
LEFT JOIN itg_flexible_assets fa
|
||
ON fa.id = a.asset_id::bigint AND a.asset_type = 'flexible_asset'
|
||
WHERE a.triggered_by_ticket_number = 'T20260502.0033'
|
||
ORDER BY a.generated_at DESC;
|
||
```
|
||
|
||
```sql
|
||
-- Every write a given ticket drove (denormalized for one-query lookup)
|
||
SELECT w.performed_at, w.asset_type, w.asset_id, w.field_name,
|
||
w.before_value, w.after_value, w.status
|
||
FROM itglue_writes w
|
||
WHERE w.triggered_by_ticket_number = 'T20260502.0033'
|
||
ORDER BY w.performed_at;
|
||
```
|
||
|
||
### Configuration audits
|
||
|
||
Same shape as Application audits but `asset_type='configuration'`:
|
||
|
||
```sql
|
||
SELECT a.id, c.name, c.hostname, c.configuration_type_name,
|
||
a.overall_score, jsonb_array_length(a.field_gaps) AS gap_count,
|
||
a.provider, a.generated_at
|
||
FROM itglue_asset_audits a
|
||
JOIN itg_configurations c ON c.id = a.asset_id::bigint
|
||
WHERE a.asset_type = 'configuration'
|
||
AND a.status = 'complete'
|
||
ORDER BY a.generated_at DESC
|
||
LIMIT 30;
|
||
```
|
||
|
||
### When asset-matcher returns nothing
|
||
|
||
`matchAssetsForAnalysis(analysisId)` joins `companies → itg_organizations`
|
||
on `LOWER(name)`. If a ticket comes back with `flexibleAssets: []` and
|
||
`configurations: []`, two common causes:
|
||
|
||
1. The IT Glue org name doesn't match the Autotask company name (case-
|
||
insensitive exact). Fix by aligning the names in either system, or
|
||
extend the join in `lib/services/analyzer/asset-audit/asset-matcher.ts`.
|
||
2. The fingerprint's `applications_involved` / `device_classes` don't
|
||
contain any term that substring-matches a real asset name. The audit
|
||
panel will show "No matches" — the user can still manually navigate
|
||
to the relevant asset's detail page and run an asset-first audit.
|
||
|
||
### Cost shape for ticket-first audits
|
||
|
||
Per-asset audit on a ticket-scoped run:
|
||
|
||
| Provider | Cost | Latency |
|
||
|---|---|---|
|
||
| Anthropic (Sonnet) | ~$0.05 | 30-60s |
|
||
| OpenRouter (DeepSeek V4 Pro) | ~$0.005 | 60-180s |
|
||
|
||
A typical ticket with 1 Application match + 1 Configuration match audited
|
||
on DeepSeek runs ~$0.01 and ~3 minutes total.
|
||
|
||
|
||
## Phase 4.3: LogLift event-log evidence pipeline
|
||
|
||
### Required env vars
|
||
|
||
```
|
||
B2_KEY_ID=… # Backblaze B2 application key id
|
||
B2_APP_KEY=… # Backblaze B2 application key secret
|
||
B2_BUCKET=wulf-audits # default; matches existing n8n bucket
|
||
B2_REGION=us-west-002 # default
|
||
B2_ENDPOINT=s3.us-west-002.backblazeb2.com # default; no scheme
|
||
OPENCLAW_API_KEY=… # webhook auth + collector variable
|
||
BETTER_AUTH_URL=… # base URL the collector POSTs back to
|
||
```
|
||
|
||
### One-time discovery
|
||
|
||
```bash
|
||
# Sign in as admin → /admin/rmm-overshell → click "Re-discover LogLift"
|
||
# Or via curl with an admin session cookie:
|
||
curl -X POST "$BETTER_AUTH_URL/api/admin/rmm/settings/discover-loglift" \
|
||
-H "Cookie: better-auth.session_token=…" -i
|
||
```
|
||
|
||
Confirm:
|
||
|
||
```sql
|
||
SELECT loglift_component_uid, loglift_component_name, loglift_discovered_at
|
||
FROM rmm_settings WHERE id = true;
|
||
```
|
||
|
||
### Inspecting LogLift uploads
|
||
|
||
```sql
|
||
-- Recent LogLift executions, with match status
|
||
SELECT e.id, e.run_id, e.target_hostname, e.status,
|
||
e.evidence_object_key,
|
||
e.parsed_evidence -> 'event_count_total' AS event_count,
|
||
e.parsed_evidence -> 'webhook_summary' -> 'criticalEvents' AS critical_events,
|
||
e.queued_at, e.completed_at
|
||
FROM rmm_executions e
|
||
WHERE e.transport = 'b2_upload'
|
||
ORDER BY e.queued_at DESC
|
||
LIMIT 25;
|
||
```
|
||
|
||
```sql
|
||
-- LogLift uploads that didn't match a Configuration (review for hostname
|
||
-- typos or unmapped Configurations)
|
||
SELECT e.run_id, e.target_hostname, e.target_company_id,
|
||
e.queued_at
|
||
FROM rmm_executions e
|
||
WHERE e.transport = 'b2_upload'
|
||
AND e.status = 'complete'
|
||
AND e.asset_id IS NULL
|
||
ORDER BY e.queued_at DESC;
|
||
```
|
||
|
||
```sql
|
||
-- LogLift uploads that auto-fired an audit
|
||
SELECT a.id AS audit_id, a.asset_id AS configuration_id,
|
||
a.overall_score, a.generated_at,
|
||
l.action, l.created_at AS triggered_at
|
||
FROM audit_log l
|
||
JOIN itglue_asset_audits a ON a.id::text = (l.details ->> 'audit_id')
|
||
WHERE l.action = 'rmm.loglift.audit_triggered'
|
||
ORDER BY l.created_at DESC LIMIT 25;
|
||
```
|
||
|
||
### Manual replay of a B2 object
|
||
|
||
If a webhook came in but Pulse was down, you can replay by re-POSTing
|
||
the original webhook payload (the collector keeps the metadata; if not,
|
||
build it from the object key + B2's metadata API). The receiver is
|
||
idempotent on `run_id` — a duplicate replay with the same `run_id` will
|
||
update the existing row rather than insert a duplicate.
|
||
|
||
### Inspecting a stuck b2_upload row
|
||
|
||
```sql
|
||
SELECT id, run_id, target_hostname, status, started_at, timeout_at,
|
||
evidence_object_key
|
||
FROM rmm_executions
|
||
WHERE transport = 'b2_upload'
|
||
AND status = 'running'
|
||
ORDER BY started_at;
|
||
```
|
||
|
||
After 5 minutes, the Overshell worker's timeout sweep flips stuck rows
|
||
to `timeout`. If the upload arrives later, the receiver still updates
|
||
the same row by `run_id` (the unique index makes this safe).
|
||
|
||
### Force a one-off audit replay from existing evidence
|
||
|
||
If the auto-audit failed at upload time (e.g. LLM timeout) and the
|
||
evidence is already in `rmm_executions`, you can re-fire the audit:
|
||
|
||
```sql
|
||
-- Find the configuration_id from the most recent LogLift upload
|
||
SELECT asset_id::text AS configuration_id
|
||
FROM rmm_executions
|
||
WHERE transport = 'b2_upload'
|
||
AND target_hostname ILIKE 'YNGHYNWNP01'
|
||
ORDER BY completed_at DESC LIMIT 1;
|
||
```
|
||
|
||
Then trigger via the existing audit endpoint:
|
||
|
||
```bash
|
||
curl -X POST "$BETTER_AUTH_URL/api/itglue/asset-audit/run" \
|
||
-H "Content-Type: application/json" \
|
||
-H "Cookie: better-auth.session_token=…" \
|
||
-d '{"assetType":"configuration","assetId":"<config_id>","provider":"anthropic"}'
|
||
```
|
||
|
||
### Why no Telegram summary?
|
||
|
||
Out of scope for v1 — that flow was a notification, not a data path.
|
||
Audits show up on the Configuration page automatically. If you want a
|
||
Slack/Teams ping when an auto-audit completes, hook it off the
|
||
`rmm.loglift.audit_triggered` audit_log entry.
|