wulf-pulse/lib/services/analyzer/asset-audit/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

451 lines
14 KiB
TypeScript

/**
* Persistence helpers for `itglue_asset_audits` and `itglue_writes`.
*
* Audit rows accumulate full LLM context snapshots (forever-retained).
* Write rows record every IT Glue PATCH attempt with before/after diffs
* and provenance back to the audit that prompted the change.
*/
import postgresClient from '@/lib/services/postgres-client';
import type {
AssetAuditResponse,
AuditConfidence,
} from '@/lib/types/analyzer';
// ─── Audit rows ───────────────────────────────────────────────────────────
export type AuditStatus = 'pending' | 'running' | 'complete' | 'failed';
export type WriteStatus = 'pending' | 'committed' | 'failed' | 'reverted';
export interface AssetAuditRow {
id: string;
asset_type: 'flexible_asset' | 'configuration';
asset_id: string;
asset_type_id: string | null;
organization_id: string | null;
generated_by_user_id: string | null;
generated_at: string;
provider: 'anthropic' | 'openrouter';
model_used: string | null;
asset_snapshot: Record<string, unknown>;
ticket_count: number;
field_gaps: AssetAuditResponse['field_gaps'];
notes_promotions: AssetAuditResponse['notes_promotions'];
contradictions: AssetAuditResponse['contradictions'];
overall_score: number | null;
estimated_cost_usd: number | null;
total_input_tokens: number | null;
total_output_tokens: number | null;
status: AuditStatus;
error_message: string | null;
triggered_by_ticket_number: string | null;
triggered_by_analysis_id: string | null;
}
const AUDIT_SELECT = `
id::text AS id,
asset_type, asset_id::text AS asset_id,
asset_type_id::text AS asset_type_id,
organization_id::text AS organization_id,
generated_by_user_id, generated_at,
provider, model_used,
asset_snapshot,
ticket_count,
field_gaps, notes_promotions, contradictions,
overall_score::float8 AS overall_score,
estimated_cost_usd::float8 AS estimated_cost_usd,
total_input_tokens, total_output_tokens,
status, error_message,
triggered_by_ticket_number,
triggered_by_analysis_id::text AS triggered_by_analysis_id
`;
interface RawAuditRow {
id: string;
asset_type: 'flexible_asset' | 'configuration';
asset_id: string;
asset_type_id: string | null;
organization_id: string | null;
generated_by_user_id: string | null;
generated_at: Date;
provider: 'anthropic' | 'openrouter';
model_used: string | null;
asset_snapshot: Record<string, unknown>;
ticket_count: number;
field_gaps: AssetAuditResponse['field_gaps'];
notes_promotions: AssetAuditResponse['notes_promotions'];
contradictions: AssetAuditResponse['contradictions'];
overall_score: number | null;
estimated_cost_usd: number | null;
total_input_tokens: number | null;
total_output_tokens: number | null;
status: AuditStatus;
error_message: string | null;
triggered_by_ticket_number: string | null;
triggered_by_analysis_id: string | null;
}
function rowToAudit(r: RawAuditRow): AssetAuditRow {
return {
...r,
generated_at: r.generated_at.toISOString(),
};
}
export interface CreateAuditInput {
asset_type: 'flexible_asset' | 'configuration';
asset_id: number | string;
asset_type_id: number | null;
organization_id: number | string | null;
generated_by_user_id: string | null;
provider: 'anthropic' | 'openrouter';
model_used: string;
asset_snapshot: Record<string, unknown>;
ticket_count: number;
response: AssetAuditResponse;
estimated_cost_usd: number;
total_input_tokens: number;
total_output_tokens: number;
/** Phase 4.1: ticket-first audit linkage. Both null for asset-first audits. */
triggered_by_ticket_number?: string | null;
triggered_by_analysis_id?: string | null;
}
export async function insertAssetAudit(input: CreateAuditInput): Promise<{ id: string }> {
const res = await postgresClient.query<{ id: string }>(
`INSERT INTO itglue_asset_audits
(asset_type, asset_id, asset_type_id, organization_id,
generated_by_user_id, provider, model_used,
asset_snapshot, ticket_count,
field_gaps, notes_promotions, contradictions,
overall_score, estimated_cost_usd,
total_input_tokens, total_output_tokens,
status,
triggered_by_ticket_number, triggered_by_analysis_id)
VALUES ($1, $2, $3, $4,
$5, $6, $7,
$8::jsonb, $9,
$10::jsonb, $11::jsonb, $12::jsonb,
$13, $14,
$15, $16,
'complete',
$17, $18)
RETURNING id::text AS id`,
[
input.asset_type,
input.asset_id,
input.asset_type_id,
input.organization_id,
input.generated_by_user_id,
input.provider,
input.model_used,
JSON.stringify(input.asset_snapshot),
input.ticket_count,
JSON.stringify(input.response.field_gaps),
JSON.stringify(input.response.notes_promotions),
JSON.stringify(input.response.contradictions),
input.response.overall_score,
input.estimated_cost_usd,
input.total_input_tokens,
input.total_output_tokens,
input.triggered_by_ticket_number ?? null,
input.triggered_by_analysis_id ?? null,
]
);
return { id: res.rows[0].id };
}
export async function insertFailedAssetAudit(input: {
asset_type: 'flexible_asset' | 'configuration';
asset_id: number | string;
asset_type_id: number | null;
organization_id: number | string | null;
generated_by_user_id: string | null;
provider: 'anthropic' | 'openrouter';
model_used: string | null;
asset_snapshot: Record<string, unknown>;
ticket_count: number;
error_message: string;
triggered_by_ticket_number?: string | null;
triggered_by_analysis_id?: string | null;
}): Promise<{ id: string }> {
const res = await postgresClient.query<{ id: string }>(
`INSERT INTO itglue_asset_audits
(asset_type, asset_id, asset_type_id, organization_id,
generated_by_user_id, provider, model_used,
asset_snapshot, ticket_count,
field_gaps, notes_promotions, contradictions,
status, error_message,
triggered_by_ticket_number, triggered_by_analysis_id)
VALUES ($1, $2, $3, $4,
$5, $6, $7,
$8::jsonb, $9,
'[]'::jsonb, '[]'::jsonb, '[]'::jsonb,
'failed', $10,
$11, $12)
RETURNING id::text AS id`,
[
input.asset_type,
input.asset_id,
input.asset_type_id,
input.organization_id,
input.generated_by_user_id,
input.provider,
input.model_used,
JSON.stringify(input.asset_snapshot),
input.ticket_count,
input.error_message,
input.triggered_by_ticket_number ?? null,
input.triggered_by_analysis_id ?? null,
]
);
return { id: res.rows[0].id };
}
export async function getLatestAssetAudit(
assetId: string | number,
assetType: 'flexible_asset' | 'configuration' = 'flexible_asset'
): Promise<AssetAuditRow | null> {
const res = await postgresClient.query<RawAuditRow>(
`SELECT ${AUDIT_SELECT}
FROM itglue_asset_audits
WHERE asset_type = $1
AND asset_id = $2
ORDER BY generated_at DESC
LIMIT 1`,
[assetType, assetId]
);
if (res.rowCount === 0) return null;
return rowToAudit(res.rows[0]);
}
/**
* Phase 4.1: fetch the most recent ticket-scoped audit for a given
* (analysis, assetType, assetId) triple. Used by the analysis page to show
* "we already audited this asset for this ticket — here's what came back".
*/
export async function getLatestTicketScopedAudit(
analysisId: string,
assetType: 'flexible_asset' | 'configuration',
assetId: string | number
): Promise<AssetAuditRow | null> {
const res = await postgresClient.query<RawAuditRow>(
`SELECT ${AUDIT_SELECT}
FROM itglue_asset_audits
WHERE asset_type = $1
AND asset_id = $2
AND triggered_by_analysis_id = $3
ORDER BY generated_at DESC
LIMIT 1`,
[assetType, assetId, analysisId]
);
if (res.rowCount === 0) return null;
return rowToAudit(res.rows[0]);
}
export async function getAssetAuditById(id: string): Promise<AssetAuditRow | null> {
const res = await postgresClient.query<RawAuditRow>(
`SELECT ${AUDIT_SELECT} FROM itglue_asset_audits WHERE id = $1`,
[id]
);
if (res.rowCount === 0) return null;
return rowToAudit(res.rows[0]);
}
export async function listAssetAudits(
assetId: string | number,
assetType: 'flexible_asset' | 'configuration' = 'flexible_asset',
limit = 20
): Promise<AssetAuditRow[]> {
const res = await postgresClient.query<RawAuditRow>(
`SELECT ${AUDIT_SELECT}
FROM itglue_asset_audits
WHERE asset_type = $1
AND asset_id = $2
ORDER BY generated_at DESC
LIMIT $3`,
[assetType, assetId, limit]
);
return res.rows.map(rowToAudit);
}
// ─── Write rows ───────────────────────────────────────────────────────────
export interface AssetWriteRow {
id: string;
audit_id: string | null;
asset_type: 'flexible_asset' | 'configuration';
asset_id: string;
field_name: string;
before_value: unknown;
after_value: unknown;
performed_by_user_id: string | null;
performed_at: string;
status: WriteStatus;
itglue_response: unknown;
error_message: string | null;
source_evidence: unknown;
}
const WRITE_SELECT = `
id::text AS id,
audit_id::text AS audit_id,
asset_type, asset_id::text AS asset_id,
field_name,
before_value, after_value,
performed_by_user_id, performed_at,
status,
itglue_response, error_message, source_evidence
`;
interface RawWriteRow {
id: string;
audit_id: string | null;
asset_type: 'flexible_asset' | 'configuration';
asset_id: string;
field_name: string;
before_value: unknown;
after_value: unknown;
performed_by_user_id: string | null;
performed_at: Date;
status: WriteStatus;
itglue_response: unknown;
error_message: string | null;
source_evidence: unknown;
}
function rowToWrite(r: RawWriteRow): AssetWriteRow {
return { ...r, performed_at: r.performed_at.toISOString() };
}
export async function createPendingWrite(input: {
audit_id: string | null;
asset_type: 'flexible_asset' | 'configuration';
asset_id: number | string;
field_name: string;
before_value: unknown;
after_value: unknown;
performed_by_user_id: string | null;
source_evidence: unknown;
/** Phase 4.1: ticket linkage carried forward from the audit row. */
triggered_by_ticket_number?: string | null;
}): Promise<{ id: string }> {
const res = await postgresClient.query<{ id: string }>(
`INSERT INTO itglue_writes
(audit_id, asset_type, asset_id, field_name,
before_value, after_value,
performed_by_user_id, status, source_evidence,
triggered_by_ticket_number)
VALUES ($1, $2, $3, $4,
$5::jsonb, $6::jsonb,
$7, 'pending', $8::jsonb,
$9)
RETURNING id::text AS id`,
[
input.audit_id,
input.asset_type,
input.asset_id,
input.field_name,
JSON.stringify(input.before_value ?? null),
JSON.stringify(input.after_value),
input.performed_by_user_id,
JSON.stringify(input.source_evidence ?? null),
input.triggered_by_ticket_number ?? null,
]
);
return { id: res.rows[0].id };
}
export async function markWriteCommitted(
id: string,
itglueResponse: unknown
): Promise<void> {
await postgresClient.query(
`UPDATE itglue_writes
SET status = 'committed',
itglue_response = $2::jsonb
WHERE id = $1`,
[id, JSON.stringify(itglueResponse ?? null)]
);
}
export async function markWriteFailed(id: string, errorMessage: string): Promise<void> {
await postgresClient.query(
`UPDATE itglue_writes
SET status = 'failed', error_message = $2
WHERE id = $1`,
[id, errorMessage]
);
}
export async function markWriteReverted(id: string): Promise<void> {
await postgresClient.query(
`UPDATE itglue_writes SET status = 'reverted' WHERE id = $1`,
[id]
);
}
export async function getWriteById(id: string): Promise<AssetWriteRow | null> {
const res = await postgresClient.query<RawWriteRow>(
`SELECT ${WRITE_SELECT} FROM itglue_writes WHERE id = $1`,
[id]
);
if (res.rowCount === 0) return null;
return rowToWrite(res.rows[0]);
}
export async function listWritesForAsset(
assetId: string | number,
limit = 50
): Promise<AssetWriteRow[]> {
const res = await postgresClient.query<RawWriteRow>(
`SELECT ${WRITE_SELECT}
FROM itglue_writes
WHERE asset_type = 'flexible_asset'
AND asset_id = $1
ORDER BY performed_at DESC
LIMIT $2`,
[assetId, limit]
);
return res.rows.map(rowToWrite);
}
export async function listAllWrites(opts: {
limit?: number;
offset?: number;
status?: WriteStatus;
}): Promise<AssetWriteRow[]> {
const limit = Math.min(opts.limit ?? 100, 500);
const offset = opts.offset ?? 0;
const params: unknown[] = [limit, offset];
let where = '';
if (opts.status) {
params.push(opts.status);
where = `WHERE status = $${params.length}`;
}
const res = await postgresClient.query<RawWriteRow>(
`SELECT ${WRITE_SELECT}
FROM itglue_writes
${where}
ORDER BY performed_at DESC
LIMIT $1 OFFSET $2`,
params
);
return res.rows.map(rowToWrite);
}
// ─── Helpers ──────────────────────────────────────────────────────────────
/**
* The IT Glue convention: trait keys are field names lowercased, hyphenated,
* stripped of repeated/leading/trailing hyphens. Used to map a human field
* name (e.g. "Wulf Application Champion(s)") to its trait key.
*/
export function fieldNameToTraitKey(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
export type { AssetAuditResponse, AuditConfidence };