wulf-pulse/lib/services/analyzer/persistence.ts
lorentz 1112a06afe feat: RMM Overshell, IT Glue audit/write-back, LogLift, link-aware bundles, dashboard overhaul
- 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>
2026-05-03 07:13:18 -04:00

612 lines
20 KiB
TypeScript

/**
* Analyzer persistence: read/write to analyzer_analyses, analyzer_jobs, and
* analyzer_shares.
*
* All writes go through the postgresClient singleton — no transactions are
* needed for the row-per-analysis writes since each is independent and the
* unique (ticket_number, analysis_version) constraint prevents duplicates.
*/
import postgresClient from '@/lib/services/postgres-client';
import {
type AggregateFingerprint,
type AnalyzerJob,
type DeepAnalysisResponse,
type JobStatus,
type PersistedAnalysis,
type StageExecutionRecord,
type TaggedEvent,
} from '@/lib/types/analyzer';
import type { ITGlueDocReference } from '@/lib/types/analyzer';
export interface InsertAnalysisInput {
ticket_number: string;
autotask_ticket_id: number;
content_hash: string;
triggered_by_user_id: string | null;
status: 'complete' | 'failed';
/** When the analysis run finished (now() if undefined). */
completed_at?: Date;
/** anthropic | openrouter — defaults to 'anthropic' for back-compat. */
provider?: 'anthropic' | 'openrouter';
haiku_used: boolean;
sonnet_used: boolean;
opus_used: boolean;
total_input_tokens: number;
total_output_tokens: number;
estimated_cost_usd: number;
/** Final analysis content (after any Opus updates). null on failure. */
analysis: DeepAnalysisResponse | null;
filtered_noise_count: number;
/**
* LEGACY (phase 1). Per-stage trace dump. Retained for back-compat until
* analyzer_stage_executions has full coverage and we drop the column.
*/
model_traces: Record<string, unknown>;
/** Phase 2: Stage 0 preprocessed event list at analysis time. */
source_snapshot?: TaggedEvent[] | null;
error_message?: string | null;
}
/**
* Returns the next monotonic analysis_version for this ticket **and provider**.
* Uses MAX(...)+1 — there is a small race if two workers call this
* simultaneously, but the UNIQUE (ticket_number, provider, analysis_version)
* constraint catches it: the loser sees a 23505 unique_violation and the
* worker should retry with a fresh version number.
*/
export async function getNextAnalysisVersion(
ticketNumber: string,
provider: 'anthropic' | 'openrouter' = 'anthropic'
): Promise<number> {
const res = await postgresClient.query<{ next_version: string }>(
`SELECT COALESCE(MAX(analysis_version), 0) + 1 AS next_version
FROM analyzer_analyses
WHERE ticket_number = $1
AND provider = $2`,
[ticketNumber, provider]
);
return Number(res.rows[0].next_version);
}
/**
* Idempotency check: returns the most recent COMPLETE analysis row whose
* content_hash matches **for the given provider**, if any. Used to
* short-circuit re-runs when the source data hasn't changed and `force=false`.
*
* Provider-scoped so a Claude run doesn't short-circuit a request for a
* DeepSeek run (and vice versa) — the user wants a parallel analysis.
*/
export async function findExistingAnalysisByContentHash(
ticketNumber: string,
contentHash: string,
provider: 'anthropic' | 'openrouter' = 'anthropic'
): Promise<{ id: string; analysis_version: number } | null> {
const res = await postgresClient.query<{ id: string; analysis_version: string }>(
`SELECT id::text AS id, analysis_version::text AS analysis_version
FROM analyzer_analyses
WHERE ticket_number = $1
AND content_hash_at_analysis = $2
AND provider = $3
AND status = 'complete'
ORDER BY analysis_version DESC
LIMIT 1`,
[ticketNumber, contentHash, provider]
);
if (res.rowCount === 0) return null;
const row = res.rows[0];
return { id: row.id, analysis_version: Number(row.analysis_version) };
}
/**
* Insert a completed (or failed) analysis row. Returns the new row's id.
*
* Note: the unique (ticket_number, analysis_version) constraint catches racing
* writers. Caller should re-fetch the next version and retry if it sees a
* unique-violation error from postgres.
*/
export async function insertAnalysis(input: InsertAnalysisInput): Promise<{
id: string;
analysis_version: number;
}> {
const provider = input.provider ?? 'anthropic';
const version = await getNextAnalysisVersion(input.ticket_number, provider);
const completedAt = input.completed_at ?? new Date();
const a = input.analysis;
const res = await postgresClient.query<{ id: string }>(
`
INSERT INTO analyzer_analyses (
ticket_number, autotask_ticket_id, analysis_version,
content_hash_at_analysis, triggered_by_user_id,
status, completed_at,
haiku_used, sonnet_used, opus_used,
total_input_tokens, total_output_tokens, estimated_cost_usd,
summary, timeline, what_was_done, what_should_have_been_done,
gaps, next_step, next_step_rationale, post_resolution_analysis,
confidence_score, needs_human_review, human_review_reasons,
itglue_docs_referenced, model_traces, filtered_noise_count, error_message,
source_snapshot, provider
)
VALUES (
$1, $2, $3,
$4, $5,
$6, $7,
$8, $9, $10,
$11, $12, $13,
$14, $15::jsonb, $16::jsonb, $17::jsonb,
$18::jsonb, $19, $20, $21,
$22, $23, $24::jsonb,
$25::jsonb, $26::jsonb, $27, $28,
$29::jsonb, $30
)
RETURNING id::text AS id
`,
[
input.ticket_number,
input.autotask_ticket_id,
version,
input.content_hash,
input.triggered_by_user_id,
input.status,
completedAt,
input.haiku_used,
input.sonnet_used,
input.opus_used,
input.total_input_tokens,
input.total_output_tokens,
input.estimated_cost_usd,
a?.summary ?? null,
a?.timeline ? JSON.stringify(a.timeline) : null,
a?.what_was_done ? JSON.stringify(a.what_was_done) : null,
a?.what_should_have_been_done
? JSON.stringify(a.what_should_have_been_done)
: null,
a?.gaps ? JSON.stringify(a.gaps) : null,
a?.next_step ?? null,
a?.next_step_rationale ?? null,
a?.post_resolution_analysis ?? null,
a?.confidence_score ?? null,
a?.needs_human_review ?? false,
a?.human_review_reasons ? JSON.stringify(a.human_review_reasons) : null,
JSON.stringify(a?.itglue_docs_referenced ?? []),
JSON.stringify(input.model_traces),
input.filtered_noise_count,
input.error_message ?? null,
input.source_snapshot ? JSON.stringify(input.source_snapshot) : null,
provider,
]
);
return { id: res.rows[0].id, analysis_version: version };
}
/**
* Bulk-insert one analyzer_stage_executions row per record. No-op when records
* is empty. Single multi-VALUES INSERT — fast enough for the few rows produced
* per pipeline run that we don't need COPY.
*/
export async function bulkInsertStageExecutions(
analysisId: string,
records: StageExecutionRecord[]
): Promise<void> {
if (records.length === 0) return;
const values: unknown[] = [analysisId];
const tuples: string[] = [];
for (const r of records) {
const base = values.length;
values.push(
r.stage,
r.stage_order,
r.model_id,
JSON.stringify(r.input_payload ?? {}),
JSON.stringify(r.output_payload ?? {}),
r.input_tokens,
r.output_tokens,
r.latency_ms,
r.started_at,
r.completed_at,
r.error_message
);
tuples.push(
`($1, $${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}::jsonb, ` +
`$${base + 5}::jsonb, $${base + 6}, $${base + 7}, $${base + 8}, ` +
`$${base + 9}, $${base + 10}, $${base + 11})`
);
}
await postgresClient.query(
`INSERT INTO analyzer_stage_executions (
analysis_id, stage, stage_order, model_id,
input_payload, output_payload,
input_tokens, output_tokens, latency_ms,
started_at, completed_at, error_message
) VALUES ${tuples.join(', ')}`,
values
);
}
/**
* Phase 2: write the Stage 6 fingerprint to an existing analysis row.
*/
export async function updateAnalysisFingerprint(
analysisId: string,
fingerprint: AggregateFingerprint
): Promise<void> {
await postgresClient.query(
`UPDATE analyzer_analyses
SET aggregate_fingerprint = $2::jsonb,
fingerprint_generated_at = NOW()
WHERE id = $1`,
[analysisId, JSON.stringify(fingerprint)]
);
}
/**
* Phase 2: persist a 'failed' analyzer_analyses row when the pipeline throws.
* Carries content_hash + source_snapshot so partial-run forensics work, plus
* any stage records the pipeline managed to record before throwing.
*/
export async function insertFailedAnalysis(input: {
ticket_number: string;
autotask_ticket_id: number;
content_hash: string;
triggered_by_user_id: string | null;
source_snapshot: TaggedEvent[];
filtered_noise_count: number;
error_message: string;
partial_input_tokens: number;
partial_output_tokens: number;
partial_cost_usd: number;
haiku_used: boolean;
sonnet_used: boolean;
opus_used: boolean;
provider?: 'anthropic' | 'openrouter';
}): Promise<{ id: string; analysis_version: number }> {
return await insertAnalysis({
ticket_number: input.ticket_number,
autotask_ticket_id: input.autotask_ticket_id,
content_hash: input.content_hash,
triggered_by_user_id: input.triggered_by_user_id,
status: 'failed',
haiku_used: input.haiku_used,
sonnet_used: input.sonnet_used,
opus_used: input.opus_used,
total_input_tokens: input.partial_input_tokens,
total_output_tokens: input.partial_output_tokens,
estimated_cost_usd: input.partial_cost_usd,
analysis: null,
filtered_noise_count: input.filtered_noise_count,
model_traces: {},
source_snapshot: input.source_snapshot,
error_message: input.error_message,
provider: input.provider,
});
}
// =============================================================================
// Job table operations
// =============================================================================
/**
* Try to claim the oldest queued job. Atomic via UPDATE ... WHERE ... RETURNING.
* Returns null if no queued jobs are available.
*/
export async function claimQueuedJob(): Promise<{
id: string;
ticket_number: string;
queued_by_user_id: string | null;
provider: 'anthropic' | 'openrouter';
} | null> {
const res = await postgresClient.query<{
id: string;
ticket_number: string;
queued_by_user_id: string | null;
provider: 'anthropic' | 'openrouter';
}>(
`
UPDATE analyzer_jobs
SET status = 'fetching', started_at = NOW()
WHERE id = (
SELECT id FROM analyzer_jobs
WHERE status = 'queued'
ORDER BY queued_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING id::text AS id, ticket_number, queued_by_user_id, provider
`
);
if (res.rowCount === 0) return null;
return res.rows[0];
}
export async function updateJobStatus(
jobId: string,
status: JobStatus
): Promise<void> {
await postgresClient.query(
`UPDATE analyzer_jobs SET status = $1 WHERE id = $2`,
[status, jobId]
);
}
export async function completeJob(
jobId: string,
resultAnalysisId: string
): Promise<void> {
await postgresClient.query(
`UPDATE analyzer_jobs
SET status = 'complete',
result_analysis_id = $1,
finished_at = NOW()
WHERE id = $2`,
[resultAnalysisId, jobId]
);
}
/**
* Reclaim orphaned in-flight jobs whose started_at is older than the threshold.
* Called once on worker boot — any job in an active state (fetching/triaging/
* itglue/analyzing/deep_review) that's been "running" longer than the expected
* pipeline ceiling is almost certainly orphaned by a container restart and
* needs to be re-queued. Returns the number of rows reset.
*/
export async function resetStaleJobsToQueued(
thresholdMinutes = 10
): Promise<number> {
const res = await postgresClient.query(
`UPDATE analyzer_jobs
SET status = 'queued',
started_at = NULL
WHERE status IN ('fetching','triaging','itglue','analyzing','deep_review')
AND started_at IS NOT NULL
AND started_at < NOW() - ($1::int || ' minutes')::interval`,
[thresholdMinutes]
);
return res.rowCount ?? 0;
}
export async function failJob(jobId: string, errorMessage: string): Promise<void> {
await postgresClient.query(
`UPDATE analyzer_jobs
SET status = 'failed',
error_message = $1,
finished_at = NOW()
WHERE id = $2`,
[errorMessage, jobId]
);
}
export interface QueueJobInput {
ticket_number: string;
queued_by_user_id: string | null;
provider?: 'anthropic' | 'openrouter';
}
export async function queueJob(input: QueueJobInput): Promise<{ id: string }> {
const res = await postgresClient.query<{ id: string }>(
`INSERT INTO analyzer_jobs (ticket_number, queued_by_user_id, provider)
VALUES ($1, $2, $3)
RETURNING id::text AS id`,
[input.ticket_number, input.queued_by_user_id, input.provider ?? 'anthropic']
);
return { id: res.rows[0].id };
}
export async function getJob(jobId: string): Promise<AnalyzerJob | null> {
const res = await postgresClient.query<{
id: string;
ticket_number: string;
queued_by_user_id: string | null;
status: JobStatus;
result_analysis_id: string | null;
queued_at: Date;
started_at: Date | null;
finished_at: Date | null;
error_message: string | null;
}>(
`SELECT id::text AS id, ticket_number, queued_by_user_id, status,
result_analysis_id::text AS result_analysis_id,
queued_at, started_at, finished_at, error_message
FROM analyzer_jobs WHERE id = $1`,
[jobId]
);
if (res.rowCount === 0) return null;
const r = res.rows[0];
return {
id: r.id,
ticketNumber: r.ticket_number,
queuedByUserId: r.queued_by_user_id,
status: r.status,
resultAnalysisId: r.result_analysis_id,
queuedAt: r.queued_at.toISOString(),
startedAt: r.started_at ? r.started_at.toISOString() : null,
finishedAt: r.finished_at ? r.finished_at.toISOString() : null,
errorMessage: r.error_message,
};
}
// =============================================================================
// Read paths used by the API routes
// =============================================================================
interface AnalysisRow {
id: string;
ticket_number: string;
autotask_ticket_id: string;
analysis_version: string;
content_hash_at_analysis: string;
triggered_by_user_id: string | null;
triggered_at: Date;
status: PersistedAnalysis['status'];
completed_at: Date | null;
haiku_used: boolean;
sonnet_used: boolean;
opus_used: boolean;
total_input_tokens: number;
total_output_tokens: number;
estimated_cost_usd: string;
summary: string | null;
timeline: unknown;
what_was_done: unknown;
what_should_have_been_done: unknown;
gaps: unknown;
next_step: string | null;
next_step_rationale: string | null;
post_resolution_analysis: string | null;
confidence_score: string | null;
needs_human_review: boolean;
human_review_reasons: unknown;
itglue_docs_referenced: unknown;
filtered_noise_count: number;
error_message: string | null;
provider: 'anthropic' | 'openrouter';
}
function rowToPersistedAnalysis(r: AnalysisRow): PersistedAnalysis {
return {
id: r.id,
ticketNumber: r.ticket_number,
autotaskTicketId: Number(r.autotask_ticket_id),
analysisVersion: Number(r.analysis_version),
contentHashAtAnalysis: r.content_hash_at_analysis,
triggeredByUserId: r.triggered_by_user_id,
triggeredAt: r.triggered_at.toISOString(),
status: r.status,
completedAt: r.completed_at ? r.completed_at.toISOString() : null,
haikuUsed: r.haiku_used,
sonnetUsed: r.sonnet_used,
opusUsed: r.opus_used,
totalInputTokens: r.total_input_tokens,
totalOutputTokens: r.total_output_tokens,
estimatedCostUsd: Number(r.estimated_cost_usd),
summary: r.summary,
// JSONB columns deserialize directly to JS objects in node-postgres; cast
// to the schema type. We trust Zod-validated writes from the pipeline.
timeline: r.timeline as PersistedAnalysis['timeline'],
whatWasDone: r.what_was_done as PersistedAnalysis['whatWasDone'],
whatShouldHaveBeenDone:
r.what_should_have_been_done as PersistedAnalysis['whatShouldHaveBeenDone'],
gaps: r.gaps as PersistedAnalysis['gaps'],
nextStep: r.next_step,
nextStepRationale: r.next_step_rationale,
postResolutionAnalysis: r.post_resolution_analysis,
confidenceScore:
r.confidence_score === null ? null : Number(r.confidence_score),
needsHumanReview: r.needs_human_review,
humanReviewReasons: r.human_review_reasons as
| PersistedAnalysis['humanReviewReasons'],
itglueDocsReferenced:
(r.itglue_docs_referenced as PersistedAnalysis['itglueDocsReferenced']) ?? [],
filteredNoiseCount: r.filtered_noise_count,
errorMessage: r.error_message,
provider: r.provider,
};
}
const ANALYSIS_SELECT = `
id::text AS id,
ticket_number,
autotask_ticket_id::text AS autotask_ticket_id,
analysis_version::text AS analysis_version,
content_hash_at_analysis,
triggered_by_user_id,
triggered_at,
status,
completed_at,
haiku_used, sonnet_used, opus_used,
total_input_tokens, total_output_tokens, estimated_cost_usd::text AS estimated_cost_usd,
summary, timeline, what_was_done, what_should_have_been_done,
gaps, next_step, next_step_rationale, post_resolution_analysis,
confidence_score::text AS confidence_score,
needs_human_review,
human_review_reasons,
itglue_docs_referenced,
filtered_noise_count,
error_message,
provider
`;
export async function getAnalysisById(
id: string
): Promise<PersistedAnalysis | null> {
const res = await postgresClient.query<AnalysisRow>(
`SELECT ${ANALYSIS_SELECT} FROM analyzer_analyses WHERE id = $1`,
[id]
);
if (res.rowCount === 0) return null;
return rowToPersistedAnalysis(res.rows[0]);
}
export async function listAnalysesByTicketNumber(
ticketNumber: string
): Promise<PersistedAnalysis[]> {
// Order chronologically (most recent first) so the latest run shows up at
// the top of the history regardless of provider. Two providers maintain
// their own monotonic version numbers, so a strict version sort would
// interleave them oddly.
const res = await postgresClient.query<AnalysisRow>(
`SELECT ${ANALYSIS_SELECT}
FROM analyzer_analyses
WHERE ticket_number = $1
ORDER BY triggered_at DESC, analysis_version DESC`,
[ticketNumber]
);
return res.rows.map(rowToPersistedAnalysis);
}
export async function listNeedsReview(opts: {
limit?: number;
offset?: number;
} = {}): Promise<PersistedAnalysis[]> {
const limit = Math.min(opts.limit ?? 50, 200);
const offset = opts.offset ?? 0;
const res = await postgresClient.query<AnalysisRow>(
`SELECT ${ANALYSIS_SELECT}
FROM analyzer_analyses
WHERE needs_human_review = true
AND status = 'complete'
ORDER BY triggered_at DESC
LIMIT $1 OFFSET $2`,
[limit, offset]
);
return res.rows.map(rowToPersistedAnalysis);
}
// =============================================================================
// Share log
// =============================================================================
export interface CreateShareInput {
analysis_id: string;
shared_by_user_id: string;
shared_with_email: string;
note?: string | null;
}
export async function createShare(
input: CreateShareInput
): Promise<{ id: string; shared_at: string }> {
const res = await postgresClient.query<{ id: string; shared_at: Date }>(
`INSERT INTO analyzer_shares
(analysis_id, shared_by_user_id, shared_with_email, note)
VALUES ($1, $2, $3, $4)
RETURNING id::text AS id, shared_at`,
[
input.analysis_id,
input.shared_by_user_id,
input.shared_with_email,
input.note ?? null,
]
);
return {
id: res.rows[0].id,
shared_at: res.rows[0].shared_at.toISOString(),
};
}
// Re-export types referenced elsewhere.
export type { TaggedEvent, ITGlueDocReference, PersistedAnalysis };