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>
This commit is contained in:
parent
378e68ad8a
commit
1112a06afe
132 changed files with 21352 additions and 743 deletions
|
|
@ -22,6 +22,12 @@ export const statement = {
|
|||
|
||||
// Settings management
|
||||
settings: ["read", "update"],
|
||||
|
||||
// IT Glue documentation read/write (Phase 4 — asset audit + write-back)
|
||||
itglue: ["read", "write"],
|
||||
|
||||
// Datto RMM Overshell evidence (Phase 4.2 — read jobs / execute scripts)
|
||||
rmm: ["read", "execute"],
|
||||
} as const;
|
||||
|
||||
// Create access control instance
|
||||
|
|
@ -36,6 +42,8 @@ export const superAdminRole = ac.newRole({
|
|||
roles: ["create", "read", "update", "delete"],
|
||||
auditLog: ["read"],
|
||||
settings: ["read", "update"],
|
||||
itglue: ["read", "write"],
|
||||
rmm: ["read", "execute"],
|
||||
});
|
||||
|
||||
// Admin role - access to admin panel and user management, but not role management
|
||||
|
|
@ -47,6 +55,8 @@ export const adminRole = ac.newRole({
|
|||
roles: ["read"],
|
||||
auditLog: ["read"],
|
||||
settings: ["read"],
|
||||
itglue: ["read", "write"],
|
||||
rmm: ["read", "execute"],
|
||||
});
|
||||
|
||||
// User role - basic access
|
||||
|
|
@ -58,6 +68,8 @@ export const userRole = ac.newRole({
|
|||
roles: [],
|
||||
auditLog: [],
|
||||
settings: [],
|
||||
itglue: ["read"],
|
||||
rmm: ["read"],
|
||||
});
|
||||
|
||||
// Helper function to check if a user has a specific permission
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ interface AggregateReportRow {
|
|||
itglue_context_included: boolean | null;
|
||||
status: AggregateReportStatus;
|
||||
error_message: string | null;
|
||||
expected_ticket_numbers: string[] | null;
|
||||
triggered_by_ticket_number: string | null;
|
||||
}
|
||||
|
||||
export interface AggregateReportSummary {
|
||||
|
|
@ -76,6 +78,9 @@ export interface AggregateReportSummary {
|
|||
totalOutputTokens: number | null;
|
||||
estimatedCostUsd: number | null;
|
||||
modelUsed: string | null;
|
||||
// Bundle (Phase 3)
|
||||
expectedTicketNumbers: string[] | null;
|
||||
triggeredByTicketNumber: string | null;
|
||||
}
|
||||
|
||||
function rowToSummary(r: AggregateReportRow): AggregateReportSummary {
|
||||
|
|
@ -107,6 +112,8 @@ function rowToSummary(r: AggregateReportRow): AggregateReportSummary {
|
|||
totalOutputTokens: r.total_output_tokens,
|
||||
estimatedCostUsd: r.estimated_cost_usd === null ? null : Number(r.estimated_cost_usd),
|
||||
modelUsed: r.model_used,
|
||||
expectedTicketNumbers: r.expected_ticket_numbers,
|
||||
triggeredByTicketNumber: r.triggered_by_ticket_number,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -123,7 +130,8 @@ const REPORT_SELECT = `
|
|||
total_input_tokens, total_output_tokens,
|
||||
estimated_cost_usd::text AS estimated_cost_usd,
|
||||
model_used, itglue_context_included,
|
||||
status, error_message
|
||||
status, error_message,
|
||||
expected_ticket_numbers, triggered_by_ticket_number
|
||||
`;
|
||||
|
||||
export interface CreateAggregateReportInput {
|
||||
|
|
@ -133,16 +141,29 @@ export interface CreateAggregateReportInput {
|
|||
ticketCount: number;
|
||||
includeItglueContext: boolean;
|
||||
reportTitle: string | null;
|
||||
/**
|
||||
* Bundle mode (Phase 3): when set, the report is created in the
|
||||
* 'pending_analyses' state and the worker will transition it to 'pending'
|
||||
* once every expected ticket has a complete analysis. Leave undefined for
|
||||
* the legacy manual-multi-select flow.
|
||||
*/
|
||||
expectedTicketNumbers?: string[];
|
||||
triggeredByTicketNumber?: string;
|
||||
}
|
||||
|
||||
export async function createAggregateReport(
|
||||
input: CreateAggregateReportInput
|
||||
): Promise<{ id: string }> {
|
||||
const isBundle =
|
||||
Array.isArray(input.expectedTicketNumbers) &&
|
||||
input.expectedTicketNumbers.length > 0;
|
||||
const initialStatus = isBundle ? 'pending_analyses' : 'pending';
|
||||
const res = await postgresClient.query<{ id: string }>(
|
||||
`INSERT INTO analyzer_aggregate_reports
|
||||
(generated_by_user_id, filter_criteria, analysis_ids,
|
||||
ticket_count, include_itglue_context, report_title, status)
|
||||
VALUES ($1, $2::jsonb, $3::uuid[], $4, $5, $6, 'pending')
|
||||
ticket_count, include_itglue_context, report_title, status,
|
||||
expected_ticket_numbers, triggered_by_ticket_number)
|
||||
VALUES ($1, $2::jsonb, $3::uuid[], $4, $5, $6, $7, $8::text[], $9)
|
||||
RETURNING id::text AS id`,
|
||||
[
|
||||
input.generatedByUserId,
|
||||
|
|
@ -151,11 +172,96 @@ export async function createAggregateReport(
|
|||
input.ticketCount,
|
||||
input.includeItglueContext,
|
||||
input.reportTitle,
|
||||
initialStatus,
|
||||
input.expectedTicketNumbers ?? null,
|
||||
input.triggeredByTicketNumber ?? null,
|
||||
]
|
||||
);
|
||||
return { id: res.rows[0].id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker chain-trigger.
|
||||
*
|
||||
* Called after a single-ticket analysis completes successfully. For each
|
||||
* pending_analyses report waiting on this ticket: append the analysis_id (if
|
||||
* not already present), and if all expected tickets now have a complete
|
||||
* analysis, transition status='pending' and fire runAggregateReport.
|
||||
*
|
||||
* Idempotent: safe to invoke multiple times for the same analysis (the
|
||||
* deduplicating UPDATE skips no-ops; the status transition is gated on the
|
||||
* full set being present so the second call is a no-op).
|
||||
*/
|
||||
export async function chainTriggerForCompletedAnalysis(
|
||||
ticketNumber: string,
|
||||
analysisId: string
|
||||
): Promise<{ readyReportIds: string[]; touchedReportIds: string[] }> {
|
||||
const res = await postgresClient.query<{
|
||||
id: string;
|
||||
expected_ticket_numbers: string[];
|
||||
analysis_ids: string[];
|
||||
}>(
|
||||
`SELECT id::text AS id,
|
||||
expected_ticket_numbers,
|
||||
analysis_ids::text[] AS analysis_ids
|
||||
FROM analyzer_aggregate_reports
|
||||
WHERE status = 'pending_analyses'
|
||||
AND expected_ticket_numbers @> ARRAY[$1]::text[]`,
|
||||
[ticketNumber]
|
||||
);
|
||||
|
||||
const touched: string[] = [];
|
||||
const ready: string[] = [];
|
||||
|
||||
for (const r of res.rows) {
|
||||
if (!r.analysis_ids.includes(analysisId)) {
|
||||
await postgresClient.query(
|
||||
`UPDATE analyzer_aggregate_reports
|
||||
SET analysis_ids = analysis_ids || $2::uuid
|
||||
WHERE id = $1
|
||||
AND NOT (analysis_ids @> ARRAY[$2::uuid])`,
|
||||
[r.id, analysisId]
|
||||
);
|
||||
touched.push(r.id);
|
||||
}
|
||||
|
||||
// Re-check whether the full set is now satisfied: every expected ticket
|
||||
// must have at least one complete analysis whose id is in analysis_ids.
|
||||
// Reads the latest analysis_ids (the UPDATE above isn't reflected in the
|
||||
// copy we loaded earlier).
|
||||
const ready_check = await postgresClient.query<{ satisfied: boolean }>(
|
||||
`SELECT (
|
||||
(SELECT COUNT(DISTINCT aa.ticket_number)
|
||||
FROM analyzer_analyses aa
|
||||
JOIN analyzer_aggregate_reports r ON r.id = $1
|
||||
WHERE aa.id = ANY(r.analysis_ids)
|
||||
AND aa.status = 'complete'
|
||||
AND aa.ticket_number = ANY(r.expected_ticket_numbers))
|
||||
=
|
||||
(SELECT array_length(expected_ticket_numbers, 1)
|
||||
FROM analyzer_aggregate_reports WHERE id = $1)
|
||||
) AS satisfied`,
|
||||
[r.id]
|
||||
);
|
||||
|
||||
if (ready_check.rows[0]?.satisfied) {
|
||||
const transition = await postgresClient.query<{ id: string }>(
|
||||
`UPDATE analyzer_aggregate_reports
|
||||
SET status = 'pending'
|
||||
WHERE id = $1
|
||||
AND status = 'pending_analyses'
|
||||
RETURNING id::text AS id`,
|
||||
[r.id]
|
||||
);
|
||||
if (transition.rowCount && transition.rowCount > 0) {
|
||||
ready.push(r.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { readyReportIds: ready, touchedReportIds: touched };
|
||||
}
|
||||
|
||||
export async function getAggregateReport(
|
||||
id: string
|
||||
): Promise<AggregateReportSummary | null> {
|
||||
|
|
@ -402,23 +508,32 @@ export async function runAggregateReport(reportId: string): Promise<void> {
|
|||
}
|
||||
|
||||
// ── Step 3: reduce LLM call ──
|
||||
// Honour the provider the bundle was created with. Manual aggregate reports
|
||||
// (no provider in filter_criteria) default to anthropic.
|
||||
const reduceProvider: 'anthropic' | 'openrouter' =
|
||||
(report.filterCriteria as { provider?: string } | null)?.provider === 'openrouter'
|
||||
? 'openrouter'
|
||||
: 'anthropic';
|
||||
const reduceStart = new Date();
|
||||
let reduceResult;
|
||||
try {
|
||||
reduceResult = await runAggregateReduceStage({
|
||||
distributions: {
|
||||
category_distribution: categories,
|
||||
client_distribution: clients,
|
||||
resolution_path_distribution: resolutionPaths,
|
||||
root_cause_distribution: rootCauses,
|
||||
date_range_actual: dateRange,
|
||||
reduceResult = await runAggregateReduceStage(
|
||||
{
|
||||
distributions: {
|
||||
category_distribution: categories,
|
||||
client_distribution: clients,
|
||||
resolution_path_distribution: resolutionPaths,
|
||||
root_cause_distribution: rootCauses,
|
||||
date_range_actual: dateRange,
|
||||
},
|
||||
fingerprints: fingerprints.map((f) => ({
|
||||
ticket_number: f.ticket_number,
|
||||
fingerprint: f.fingerprint,
|
||||
})),
|
||||
itglue_doc_titles: itglueDocTitles,
|
||||
},
|
||||
fingerprints: fingerprints.map((f) => ({
|
||||
ticket_number: f.ticket_number,
|
||||
fingerprint: f.fingerprint,
|
||||
})),
|
||||
itglue_doc_titles: itglueDocTitles,
|
||||
});
|
||||
{ provider: reduceProvider }
|
||||
);
|
||||
} catch (err) {
|
||||
const reduceEnd = new Date();
|
||||
stageRecords.push({
|
||||
|
|
|
|||
264
lib/services/analyzer/asset-audit/asset-matcher.ts
Normal file
264
lib/services/analyzer/asset-audit/asset-matcher.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
/**
|
||||
* Match an analyzer_analyses row to candidate IT Glue assets to audit.
|
||||
*
|
||||
* Inputs from the analysis fingerprint:
|
||||
* - applications_involved → match against itg_flexible_assets.name (Application type)
|
||||
* - device_classes / vendors_involved + ticket-mentioned device names →
|
||||
* match against itg_configurations.name + hostname
|
||||
*
|
||||
* Same-client only (looked up via companies → itg_organizations name match).
|
||||
* Score: exact match (3) > word-boundary match (2) > substring (1).
|
||||
*/
|
||||
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
const APPLICATION_TYPE_ID = 3790;
|
||||
const PER_KIND_LIMIT = 5;
|
||||
|
||||
export interface MatchedFlexibleAsset {
|
||||
id: string;
|
||||
name: string | null;
|
||||
type_name: string | null;
|
||||
score: number;
|
||||
matched_term: string;
|
||||
}
|
||||
|
||||
export interface MatchedConfiguration {
|
||||
id: string;
|
||||
name: string;
|
||||
hostname: string | null;
|
||||
type_name: string | null;
|
||||
score: number;
|
||||
matched_term: string;
|
||||
}
|
||||
|
||||
export interface AssetMatchResult {
|
||||
ticketNumber: string;
|
||||
organizationId: string | null;
|
||||
organizationName: string | null;
|
||||
flexibleAssets: MatchedFlexibleAsset[];
|
||||
configurations: MatchedConfiguration[];
|
||||
}
|
||||
|
||||
interface AnalysisRow {
|
||||
ticket_number: string;
|
||||
fingerprint: Record<string, unknown> | null;
|
||||
company_id: string | null;
|
||||
itglue_org_id: string | null;
|
||||
itglue_org_name: string | null;
|
||||
}
|
||||
|
||||
function normalizeTerm(s: string): string {
|
||||
return s.toLowerCase().trim();
|
||||
}
|
||||
|
||||
function scoreMatch(haystack: string | null, needle: string): number {
|
||||
if (!haystack) return 0;
|
||||
const h = haystack.toLowerCase();
|
||||
const n = needle.toLowerCase();
|
||||
if (h === n) return 3;
|
||||
// Word boundary: surrounded by non-alphanum or start/end.
|
||||
const re = new RegExp(`(^|[^a-z0-9])${n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`);
|
||||
if (re.test(h)) return 2;
|
||||
if (h.includes(n)) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function dedupeByKey<T extends { id: string }>(items: T[]): T[] {
|
||||
const seen = new Map<string, T>();
|
||||
for (const item of items) {
|
||||
const existing = seen.get(item.id);
|
||||
if (
|
||||
!existing ||
|
||||
('score' in item &&
|
||||
'score' in existing &&
|
||||
(item as unknown as { score: number }).score >
|
||||
(existing as unknown as { score: number }).score)
|
||||
) {
|
||||
seen.set(item.id, item);
|
||||
}
|
||||
}
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the analysis + ticket + IT Glue org mapping in one round-trip.
|
||||
* Returns null if the analysis isn't complete or the company can't be
|
||||
* mapped to an IT Glue organization.
|
||||
*/
|
||||
async function loadAnalysisContext(analysisId: string): Promise<AnalysisRow | null> {
|
||||
const res = await postgresClient.query<AnalysisRow>(
|
||||
`SELECT aa.ticket_number,
|
||||
aa.aggregate_fingerprint AS fingerprint,
|
||||
t.company_id::text AS company_id,
|
||||
o.id::text AS itglue_org_id,
|
||||
o.name AS itglue_org_name
|
||||
FROM analyzer_analyses aa
|
||||
JOIN tickets t ON t.ticket_number = aa.ticket_number
|
||||
LEFT JOIN companies c ON c.id = t.company_id
|
||||
LEFT JOIN itg_organizations o ON LOWER(o.name) = LOWER(c.company_name)
|
||||
WHERE aa.id = $1
|
||||
AND aa.status = 'complete'
|
||||
LIMIT 1`,
|
||||
[analysisId]
|
||||
);
|
||||
if (res.rowCount === 0) return null;
|
||||
return res.rows[0];
|
||||
}
|
||||
|
||||
interface FlexAssetCandidate {
|
||||
id: string;
|
||||
name: string | null;
|
||||
type_name: string | null;
|
||||
}
|
||||
|
||||
interface ConfigCandidate {
|
||||
id: string;
|
||||
name: string;
|
||||
hostname: string | null;
|
||||
type_name: string | null;
|
||||
}
|
||||
|
||||
async function loadFlexAssetCandidates(orgId: string): Promise<FlexAssetCandidate[]> {
|
||||
const res = await postgresClient.query<FlexAssetCandidate>(
|
||||
`SELECT id::text AS id,
|
||||
name,
|
||||
flexible_asset_type_name AS type_name
|
||||
FROM itg_flexible_assets
|
||||
WHERE organization_id = $1
|
||||
AND flexible_asset_type_id = $2
|
||||
AND COALESCE(archived, false) = false`,
|
||||
[orgId, APPLICATION_TYPE_ID]
|
||||
);
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
async function loadConfigurationCandidates(
|
||||
orgId: string
|
||||
): Promise<ConfigCandidate[]> {
|
||||
const res = await postgresClient.query<ConfigCandidate>(
|
||||
`SELECT id::text AS id,
|
||||
name, hostname,
|
||||
configuration_type_name AS type_name
|
||||
FROM itg_configurations
|
||||
WHERE organization_id = $1`,
|
||||
[orgId]
|
||||
);
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
function fingerprintTerms(fp: Record<string, unknown> | null): string[] {
|
||||
if (!fp) return [];
|
||||
const out = new Set<string>();
|
||||
for (const key of [
|
||||
'applications_involved',
|
||||
'device_classes',
|
||||
'vendors_involved',
|
||||
]) {
|
||||
const arr = fp[key];
|
||||
if (Array.isArray(arr)) {
|
||||
for (const v of arr) {
|
||||
if (typeof v === 'string' && v.trim().length > 0) {
|
||||
out.add(normalizeTerm(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(out);
|
||||
}
|
||||
|
||||
export async function matchAssetsForAnalysis(
|
||||
analysisId: string
|
||||
): Promise<AssetMatchResult | null> {
|
||||
const ctx = await loadAnalysisContext(analysisId);
|
||||
if (!ctx) return null;
|
||||
if (!ctx.itglue_org_id) {
|
||||
return {
|
||||
ticketNumber: ctx.ticket_number,
|
||||
organizationId: null,
|
||||
organizationName: null,
|
||||
flexibleAssets: [],
|
||||
configurations: [],
|
||||
};
|
||||
}
|
||||
|
||||
const terms = fingerprintTerms(ctx.fingerprint);
|
||||
if (terms.length === 0) {
|
||||
return {
|
||||
ticketNumber: ctx.ticket_number,
|
||||
organizationId: ctx.itglue_org_id,
|
||||
organizationName: ctx.itglue_org_name,
|
||||
flexibleAssets: [],
|
||||
configurations: [],
|
||||
};
|
||||
}
|
||||
|
||||
const [flexCandidates, configCandidates] = await Promise.all([
|
||||
loadFlexAssetCandidates(ctx.itglue_org_id),
|
||||
loadConfigurationCandidates(ctx.itglue_org_id),
|
||||
]);
|
||||
|
||||
const flexHits: MatchedFlexibleAsset[] = [];
|
||||
for (const c of flexCandidates) {
|
||||
let bestScore = 0;
|
||||
let bestTerm = '';
|
||||
for (const term of terms) {
|
||||
const s = scoreMatch(c.name, term);
|
||||
if (s > bestScore) {
|
||||
bestScore = s;
|
||||
bestTerm = term;
|
||||
}
|
||||
}
|
||||
if (bestScore > 0) {
|
||||
flexHits.push({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
type_name: c.type_name,
|
||||
score: bestScore,
|
||||
matched_term: bestTerm,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const configHits: MatchedConfiguration[] = [];
|
||||
for (const c of configCandidates) {
|
||||
let bestScore = 0;
|
||||
let bestTerm = '';
|
||||
for (const term of terms) {
|
||||
const sName = scoreMatch(c.name, term);
|
||||
const sHost = scoreMatch(c.hostname, term);
|
||||
const s = Math.max(sName, sHost);
|
||||
if (s > bestScore) {
|
||||
bestScore = s;
|
||||
bestTerm = term;
|
||||
}
|
||||
}
|
||||
if (bestScore > 0) {
|
||||
configHits.push({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
hostname: c.hostname,
|
||||
type_name: c.type_name,
|
||||
score: bestScore,
|
||||
matched_term: bestTerm,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sortedFlex = dedupeByKey(flexHits)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, PER_KIND_LIMIT);
|
||||
const sortedConfig = dedupeByKey(configHits)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, PER_KIND_LIMIT);
|
||||
|
||||
return {
|
||||
ticketNumber: ctx.ticket_number,
|
||||
organizationId: ctx.itglue_org_id,
|
||||
organizationName: ctx.itglue_org_name,
|
||||
flexibleAssets: sortedFlex,
|
||||
configurations: sortedConfig,
|
||||
};
|
||||
}
|
||||
|
||||
export const _MATCHER_INTERNALS = { scoreMatch, fingerprintTerms };
|
||||
719
lib/services/analyzer/asset-audit/data-builder.ts
Normal file
719
lib/services/analyzer/asset-audit/data-builder.ts
Normal file
|
|
@ -0,0 +1,719 @@
|
|||
/**
|
||||
* Data builder for the IT Glue asset audit pipeline.
|
||||
*
|
||||
* Pulls every input the LLM needs to evaluate one IT Glue record (Application
|
||||
* flexible asset OR Configuration) against ticket history and IT Glue's own
|
||||
* field schema:
|
||||
*
|
||||
* 1. The asset's current contents (redacted).
|
||||
* 2. The field schema with hints (IT Glue's per-field documentation, or
|
||||
* curated hints for Configurations which don't expose a *_fields table).
|
||||
* 3. Peer exemplars from the same client — well-filled assets of the same
|
||||
* type, redacted.
|
||||
* 4. Best-in-class exemplars across all clients of the same type.
|
||||
* 5. Per-field fill-rate stats (per-client + global).
|
||||
* 6. Recent ticket fingerprints whose applications/devices overlap the
|
||||
* asset, with summaries.
|
||||
*
|
||||
* Phase 4.1 additions:
|
||||
* - assetType dispatch: 'flexible_asset' | 'configuration'
|
||||
* - ticketScopeAnalysisId: when set, ticket evidence is the single source
|
||||
* analysis only, so the LLM looks for what *this ticket* taught us.
|
||||
*/
|
||||
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import { redact } from '../itglue-redact';
|
||||
import {
|
||||
listLatestEvidenceForAsset,
|
||||
listLatestEvidenceForCompany,
|
||||
} from '@/lib/services/rmm/persistence';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────
|
||||
|
||||
export type AuditAssetType = 'flexible_asset' | 'configuration';
|
||||
|
||||
/**
|
||||
* Generic asset shape the prompt consumes. For flexible assets, `traits` is
|
||||
* the IT Glue traits blob. For configurations, `traits` is a synthesized
|
||||
* map of editable columns → current values, so the prompt stays
|
||||
* type-agnostic.
|
||||
*/
|
||||
export interface AuditAssetRow {
|
||||
id: string;
|
||||
organization_id: string | null;
|
||||
organization_name: string | null;
|
||||
type_id: string | null;
|
||||
type_name: string | null;
|
||||
name: string | null;
|
||||
traits: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AuditFieldRow {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: string | null;
|
||||
hint: string | null;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export interface FillRateRow {
|
||||
field_name: string;
|
||||
fill_rate: number; // 0..1
|
||||
}
|
||||
|
||||
export interface TicketEvidenceRow {
|
||||
ticket_number: string;
|
||||
triggered_at: string;
|
||||
summary: string | null;
|
||||
fingerprint: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 4.2: live evidence captured by an Overshell execution. The audit
|
||||
* pipeline injects this as a 7th LLM context arm.
|
||||
*/
|
||||
export interface RmmEvidenceRow {
|
||||
execution_id: string;
|
||||
script_id: string;
|
||||
target_type: 'site_anchor' | 'asset_self';
|
||||
target_hostname: string | null;
|
||||
captured_at: string;
|
||||
parsed: unknown;
|
||||
}
|
||||
|
||||
export interface AssetAuditContext {
|
||||
asset_type: AuditAssetType;
|
||||
asset: AuditAssetRow;
|
||||
type_id: number | null;
|
||||
type_name: string | null;
|
||||
fields: AuditFieldRow[];
|
||||
peer_same_client: AuditAssetRow[];
|
||||
peer_global: AuditAssetRow[];
|
||||
fill_rate_client: FillRateRow[];
|
||||
fill_rate_global: FillRateRow[];
|
||||
ticket_evidence: TicketEvidenceRow[];
|
||||
/** Set when the audit was launched from a single ticket's analysis. */
|
||||
ticket_scope: { analysis_id: string; ticket_number: string } | null;
|
||||
rmm_evidence: RmmEvidenceRow[];
|
||||
}
|
||||
|
||||
// ─── Tunables ─────────────────────────────────────────────────────────────
|
||||
|
||||
const PEER_SAME_CLIENT_LIMIT = 5;
|
||||
const PEER_GLOBAL_LIMIT = 3;
|
||||
const TICKET_EVIDENCE_LIMIT = 20;
|
||||
|
||||
// ─── Errors ───────────────────────────────────────────────────────────────
|
||||
|
||||
export class AssetAuditNotFoundError extends Error {
|
||||
constructor(assetType: AuditAssetType, assetId: string | number) {
|
||||
super(`${assetType} ${assetId} not found in mirror`);
|
||||
this.name = 'AssetAuditNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function fillCount(traits: Record<string, unknown> | null | undefined): number {
|
||||
if (!traits) return 0;
|
||||
let n = 0;
|
||||
for (const k of Object.keys(traits)) {
|
||||
const v = traits[k];
|
||||
if (v === null || v === undefined) continue;
|
||||
if (typeof v === 'string' && v.trim().length === 0) continue;
|
||||
if (Array.isArray(v) && v.length === 0) continue;
|
||||
n += 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// ─── Flexible-asset loaders ───────────────────────────────────────────────
|
||||
|
||||
interface RawFlexAsset {
|
||||
id: string;
|
||||
organization_id: string | null;
|
||||
organization_name: string | null;
|
||||
flexible_asset_type_id: string;
|
||||
flexible_asset_type_name: string | null;
|
||||
name: string | null;
|
||||
traits: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
function flexAssetToAudit(r: RawFlexAsset): AuditAssetRow {
|
||||
return {
|
||||
id: r.id,
|
||||
organization_id: r.organization_id,
|
||||
organization_name: r.organization_name,
|
||||
type_id: r.flexible_asset_type_id,
|
||||
type_name: r.flexible_asset_type_name,
|
||||
name: r.name,
|
||||
traits: r.traits ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
async function loadFlexAsset(assetId: string | number): Promise<AuditAssetRow & { _raw: RawFlexAsset }> {
|
||||
const res = await postgresClient.query<RawFlexAsset>(
|
||||
`SELECT id::text AS id,
|
||||
organization_id::text AS organization_id,
|
||||
organization_name,
|
||||
flexible_asset_type_id::text AS flexible_asset_type_id,
|
||||
flexible_asset_type_name,
|
||||
name,
|
||||
traits
|
||||
FROM itg_flexible_assets
|
||||
WHERE id = $1
|
||||
AND COALESCE(archived, false) = false
|
||||
LIMIT 1`,
|
||||
[assetId]
|
||||
);
|
||||
if (res.rowCount === 0) throw new AssetAuditNotFoundError('flexible_asset', assetId);
|
||||
const r = res.rows[0];
|
||||
return { ...flexAssetToAudit(r), _raw: r };
|
||||
}
|
||||
|
||||
async function loadFlexFields(typeId: string): Promise<AuditFieldRow[]> {
|
||||
const res = await postgresClient.query<AuditFieldRow>(
|
||||
`SELECT id::text AS id, name, kind, hint, required
|
||||
FROM itg_flexible_asset_fields
|
||||
WHERE flexible_asset_type_id = $1
|
||||
ORDER BY id`,
|
||||
[typeId]
|
||||
);
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
async function loadFlexPeerSameClient(
|
||||
orgId: string | null,
|
||||
typeId: string,
|
||||
excludeId: string,
|
||||
limit: number
|
||||
): Promise<AuditAssetRow[]> {
|
||||
if (!orgId) return [];
|
||||
const res = await postgresClient.query<RawFlexAsset>(
|
||||
`SELECT id::text AS id,
|
||||
organization_id::text AS organization_id,
|
||||
organization_name,
|
||||
flexible_asset_type_id::text AS flexible_asset_type_id,
|
||||
flexible_asset_type_name,
|
||||
name,
|
||||
traits
|
||||
FROM itg_flexible_assets
|
||||
WHERE organization_id = $1
|
||||
AND flexible_asset_type_id = $2
|
||||
AND id <> $3
|
||||
AND COALESCE(archived, false) = false`,
|
||||
[orgId, typeId, excludeId]
|
||||
);
|
||||
const rows = res.rows.map(flexAssetToAudit);
|
||||
rows.sort((a, b) => fillCount(b.traits) - fillCount(a.traits));
|
||||
return rows.slice(0, limit);
|
||||
}
|
||||
|
||||
async function loadFlexPeerGlobal(
|
||||
typeId: string,
|
||||
excludeId: string,
|
||||
excludeOrgId: string | null,
|
||||
limit: number
|
||||
): Promise<AuditAssetRow[]> {
|
||||
const res = await postgresClient.query<RawFlexAsset>(
|
||||
`SELECT id::text AS id,
|
||||
organization_id::text AS organization_id,
|
||||
organization_name,
|
||||
flexible_asset_type_id::text AS flexible_asset_type_id,
|
||||
flexible_asset_type_name,
|
||||
name,
|
||||
traits
|
||||
FROM itg_flexible_assets
|
||||
WHERE flexible_asset_type_id = $1
|
||||
AND id <> $2
|
||||
AND ($3::text IS NULL OR organization_id::text <> $3)
|
||||
AND COALESCE(archived, false) = false
|
||||
ORDER BY synced_at DESC
|
||||
LIMIT 200`,
|
||||
[typeId, excludeId, excludeOrgId]
|
||||
);
|
||||
const rows = res.rows.map(flexAssetToAudit);
|
||||
rows.sort((a, b) => fillCount(b.traits) - fillCount(a.traits));
|
||||
return rows.slice(0, limit);
|
||||
}
|
||||
|
||||
function flexFieldKey(name: string): string {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
async function loadFlexFillRates(
|
||||
typeId: string,
|
||||
orgIdFilter: string | null,
|
||||
fields: AuditFieldRow[]
|
||||
): Promise<FillRateRow[]> {
|
||||
const params: unknown[] = [typeId];
|
||||
let where = `flexible_asset_type_id = $1 AND COALESCE(archived, false) = false`;
|
||||
if (orgIdFilter) {
|
||||
params.push(orgIdFilter);
|
||||
where += ` AND organization_id = $${params.length}`;
|
||||
}
|
||||
const res = await postgresClient.query<{ traits: Record<string, unknown> | null }>(
|
||||
`SELECT traits FROM itg_flexible_assets WHERE ${where}`,
|
||||
params
|
||||
);
|
||||
const total = res.rowCount ?? 0;
|
||||
if (total === 0) return fields.map((f) => ({ field_name: f.name, fill_rate: 0 }));
|
||||
return fields.map((f) => {
|
||||
const key = flexFieldKey(f.name);
|
||||
let filled = 0;
|
||||
for (const row of res.rows) {
|
||||
const v = row.traits?.[key];
|
||||
if (v === null || v === undefined) continue;
|
||||
if (typeof v === 'string' && v.trim().length === 0) continue;
|
||||
if (Array.isArray(v) && v.length === 0) continue;
|
||||
if (
|
||||
typeof v === 'object' &&
|
||||
v !== null &&
|
||||
'values' in (v as Record<string, unknown>) &&
|
||||
Array.isArray((v as { values: unknown[] }).values) &&
|
||||
(v as { values: unknown[] }).values.length === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
filled += 1;
|
||||
}
|
||||
return {
|
||||
field_name: f.name,
|
||||
fill_rate: Math.round((filled / total) * 100) / 100,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Configuration loaders ────────────────────────────────────────────────
|
||||
|
||||
interface RawConfiguration {
|
||||
id: string;
|
||||
organization_id: string | null;
|
||||
organization_name: string | null;
|
||||
configuration_type_id: string | null;
|
||||
configuration_type_name: string | null;
|
||||
configuration_status_id: string | null;
|
||||
configuration_status_name: string | null;
|
||||
manufacturer_id: string | null;
|
||||
manufacturer_name: string | null;
|
||||
model_id: string | null;
|
||||
model_name: string | null;
|
||||
operating_system_id: string | null;
|
||||
operating_system_name: string | null;
|
||||
contact_id: string | null;
|
||||
location_id: string | null;
|
||||
name: string;
|
||||
hostname: string | null;
|
||||
primary_ip: string | null;
|
||||
mac_address: string | null;
|
||||
serial_number: string | null;
|
||||
asset_tag: string | null;
|
||||
position: string | null;
|
||||
notes: string | null;
|
||||
operating_system_notes: string | null;
|
||||
}
|
||||
|
||||
const CONFIGURATION_FIELDS: AuditFieldRow[] = [
|
||||
{ id: '1', name: 'name', kind: 'Text', hint: 'Display name. Match the hostname or a stable label techs recognize.', required: true },
|
||||
{ id: '2', name: 'hostname', kind: 'Text', hint: 'FQDN or NetBIOS name as it appears on the network. Should match what shows up in DNS and on the asset itself.', required: false },
|
||||
{ id: '3', name: 'primary_ip', kind: 'Text', hint: 'Primary IP address. Static where possible; capture even if DHCP-assigned for current state.', required: false },
|
||||
{ id: '4', name: 'mac_address', kind: 'Text', hint: 'Primary network adapter MAC.', required: false },
|
||||
{ id: '5', name: 'serial_number', kind: 'Text', hint: 'Hardware serial / VM UUID — needed for vendor warranty calls.', required: false },
|
||||
{ id: '6', name: 'asset_tag', kind: 'Text', hint: 'Physical or logical asset tag if the customer uses one.', required: false },
|
||||
{ id: '7', name: 'position', kind: 'Text', hint: 'Rack U position, room location, or VM cluster placement.', required: false },
|
||||
{ id: '8', name: 'configuration_type_name', kind: 'Select', hint: 'Server, Workstation, Printer, Firewall, Switch, etc. Drives downstream filtering and audit prompts.', required: false },
|
||||
{ id: '9', name: 'configuration_status_name', kind: 'Select', hint: 'Active, Inactive, Decommissioned, Spare. Stale Active records hide retired infra.', required: false },
|
||||
{ id: '10', name: 'manufacturer_name', kind: 'Tag', hint: 'Hardware/VM platform vendor (Dell, HPE, VMware, Hyper-V).', required: false },
|
||||
{ id: '11', name: 'model_name', kind: 'Tag', hint: 'Model identifier (PowerEdge R650, Surface Laptop 5).', required: false },
|
||||
{ id: '12', name: 'operating_system_name', kind: 'Tag', hint: 'OS + version (Windows Server 2019, Ubuntu 22.04). Stale OS = stale patch picture.', required: false },
|
||||
{ id: '13', name: 'operating_system_notes', kind: 'Textbox', hint: 'OS-specific gotchas: hotfix levels, schedule windows, named services and what they do, important roles installed (DC, DHCP, DNS, RDS).', required: false },
|
||||
{ id: '14', name: 'notes', kind: 'Textbox', hint: 'Free-text general notes. Keep architectural details (integration paths, data flows) here only if no structured field fits.', required: false },
|
||||
{ id: '15', name: 'contact_id', kind: 'Tag', hint: 'Primary user / responsible contact (workstation = end user; server = champion).', required: false },
|
||||
{ id: '16', name: 'location_id', kind: 'Tag', hint: 'IT Glue location/site this configuration lives at.', required: false },
|
||||
];
|
||||
|
||||
function configToAudit(r: RawConfiguration): AuditAssetRow {
|
||||
// Synthesize a traits-style map for prompt consistency.
|
||||
const traits: Record<string, unknown> = {
|
||||
name: r.name ?? null,
|
||||
hostname: r.hostname ?? null,
|
||||
primary_ip: r.primary_ip ?? null,
|
||||
mac_address: r.mac_address ?? null,
|
||||
serial_number: r.serial_number ?? null,
|
||||
asset_tag: r.asset_tag ?? null,
|
||||
position: r.position ?? null,
|
||||
configuration_type_name: r.configuration_type_name ?? null,
|
||||
configuration_status_name: r.configuration_status_name ?? null,
|
||||
manufacturer_name: r.manufacturer_name ?? null,
|
||||
model_name: r.model_name ?? null,
|
||||
operating_system_name: r.operating_system_name ?? null,
|
||||
operating_system_notes: r.operating_system_notes ?? null,
|
||||
notes: r.notes ?? null,
|
||||
contact_id: r.contact_id ?? null,
|
||||
location_id: r.location_id ?? null,
|
||||
};
|
||||
return {
|
||||
id: r.id,
|
||||
organization_id: r.organization_id,
|
||||
organization_name: r.organization_name,
|
||||
type_id: r.configuration_type_id,
|
||||
type_name: r.configuration_type_name,
|
||||
name: r.name,
|
||||
traits,
|
||||
};
|
||||
}
|
||||
|
||||
const CONFIG_SELECT = `
|
||||
id::text AS id,
|
||||
organization_id::text AS organization_id,
|
||||
organization_name,
|
||||
configuration_type_id::text AS configuration_type_id,
|
||||
configuration_type_name,
|
||||
configuration_status_id::text AS configuration_status_id,
|
||||
configuration_status_name,
|
||||
manufacturer_id::text AS manufacturer_id,
|
||||
manufacturer_name,
|
||||
model_id::text AS model_id,
|
||||
model_name,
|
||||
operating_system_id::text AS operating_system_id,
|
||||
operating_system_name,
|
||||
contact_id::text AS contact_id,
|
||||
location_id::text AS location_id,
|
||||
name, hostname, primary_ip, mac_address, serial_number, asset_tag,
|
||||
position, notes, operating_system_notes
|
||||
`;
|
||||
|
||||
async function loadConfiguration(assetId: string | number): Promise<AuditAssetRow & { type_id_str: string | null }> {
|
||||
const res = await postgresClient.query<RawConfiguration>(
|
||||
`SELECT ${CONFIG_SELECT} FROM itg_configurations WHERE id = $1 LIMIT 1`,
|
||||
[assetId]
|
||||
);
|
||||
if (res.rowCount === 0) throw new AssetAuditNotFoundError('configuration', assetId);
|
||||
return { ...configToAudit(res.rows[0]), type_id_str: res.rows[0].configuration_type_id };
|
||||
}
|
||||
|
||||
async function loadConfigPeerSameClient(
|
||||
orgId: string | null,
|
||||
typeId: string | null,
|
||||
excludeId: string,
|
||||
limit: number
|
||||
): Promise<AuditAssetRow[]> {
|
||||
if (!orgId || !typeId) return [];
|
||||
const res = await postgresClient.query<RawConfiguration>(
|
||||
`SELECT ${CONFIG_SELECT}
|
||||
FROM itg_configurations
|
||||
WHERE organization_id = $1
|
||||
AND configuration_type_id = $2
|
||||
AND id <> $3`,
|
||||
[orgId, typeId, excludeId]
|
||||
);
|
||||
const rows = res.rows.map(configToAudit);
|
||||
rows.sort((a, b) => fillCount(b.traits) - fillCount(a.traits));
|
||||
return rows.slice(0, limit);
|
||||
}
|
||||
|
||||
async function loadConfigPeerGlobal(
|
||||
typeId: string | null,
|
||||
excludeId: string,
|
||||
excludeOrgId: string | null,
|
||||
limit: number
|
||||
): Promise<AuditAssetRow[]> {
|
||||
if (!typeId) return [];
|
||||
const res = await postgresClient.query<RawConfiguration>(
|
||||
`SELECT ${CONFIG_SELECT}
|
||||
FROM itg_configurations
|
||||
WHERE configuration_type_id = $1
|
||||
AND id <> $2
|
||||
AND ($3::text IS NULL OR organization_id::text <> $3)
|
||||
LIMIT 200`,
|
||||
[typeId, excludeId, excludeOrgId]
|
||||
);
|
||||
const rows = res.rows.map(configToAudit);
|
||||
rows.sort((a, b) => fillCount(b.traits) - fillCount(a.traits));
|
||||
return rows.slice(0, limit);
|
||||
}
|
||||
|
||||
async function loadConfigFillRates(
|
||||
typeId: string | null,
|
||||
orgIdFilter: string | null
|
||||
): Promise<FillRateRow[]> {
|
||||
if (!typeId) {
|
||||
return CONFIGURATION_FIELDS.map((f) => ({ field_name: f.name, fill_rate: 0 }));
|
||||
}
|
||||
const params: unknown[] = [typeId];
|
||||
let where = `configuration_type_id = $1`;
|
||||
if (orgIdFilter) {
|
||||
params.push(orgIdFilter);
|
||||
where += ` AND organization_id = $${params.length}`;
|
||||
}
|
||||
const res = await postgresClient.query<RawConfiguration>(
|
||||
`SELECT ${CONFIG_SELECT} FROM itg_configurations WHERE ${where}`,
|
||||
params
|
||||
);
|
||||
const total = res.rowCount ?? 0;
|
||||
if (total === 0) {
|
||||
return CONFIGURATION_FIELDS.map((f) => ({ field_name: f.name, fill_rate: 0 }));
|
||||
}
|
||||
return CONFIGURATION_FIELDS.map((f) => {
|
||||
let filled = 0;
|
||||
for (const row of res.rows) {
|
||||
const v = (row as unknown as Record<string, unknown>)[f.name];
|
||||
if (v === null || v === undefined) continue;
|
||||
if (typeof v === 'string' && v.trim().length === 0) continue;
|
||||
filled += 1;
|
||||
}
|
||||
return {
|
||||
field_name: f.name,
|
||||
fill_rate: Math.round((filled / total) * 100) / 100,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Ticket evidence (shared) ────────────────────────────────────────────
|
||||
|
||||
async function loadTicketEvidence(
|
||||
orgId: string | null,
|
||||
assetName: string | null,
|
||||
limit: number
|
||||
): Promise<TicketEvidenceRow[]> {
|
||||
if (!orgId || !assetName) return [];
|
||||
const compRes = await postgresClient.query<{ id: string }>(
|
||||
`SELECT c.id::text AS id
|
||||
FROM companies c
|
||||
JOIN itg_organizations o ON LOWER(c.company_name) = LOWER(o.name)
|
||||
WHERE o.id = $1
|
||||
LIMIT 1`,
|
||||
[orgId]
|
||||
);
|
||||
if (compRes.rowCount === 0) return [];
|
||||
const companyId = compRes.rows[0].id;
|
||||
const lowerName = assetName.toLowerCase();
|
||||
const res = await postgresClient.query<{
|
||||
ticket_number: string;
|
||||
triggered_at: Date;
|
||||
summary: string | null;
|
||||
fingerprint: Record<string, unknown> | null;
|
||||
}>(
|
||||
`SELECT aa.ticket_number, aa.triggered_at, aa.summary,
|
||||
aa.aggregate_fingerprint AS fingerprint
|
||||
FROM analyzer_analyses aa
|
||||
JOIN tickets t ON t.ticket_number = aa.ticket_number
|
||||
WHERE t.company_id = $1
|
||||
AND aa.status = 'complete'
|
||||
AND aa.aggregate_fingerprint IS NOT NULL
|
||||
AND (
|
||||
LOWER(COALESCE(aa.summary, '')) LIKE '%' || $2 || '%'
|
||||
OR aa.aggregate_fingerprint::text ILIKE '%' || $2 || '%'
|
||||
)
|
||||
ORDER BY aa.triggered_at DESC
|
||||
LIMIT $3`,
|
||||
[companyId, lowerName, limit]
|
||||
);
|
||||
return res.rows.map((r) => ({
|
||||
ticket_number: r.ticket_number,
|
||||
triggered_at: r.triggered_at.toISOString(),
|
||||
summary: r.summary,
|
||||
fingerprint: r.fingerprint ?? {},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticket-scoped evidence — returns just the one analysis the audit was
|
||||
* launched from. Used when the user clicks "Check IT Glue documentation"
|
||||
* on a specific analysis page; the audit looks for what *this ticket*
|
||||
* taught us, not all-time history.
|
||||
*/
|
||||
/**
|
||||
* Phase 4.2: pull the latest successful Overshell evidence for the audit's
|
||||
* client + asset. Site-anchored scripts: latest per script_id within 7
|
||||
* days. Asset-self scripts: latest per script_id, no time limit
|
||||
* (asset-self facts are durable until something changes).
|
||||
*/
|
||||
async function loadRmmEvidence(
|
||||
itglueOrgId: string | null,
|
||||
assetType: AuditAssetType,
|
||||
assetId: string
|
||||
): Promise<RmmEvidenceRow[]> {
|
||||
if (!itglueOrgId) return [];
|
||||
// Map IT Glue org → Autotask company (same join the ticket-evidence loader uses).
|
||||
const compRes = await postgresClient.query<{ id: string }>(
|
||||
`SELECT c.id::text AS id
|
||||
FROM companies c
|
||||
JOIN itg_organizations o ON LOWER(c.company_name) = LOWER(o.name)
|
||||
WHERE o.id = $1
|
||||
LIMIT 1`,
|
||||
[itglueOrgId]
|
||||
);
|
||||
if (compRes.rowCount === 0) return [];
|
||||
const companyId = compRes.rows[0].id;
|
||||
|
||||
const [siteAnchored, assetSelf] = await Promise.all([
|
||||
listLatestEvidenceForCompany(companyId, 7),
|
||||
listLatestEvidenceForAsset(assetType, assetId),
|
||||
]);
|
||||
|
||||
// Combine: site-anchored first (general context), then asset-self.
|
||||
const out: RmmEvidenceRow[] = [];
|
||||
for (const e of siteAnchored) {
|
||||
if (e.targetType !== 'site_anchor') continue;
|
||||
out.push({
|
||||
execution_id: e.id,
|
||||
script_id: e.scriptId,
|
||||
target_type: e.targetType,
|
||||
target_hostname: e.targetHostname,
|
||||
captured_at: e.completedAt ?? e.queuedAt,
|
||||
parsed: redact(e.parsedEvidence ?? null),
|
||||
});
|
||||
}
|
||||
for (const e of assetSelf) {
|
||||
out.push({
|
||||
execution_id: e.id,
|
||||
script_id: e.scriptId,
|
||||
target_type: e.targetType,
|
||||
target_hostname: e.targetHostname,
|
||||
captured_at: e.completedAt ?? e.queuedAt,
|
||||
parsed: redact(e.parsedEvidence ?? null),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function loadSingleTicketEvidence(
|
||||
analysisId: string
|
||||
): Promise<TicketEvidenceRow[]> {
|
||||
const res = await postgresClient.query<{
|
||||
ticket_number: string;
|
||||
triggered_at: Date;
|
||||
summary: string | null;
|
||||
fingerprint: Record<string, unknown> | null;
|
||||
}>(
|
||||
`SELECT ticket_number, triggered_at, summary,
|
||||
aggregate_fingerprint AS fingerprint
|
||||
FROM analyzer_analyses
|
||||
WHERE id = $1 AND status = 'complete'
|
||||
LIMIT 1`,
|
||||
[analysisId]
|
||||
);
|
||||
if (res.rowCount === 0) return [];
|
||||
const r = res.rows[0];
|
||||
return [
|
||||
{
|
||||
ticket_number: r.ticket_number,
|
||||
triggered_at: r.triggered_at.toISOString(),
|
||||
summary: r.summary,
|
||||
fingerprint: r.fingerprint ?? {},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ─── Top-level builder ────────────────────────────────────────────────────
|
||||
|
||||
export interface BuildAssetAuditContextInput {
|
||||
assetType: AuditAssetType;
|
||||
assetId: string | number;
|
||||
/** When set, ticket evidence is just this one analysis (ticket-first mode). */
|
||||
ticketScopeAnalysisId?: string;
|
||||
}
|
||||
|
||||
export async function buildAssetAuditContext(
|
||||
input: BuildAssetAuditContextInput
|
||||
): Promise<AssetAuditContext> {
|
||||
const { assetType, assetId, ticketScopeAnalysisId } = input;
|
||||
|
||||
if (assetType === 'flexible_asset') {
|
||||
const asset = await loadFlexAsset(assetId);
|
||||
const typeId = asset.type_id ?? '';
|
||||
const fields = await loadFlexFields(typeId);
|
||||
|
||||
const [peerSameClient, peerGlobal, fillRateClient, fillRateGlobal, rmmEvidence] =
|
||||
await Promise.all([
|
||||
loadFlexPeerSameClient(asset.organization_id, typeId, asset.id, PEER_SAME_CLIENT_LIMIT),
|
||||
loadFlexPeerGlobal(typeId, asset.id, asset.organization_id, PEER_GLOBAL_LIMIT),
|
||||
loadFlexFillRates(typeId, asset.organization_id, fields),
|
||||
loadFlexFillRates(typeId, null, fields),
|
||||
loadRmmEvidence(asset.organization_id, 'flexible_asset', asset.id),
|
||||
]);
|
||||
|
||||
let ticketEvidence: TicketEvidenceRow[];
|
||||
let ticketScope: AssetAuditContext['ticket_scope'] = null;
|
||||
if (ticketScopeAnalysisId) {
|
||||
ticketEvidence = await loadSingleTicketEvidence(ticketScopeAnalysisId);
|
||||
if (ticketEvidence.length > 0) {
|
||||
ticketScope = {
|
||||
analysis_id: ticketScopeAnalysisId,
|
||||
ticket_number: ticketEvidence[0].ticket_number,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
ticketEvidence = await loadTicketEvidence(asset.organization_id, asset.name, TICKET_EVIDENCE_LIMIT);
|
||||
}
|
||||
|
||||
return {
|
||||
asset_type: 'flexible_asset',
|
||||
asset: { ...asset, traits: redact(asset.traits) },
|
||||
type_id: Number(typeId) || null,
|
||||
type_name: asset.type_name,
|
||||
fields,
|
||||
peer_same_client: peerSameClient.map((p) => ({ ...p, traits: redact(p.traits) })),
|
||||
peer_global: peerGlobal.map((p) => ({ ...p, traits: redact(p.traits) })),
|
||||
fill_rate_client: fillRateClient,
|
||||
fill_rate_global: fillRateGlobal,
|
||||
ticket_evidence: ticketEvidence,
|
||||
ticket_scope: ticketScope,
|
||||
rmm_evidence: rmmEvidence,
|
||||
};
|
||||
}
|
||||
|
||||
// Configurations
|
||||
const config = await loadConfiguration(assetId);
|
||||
const typeId = config.type_id_str;
|
||||
|
||||
const [peerSameClient, peerGlobal, fillRateClient, fillRateGlobal, rmmEvidence] =
|
||||
await Promise.all([
|
||||
loadConfigPeerSameClient(config.organization_id, typeId, config.id, PEER_SAME_CLIENT_LIMIT),
|
||||
loadConfigPeerGlobal(typeId, config.id, config.organization_id, PEER_GLOBAL_LIMIT),
|
||||
loadConfigFillRates(typeId, config.organization_id),
|
||||
loadConfigFillRates(typeId, null),
|
||||
loadRmmEvidence(config.organization_id, 'configuration', config.id),
|
||||
]);
|
||||
|
||||
let ticketEvidence: TicketEvidenceRow[];
|
||||
let ticketScope: AssetAuditContext['ticket_scope'] = null;
|
||||
if (ticketScopeAnalysisId) {
|
||||
ticketEvidence = await loadSingleTicketEvidence(ticketScopeAnalysisId);
|
||||
if (ticketEvidence.length > 0) {
|
||||
ticketScope = {
|
||||
analysis_id: ticketScopeAnalysisId,
|
||||
ticket_number: ticketEvidence[0].ticket_number,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// For configurations, also try matching on hostname for richer evidence.
|
||||
const matchTerm = config.name ?? (config.traits.hostname as string | null) ?? null;
|
||||
ticketEvidence = await loadTicketEvidence(config.organization_id, matchTerm, TICKET_EVIDENCE_LIMIT);
|
||||
}
|
||||
|
||||
return {
|
||||
asset_type: 'configuration',
|
||||
asset: { ...config, traits: redact(config.traits) },
|
||||
type_id: typeId !== null ? Number(typeId) : null,
|
||||
type_name: config.type_name,
|
||||
fields: CONFIGURATION_FIELDS,
|
||||
peer_same_client: peerSameClient.map((p) => ({ ...p, traits: redact(p.traits) })),
|
||||
peer_global: peerGlobal.map((p) => ({ ...p, traits: redact(p.traits) })),
|
||||
fill_rate_client: fillRateClient,
|
||||
fill_rate_global: fillRateGlobal,
|
||||
ticket_evidence: ticketEvidence,
|
||||
ticket_scope: ticketScope,
|
||||
rmm_evidence: rmmEvidence,
|
||||
};
|
||||
}
|
||||
|
||||
// Exported for tests + observability.
|
||||
export const _ASSET_AUDIT_INTERNALS = {
|
||||
PEER_SAME_CLIENT_LIMIT,
|
||||
PEER_GLOBAL_LIMIT,
|
||||
TICKET_EVIDENCE_LIMIT,
|
||||
CONFIGURATION_FIELDS,
|
||||
fillCount,
|
||||
};
|
||||
451
lib/services/analyzer/asset-audit/persistence.ts
Normal file
451
lib/services/analyzer/asset-audit/persistence.ts
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
/**
|
||||
* 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 };
|
||||
236
lib/services/analyzer/asset-audit/prompt.ts
Normal file
236
lib/services/analyzer/asset-audit/prompt.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
/**
|
||||
* System prompt + user payload builder for the IT Glue asset audit stage.
|
||||
*
|
||||
* The output schema is `AssetAuditResponse` from `lib/types/analyzer.ts` —
|
||||
* field gaps, notes promotions, contradictions, overall score. The prompt
|
||||
* is deliberately tight on what counts as a "gap":
|
||||
* - empty field that other tickets needed → high confidence
|
||||
* - empty field with no ticket evidence → suggested_value: null, low/medium
|
||||
* (LLM may flag as opportunity but with no concrete value)
|
||||
*
|
||||
* Provider-agnostic: the same prompt runs on Claude Sonnet or DeepSeek V4
|
||||
* Pro via callLLMStage. Phase 4.1 supports both Application (flexible
|
||||
* asset) and Configuration audits via assetType-aware prompt selection.
|
||||
*/
|
||||
|
||||
import type { AssetAuditContext } from './data-builder';
|
||||
|
||||
const LIVE_EVIDENCE_NOTE = `When a "LIVE RMM EVIDENCE" section is present, treat its parsed contents as authoritative current state of the environment, captured by remote PowerShell within the last few days. Use it to justify suggested values with high confidence — e.g. if Get-Services lists "BartenderProcessService" running on the target and a ticket asked about BarTender printing, suggest adding that service name to operating_system_notes with confidence=high. Cite execution_id alongside ticket numbers in evidence_ticket_numbers (it's fine to mix them).
|
||||
|
||||
When a "loglift-eventlogs" evidence row is present, the parsed_evidence contains a slim view of a Windows event-log + system-context capture: system_context (OS, hardware, uptime, last boot, pending reboot, memory, disks, recent updates), summary (TotalEvents / CriticalEvents / ByLevel / TimeRange / TopEventIds), and top_events — the highest-severity events sorted Critical → Error → Warning → Information, then most-recent. event_count_total is the original count; top_events is capped at 100. When citing event evidence in evidence_ticket_numbers it's fine to write "event:<EventId>" or "execution:<execution_id>". Do NOT claim "no errors observed" if event_count_total is large — say "of the top events captured" instead. Treat system_context as authoritative for OS / hardware / disk / memory facts on the matched Configuration.
|
||||
`;
|
||||
|
||||
const COMMON_RULES = `${LIVE_EVIDENCE_NOTE}
|
||||
Categorize findings into three buckets:
|
||||
|
||||
1. **field_gaps** — empty or anemic fields that, given the ticket evidence, would have measurably helped a tech diagnose or escalate faster. For each:
|
||||
- field_name: the EXACT field name from the provided schema (do not invent fields)
|
||||
- why_missing_matters: 1 sentence connecting the gap to a real ticket scenario
|
||||
- suggested_value: a concrete value derived from the ticket evidence, or null if you cannot infer one with high confidence
|
||||
- evidence_ticket_numbers: tickets that demonstrate the need
|
||||
- confidence: high (clear evidence + suggestion), medium (clear gap, weaker suggestion), low (opportunity, no concrete value)
|
||||
|
||||
2. **notes_promotions** — substrings of the existing free-text Notes / Operating-System-Notes field that are actually structured data and belong in a dedicated field. For each:
|
||||
- quoted_note_text: the exact substring from the Notes field
|
||||
- target_field: the field where it belongs (must exist in schema)
|
||||
- suggested_value: how the value should look in the structured field
|
||||
- confidence
|
||||
|
||||
3. **contradictions** — places where the record's fields disagree with each other or with ticket evidence (e.g. Notes say "2-3 VMs" but only 1 VM is tagged; OS field says Server 2016 but a ticket recently mentioned PowerShell 7 features).
|
||||
|
||||
Rules:
|
||||
- Never invent fields that aren't in the provided schema.
|
||||
- Never suggest a value you cannot point to evidence for. Use null instead.
|
||||
- Prefer high signal over volume — 3 strong gaps beats 10 generic ones.
|
||||
- The "fill_rate" stats tell you what's normal for this asset type. A field that's empty here but populated >80% of the time elsewhere is a stronger gap than one that's empty 80% of the time across all clients.
|
||||
- DO NOT suggest values for password, secret, key, token, or credential fields — those are out of scope.
|
||||
- overall_score is your 0-1 self-rated assessment of how complete the record is for diagnostic purposes (1 = nothing missing, 0 = empty).
|
||||
|
||||
Respond ONLY with JSON. No prose, no code fences.
|
||||
|
||||
Schema:
|
||||
{
|
||||
"field_gaps": [{"field_name": str, "why_missing_matters": str, "suggested_value": str | null, "evidence_ticket_numbers": str[], "confidence": "high" | "medium" | "low"}],
|
||||
"notes_promotions": [{"quoted_note_text": str, "target_field": str, "suggested_value": str, "confidence": "high" | "medium" | "low"}],
|
||||
"contradictions": [{"description": str, "evidence": str}],
|
||||
"overall_score": number
|
||||
}`;
|
||||
|
||||
const FLEXIBLE_ASSET_PROMPT = `You are auditing an IT Glue **flexible-asset** record (typically an Application) for Wulf Consulting, an MSP. Your job is to identify what should be documented in this record based on (a) the asset type's field schema with hints, (b) what comparable records contain, (c) what tickets actually needed to know.
|
||||
|
||||
${COMMON_RULES}`;
|
||||
|
||||
const CONFIGURATION_PROMPT = `You are auditing an IT Glue **Configuration** record (a server, workstation, network device, etc.) for Wulf Consulting, an MSP. Your job is to identify what should be documented in this record based on (a) the field schema with hints below, (b) what comparable Configuration records contain, (c) what tickets actually needed to know.
|
||||
|
||||
Configuration audits care especially about:
|
||||
- **Hostname / FQDN consistency** — the name field, hostname, and what tickets call the device should agree.
|
||||
- **Operating-system currency** — OS version drives patch posture, support tier, escalation path.
|
||||
- **Named services** — when tickets resolve by restarting or fixing a specific Windows service, that service name should be captured (in operating_system_notes ideally) so the next tech finds it without grepping ticket history.
|
||||
- **Networking facts** — primary IP, MAC, position. If tickets reveal an IP change or a new NIC, surface it.
|
||||
- **Architecture relationships** — which apps run on this server, which integrations flow through it. If the Notes field is the only place this lives, flag a notes_promotion to a more visible field where the schema allows.
|
||||
- **Contact ownership** — workstations should have an end-user contact; servers should have a champion or responsible team.
|
||||
|
||||
The 'name' field is the IT Glue display name. 'hostname' is the technical name on the network. They are often the same; flag when they disagree.
|
||||
|
||||
${COMMON_RULES}`;
|
||||
|
||||
const TICKET_SCOPED_SUFFIX = `
|
||||
|
||||
THIS AUDIT IS SCOPED TO A SINGLE TICKET. The ticket evidence section contains exactly one analysis — the ticket the user just analyzed. Frame your gaps as "what did this ticket teach us that the documentation doesn't say?" rather than all-time history. Cite ticket numbers, not theoretical scenarios.`;
|
||||
|
||||
const PAYLOAD_CHAR_CAP = 80_000;
|
||||
|
||||
export function getSystemPrompt(ctx: AssetAuditContext): string {
|
||||
const base =
|
||||
ctx.asset_type === 'configuration'
|
||||
? CONFIGURATION_PROMPT
|
||||
: FLEXIBLE_ASSET_PROMPT;
|
||||
return ctx.ticket_scope ? base + TICKET_SCOPED_SUFFIX : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the user payload. If we exceed the size cap (rare), trim the
|
||||
* peer_global section first (least-load-bearing), then drop older ticket
|
||||
* evidence one at a time. Schema and fill rates are never dropped — they're
|
||||
* the lookup table the LLM needs to answer correctly.
|
||||
*/
|
||||
export function buildAssetAuditUserPayload(ctx: AssetAuditContext): {
|
||||
payload: string;
|
||||
trimmed: { peer_global_dropped: number; tickets_dropped: number; rmm_dropped: number };
|
||||
} {
|
||||
const trimmed = { peer_global_dropped: 0, tickets_dropped: 0, rmm_dropped: 0 };
|
||||
|
||||
const peerGlobal = ctx.peer_global.slice();
|
||||
const tickets = ctx.ticket_evidence.slice();
|
||||
const rmmEvidence = (ctx.rmm_evidence ?? []).slice();
|
||||
|
||||
const assetTypeLabel =
|
||||
ctx.asset_type === 'configuration'
|
||||
? 'CONFIGURATION'
|
||||
: 'FLEXIBLE ASSET';
|
||||
|
||||
const ticketHeader = ctx.ticket_scope
|
||||
? `=== TICKET EVIDENCE (single ticket — this audit is scoped to ${ctx.ticket_scope.ticket_number}) ===`
|
||||
: `=== TICKET EVIDENCE (this client, recent, mentions of the asset) ===`;
|
||||
|
||||
function render(): string {
|
||||
const sections: string[] = [
|
||||
`=== ${assetTypeLabel} UNDER AUDIT ===`,
|
||||
JSON.stringify(
|
||||
{
|
||||
id: ctx.asset.id,
|
||||
name: ctx.asset.name,
|
||||
organization_name: ctx.asset.organization_name,
|
||||
type_name: ctx.type_name,
|
||||
fields: ctx.asset.traits,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
``,
|
||||
`=== FIELD SCHEMA (with hints) ===`,
|
||||
JSON.stringify(ctx.fields, null, 2),
|
||||
``,
|
||||
`=== FILL-RATE STATS ===`,
|
||||
JSON.stringify(
|
||||
{
|
||||
this_client: ctx.fill_rate_client,
|
||||
across_all_clients: ctx.fill_rate_global,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
``,
|
||||
`=== PEER EXEMPLARS — SAME CLIENT ===`,
|
||||
JSON.stringify(
|
||||
ctx.peer_same_client.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
fields: p.traits,
|
||||
})),
|
||||
null,
|
||||
2
|
||||
),
|
||||
``,
|
||||
`=== PEER EXEMPLARS — BEST-IN-CLASS ACROSS ALL CLIENTS ===`,
|
||||
JSON.stringify(
|
||||
peerGlobal.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
organization_name: p.organization_name,
|
||||
fields: p.traits,
|
||||
})),
|
||||
null,
|
||||
2
|
||||
),
|
||||
];
|
||||
|
||||
if (rmmEvidence.length > 0) {
|
||||
sections.push(
|
||||
``,
|
||||
`=== LIVE RMM EVIDENCE (most recent successful Overshell runs; AUTHORITATIVE current state) ===`,
|
||||
JSON.stringify(
|
||||
rmmEvidence.map((e) => ({
|
||||
execution_id: e.execution_id,
|
||||
script_id: e.script_id,
|
||||
target_type: e.target_type,
|
||||
target_hostname: e.target_hostname,
|
||||
captured_at: e.captured_at,
|
||||
parsed: e.parsed,
|
||||
})),
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
sections.push(
|
||||
``,
|
||||
ticketHeader,
|
||||
JSON.stringify(
|
||||
tickets.map((t) => ({
|
||||
ticket_number: t.ticket_number,
|
||||
triggered_at: t.triggered_at,
|
||||
summary: t.summary,
|
||||
fingerprint: t.fingerprint,
|
||||
})),
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
let payload = render();
|
||||
while (payload.length > PAYLOAD_CHAR_CAP) {
|
||||
// Drop in this priority order: peer_global → ticket_evidence (oldest) →
|
||||
// rmm_evidence (least recent first). RMM evidence drops last because
|
||||
// it's the highest-value live data.
|
||||
if (peerGlobal.length > 0) {
|
||||
peerGlobal.pop();
|
||||
trimmed.peer_global_dropped += 1;
|
||||
} else if (tickets.length > 0) {
|
||||
tickets.shift();
|
||||
trimmed.tickets_dropped += 1;
|
||||
} else if (rmmEvidence.length > 0) {
|
||||
rmmEvidence.pop();
|
||||
trimmed.rmm_dropped += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
payload = render();
|
||||
}
|
||||
return { payload, trimmed };
|
||||
}
|
||||
|
||||
// Back-compat export for tests + any external callers that still import the
|
||||
// flexible-asset prompt directly.
|
||||
export const SYSTEM_PROMPT = FLEXIBLE_ASSET_PROMPT;
|
||||
|
||||
export const _PROMPT_INTERNALS = {
|
||||
PAYLOAD_CHAR_CAP,
|
||||
FLEXIBLE_ASSET_PROMPT,
|
||||
CONFIGURATION_PROMPT,
|
||||
TICKET_SCOPED_SUFFIX,
|
||||
};
|
||||
204
lib/services/analyzer/asset-audit/runner.test.ts
Normal file
204
lib/services/analyzer/asset-audit/runner.test.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { _ASSET_AUDIT_INTERNALS } from './data-builder';
|
||||
import { fieldNameToTraitKey } from './persistence';
|
||||
import { buildAssetAuditUserPayload, _PROMPT_INTERNALS } from './prompt';
|
||||
import { AssetAuditResponse } from '@/lib/types/analyzer';
|
||||
|
||||
describe('fillCount', () => {
|
||||
const { fillCount } = _ASSET_AUDIT_INTERNALS;
|
||||
|
||||
it('counts populated trait keys', () => {
|
||||
expect(fillCount({ a: 'x', b: 'y' })).toBe(2);
|
||||
});
|
||||
|
||||
it('drops empty strings, empty arrays, null, undefined', () => {
|
||||
expect(
|
||||
fillCount({
|
||||
a: '',
|
||||
b: ' ',
|
||||
c: null,
|
||||
d: undefined,
|
||||
e: [],
|
||||
f: 'real',
|
||||
})
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('counts non-empty objects/arrays', () => {
|
||||
expect(fillCount({ a: { values: [1] }, b: [1, 2] })).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 0 for null/undefined input', () => {
|
||||
expect(fillCount(null)).toBe(0);
|
||||
expect(fillCount(undefined)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fieldNameToTraitKey', () => {
|
||||
it('matches IT Glue trait-key convention', () => {
|
||||
expect(fieldNameToTraitKey('Name')).toBe('name');
|
||||
expect(fieldNameToTraitKey('Wulf Application Champion(s)')).toBe(
|
||||
'wulf-application-champion-s'
|
||||
);
|
||||
expect(fieldNameToTraitKey('Application on Device(s)')).toBe(
|
||||
'application-on-device-s'
|
||||
);
|
||||
expect(fieldNameToTraitKey('Client/Server Software Installation Media Location'))
|
||||
.toBe('client-server-software-installation-media-location');
|
||||
});
|
||||
|
||||
it('strips leading/trailing/repeated hyphens', () => {
|
||||
expect(fieldNameToTraitKey(' --Foo-- ')).toBe('foo');
|
||||
expect(fieldNameToTraitKey('A & B')).toBe('a-b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AssetAuditResponse Zod schema', () => {
|
||||
it('accepts a well-formed response', () => {
|
||||
const ok = AssetAuditResponse.safeParse({
|
||||
field_gaps: [
|
||||
{
|
||||
field_name: 'Wulf Application Champion(s)',
|
||||
why_missing_matters: 'Jake Hammel is the SME but not recorded.',
|
||||
suggested_value: 'Jake Hammel',
|
||||
evidence_ticket_numbers: ['T20260502.0033'],
|
||||
confidence: 'high',
|
||||
},
|
||||
],
|
||||
notes_promotions: [
|
||||
{
|
||||
quoted_note_text: 'Per Jake if down send to Steve Cianflone',
|
||||
target_field: 'Vendor Maintenance/Support',
|
||||
suggested_value: 'Steve Cianflone (Kastech)',
|
||||
confidence: 'medium',
|
||||
},
|
||||
],
|
||||
contradictions: [
|
||||
{
|
||||
description: 'Notes say 2-3 VMs, Application-on-Device-s lists 1',
|
||||
evidence: 'Notes field; application-on-device-s.values',
|
||||
},
|
||||
],
|
||||
overall_score: 0.55,
|
||||
});
|
||||
expect(ok.success).toBe(true);
|
||||
});
|
||||
|
||||
it('allows null suggested_value', () => {
|
||||
const ok = AssetAuditResponse.safeParse({
|
||||
field_gaps: [
|
||||
{
|
||||
field_name: 'URL',
|
||||
why_missing_matters: 'Vendor admin console URL not recorded.',
|
||||
suggested_value: null,
|
||||
evidence_ticket_numbers: [],
|
||||
confidence: 'low',
|
||||
},
|
||||
],
|
||||
notes_promotions: [],
|
||||
contradictions: [],
|
||||
overall_score: 0.7,
|
||||
});
|
||||
expect(ok.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects out-of-range overall_score', () => {
|
||||
const bad = AssetAuditResponse.safeParse({
|
||||
field_gaps: [],
|
||||
notes_promotions: [],
|
||||
contradictions: [],
|
||||
overall_score: 1.5,
|
||||
});
|
||||
expect(bad.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects bogus confidence values', () => {
|
||||
const bad = AssetAuditResponse.safeParse({
|
||||
field_gaps: [
|
||||
{
|
||||
field_name: 'X',
|
||||
why_missing_matters: 'y',
|
||||
suggested_value: null,
|
||||
evidence_ticket_numbers: [],
|
||||
confidence: 'sky-high',
|
||||
},
|
||||
],
|
||||
notes_promotions: [],
|
||||
contradictions: [],
|
||||
overall_score: 0.5,
|
||||
});
|
||||
expect(bad.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAssetAuditUserPayload', () => {
|
||||
function makeCtx(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
asset_type: 'flexible_asset',
|
||||
asset: {
|
||||
id: '1',
|
||||
organization_id: '100',
|
||||
organization_name: 'Acme',
|
||||
type_id: '3790',
|
||||
type_name: 'Applications',
|
||||
name: 'MISYS',
|
||||
traits: { name: 'MISYS', version: '6.3' },
|
||||
},
|
||||
type_id: 3790,
|
||||
type_name: 'Applications',
|
||||
fields: [
|
||||
{ id: '1', name: 'Name', kind: 'Text', hint: null, required: true },
|
||||
],
|
||||
peer_same_client: [],
|
||||
peer_global: [],
|
||||
fill_rate_client: [{ field_name: 'Name', fill_rate: 1.0 }],
|
||||
fill_rate_global: [{ field_name: 'Name', fill_rate: 0.95 }],
|
||||
ticket_evidence: [],
|
||||
ticket_scope: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('renders all sections (flexible asset)', () => {
|
||||
const ctx = makeCtx();
|
||||
const { payload, trimmed } = buildAssetAuditUserPayload(ctx as never);
|
||||
expect(payload).toContain('=== FLEXIBLE ASSET UNDER AUDIT ===');
|
||||
expect(payload).toContain('=== FIELD SCHEMA');
|
||||
expect(payload).toContain('=== FILL-RATE STATS ===');
|
||||
expect(payload).toContain('=== PEER EXEMPLARS — SAME CLIENT ===');
|
||||
expect(payload).toContain('=== PEER EXEMPLARS — BEST-IN-CLASS');
|
||||
expect(payload).toContain('=== TICKET EVIDENCE');
|
||||
expect(trimmed.peer_global_dropped).toBe(0);
|
||||
expect(trimmed.tickets_dropped).toBe(0);
|
||||
});
|
||||
|
||||
it('renders configuration header when assetType is configuration', () => {
|
||||
const ctx = makeCtx({ asset_type: 'configuration' });
|
||||
const { payload } = buildAssetAuditUserPayload(ctx as never);
|
||||
expect(payload).toContain('=== CONFIGURATION UNDER AUDIT ===');
|
||||
});
|
||||
|
||||
it('renders ticket-scoped header when ticket_scope is set', () => {
|
||||
const ctx = makeCtx({
|
||||
ticket_scope: { analysis_id: 'a', ticket_number: 'T20260502.0033' },
|
||||
});
|
||||
const { payload } = buildAssetAuditUserPayload(ctx as never);
|
||||
expect(payload).toContain('single ticket');
|
||||
expect(payload).toContain('T20260502.0033');
|
||||
});
|
||||
|
||||
it('trims peer_global before tickets when over the cap', () => {
|
||||
const bigPeer = {
|
||||
id: 'x',
|
||||
organization_id: '99',
|
||||
organization_name: 'Other',
|
||||
type_id: '3790',
|
||||
type_name: 'Applications',
|
||||
name: 'BigPeer',
|
||||
traits: { huge: 'x'.repeat(_PROMPT_INTERNALS.PAYLOAD_CHAR_CAP) },
|
||||
};
|
||||
const ctx = makeCtx({ peer_global: [bigPeer, bigPeer] });
|
||||
const { trimmed } = buildAssetAuditUserPayload(ctx as never);
|
||||
expect(trimmed.peer_global_dropped).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
136
lib/services/analyzer/asset-audit/runner.ts
Normal file
136
lib/services/analyzer/asset-audit/runner.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* Runs one audit against an IT Glue record.
|
||||
*
|
||||
* Phase 4 supported only Application (flexible_asset) audits, all-time
|
||||
* scoped. Phase 4.1 generalizes:
|
||||
* - assetType: 'flexible_asset' | 'configuration'
|
||||
* - ticketScopeAnalysisId: when set, ticket evidence is the single source
|
||||
* analysis only ("ticket-first" mode).
|
||||
*
|
||||
* Flow:
|
||||
* 1. buildAssetAuditContext() — pulls everything; redacted.
|
||||
* 2. callLLMStage() — single shot using the deep-analysis tier model for
|
||||
* the requested provider (Sonnet for Anthropic, V4 Pro for OpenRouter).
|
||||
* 3. insertAssetAudit() — persists the row, including ticket linkage.
|
||||
*/
|
||||
|
||||
import { callLLMStage } from '@/lib/services/llm/call';
|
||||
import {
|
||||
type Provider,
|
||||
stageModelsFor,
|
||||
} from '@/lib/services/llm/models';
|
||||
import { AssetAuditResponse } from '@/lib/types/analyzer';
|
||||
import {
|
||||
buildAssetAuditContext,
|
||||
type AuditAssetType,
|
||||
} from './data-builder';
|
||||
import { getSystemPrompt, buildAssetAuditUserPayload } from './prompt';
|
||||
import {
|
||||
insertAssetAudit,
|
||||
insertFailedAssetAudit,
|
||||
} from './persistence';
|
||||
|
||||
const STAGE_MAX_TOKENS = 8_000;
|
||||
|
||||
export interface RunAssetAuditInput {
|
||||
assetType: AuditAssetType;
|
||||
assetId: number | string;
|
||||
generatedByUserId: string | null;
|
||||
provider?: Provider;
|
||||
/** Phase 4.1: when set, ticket evidence narrows to just this analysis. */
|
||||
ticketScopeAnalysisId?: string;
|
||||
}
|
||||
|
||||
export interface RunAssetAuditResult {
|
||||
auditId: string;
|
||||
status: 'complete' | 'failed';
|
||||
ticketCount: number;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export async function runAssetAudit(
|
||||
input: RunAssetAuditInput
|
||||
): Promise<RunAssetAuditResult> {
|
||||
const provider: Provider = input.provider ?? 'anthropic';
|
||||
const model = stageModelsFor(provider).deep_analysis;
|
||||
|
||||
const ctx = await buildAssetAuditContext({
|
||||
assetType: input.assetType,
|
||||
assetId: input.assetId,
|
||||
ticketScopeAnalysisId: input.ticketScopeAnalysisId,
|
||||
});
|
||||
const ticketCount = ctx.ticket_evidence.length;
|
||||
|
||||
const assetSnapshot = {
|
||||
id: ctx.asset.id,
|
||||
name: ctx.asset.name,
|
||||
organization_id: ctx.asset.organization_id,
|
||||
organization_name: ctx.asset.organization_name,
|
||||
asset_type: ctx.asset_type,
|
||||
type_id: ctx.type_id,
|
||||
type_name: ctx.type_name,
|
||||
fields: ctx.asset.traits,
|
||||
};
|
||||
|
||||
const triggeredByTicketNumber = ctx.ticket_scope?.ticket_number ?? null;
|
||||
const triggeredByAnalysisId = ctx.ticket_scope?.analysis_id ?? null;
|
||||
|
||||
const { payload } = buildAssetAuditUserPayload(ctx);
|
||||
const systemPrompt = getSystemPrompt(ctx);
|
||||
|
||||
try {
|
||||
const result = await callLLMStage({
|
||||
model,
|
||||
system: systemPrompt,
|
||||
user: payload,
|
||||
schema: AssetAuditResponse,
|
||||
maxTokens: STAGE_MAX_TOKENS,
|
||||
});
|
||||
|
||||
const inserted = await insertAssetAudit({
|
||||
asset_type: ctx.asset_type,
|
||||
asset_id: ctx.asset.id,
|
||||
asset_type_id: ctx.type_id,
|
||||
organization_id: ctx.asset.organization_id,
|
||||
generated_by_user_id: input.generatedByUserId,
|
||||
provider,
|
||||
model_used: model,
|
||||
asset_snapshot: assetSnapshot,
|
||||
ticket_count: ticketCount,
|
||||
response: result.data,
|
||||
estimated_cost_usd: result.estimated_cost_usd,
|
||||
total_input_tokens: result.usage.input_tokens,
|
||||
total_output_tokens: result.usage.output_tokens,
|
||||
triggered_by_ticket_number: triggeredByTicketNumber,
|
||||
triggered_by_analysis_id: triggeredByAnalysisId,
|
||||
});
|
||||
|
||||
return {
|
||||
auditId: inserted.id,
|
||||
status: 'complete',
|
||||
ticketCount,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const failed = await insertFailedAssetAudit({
|
||||
asset_type: ctx.asset_type,
|
||||
asset_id: ctx.asset.id,
|
||||
asset_type_id: ctx.type_id,
|
||||
organization_id: ctx.asset.organization_id,
|
||||
generated_by_user_id: input.generatedByUserId,
|
||||
provider,
|
||||
model_used: model,
|
||||
asset_snapshot: assetSnapshot,
|
||||
ticket_count: ticketCount,
|
||||
error_message: message,
|
||||
triggered_by_ticket_number: triggeredByTicketNumber,
|
||||
triggered_by_analysis_id: triggeredByAnalysisId,
|
||||
});
|
||||
return {
|
||||
auditId: failed.id,
|
||||
status: 'failed',
|
||||
ticketCount,
|
||||
errorMessage: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
223
lib/services/analyzer/asset-audit/xrefs.ts
Normal file
223
lib/services/analyzer/asset-audit/xrefs.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
/**
|
||||
* Cross-reference persistence between tickets and IT Glue assets.
|
||||
*
|
||||
* Three relationship types:
|
||||
* - 'referenced' — the analyzer cited this asset/doc when
|
||||
* analyzing the ticket (from
|
||||
* analyzer_analyses.itglue_docs_referenced)
|
||||
* - 'updated' — a ticket-driven audit produced a write to
|
||||
* the asset
|
||||
* - 'should_have_referenced' — a gap text suggests we needed this asset/doc
|
||||
* but didn't find it (reserved for future use;
|
||||
* not populated automatically yet)
|
||||
*
|
||||
* Inserts use ON CONFLICT DO NOTHING against the unique index so re-runs and
|
||||
* idempotent retries don't pollute the table.
|
||||
*/
|
||||
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
export type XrefAssetType = 'flexible_asset' | 'configuration' | 'document';
|
||||
export type XrefRelationship = 'referenced' | 'updated' | 'should_have_referenced';
|
||||
export type XrefSource = 'analyzer_referenced' | 'audit_write' | 'manual';
|
||||
export type XrefConfidence = 'high' | 'medium' | 'low' | null;
|
||||
|
||||
export interface XrefRow {
|
||||
id: string;
|
||||
ticketNumber: string;
|
||||
analysisId: string | null;
|
||||
assetType: XrefAssetType;
|
||||
assetId: string;
|
||||
relationship: XrefRelationship;
|
||||
source: XrefSource;
|
||||
confidence: XrefConfidence;
|
||||
details: unknown;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface RawXrefRow {
|
||||
id: string;
|
||||
ticket_number: string;
|
||||
analysis_id: string | null;
|
||||
asset_type: XrefAssetType;
|
||||
asset_id: string;
|
||||
relationship: XrefRelationship;
|
||||
source: XrefSource;
|
||||
confidence: XrefConfidence;
|
||||
details: unknown;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
const XREF_SELECT = `
|
||||
id::text AS id,
|
||||
ticket_number,
|
||||
analysis_id::text AS analysis_id,
|
||||
asset_type,
|
||||
asset_id::text AS asset_id,
|
||||
relationship, source, confidence,
|
||||
details,
|
||||
created_at
|
||||
`;
|
||||
|
||||
function rowToXref(r: RawXrefRow): XrefRow {
|
||||
return {
|
||||
id: r.id,
|
||||
ticketNumber: r.ticket_number,
|
||||
analysisId: r.analysis_id,
|
||||
assetType: r.asset_type,
|
||||
assetId: r.asset_id,
|
||||
relationship: r.relationship,
|
||||
source: r.source,
|
||||
confidence: r.confidence,
|
||||
details: r.details,
|
||||
createdAt: r.created_at.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Inserts ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface InsertXrefRowInput {
|
||||
ticketNumber: string;
|
||||
analysisId: string | null;
|
||||
assetType: XrefAssetType;
|
||||
assetId: string | number;
|
||||
relationship: XrefRelationship;
|
||||
source: XrefSource;
|
||||
confidence?: XrefConfidence;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
export async function insertXref(input: InsertXrefRowInput): Promise<void> {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO itglue_ticket_xrefs
|
||||
(ticket_number, analysis_id, asset_type, asset_id,
|
||||
relationship, source, confidence, details)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[
|
||||
input.ticketNumber,
|
||||
input.analysisId,
|
||||
input.assetType,
|
||||
input.assetId,
|
||||
input.relationship,
|
||||
input.source,
|
||||
input.confidence ?? null,
|
||||
JSON.stringify(input.details ?? null),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-insert xref rows from an analyzer_analyses.itglue_docs_referenced
|
||||
* payload. Each entry is an ITGlueDocReference: { id, name, url, doc_type,
|
||||
* relevance_reason }. We map doc_type → xref asset_type.
|
||||
*/
|
||||
export interface AnalyzerDocReference {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
url?: string | null;
|
||||
doc_type?: string | null;
|
||||
relevance_reason?: string | null;
|
||||
}
|
||||
|
||||
function mapDocTypeToAssetType(docType: string | null | undefined): XrefAssetType | null {
|
||||
if (!docType) return null;
|
||||
const t = docType.toLowerCase();
|
||||
if (t === 'flexible_asset' || t === 'flexible-asset' || t === 'flex_asset') return 'flexible_asset';
|
||||
if (t === 'configuration') return 'configuration';
|
||||
if (t === 'document') return 'document';
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function insertReferencedXrefsFromAnalysis(input: {
|
||||
ticketNumber: string;
|
||||
analysisId: string;
|
||||
references: AnalyzerDocReference[];
|
||||
}): Promise<{ inserted: number; skipped: number }> {
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
for (const ref of input.references) {
|
||||
const assetType = mapDocTypeToAssetType(ref.doc_type ?? null);
|
||||
if (!assetType) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const numericId = Number(ref.id);
|
||||
if (!Number.isFinite(numericId)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
await insertXref({
|
||||
ticketNumber: input.ticketNumber,
|
||||
analysisId: input.analysisId,
|
||||
assetType,
|
||||
assetId: numericId,
|
||||
relationship: 'referenced',
|
||||
source: 'analyzer_referenced',
|
||||
confidence: 'high',
|
||||
details: {
|
||||
name: ref.name ?? null,
|
||||
url: ref.url ?? null,
|
||||
relevance_reason: ref.relevance_reason ?? null,
|
||||
},
|
||||
});
|
||||
inserted += 1;
|
||||
}
|
||||
return { inserted, skipped };
|
||||
}
|
||||
|
||||
export async function insertUpdatedXref(input: {
|
||||
ticketNumber: string;
|
||||
analysisId: string | null;
|
||||
assetType: 'flexible_asset' | 'configuration';
|
||||
assetId: string | number;
|
||||
writeId: string;
|
||||
fieldName: string;
|
||||
}): Promise<void> {
|
||||
await insertXref({
|
||||
ticketNumber: input.ticketNumber,
|
||||
analysisId: input.analysisId,
|
||||
assetType: input.assetType,
|
||||
assetId: input.assetId,
|
||||
relationship: 'updated',
|
||||
source: 'audit_write',
|
||||
confidence: 'high',
|
||||
details: {
|
||||
write_id: input.writeId,
|
||||
field_name: input.fieldName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Queries ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listXrefsForAsset(
|
||||
assetType: 'flexible_asset' | 'configuration',
|
||||
assetId: string | number,
|
||||
limit = 100
|
||||
): Promise<XrefRow[]> {
|
||||
const res = await postgresClient.query<RawXrefRow>(
|
||||
`SELECT ${XREF_SELECT}
|
||||
FROM itglue_ticket_xrefs
|
||||
WHERE asset_type = $1 AND asset_id = $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3`,
|
||||
[assetType, assetId, limit]
|
||||
);
|
||||
return res.rows.map(rowToXref);
|
||||
}
|
||||
|
||||
export async function listXrefsForTicket(
|
||||
ticketNumber: string,
|
||||
limit = 100
|
||||
): Promise<XrefRow[]> {
|
||||
const res = await postgresClient.query<RawXrefRow>(
|
||||
`SELECT ${XREF_SELECT}
|
||||
FROM itglue_ticket_xrefs
|
||||
WHERE ticket_number = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2`,
|
||||
[ticketNumber, limit]
|
||||
);
|
||||
return res.rows.map(rowToXref);
|
||||
}
|
||||
|
|
@ -42,6 +42,7 @@ interface TicketRow {
|
|||
create_date: Date | string;
|
||||
last_activity_date: Date | string;
|
||||
resolved_date_time: Date | string | null;
|
||||
problem_ticket_id: string | null;
|
||||
}
|
||||
|
||||
interface NoteRow {
|
||||
|
|
@ -112,7 +113,8 @@ export async function loadTicketBundle(
|
|||
AS assignee_email,
|
||||
t.create_date AS create_date,
|
||||
t.last_activity_date AS last_activity_date,
|
||||
t.resolved_date_time AS resolved_date_time
|
||||
t.resolved_date_time AS resolved_date_time,
|
||||
t.problem_ticket_id::text AS problem_ticket_id
|
||||
FROM tickets t
|
||||
WHERE t.ticket_number = $1
|
||||
AND COALESCE(t.is_deleted, false) = false
|
||||
|
|
@ -197,6 +199,7 @@ export async function loadTicketBundle(
|
|||
create_date: toIsoRequired(t.create_date),
|
||||
last_activity_date: toIsoRequired(t.last_activity_date),
|
||||
resolved_date_time: toIso(t.resolved_date_time),
|
||||
problem_ticket_id: t.problem_ticket_id ? Number(t.problem_ticket_id) : null,
|
||||
},
|
||||
notes: notesRes.rows.map((n) => ({
|
||||
id: Number(n.id),
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@
|
|||
"assignee_email": "cimler@wulfconsulting.com",
|
||||
"create_date": "2026-04-24T12:53:50.163Z",
|
||||
"last_activity_date": "2026-04-29T13:09:58.070Z",
|
||||
"resolved_date_time": null
|
||||
"resolved_date_time": null,
|
||||
"problem_ticket_id": null
|
||||
},
|
||||
"notes": [
|
||||
{
|
||||
|
|
|
|||
340
lib/services/analyzer/link-discovery.test.ts
Normal file
340
lib/services/analyzer/link-discovery.test.ts
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
extractExplicitFromText,
|
||||
detectProblemTicket,
|
||||
TICKET_NUMBER_REGEX,
|
||||
MAX_EXPLICIT_LINKS,
|
||||
discoverExplicitLinks,
|
||||
} from './link-discovery';
|
||||
import type { RawTicketBundle } from './preprocessor';
|
||||
|
||||
vi.mock('@/lib/services/postgres-client', () => ({
|
||||
default: {
|
||||
query: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
const mockedQuery = postgresClient.query as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
function bundle(partial: Partial<RawTicketBundle['ticket']> = {}): RawTicketBundle {
|
||||
return {
|
||||
ticket: {
|
||||
id: 1,
|
||||
ticket_number: 'T20260430.0084',
|
||||
title: 'Master problem ticket — Hynes',
|
||||
description: null,
|
||||
status: 1,
|
||||
status_label: 'New',
|
||||
priority: 1,
|
||||
priority_label: 'High',
|
||||
queue_id: null,
|
||||
queue_label: null,
|
||||
company_id: 100,
|
||||
company_name: 'Hynes Industries',
|
||||
contact_id: null,
|
||||
contact_name: null,
|
||||
contact_email: null,
|
||||
assigned_resource_id: null,
|
||||
assignee_name: null,
|
||||
assignee_email: null,
|
||||
create_date: '2026-04-30T12:00:00Z',
|
||||
last_activity_date: '2026-04-30T12:00:00Z',
|
||||
resolved_date_time: null,
|
||||
problem_ticket_id: null,
|
||||
...partial,
|
||||
},
|
||||
notes: [],
|
||||
time_entries: [],
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedQuery.mockReset();
|
||||
});
|
||||
|
||||
describe('TICKET_NUMBER_REGEX', () => {
|
||||
it('matches the canonical Pulse format', () => {
|
||||
const m = 'see T20260430.0084 and T20260427.0142'.match(TICKET_NUMBER_REGEX);
|
||||
expect(m).toEqual(['T20260430.0084', 'T20260427.0142']);
|
||||
});
|
||||
|
||||
it('does not match invalid lengths', () => {
|
||||
expect('T2026.0084'.match(TICKET_NUMBER_REGEX)).toBeNull();
|
||||
expect('T20260430.84'.match(TICKET_NUMBER_REGEX)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractExplicitFromText', () => {
|
||||
it('returns medium-confidence refs from a free-text mention', () => {
|
||||
const r = extractExplicitFromText(
|
||||
'See T20260427.0142 for context.',
|
||||
'note_mention'
|
||||
);
|
||||
expect(r.refs).toEqual([
|
||||
{ ticket_number: 'T20260427.0142', source: 'note_mention', confidence: 'medium' },
|
||||
]);
|
||||
expect(r.hasRelatedTicketsSection).toBe(false);
|
||||
});
|
||||
|
||||
it('marks refs in a RELATED TICKETS: block as high confidence', () => {
|
||||
const text = `Master problem ticket.
|
||||
|
||||
RELATED TICKETS:
|
||||
T20260428.0053 — Allison Leone packet loss (OPEN)
|
||||
T20260427.0142 — George Droder Zoom dropping (OPEN)
|
||||
|
||||
AFFECTED USERS:
|
||||
Allison, George`;
|
||||
const r = extractExplicitFromText(text, 'description_mention');
|
||||
expect(r.hasRelatedTicketsSection).toBe(true);
|
||||
expect(r.refs).toEqual([
|
||||
{
|
||||
ticket_number: 'T20260428.0053',
|
||||
source: 'related_tickets_section',
|
||||
confidence: 'high',
|
||||
},
|
||||
{
|
||||
ticket_number: 'T20260427.0142',
|
||||
source: 'related_tickets_section',
|
||||
confidence: 'high',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not mark refs after the RELATED TICKETS section ends as high', () => {
|
||||
const text = `RELATED TICKETS:
|
||||
T20260428.0053 — first
|
||||
|
||||
OTHER NOTES:
|
||||
Background investigation found T20260101.0001 was a duplicate.`;
|
||||
const r = extractExplicitFromText(text, 'description_mention');
|
||||
const high = r.refs.find((x) => x.ticket_number === 'T20260428.0053');
|
||||
const other = r.refs.find((x) => x.ticket_number === 'T20260101.0001');
|
||||
expect(high?.confidence).toBe('high');
|
||||
expect(other?.confidence).toBe('medium');
|
||||
expect(other?.source).toBe('description_mention');
|
||||
});
|
||||
|
||||
it('dedupes within a single text', () => {
|
||||
const r = extractExplicitFromText(
|
||||
'T20260427.0142 first, T20260427.0142 again, and T20260427.0142 once more.',
|
||||
'note_mention'
|
||||
);
|
||||
expect(r.refs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns empty for empty input', () => {
|
||||
expect(extractExplicitFromText('', 'note_mention').refs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectProblemTicket', () => {
|
||||
it('flags master-problem-ticket title', () => {
|
||||
const r = detectProblemTicket(
|
||||
bundle({ title: 'Master problem ticket — recurring degradation' }),
|
||||
false
|
||||
);
|
||||
expect(r.isProblemTicket).toBe(true);
|
||||
expect(r.signals).toContain('title:master_problem_ticket');
|
||||
});
|
||||
|
||||
it('flags problem-ticket title', () => {
|
||||
const r = detectProblemTicket(
|
||||
bundle({ title: 'Problem ticket: keyboard outage' }),
|
||||
false
|
||||
);
|
||||
expect(r.isProblemTicket).toBe(true);
|
||||
expect(r.signals).toContain('title:problem_ticket');
|
||||
});
|
||||
|
||||
it('flags presence of RELATED TICKETS section', () => {
|
||||
const r = detectProblemTicket(
|
||||
bundle({ title: 'Plain ticket' }),
|
||||
true
|
||||
);
|
||||
expect(r.isProblemTicket).toBe(true);
|
||||
expect(r.signals).toContain('description:related_tickets_section');
|
||||
});
|
||||
|
||||
it('flags problem_ticket_id column', () => {
|
||||
const r = detectProblemTicket(
|
||||
bundle({ title: 'Plain ticket', problem_ticket_id: 999 }),
|
||||
false
|
||||
);
|
||||
expect(r.isProblemTicket).toBe(true);
|
||||
expect(r.signals).toContain('column:problem_ticket_id');
|
||||
});
|
||||
|
||||
it('returns false when none of the signals are present', () => {
|
||||
const r = detectProblemTicket(bundle({ title: 'Plain ticket' }), false);
|
||||
expect(r.isProblemTicket).toBe(false);
|
||||
expect(r.signals).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('discoverExplicitLinks', () => {
|
||||
it('skips self-references and unknown tickets', async () => {
|
||||
const b = bundle({
|
||||
description:
|
||||
'master ref T20260430.0084 (self), real ref T20260428.0053, ghost T20260101.9999',
|
||||
});
|
||||
// First call: meta lookup. Only T20260428.0053 exists.
|
||||
mockedQuery.mockResolvedValueOnce({
|
||||
rowCount: 1,
|
||||
rows: [
|
||||
{
|
||||
ticket_number: 'T20260428.0053',
|
||||
title: 'Allison Leone',
|
||||
status_label: 'Open',
|
||||
last_activity_date: '2026-04-30T10:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
const r = await discoverExplicitLinks(b);
|
||||
expect(r.explicit).toHaveLength(1);
|
||||
expect(r.explicit[0].ticket_number).toBe('T20260428.0053');
|
||||
});
|
||||
|
||||
it('caps explicit refs at MAX_EXPLICIT_LINKS', async () => {
|
||||
const refs = Array.from({ length: 30 }, (_, i) => `T2026010${i}.0001`).join(', ');
|
||||
const b = bundle({ description: `Many refs: ${refs}` });
|
||||
// Return meta for all 15 it queries.
|
||||
mockedQuery.mockImplementationOnce(async (_sql: string, params: unknown[]) => {
|
||||
const numbers = params[0] as string[];
|
||||
expect(numbers.length).toBeLessThanOrEqual(MAX_EXPLICIT_LINKS);
|
||||
return {
|
||||
rowCount: numbers.length,
|
||||
rows: numbers.map((n) => ({
|
||||
ticket_number: n,
|
||||
title: 't',
|
||||
status_label: 'Open',
|
||||
last_activity_date: '2026-04-30T10:00:00Z',
|
||||
})),
|
||||
};
|
||||
});
|
||||
const r = await discoverExplicitLinks(b);
|
||||
expect(r.explicit.length).toBeLessThanOrEqual(MAX_EXPLICIT_LINKS);
|
||||
});
|
||||
|
||||
it('resolves problem_ticket_id and dedupes against text mention', async () => {
|
||||
const b = bundle({
|
||||
description: 'See T20260427.0142 for context',
|
||||
problem_ticket_id: 555,
|
||||
});
|
||||
// Call 1: resolve problem_ticket_id → ticket_number.
|
||||
mockedQuery.mockResolvedValueOnce({
|
||||
rowCount: 1,
|
||||
rows: [{ ticket_number: 'T20260427.0142' }],
|
||||
});
|
||||
// Call 2: meta lookup.
|
||||
mockedQuery.mockResolvedValueOnce({
|
||||
rowCount: 1,
|
||||
rows: [
|
||||
{
|
||||
ticket_number: 'T20260427.0142',
|
||||
title: 'George Droder',
|
||||
status_label: 'Open',
|
||||
last_activity_date: '2026-04-29T10:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
const r = await discoverExplicitLinks(b);
|
||||
// Same ticket from two sources should appear once at the higher confidence.
|
||||
expect(r.explicit).toHaveLength(1);
|
||||
expect(r.explicit[0].confidence).toBe('high');
|
||||
expect(r.explicit[0].source).toBe('problem_ticket_id');
|
||||
});
|
||||
|
||||
it('sorts high confidence first, then by activity date desc', async () => {
|
||||
const b = bundle({
|
||||
description: `Master.
|
||||
|
||||
RELATED TICKETS:
|
||||
T20260428.0053 — high
|
||||
|
||||
Body mention: T20260427.0142 — medium`,
|
||||
});
|
||||
mockedQuery.mockResolvedValueOnce({
|
||||
rowCount: 2,
|
||||
rows: [
|
||||
{
|
||||
ticket_number: 'T20260427.0142',
|
||||
title: 'a',
|
||||
status_label: 'Open',
|
||||
last_activity_date: '2026-04-30T10:00:00Z',
|
||||
},
|
||||
{
|
||||
ticket_number: 'T20260428.0053',
|
||||
title: 'b',
|
||||
status_label: 'Open',
|
||||
last_activity_date: '2026-04-29T10:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
const r = await discoverExplicitLinks(b);
|
||||
expect(r.explicit.map((x) => x.ticket_number)).toEqual([
|
||||
'T20260428.0053',
|
||||
'T20260427.0142',
|
||||
]);
|
||||
expect(r.isProblemTicket).toBe(true);
|
||||
expect(r.problemTicketSignals).toContain('description:related_tickets_section');
|
||||
});
|
||||
|
||||
it('returns empty when there are no refs and no signals', async () => {
|
||||
const b = bundle({ description: 'No ticket refs in here.', title: 'Plain ticket' });
|
||||
mockedQuery.mockResolvedValueOnce({ rowCount: 0, rows: [] });
|
||||
const r = await discoverExplicitLinks(b);
|
||||
expect(r.explicit).toEqual([]);
|
||||
expect(r.isProblemTicket).toBe(false);
|
||||
});
|
||||
|
||||
it('parses refs out of retained notes too, ignoring workflow noise', async () => {
|
||||
const b = bundle({
|
||||
description: null,
|
||||
title: 'Plain',
|
||||
});
|
||||
// Inject a workflow-noise note (filtered) and a real note (kept).
|
||||
b.notes = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Workflow Rule "Foo" fired.',
|
||||
description: 'Mentions T20260101.0001 but should be ignored',
|
||||
note_type: 13,
|
||||
publish: 1,
|
||||
creator_resource_id: 4,
|
||||
creator_name: 'Autotask Administrator',
|
||||
creator_email: null,
|
||||
creator_type: 1,
|
||||
create_date_time: '2026-04-30T12:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Tech note',
|
||||
description: 'See T20260427.0142 for the related issue',
|
||||
note_type: 1,
|
||||
publish: 1,
|
||||
creator_resource_id: 50,
|
||||
creator_name: 'Tech',
|
||||
creator_email: 'tech@wulfconsulting.com',
|
||||
creator_type: 1,
|
||||
create_date_time: '2026-04-30T13:00:00Z',
|
||||
},
|
||||
];
|
||||
mockedQuery.mockResolvedValueOnce({
|
||||
rowCount: 1,
|
||||
rows: [
|
||||
{
|
||||
ticket_number: 'T20260427.0142',
|
||||
title: 'real',
|
||||
status_label: 'Open',
|
||||
last_activity_date: '2026-04-30T10:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
const r = await discoverExplicitLinks(b);
|
||||
expect(r.explicit.map((x) => x.ticket_number)).toEqual(['T20260427.0142']);
|
||||
expect(r.explicit[0].source).toBe('note_mention');
|
||||
});
|
||||
});
|
||||
441
lib/services/analyzer/link-discovery.ts
Normal file
441
lib/services/analyzer/link-discovery.ts
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
/**
|
||||
* Link discovery for the AI Ticket Analyzer.
|
||||
*
|
||||
* Given a ticket bundle (from data-access.loadTicketBundle), find every other
|
||||
* ticket the analyzer should bundle in. Two arms:
|
||||
*
|
||||
* 1. Explicit (cheap, deterministic): regex over the description + each
|
||||
* retained note for ticket-number references, recognition of the
|
||||
* structured "RELATED TICKETS:" block, and the ticket's
|
||||
* problem_ticket_id column. No LLM calls.
|
||||
*
|
||||
* 2. Suggested (Haiku, opt-in): one LLM pass over recent same-company
|
||||
* tickets ranking semantic similarity to the master.
|
||||
*
|
||||
* Returns refs paired with confidence + source so the UI can surface the
|
||||
* provenance of each suggestion.
|
||||
*/
|
||||
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import {
|
||||
type DiscoveredLinks,
|
||||
type LinkConfidence,
|
||||
type LinkSource,
|
||||
type TicketRef,
|
||||
} from '@/lib/types/analyzer';
|
||||
import type { RawTicketBundle } from './preprocessor';
|
||||
import { isWorkflowNoise, isEmailNotification } from './preprocessor';
|
||||
import { callLLMStage } from '@/lib/services/llm/call';
|
||||
import { HAIKU } from '@/lib/services/llm/models';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Pulse ticket-number format: T<YYYYMMDD>.<####>. Confirmed against
|
||||
* tickets.ticket_number in migration 001 and Autotask's webhook payloads.
|
||||
*/
|
||||
export const TICKET_NUMBER_REGEX = /T\d{8}\.\d{4}/g;
|
||||
|
||||
export const MAX_EXPLICIT_LINKS = 15;
|
||||
export const MAX_SUGGESTED_LINKS = 5;
|
||||
const SUGGESTED_CANDIDATE_LIMIT = 50;
|
||||
const SUGGESTED_CANDIDATE_DAYS = 30;
|
||||
const SUGGESTED_DESCRIPTION_CHAR_CAP = 1024;
|
||||
const SUGGESTED_MAX_TOKENS = 1500;
|
||||
|
||||
interface RawRef {
|
||||
ticket_number: string;
|
||||
source: LinkSource;
|
||||
confidence: LinkConfidence;
|
||||
}
|
||||
|
||||
interface ExtractedExplicit {
|
||||
refs: RawRef[];
|
||||
hasRelatedTicketsSection: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull every T-number out of a single chunk of free text. Refs that appear
|
||||
* inside (or directly after) the literal "RELATED TICKETS:" header are flagged
|
||||
* 'high' confidence; others are 'medium'.
|
||||
*/
|
||||
export function extractExplicitFromText(
|
||||
text: string,
|
||||
source: LinkSource
|
||||
): ExtractedExplicit {
|
||||
if (!text) return { refs: [], hasRelatedTicketsSection: false };
|
||||
|
||||
const refs: RawRef[] = [];
|
||||
|
||||
// Detect a "RELATED TICKETS:" block: header line, followed by lines containing
|
||||
// T-numbers, until either an empty line or a new section header (UPPER CASE
|
||||
// followed by colon at start of line).
|
||||
const sectionHeaderMatch = /^[ \t]*RELATED TICKETS\s*:?\s*$/im.exec(text);
|
||||
let sectionRefs = new Set<string>();
|
||||
if (sectionHeaderMatch && sectionHeaderMatch.index !== undefined) {
|
||||
const after = text.slice(
|
||||
sectionHeaderMatch.index + sectionHeaderMatch[0].length
|
||||
);
|
||||
// Lookahead: stop at next blank line, or at a line that looks like a new
|
||||
// ALL-CAPS section header. This is permissive — the format we've seen at
|
||||
// Wulf is `T20260428.0053 — note text\nT20260427.0142 — note text\n\n`.
|
||||
const sectionEnd = after.search(/\n\s*\n|\n[A-Z][A-Z _]+:/);
|
||||
const section = sectionEnd === -1 ? after : after.slice(0, sectionEnd);
|
||||
const matches = section.match(TICKET_NUMBER_REGEX) ?? [];
|
||||
sectionRefs = new Set(matches);
|
||||
}
|
||||
|
||||
const allMatches = text.match(TICKET_NUMBER_REGEX) ?? [];
|
||||
const seen = new Set<string>();
|
||||
for (const num of allMatches) {
|
||||
if (seen.has(num)) continue;
|
||||
seen.add(num);
|
||||
if (sectionRefs.has(num)) {
|
||||
refs.push({
|
||||
ticket_number: num,
|
||||
source: 'related_tickets_section',
|
||||
confidence: 'high',
|
||||
});
|
||||
} else {
|
||||
refs.push({ ticket_number: num, source, confidence: 'medium' });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
refs,
|
||||
hasRelatedTicketsSection: sectionRefs.size > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Look at the title + description for hints that this is a master/problem
|
||||
* ticket. Used purely as a UI signal — does not gate any behavior.
|
||||
*/
|
||||
export function detectProblemTicket(
|
||||
bundle: RawTicketBundle,
|
||||
hasRelatedTicketsSection: boolean
|
||||
): { isProblemTicket: boolean; signals: string[] } {
|
||||
const signals: string[] = [];
|
||||
const title = (bundle.ticket.title ?? '').toLowerCase();
|
||||
if (title.includes('master problem ticket')) signals.push('title:master_problem_ticket');
|
||||
else if (title.includes('problem ticket')) signals.push('title:problem_ticket');
|
||||
if (hasRelatedTicketsSection) signals.push('description:related_tickets_section');
|
||||
if (bundle.ticket.problem_ticket_id !== null) signals.push('column:problem_ticket_id');
|
||||
return { isProblemTicket: signals.length > 0, signals };
|
||||
}
|
||||
|
||||
interface MetaRow {
|
||||
ticket_number: string;
|
||||
title: string | null;
|
||||
status_label: string | null;
|
||||
last_activity_date: Date | string | null;
|
||||
}
|
||||
|
||||
async function loadTicketMeta(
|
||||
ticketNumbers: string[]
|
||||
): Promise<Map<string, MetaRow>> {
|
||||
const out = new Map<string, MetaRow>();
|
||||
if (ticketNumbers.length === 0) return out;
|
||||
const res = await postgresClient.query<{
|
||||
ticket_number: string;
|
||||
title: string | null;
|
||||
status_label: string | null;
|
||||
last_activity_date: Date | string | null;
|
||||
}>(
|
||||
`SELECT t.ticket_number,
|
||||
t.title,
|
||||
(SELECT label FROM statuses WHERE value = t.status) AS status_label,
|
||||
t.last_activity_date
|
||||
FROM tickets t
|
||||
WHERE t.ticket_number = ANY($1::text[])
|
||||
AND COALESCE(t.is_deleted, false) = false`,
|
||||
[ticketNumbers]
|
||||
);
|
||||
for (const r of res.rows) out.set(r.ticket_number, r);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function resolveProblemTicketNumber(
|
||||
problemTicketId: number
|
||||
): Promise<string | null> {
|
||||
const res = await postgresClient.query<{ ticket_number: string }>(
|
||||
`SELECT ticket_number FROM tickets
|
||||
WHERE id = $1 AND COALESCE(is_deleted, false) = false LIMIT 1`,
|
||||
[problemTicketId]
|
||||
);
|
||||
return res.rowCount === 0 ? null : res.rows[0].ticket_number;
|
||||
}
|
||||
|
||||
function toIsoOrNull(d: Date | string | null): string | null {
|
||||
if (d === null || d === undefined) return null;
|
||||
if (d instanceof Date) return d.toISOString();
|
||||
return new Date(d).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a TicketRef list, dedup-merging the same ticket_number across multiple
|
||||
* sources (highest confidence wins; first-seen source is preserved).
|
||||
*/
|
||||
function consolidate(
|
||||
raw: RawRef[],
|
||||
meta: Map<string, MetaRow>
|
||||
): TicketRef[] {
|
||||
const merged = new Map<string, RawRef>();
|
||||
for (const r of raw) {
|
||||
const existing = merged.get(r.ticket_number);
|
||||
if (!existing) {
|
||||
merged.set(r.ticket_number, r);
|
||||
continue;
|
||||
}
|
||||
// Promote to high if any source claims high.
|
||||
if (existing.confidence !== 'high' && r.confidence === 'high') {
|
||||
merged.set(r.ticket_number, r);
|
||||
}
|
||||
}
|
||||
const out: TicketRef[] = [];
|
||||
for (const [num, ref] of merged.entries()) {
|
||||
const m = meta.get(num);
|
||||
if (!m) continue; // not in our local mirror — drop silently
|
||||
out.push({
|
||||
ticket_number: num,
|
||||
title: m.title,
|
||||
status_label: m.status_label,
|
||||
last_activity_date: toIsoOrNull(m.last_activity_date),
|
||||
source: ref.source,
|
||||
confidence: ref.confidence,
|
||||
reason: null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function discoverExplicitLinks(
|
||||
bundle: RawTicketBundle
|
||||
): Promise<{
|
||||
explicit: TicketRef[];
|
||||
isProblemTicket: boolean;
|
||||
problemTicketSignals: string[];
|
||||
}> {
|
||||
const raw: RawRef[] = [];
|
||||
let hasSection = false;
|
||||
|
||||
// 1. Description.
|
||||
if (bundle.ticket.description) {
|
||||
const r = extractExplicitFromText(
|
||||
bundle.ticket.description,
|
||||
'description_mention'
|
||||
);
|
||||
raw.push(...r.refs);
|
||||
if (r.hasRelatedTicketsSection) hasSection = true;
|
||||
}
|
||||
|
||||
// 2. Each retained note (filtered the same way the preprocessor does).
|
||||
for (const note of bundle.notes) {
|
||||
if (isWorkflowNoise(note) || isEmailNotification(note)) continue;
|
||||
if (!note.description) continue;
|
||||
const r = extractExplicitFromText(note.description, 'note_mention');
|
||||
raw.push(...r.refs);
|
||||
if (r.hasRelatedTicketsSection) hasSection = true;
|
||||
}
|
||||
|
||||
// 3. problem_ticket_id column.
|
||||
if (bundle.ticket.problem_ticket_id !== null) {
|
||||
const ptn = await resolveProblemTicketNumber(bundle.ticket.problem_ticket_id);
|
||||
if (ptn) {
|
||||
raw.push({
|
||||
ticket_number: ptn,
|
||||
source: 'problem_ticket_id',
|
||||
confidence: 'high',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Drop self-references.
|
||||
const selfNumber = bundle.ticket.ticket_number;
|
||||
const filteredRaw = raw.filter((r) => r.ticket_number !== selfNumber);
|
||||
|
||||
// 5. Cap and verify against local mirror.
|
||||
const uniqueNumbers = Array.from(
|
||||
new Set(filteredRaw.map((r) => r.ticket_number))
|
||||
).slice(0, MAX_EXPLICIT_LINKS);
|
||||
const meta = await loadTicketMeta(uniqueNumbers);
|
||||
const refsInSet = filteredRaw.filter((r) => uniqueNumbers.includes(r.ticket_number));
|
||||
const explicit = consolidate(refsInSet, meta);
|
||||
|
||||
// 6. Sort: high confidence first, then most-recent activity.
|
||||
explicit.sort((a, b) => {
|
||||
if (a.confidence !== b.confidence) {
|
||||
return a.confidence === 'high' ? -1 : 1;
|
||||
}
|
||||
const at = a.last_activity_date ?? '';
|
||||
const bt = b.last_activity_date ?? '';
|
||||
return bt.localeCompare(at);
|
||||
});
|
||||
|
||||
const ptDetect = detectProblemTicket(bundle, hasSection);
|
||||
return {
|
||||
explicit,
|
||||
isProblemTicket: ptDetect.isProblemTicket,
|
||||
problemTicketSignals: ptDetect.signals,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LLM-suggested arm (Haiku) — opt-in.
|
||||
// =============================================================================
|
||||
|
||||
const SuggestedSchema = z.object({
|
||||
suggestions: z
|
||||
.array(
|
||||
z.object({
|
||||
ticket_number: z.string(),
|
||||
reason: z.string(),
|
||||
})
|
||||
)
|
||||
.max(MAX_SUGGESTED_LINKS),
|
||||
});
|
||||
|
||||
const SUGGEST_SYSTEM_PROMPT = `You are helping decide which other tickets at this MSP client are likely related to a master ticket the user is investigating.
|
||||
|
||||
You will receive:
|
||||
- The master ticket (number, title, first ~1KB of description).
|
||||
- A list of recent same-client tickets with their numbers and titles.
|
||||
|
||||
Return up to ${MAX_SUGGESTED_LINKS} candidate tickets that look semantically related — same affected systems, users, sites, vendors, symptoms, or recurrence patterns. Skip generic alert tickets that aren't clearly related. Skip tickets that share only the client name.
|
||||
|
||||
Respond ONLY with JSON. No prose, no code fences.
|
||||
|
||||
Schema:
|
||||
{ "suggestions": [{ "ticket_number": "T20260430.0084", "reason": "one short sentence" }] }`;
|
||||
|
||||
interface CandidateRow {
|
||||
ticket_number: string;
|
||||
title: string | null;
|
||||
status_label: string | null;
|
||||
last_activity_date: Date | string | null;
|
||||
create_date: Date | string;
|
||||
}
|
||||
|
||||
async function loadSuggestionCandidates(
|
||||
bundle: RawTicketBundle,
|
||||
excludeNumbers: Set<string>
|
||||
): Promise<CandidateRow[]> {
|
||||
const res = await postgresClient.query<CandidateRow>(
|
||||
`SELECT t.ticket_number,
|
||||
t.title,
|
||||
(SELECT label FROM statuses WHERE value = t.status) AS status_label,
|
||||
t.last_activity_date,
|
||||
t.create_date
|
||||
FROM tickets t
|
||||
WHERE t.company_id = $1
|
||||
AND COALESCE(t.is_deleted, false) = false
|
||||
AND t.ticket_number <> $2
|
||||
AND t.create_date >= ($3::timestamp - ($4::int || ' days')::interval)
|
||||
AND t.create_date <= ($3::timestamp + INTERVAL '1 day')
|
||||
ORDER BY t.create_date DESC
|
||||
LIMIT $5`,
|
||||
[
|
||||
bundle.ticket.company_id,
|
||||
bundle.ticket.ticket_number,
|
||||
bundle.ticket.create_date,
|
||||
SUGGESTED_CANDIDATE_DAYS,
|
||||
SUGGESTED_CANDIDATE_LIMIT + excludeNumbers.size,
|
||||
]
|
||||
);
|
||||
return res.rows.filter((r) => !excludeNumbers.has(r.ticket_number)).slice(
|
||||
0,
|
||||
SUGGESTED_CANDIDATE_LIMIT
|
||||
);
|
||||
}
|
||||
|
||||
export async function suggestRelatedLinks(
|
||||
bundle: RawTicketBundle,
|
||||
excludeTicketNumbers: string[]
|
||||
): Promise<TicketRef[]> {
|
||||
const exclude = new Set(excludeTicketNumbers);
|
||||
exclude.add(bundle.ticket.ticket_number);
|
||||
|
||||
const candidates = await loadSuggestionCandidates(bundle, exclude);
|
||||
if (candidates.length === 0) return [];
|
||||
|
||||
const description = (bundle.ticket.description ?? '').slice(
|
||||
0,
|
||||
SUGGESTED_DESCRIPTION_CHAR_CAP
|
||||
);
|
||||
|
||||
const userPayload = [
|
||||
`=== MASTER TICKET ===`,
|
||||
JSON.stringify(
|
||||
{
|
||||
ticket_number: bundle.ticket.ticket_number,
|
||||
title: bundle.ticket.title,
|
||||
description,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
``,
|
||||
`=== CANDIDATE TICKETS (recent same-client, newest first) ===`,
|
||||
JSON.stringify(
|
||||
candidates.map((c) => ({
|
||||
ticket_number: c.ticket_number,
|
||||
title: c.title,
|
||||
})),
|
||||
null,
|
||||
2
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
const result = await callLLMStage({
|
||||
model: HAIKU,
|
||||
system: SUGGEST_SYSTEM_PROMPT,
|
||||
user: userPayload,
|
||||
schema: SuggestedSchema,
|
||||
maxTokens: SUGGESTED_MAX_TOKENS,
|
||||
});
|
||||
|
||||
const candidateMap = new Map(candidates.map((c) => [c.ticket_number, c]));
|
||||
const out: TicketRef[] = [];
|
||||
for (const s of result.data.suggestions) {
|
||||
const c = candidateMap.get(s.ticket_number);
|
||||
if (!c) continue; // hallucination guard — model named a non-candidate
|
||||
if (exclude.has(s.ticket_number)) continue;
|
||||
out.push({
|
||||
ticket_number: s.ticket_number,
|
||||
title: c.title,
|
||||
status_label: c.status_label,
|
||||
last_activity_date: toIsoOrNull(c.last_activity_date),
|
||||
source: 'llm_suggested',
|
||||
confidence: 'medium',
|
||||
reason: s.reason,
|
||||
});
|
||||
if (out.length >= MAX_SUGGESTED_LINKS) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function discoverLinks(
|
||||
bundle: RawTicketBundle,
|
||||
options: { includeSuggested?: boolean } = {}
|
||||
): Promise<DiscoveredLinks> {
|
||||
const explicitResult = await discoverExplicitLinks(bundle);
|
||||
let suggested: TicketRef[] = [];
|
||||
if (options.includeSuggested) {
|
||||
const exclude = explicitResult.explicit.map((r) => r.ticket_number);
|
||||
try {
|
||||
suggested = await suggestRelatedLinks(bundle, exclude);
|
||||
} catch (err) {
|
||||
// Suggestion is opportunistic — never fail the whole call on its
|
||||
// account. Surface the failure to logs only.
|
||||
console.warn(
|
||||
`[ANALYZER-LINKS] suggestion arm failed for ${bundle.ticket.ticket_number}:`,
|
||||
err instanceof Error ? err.message : err
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
explicit: explicitResult.explicit,
|
||||
suggested,
|
||||
isProblemTicket: explicitResult.isProblemTicket,
|
||||
problemTicketSignals: explicitResult.problemTicketSignals,
|
||||
};
|
||||
}
|
||||
|
|
@ -28,6 +28,9 @@ export interface InsertAnalysisInput {
|
|||
/** 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;
|
||||
|
|
@ -49,40 +52,49 @@ export interface InsertAnalysisInput {
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns the next monotonic analysis_version for this ticket. Uses MAX(...)+1
|
||||
* — there is a small race if two workers call this simultaneously, but the
|
||||
* UNIQUE (ticket_number, analysis_version) constraint catches it: the loser
|
||||
* sees a 23505 unique_violation and the worker should retry with a fresh
|
||||
* version number.
|
||||
* 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): Promise<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`,
|
||||
[ticketNumber]
|
||||
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, if any. Used to short-circuit re-runs when the source
|
||||
* data hasn't changed and `force=false`.
|
||||
* 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
|
||||
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]
|
||||
[ticketNumber, contentHash, provider]
|
||||
);
|
||||
if (res.rowCount === 0) return null;
|
||||
const row = res.rows[0];
|
||||
|
|
@ -100,7 +112,8 @@ export async function insertAnalysis(input: InsertAnalysisInput): Promise<{
|
|||
id: string;
|
||||
analysis_version: number;
|
||||
}> {
|
||||
const version = await getNextAnalysisVersion(input.ticket_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;
|
||||
|
||||
|
|
@ -116,7 +129,7 @@ export async function insertAnalysis(input: InsertAnalysisInput): Promise<{
|
|||
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
|
||||
source_snapshot, provider
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3,
|
||||
|
|
@ -128,7 +141,7 @@ export async function insertAnalysis(input: InsertAnalysisInput): Promise<{
|
|||
$18::jsonb, $19, $20, $21,
|
||||
$22, $23, $24::jsonb,
|
||||
$25::jsonb, $26::jsonb, $27, $28,
|
||||
$29::jsonb
|
||||
$29::jsonb, $30
|
||||
)
|
||||
RETURNING id::text AS id
|
||||
`,
|
||||
|
|
@ -164,6 +177,7 @@ export async function insertAnalysis(input: InsertAnalysisInput): Promise<{
|
|||
input.filtered_noise_count,
|
||||
input.error_message ?? null,
|
||||
input.source_snapshot ? JSON.stringify(input.source_snapshot) : null,
|
||||
provider,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -249,6 +263,7 @@ export async function insertFailedAnalysis(input: {
|
|||
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,
|
||||
|
|
@ -267,6 +282,7 @@ export async function insertFailedAnalysis(input: {
|
|||
model_traces: {},
|
||||
source_snapshot: input.source_snapshot,
|
||||
error_message: input.error_message,
|
||||
provider: input.provider,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -282,11 +298,13 @@ 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
|
||||
|
|
@ -298,7 +316,7 @@ export async function claimQueuedJob(): Promise<{
|
|||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING id::text AS id, ticket_number, queued_by_user_id
|
||||
RETURNING id::text AS id, ticket_number, queued_by_user_id, provider
|
||||
`
|
||||
);
|
||||
if (res.rowCount === 0) return null;
|
||||
|
|
@ -365,14 +383,15 @@ export async function failJob(jobId: string, errorMessage: string): Promise<void
|
|||
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)
|
||||
VALUES ($1, $2)
|
||||
`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.ticket_number, input.queued_by_user_id, input.provider ?? 'anthropic']
|
||||
);
|
||||
return { id: res.rows[0].id };
|
||||
}
|
||||
|
|
@ -444,6 +463,7 @@ interface AnalysisRow {
|
|||
itglue_docs_referenced: unknown;
|
||||
filtered_noise_count: number;
|
||||
error_message: string | null;
|
||||
provider: 'anthropic' | 'openrouter';
|
||||
}
|
||||
|
||||
function rowToPersistedAnalysis(r: AnalysisRow): PersistedAnalysis {
|
||||
|
|
@ -483,6 +503,7 @@ function rowToPersistedAnalysis(r: AnalysisRow): PersistedAnalysis {
|
|||
(r.itglue_docs_referenced as PersistedAnalysis['itglueDocsReferenced']) ?? [],
|
||||
filteredNoiseCount: r.filtered_noise_count,
|
||||
errorMessage: r.error_message,
|
||||
provider: r.provider,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -505,7 +526,8 @@ const ANALYSIS_SELECT = `
|
|||
human_review_reasons,
|
||||
itglue_docs_referenced,
|
||||
filtered_noise_count,
|
||||
error_message
|
||||
error_message,
|
||||
provider
|
||||
`;
|
||||
|
||||
export async function getAnalysisById(
|
||||
|
|
@ -522,11 +544,15 @@ export async function getAnalysisById(
|
|||
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 analysis_version DESC`,
|
||||
ORDER BY triggered_at DESC, analysis_version DESC`,
|
||||
[ticketNumber]
|
||||
);
|
||||
return res.rows.map(rowToPersistedAnalysis);
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ import {
|
|||
findExistingAnalysisByContentHash,
|
||||
} from './persistence';
|
||||
import type { TokenUsage } from '@/lib/services/llm/pricing';
|
||||
import {
|
||||
type Provider,
|
||||
stageModelsFor,
|
||||
} from '@/lib/services/llm/models';
|
||||
import type Anthropic from '@anthropic-ai/sdk';
|
||||
|
||||
/**
|
||||
|
|
@ -64,6 +68,8 @@ export interface PipelineInput {
|
|||
force?: boolean;
|
||||
/** Override Stage 4 — useful for tests + cost-conscious operators. */
|
||||
forceSkipOpus?: boolean;
|
||||
/** LLM provider for this run. Defaults to 'anthropic' for back-compat. */
|
||||
provider?: Provider;
|
||||
}
|
||||
|
||||
export interface PipelineRunMeta {
|
||||
|
|
@ -209,6 +215,8 @@ export async function runPipeline(
|
|||
): Promise<PipelineResult> {
|
||||
const itglueSearchFn = deps.itglueSearch ?? itglueSearch;
|
||||
const anthropic = deps.anthropic;
|
||||
const provider: Provider = input.provider ?? 'anthropic';
|
||||
const stageModels = stageModelsFor(provider);
|
||||
|
||||
// ── Stage 0: preprocess ──────────────────────────────────────────────────
|
||||
await callbacks.onStage?.('fetching');
|
||||
|
|
@ -242,7 +250,8 @@ export async function runPipeline(
|
|||
if (!input.force) {
|
||||
const existing = await findExistingAnalysisByContentHash(
|
||||
pre.header.ticket_number,
|
||||
pre.content_hash
|
||||
pre.content_hash,
|
||||
provider
|
||||
);
|
||||
if (existing) {
|
||||
return {
|
||||
|
|
@ -264,27 +273,28 @@ export async function runPipeline(
|
|||
let estimatedCostUsd = 0;
|
||||
const traces: PipelineSuccess['model_traces'] = {};
|
||||
|
||||
// ── Stage 1: Haiku triage ────────────────────────────────────────────────
|
||||
// ── Stage 1: triage ──────────────────────────────────────────────────────
|
||||
await callbacks.onStage?.('triaging');
|
||||
const triageModel = stageModels.triage;
|
||||
const triageResult = await recordedStage(
|
||||
{
|
||||
stage: 'triage',
|
||||
stage_order: 2,
|
||||
model_id: 'claude-haiku-4-5',
|
||||
model_id: triageModel,
|
||||
input_payload: {
|
||||
ticket_number: pre.header.ticket_number,
|
||||
events_count: pre.events.length,
|
||||
filtered_noise_count: pre.counts.filtered_noise,
|
||||
},
|
||||
},
|
||||
() => runTriageStage(pre, anthropic),
|
||||
() => runTriageStage(pre, anthropic, triageModel),
|
||||
callbacks,
|
||||
(r) => r.data
|
||||
);
|
||||
usage = addUsage(usage, triageResult.usage);
|
||||
estimatedCostUsd += triageResult.estimated_cost_usd;
|
||||
traces.triage = {
|
||||
model: 'claude-haiku-4-5',
|
||||
model: triageModel,
|
||||
attempts: triageResult.attempts,
|
||||
input_tokens: triageResult.usage.input_tokens,
|
||||
output_tokens: triageResult.usage.output_tokens,
|
||||
|
|
@ -349,13 +359,14 @@ export async function runPipeline(
|
|||
};
|
||||
}
|
||||
|
||||
// ── Stage 3: Sonnet deep analysis ────────────────────────────────────────
|
||||
// ── Stage 3: deep analysis ───────────────────────────────────────────────
|
||||
await callbacks.onStage?.('analyzing');
|
||||
const deepAnalysisModel = stageModels.deep_analysis;
|
||||
const sonnetResult = await recordedStage(
|
||||
{
|
||||
stage: 'analyze',
|
||||
stage_order: 4,
|
||||
model_id: 'claude-sonnet-4-6',
|
||||
model_id: deepAnalysisModel,
|
||||
input_payload: {
|
||||
ticket_number: pre.header.ticket_number,
|
||||
events_count: pre.events.length,
|
||||
|
|
@ -366,7 +377,8 @@ export async function runPipeline(
|
|||
() =>
|
||||
runDeepAnalysisStage(
|
||||
{ pre, triage: triageResult.data, itglue_docs: itglueDocs },
|
||||
anthropic
|
||||
anthropic,
|
||||
deepAnalysisModel
|
||||
),
|
||||
callbacks,
|
||||
(r) => r.data
|
||||
|
|
@ -374,7 +386,7 @@ export async function runPipeline(
|
|||
usage = addUsage(usage, sonnetResult.usage);
|
||||
estimatedCostUsd += sonnetResult.estimated_cost_usd;
|
||||
traces.deep_analysis = {
|
||||
model: 'claude-sonnet-4-6',
|
||||
model: deepAnalysisModel,
|
||||
attempts: sonnetResult.attempts,
|
||||
input_tokens: sonnetResult.usage.input_tokens,
|
||||
output_tokens: sonnetResult.usage.output_tokens,
|
||||
|
|
@ -410,11 +422,12 @@ export async function runPipeline(
|
|||
};
|
||||
} else {
|
||||
await callbacks.onStage?.('deep_review');
|
||||
const deepReasoningModel = stageModels.deep_reasoning;
|
||||
const opusResult = await recordedStage(
|
||||
{
|
||||
stage: 'deep_review',
|
||||
stage_order: 5,
|
||||
model_id: 'claude-opus-4-7',
|
||||
model_id: deepReasoningModel,
|
||||
input_payload: {
|
||||
ticket_number: pre.header.ticket_number,
|
||||
events_count: pre.events.length,
|
||||
|
|
@ -425,7 +438,8 @@ export async function runPipeline(
|
|||
() =>
|
||||
runDeepReasoningStage(
|
||||
{ pre, triage: triageResult.data, sonnet: sonnetResult.data },
|
||||
anthropic
|
||||
anthropic,
|
||||
deepReasoningModel
|
||||
),
|
||||
callbacks,
|
||||
// Per spec: store the FULL Opus response including opus_notes, not
|
||||
|
|
@ -436,7 +450,7 @@ export async function runPipeline(
|
|||
estimatedCostUsd += opusResult.estimated_cost_usd;
|
||||
opusUsed = true;
|
||||
traces.deep_reasoning = {
|
||||
model: 'claude-opus-4-7',
|
||||
model: deepReasoningModel,
|
||||
attempts: opusResult.attempts,
|
||||
input_tokens: opusResult.usage.input_tokens,
|
||||
output_tokens: opusResult.usage.output_tokens,
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export interface RawTicketHeader {
|
|||
create_date: string;
|
||||
last_activity_date: string;
|
||||
resolved_date_time: string | null;
|
||||
problem_ticket_id: number | null;
|
||||
}
|
||||
|
||||
export interface RawTicketNote {
|
||||
|
|
|
|||
|
|
@ -112,8 +112,15 @@ export interface AggregateReduceInput {
|
|||
|
||||
export function selectReduceModel(
|
||||
fingerprintCount: number,
|
||||
forceOpus = false
|
||||
forceOpus = false,
|
||||
provider: 'anthropic' | 'openrouter' = 'anthropic'
|
||||
): ModelId {
|
||||
if (provider === 'openrouter') {
|
||||
// OpenRouter side: V4 Pro for the standard reduce; R1 if forceOpus is
|
||||
// requested (extra reasoning depth, higher cost).
|
||||
if (forceOpus) return 'deepseek/deepseek-r1-0528';
|
||||
return 'deepseek/deepseek-v4-pro';
|
||||
}
|
||||
if (forceOpus) return OPUS;
|
||||
// Per spec D.6: Sonnet up to 100; Opus is opt-in. Above 25, send only the
|
||||
// structured fingerprints (no narrative excerpts) — handled at payload-build time.
|
||||
|
|
@ -153,9 +160,17 @@ function buildUserPayload(input: AggregateReduceInput): string {
|
|||
|
||||
export async function runAggregateReduceStage(
|
||||
input: AggregateReduceInput,
|
||||
options: { forceOpus?: boolean; injectedClient?: Anthropic } = {}
|
||||
options: {
|
||||
forceOpus?: boolean;
|
||||
injectedClient?: Anthropic;
|
||||
provider?: 'anthropic' | 'openrouter';
|
||||
} = {}
|
||||
): Promise<LLMCallResult<AggregateReduceResponse> & { model_used: ModelId }> {
|
||||
const model = selectReduceModel(input.fingerprints.length, options.forceOpus);
|
||||
const model = selectReduceModel(
|
||||
input.fingerprints.length,
|
||||
options.forceOpus,
|
||||
options.provider ?? 'anthropic'
|
||||
);
|
||||
const result = await callLLMStage({
|
||||
model,
|
||||
system: SYSTEM_PROMPT,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
import { TriageResponse, type PreprocessedTicket, type TaggedEvent } from '@/lib/types/analyzer';
|
||||
import { callLLMStage, type LLMCallResult } from '@/lib/services/llm/call';
|
||||
import { HAIKU } from '@/lib/services/llm/models';
|
||||
import { HAIKU, type ModelId } from '@/lib/services/llm/models';
|
||||
import type Anthropic from '@anthropic-ai/sdk';
|
||||
|
||||
const STAGE1_MAX_TOKENS = 4_000;
|
||||
|
|
@ -109,12 +109,13 @@ export interface TriageStageResult extends LLMCallResult<TriageResponse> {
|
|||
|
||||
export async function runTriageStage(
|
||||
pre: PreprocessedTicket,
|
||||
injectedClient?: Anthropic
|
||||
injectedClient?: Anthropic,
|
||||
modelOverride?: ModelId
|
||||
): Promise<TriageStageResult> {
|
||||
const { payload, events_dropped } = buildTriageUserPayload(pre);
|
||||
|
||||
const result = await callLLMStage({
|
||||
model: HAIKU,
|
||||
model: modelOverride ?? HAIKU,
|
||||
system: SYSTEM_PROMPT,
|
||||
user: payload,
|
||||
schema: TriageResponse,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
type TriageResponse,
|
||||
} from '@/lib/types/analyzer';
|
||||
import { callLLMStage, type LLMCallResult } from '@/lib/services/llm/call';
|
||||
import { SONNET } from '@/lib/services/llm/models';
|
||||
import { SONNET, type ModelId } from '@/lib/services/llm/models';
|
||||
import type { RedactedDoc } from '@/lib/services/analyzer/itglue-search';
|
||||
import type Anthropic from '@anthropic-ai/sdk';
|
||||
|
||||
|
|
@ -171,11 +171,12 @@ export interface DeepAnalysisStageResult extends LLMCallResult<DeepAnalysisRespo
|
|||
|
||||
export async function runDeepAnalysisStage(
|
||||
input: DeepAnalysisInput,
|
||||
injectedClient?: Anthropic
|
||||
injectedClient?: Anthropic,
|
||||
modelOverride?: ModelId
|
||||
): Promise<DeepAnalysisStageResult> {
|
||||
const { payload, events_dropped } = buildDeepAnalysisUserPayload(input);
|
||||
const result = await callLLMStage({
|
||||
model: SONNET,
|
||||
model: modelOverride ?? SONNET,
|
||||
system: SYSTEM_PROMPT,
|
||||
user: payload,
|
||||
schema: DeepAnalysisResponse,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
type TriageResponse,
|
||||
} from '@/lib/types/analyzer';
|
||||
import { callLLMStage, type LLMCallResult } from '@/lib/services/llm/call';
|
||||
import { OPUS } from '@/lib/services/llm/models';
|
||||
import { OPUS, type ModelId } from '@/lib/services/llm/models';
|
||||
import type Anthropic from '@anthropic-ai/sdk';
|
||||
|
||||
const STAGE4_MAX_TOKENS = 16_000;
|
||||
|
|
@ -129,11 +129,12 @@ export interface DeepReasoningStageResult extends LLMCallResult<OpusResponse> {
|
|||
|
||||
export async function runDeepReasoningStage(
|
||||
input: DeepReasoningInput,
|
||||
injectedClient?: Anthropic
|
||||
injectedClient?: Anthropic,
|
||||
modelOverride?: ModelId
|
||||
): Promise<DeepReasoningStageResult> {
|
||||
const { payload, events_dropped } = buildDeepReasoningUserPayload(input);
|
||||
const result = await callLLMStage({
|
||||
model: OPUS,
|
||||
model: modelOverride ?? OPUS,
|
||||
system: SYSTEM_PROMPT,
|
||||
user: payload,
|
||||
schema: OpusResponse,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
type TriageResponse,
|
||||
} from '@/lib/types/analyzer';
|
||||
import { callLLMStage, type LLMCallResult } from '@/lib/services/llm/call';
|
||||
import { HAIKU } from '@/lib/services/llm/models';
|
||||
import { HAIKU, type ModelId } from '@/lib/services/llm/models';
|
||||
import type Anthropic from '@anthropic-ai/sdk';
|
||||
|
||||
const STAGE6_MAX_TOKENS = 4_000;
|
||||
|
|
@ -109,10 +109,12 @@ export function buildFingerprintUserPayload(input: FingerprintInput): string {
|
|||
|
||||
export async function runFingerprintStage(
|
||||
input: FingerprintInput,
|
||||
injectedClient?: Anthropic
|
||||
injectedClient?: Anthropic,
|
||||
modelOverride?: ModelId
|
||||
): Promise<LLMCallResult<AggregateFingerprint>> {
|
||||
const model = modelOverride ?? HAIKU;
|
||||
const result = await callLLMStage({
|
||||
model: HAIKU,
|
||||
model,
|
||||
system: SYSTEM_PROMPT,
|
||||
user: buildFingerprintUserPayload(input),
|
||||
schema: AggregateFingerprint,
|
||||
|
|
@ -126,7 +128,7 @@ export async function runFingerprintStage(
|
|||
...result,
|
||||
data: {
|
||||
...result.data,
|
||||
generated_by_model: HAIKU,
|
||||
generated_by_model: model,
|
||||
generated_at: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -27,7 +27,12 @@ import {
|
|||
import { loadTicketBundle, TicketNotFoundError } from './data-access';
|
||||
import { runPipeline, type PipelineResult } from './pipeline';
|
||||
import { runFingerprintStage } from './stages/stage6-fingerprint';
|
||||
import { HAIKU } from '@/lib/services/llm/models';
|
||||
import {
|
||||
chainTriggerForCompletedAnalysis,
|
||||
runAggregateReport,
|
||||
} from './aggregate-persistence';
|
||||
import { insertReferencedXrefsFromAnalysis } from './asset-audit/xrefs';
|
||||
import { stageModelsFor, type Provider } from '@/lib/services/llm/models';
|
||||
import type {
|
||||
PreprocessedTicket,
|
||||
StageExecutionRecord,
|
||||
|
|
@ -89,7 +94,12 @@ class AnalyzerWorker {
|
|||
try {
|
||||
const claimed = await claimQueuedJob();
|
||||
if (claimed) {
|
||||
await this.runJob(claimed.id, claimed.ticket_number, claimed.queued_by_user_id);
|
||||
await this.runJob(
|
||||
claimed.id,
|
||||
claimed.ticket_number,
|
||||
claimed.queued_by_user_id,
|
||||
claimed.provider
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ANALYZER-WORKER] poll error:', err);
|
||||
|
|
@ -106,7 +116,8 @@ class AnalyzerWorker {
|
|||
async runJob(
|
||||
jobId: string,
|
||||
ticketNumber: string,
|
||||
triggeredByUserId: string | null
|
||||
triggeredByUserId: string | null,
|
||||
provider: Provider = 'anthropic'
|
||||
): Promise<{ analysis_id: string | null; outcome: PipelineResult['outcome'] | 'failed' }> {
|
||||
// Phase 2: collect per-stage records as the pipeline runs, plus the
|
||||
// preprocessed bundle, so we can persist a failed analyzer_analyses row
|
||||
|
|
@ -118,7 +129,7 @@ class AnalyzerWorker {
|
|||
const bundle = await loadTicketBundle(ticketNumber);
|
||||
|
||||
const result = await runPipeline(
|
||||
{ bundle, force: false },
|
||||
{ bundle, force: false, provider },
|
||||
{},
|
||||
{
|
||||
onStage: (stage) => updateJobStatus(jobId, stage),
|
||||
|
|
@ -134,6 +145,30 @@ class AnalyzerWorker {
|
|||
if (result.outcome === 'idempotent_short_circuit') {
|
||||
// Point the job at the existing analysis so the UI can navigate to it.
|
||||
await completeJob(jobId, result.existing_analysis_id);
|
||||
|
||||
// Chain-trigger may need to run here too: the bundle endpoint queues
|
||||
// jobs for tickets whose content hash didn't match a complete row, but
|
||||
// a parallel analysis may have completed between then and now.
|
||||
try {
|
||||
const chain = await chainTriggerForCompletedAnalysis(
|
||||
ticketNumber,
|
||||
result.existing_analysis_id
|
||||
);
|
||||
for (const reportId of chain.readyReportIds) {
|
||||
void runAggregateReport(reportId).catch((err) => {
|
||||
console.error(
|
||||
`[ANALYZER-WORKER] aggregate report ${reportId} runner threw:`,
|
||||
err
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (chainErr) {
|
||||
console.error(
|
||||
'[ANALYZER-WORKER] chain-trigger (short-circuit) failed:',
|
||||
chainErr
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
analysis_id: result.existing_analysis_id,
|
||||
outcome: 'idempotent_short_circuit',
|
||||
|
|
@ -146,6 +181,7 @@ class AnalyzerWorker {
|
|||
content_hash: result.pre.content_hash,
|
||||
triggered_by_user_id: triggeredByUserId,
|
||||
status: 'complete',
|
||||
provider,
|
||||
haiku_used: result.meta.haiku_used,
|
||||
sonnet_used: result.meta.sonnet_used,
|
||||
opus_used: result.meta.opus_used,
|
||||
|
|
@ -159,17 +195,22 @@ class AnalyzerWorker {
|
|||
});
|
||||
|
||||
// Stage 6 — fingerprint. Failure-tolerant: log and continue.
|
||||
const fingerprintModel = stageModelsFor(provider).fingerprint;
|
||||
const fpStart = new Date();
|
||||
let fpInputTokens: number | null = null;
|
||||
let fpOutputTokens: number | null = null;
|
||||
let fpOutput: unknown = {};
|
||||
let fpErr: Error | null = null;
|
||||
try {
|
||||
const fp = await runFingerprintStage({
|
||||
triage: result.triage_response,
|
||||
sonnet: result.sonnet_response,
|
||||
opus: result.opus_response,
|
||||
});
|
||||
const fp = await runFingerprintStage(
|
||||
{
|
||||
triage: result.triage_response,
|
||||
sonnet: result.sonnet_response,
|
||||
opus: result.opus_response,
|
||||
},
|
||||
undefined,
|
||||
fingerprintModel
|
||||
);
|
||||
fpInputTokens = fp.usage.input_tokens;
|
||||
fpOutputTokens = fp.usage.output_tokens;
|
||||
fpOutput = fp.data;
|
||||
|
|
@ -184,7 +225,7 @@ class AnalyzerWorker {
|
|||
stageRecords.push({
|
||||
stage: 'fingerprint',
|
||||
stage_order: 6,
|
||||
model_id: HAIKU,
|
||||
model_id: fingerprintModel,
|
||||
input_payload: {
|
||||
triage_category: result.triage_response.category,
|
||||
ticket_number: result.pre.header.ticket_number,
|
||||
|
|
@ -202,6 +243,53 @@ class AnalyzerWorker {
|
|||
await bulkInsertStageExecutions(inserted.id, stageRecords);
|
||||
|
||||
await completeJob(jobId, inserted.id);
|
||||
|
||||
// Phase 4.1: ingest xref rows for every IT Glue doc the analyzer cited.
|
||||
// Best-effort; never fail the job on xref failure.
|
||||
try {
|
||||
const refs = result.analysis.itglue_docs_referenced ?? [];
|
||||
if (refs.length > 0) {
|
||||
await insertReferencedXrefsFromAnalysis({
|
||||
ticketNumber: result.pre.header.ticket_number,
|
||||
analysisId: inserted.id,
|
||||
references: refs.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
doc_type: r.doc_type,
|
||||
relevance_reason: r.relevance_reason,
|
||||
})),
|
||||
});
|
||||
}
|
||||
} catch (xrefErr) {
|
||||
console.warn(
|
||||
`[ANALYZER-WORKER] xref ingestion failed for analysis ${inserted.id}:`,
|
||||
xrefErr instanceof Error ? xrefErr.message : xrefErr
|
||||
);
|
||||
}
|
||||
|
||||
// Chain-trigger any pending_analyses bundles waiting on this ticket.
|
||||
// Best-effort: a failure here must not fail the job.
|
||||
try {
|
||||
const chain = await chainTriggerForCompletedAnalysis(
|
||||
result.pre.header.ticket_number,
|
||||
inserted.id
|
||||
);
|
||||
for (const reportId of chain.readyReportIds) {
|
||||
void runAggregateReport(reportId).catch((err) => {
|
||||
console.error(
|
||||
`[ANALYZER-WORKER] aggregate report ${reportId} runner threw:`,
|
||||
err
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (chainErr) {
|
||||
console.error(
|
||||
'[ANALYZER-WORKER] chain-trigger failed (job already complete):',
|
||||
chainErr
|
||||
);
|
||||
}
|
||||
|
||||
return { analysis_id: inserted.id, outcome: 'complete' };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
|
@ -234,6 +322,7 @@ class AnalyzerWorker {
|
|||
haiku_used: stageRecords.some((r) => r.stage === 'triage'),
|
||||
sonnet_used: stageRecords.some((r) => r.stage === 'analyze'),
|
||||
opus_used: stageRecords.some((r) => r.stage === 'deep_review'),
|
||||
provider,
|
||||
});
|
||||
await bulkInsertStageExecutions(failedAnalysis.id, stageRecords);
|
||||
} catch (persistErr) {
|
||||
|
|
|
|||
130
lib/services/b2/client.test.ts
Normal file
130
lib/services/b2/client.test.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
OBJECT_KEY_REGEX,
|
||||
presignDownload,
|
||||
presignUpload,
|
||||
B2InvalidObjectKeyError,
|
||||
type B2Config,
|
||||
_B2_INTERNALS,
|
||||
} from './client';
|
||||
|
||||
const FIXTURE_CFG: B2Config = {
|
||||
keyId: 'AKIA-FIXTURE',
|
||||
secret: 'sec-fixture',
|
||||
bucket: 'wulf-audits',
|
||||
region: 'us-west-002',
|
||||
endpoint: 's3.us-west-002.backblazeb2.com',
|
||||
};
|
||||
|
||||
describe('OBJECT_KEY_REGEX', () => {
|
||||
it('accepts the production shape', () => {
|
||||
expect(
|
||||
OBJECT_KEY_REGEX.test(
|
||||
'ba03268b-5528-4dde-ad76-867523446ecd/unknown-server/eventlogs_20251202_173301.json.gz'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
OBJECT_KEY_REGEX.test(
|
||||
'site_uuid_short/MISYS-SQL/eventlogs_20260502_120000.json.gz'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects path traversal', () => {
|
||||
expect(OBJECT_KEY_REGEX.test('../etc/passwd')).toBe(false);
|
||||
expect(OBJECT_KEY_REGEX.test('site/../../escape/eventlogs_1.json.gz')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects wrong shapes', () => {
|
||||
expect(OBJECT_KEY_REGEX.test('site/host/something.json.gz')).toBe(false); // missing eventlogs_ prefix
|
||||
expect(OBJECT_KEY_REGEX.test('eventlogs_1.json.gz')).toBe(false); // missing prefix dirs
|
||||
expect(OBJECT_KEY_REGEX.test('site/host/eventlogs_1.json')).toBe(false); // missing .gz
|
||||
expect(OBJECT_KEY_REGEX.test('site host/x/eventlogs_1.json.gz')).toBe(false); // space in client id
|
||||
});
|
||||
});
|
||||
|
||||
describe('presignDownload + presignUpload', () => {
|
||||
const realDate = Date;
|
||||
beforeEach(() => {
|
||||
// Pin time so signatures are deterministic.
|
||||
const fixed = new Date('2026-05-02T20:00:00.000Z');
|
||||
vi.stubGlobal(
|
||||
'Date',
|
||||
class extends realDate {
|
||||
constructor(...args: unknown[]) {
|
||||
if (args.length === 0) {
|
||||
super(fixed.getTime());
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
super(...(args as [any]));
|
||||
}
|
||||
}
|
||||
static now() {
|
||||
return fixed.getTime();
|
||||
}
|
||||
} as unknown as DateConstructor
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('produces a stable presigned GET URL', () => {
|
||||
const url = presignDownload(
|
||||
'site/host/eventlogs_20260502_120000.json.gz',
|
||||
600,
|
||||
FIXTURE_CFG
|
||||
);
|
||||
expect(url).toContain('https://s3.us-west-002.backblazeb2.com/wulf-audits/');
|
||||
expect(url).toContain('X-Amz-Algorithm=AWS4-HMAC-SHA256');
|
||||
expect(url).toContain('X-Amz-Credential=AKIA-FIXTURE');
|
||||
expect(url).toContain('X-Amz-Date=20260502T200000Z');
|
||||
expect(url).toContain('X-Amz-Expires=600');
|
||||
expect(url).toContain('X-Amz-SignedHeaders=host');
|
||||
expect(url).toMatch(/X-Amz-Signature=[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it('produces a presigned PUT URL with PUT method scope', () => {
|
||||
const url = presignUpload(
|
||||
'site/host/eventlogs_20260502_120000.json.gz',
|
||||
1800,
|
||||
FIXTURE_CFG
|
||||
);
|
||||
expect(url).toContain('X-Amz-Expires=1800');
|
||||
expect(url).toMatch(/X-Amz-Signature=[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it('rejects path-traversal object keys', () => {
|
||||
expect(() =>
|
||||
presignDownload('../etc/eventlogs_1.json.gz', 600, FIXTURE_CFG)
|
||||
).toThrow(B2InvalidObjectKeyError);
|
||||
});
|
||||
|
||||
it('different methods produce different signatures (sanity check)', () => {
|
||||
const get = presignDownload(
|
||||
'site/host/eventlogs_20260502_120000.json.gz',
|
||||
600,
|
||||
FIXTURE_CFG
|
||||
);
|
||||
const put = presignUpload(
|
||||
'site/host/eventlogs_20260502_120000.json.gz',
|
||||
600,
|
||||
FIXTURE_CFG
|
||||
);
|
||||
const sigGet = get.split('X-Amz-Signature=')[1];
|
||||
const sigPut = put.split('X-Amz-Signature=')[1];
|
||||
expect(sigGet).not.toBe(sigPut);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveSigningKey', () => {
|
||||
it('produces a 32-byte HMAC-SHA256 chain', () => {
|
||||
const k = _B2_INTERNALS.deriveSigningKey(
|
||||
'sec-fixture',
|
||||
'20260502',
|
||||
'us-west-002',
|
||||
's3'
|
||||
);
|
||||
expect(k.length).toBe(32);
|
||||
});
|
||||
});
|
||||
211
lib/services/b2/client.ts
Normal file
211
lib/services/b2/client.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
/**
|
||||
* Backblaze B2 client (S3-compatible) for the LogLift evidence pipeline.
|
||||
*
|
||||
* Implements AWS Signature Version 4 presigned URLs (matches the n8n
|
||||
* collector's expectations) for both downloads (Pulse fetching uploaded
|
||||
* payloads) and uploads (Pulse handing the collector a presigned PUT
|
||||
* target so the script doesn't carry credentials).
|
||||
*
|
||||
* Port of the SigV4 implementation from `docs/LogLift Review.json` —
|
||||
* battle-tested in production via the existing n8n flow.
|
||||
*/
|
||||
|
||||
import { createHash, createHmac } from 'crypto';
|
||||
|
||||
export interface B2Config {
|
||||
keyId: string;
|
||||
secret: string;
|
||||
bucket: string;
|
||||
region: string;
|
||||
/** S3-compatible endpoint, e.g. `s3.us-west-002.backblazeb2.com` (no scheme). */
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
/** Hard cap on bytes Pulse will read from a B2 object. */
|
||||
export const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024; // 25 MB
|
||||
|
||||
/**
|
||||
* Object-key shape we accept from inbound webhooks. Path-traversal guard
|
||||
* — must be `{client_id_or_uuid}/{computer_name}/eventlogs_{timestamp}.json.gz`.
|
||||
*/
|
||||
export const OBJECT_KEY_REGEX =
|
||||
/^[A-Za-z0-9_-]+\/[A-Za-z0-9_.-]+\/eventlogs_[0-9_]+\.json\.gz$/;
|
||||
|
||||
export class B2NotConfiguredError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
'Backblaze B2 is not configured. Set B2_KEY_ID + B2_APP_KEY (and optionally B2_BUCKET / B2_REGION / B2_ENDPOINT).'
|
||||
);
|
||||
this.name = 'B2NotConfiguredError';
|
||||
}
|
||||
}
|
||||
|
||||
export class B2InvalidObjectKeyError extends Error {
|
||||
constructor(objectKey: string) {
|
||||
super(`Invalid object key shape: ${objectKey.slice(0, 200)}`);
|
||||
this.name = 'B2InvalidObjectKeyError';
|
||||
}
|
||||
}
|
||||
|
||||
export function isB2Configured(): boolean {
|
||||
return !!(process.env.B2_KEY_ID && process.env.B2_APP_KEY);
|
||||
}
|
||||
|
||||
export function getB2Config(): B2Config {
|
||||
const keyId = process.env.B2_KEY_ID;
|
||||
const secret = process.env.B2_APP_KEY;
|
||||
if (!keyId || !secret) throw new B2NotConfiguredError();
|
||||
return {
|
||||
keyId,
|
||||
secret,
|
||||
bucket: process.env.B2_BUCKET || 'wulf-audits',
|
||||
region: process.env.B2_REGION || 'us-west-002',
|
||||
endpoint: process.env.B2_ENDPOINT || 's3.us-west-002.backblazeb2.com',
|
||||
};
|
||||
}
|
||||
|
||||
function sign(key: Buffer | string, msg: string): Buffer {
|
||||
return createHmac('sha256', key).update(msg, 'utf8').digest();
|
||||
}
|
||||
|
||||
function deriveSigningKey(
|
||||
secret: string,
|
||||
dateStamp: string,
|
||||
region: string,
|
||||
service: string
|
||||
): Buffer {
|
||||
const kDate = sign('AWS4' + secret, dateStamp);
|
||||
const kRegion = sign(kDate, region);
|
||||
const kService = sign(kRegion, service);
|
||||
return sign(kService, 'aws4_request');
|
||||
}
|
||||
|
||||
interface PresignParams {
|
||||
method: 'GET' | 'PUT';
|
||||
objectKey: string;
|
||||
expiresInSeconds: number;
|
||||
config: B2Config;
|
||||
}
|
||||
|
||||
function presign(params: PresignParams): string {
|
||||
const { method, objectKey, expiresInSeconds, config } = params;
|
||||
const host = config.endpoint;
|
||||
// We do NOT URL-encode slashes in the path itself; SigV4 wants the
|
||||
// literal canonical URI with the object key as-is (slashes intact).
|
||||
const canonicalUri = '/' + config.bucket + '/' + objectKey;
|
||||
const algorithm = 'AWS4-HMAC-SHA256';
|
||||
|
||||
const now = new Date();
|
||||
const amzDate = now.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||
const dateStamp = amzDate.slice(0, 8);
|
||||
const credentialScope = `${dateStamp}/${config.region}/s3/aws4_request`;
|
||||
|
||||
const canonicalHeaders = `host:${host}\n`;
|
||||
const signedHeaders = 'host';
|
||||
|
||||
const qs: Record<string, string> = {
|
||||
'X-Amz-Algorithm': algorithm,
|
||||
'X-Amz-Credential': encodeURIComponent(`${config.keyId}/${credentialScope}`),
|
||||
'X-Amz-Date': amzDate,
|
||||
'X-Amz-Expires': String(expiresInSeconds),
|
||||
'X-Amz-SignedHeaders': signedHeaders,
|
||||
};
|
||||
|
||||
const canonicalQueryString = Object.keys(qs)
|
||||
.sort()
|
||||
.map((k) => `${k}=${qs[k]}`)
|
||||
.join('&');
|
||||
|
||||
const payloadHash = 'UNSIGNED-PAYLOAD';
|
||||
|
||||
const canonicalRequest = [
|
||||
method,
|
||||
canonicalUri,
|
||||
canonicalQueryString,
|
||||
canonicalHeaders,
|
||||
signedHeaders,
|
||||
payloadHash,
|
||||
].join('\n');
|
||||
|
||||
const stringToSign = [
|
||||
algorithm,
|
||||
amzDate,
|
||||
credentialScope,
|
||||
createHash('sha256').update(canonicalRequest, 'utf8').digest('hex'),
|
||||
].join('\n');
|
||||
|
||||
const signingKey = deriveSigningKey(config.secret, dateStamp, config.region, 's3');
|
||||
const signature = createHmac('sha256', signingKey)
|
||||
.update(stringToSign, 'utf8')
|
||||
.digest('hex');
|
||||
|
||||
return `https://${host}${canonicalUri}?${canonicalQueryString}&X-Amz-Signature=${signature}`;
|
||||
}
|
||||
|
||||
export function presignDownload(
|
||||
objectKey: string,
|
||||
expiresInSeconds = 600,
|
||||
cfg: B2Config = getB2Config()
|
||||
): string {
|
||||
if (!OBJECT_KEY_REGEX.test(objectKey)) throw new B2InvalidObjectKeyError(objectKey);
|
||||
return presign({ method: 'GET', objectKey, expiresInSeconds, config: cfg });
|
||||
}
|
||||
|
||||
export function presignUpload(
|
||||
objectKey: string,
|
||||
expiresInSeconds = 1800,
|
||||
cfg: B2Config = getB2Config()
|
||||
): string {
|
||||
if (!OBJECT_KEY_REGEX.test(objectKey)) throw new B2InvalidObjectKeyError(objectKey);
|
||||
return presign({ method: 'PUT', objectKey, expiresInSeconds, config: cfg });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a B2 object to a Buffer. Caps at MAX_DOWNLOAD_BYTES — refuses to
|
||||
* read past that even if the server returns more.
|
||||
*/
|
||||
export async function downloadToBuffer(
|
||||
objectKey: string,
|
||||
cfg: B2Config = getB2Config()
|
||||
): Promise<Buffer> {
|
||||
const url = presignDownload(objectKey, 600, cfg);
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`B2 GET ${objectKey} → ${res.status} ${res.statusText}: ${text.slice(0, 300)}`);
|
||||
}
|
||||
// Best-effort content-length check before reading the body.
|
||||
const contentLength = res.headers.get('content-length');
|
||||
if (contentLength && Number(contentLength) > MAX_DOWNLOAD_BYTES) {
|
||||
throw new Error(
|
||||
`B2 object ${objectKey} too large: ${contentLength} bytes (cap ${MAX_DOWNLOAD_BYTES})`
|
||||
);
|
||||
}
|
||||
if (!res.body) {
|
||||
throw new Error(`B2 GET ${objectKey} returned no body`);
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
total += value.byteLength;
|
||||
if (total > MAX_DOWNLOAD_BYTES) {
|
||||
try { await reader.cancel(); } catch { /* ignore */ }
|
||||
throw new Error(
|
||||
`B2 object ${objectKey} exceeded ${MAX_DOWNLOAD_BYTES} bytes mid-stream`
|
||||
);
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
return Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength)));
|
||||
}
|
||||
|
||||
// Test-only exports.
|
||||
export const _B2_INTERNALS = {
|
||||
deriveSigningKey,
|
||||
presign,
|
||||
};
|
||||
|
|
@ -492,6 +492,40 @@ export class DattoRMMClient {
|
|||
return allComponents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a Datto RMM component whose name matches the given regex
|
||||
* (case-insensitive). Returns the first match, or null.
|
||||
*
|
||||
* Generic helper used to auto-discover both the Overshell component
|
||||
* (Phase 4.2) and the LogLift collector (Phase 4.3) without admins
|
||||
* needing to paste UIDs.
|
||||
*/
|
||||
async findComponentByName(
|
||||
pattern: RegExp
|
||||
): Promise<{ uid: string; name: string } | null> {
|
||||
const components = await this.getComponents();
|
||||
for (const c of components) {
|
||||
const name: string =
|
||||
c?.name ?? c?.componentName ?? c?.displayName ?? '';
|
||||
const uid: string = c?.uid ?? c?.componentUid ?? c?.id ?? '';
|
||||
if (!uid) continue;
|
||||
if (pattern.test(name)) {
|
||||
return { uid, name };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Back-compat alias — Phase 4.2 callers expect this name. Defaults to
|
||||
* `/overshell/i`. Equivalent to findComponentByName(/overshell/i).
|
||||
*/
|
||||
async findOvershellComponent(
|
||||
pattern: RegExp = /overshell/i
|
||||
): Promise<{ uid: string; name: string } | null> {
|
||||
return this.findComponentByName(pattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a quick job on a device.
|
||||
* PUT /api/v2/device/{deviceUid}/quickjob
|
||||
|
|
|
|||
249
lib/services/device-link-reconciler.ts
Normal file
249
lib/services/device-link-reconciler.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
/**
|
||||
* Device-link reconciler — links unlinked device_external_ids rows to a
|
||||
* configuration_item. Cascading match strategies, highest confidence first.
|
||||
* Conflicts (multiple matches) are logged for admin review, not auto-merged.
|
||||
*
|
||||
* Wire into sync-scheduler.ts as an hourly cron when ready. Not wired yet —
|
||||
* reviewer should approve the match strategies + conflict policy first.
|
||||
*/
|
||||
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
type LinkConfidence = 'canonical' | 'exact_uid' | 'exact_serial' | 'hostname_in_company' | 'mac' | 'manual';
|
||||
|
||||
interface UnlinkedRow {
|
||||
id: number;
|
||||
source: string;
|
||||
source_id: string;
|
||||
hostname: string | null;
|
||||
serial: string | null;
|
||||
mac: string | null;
|
||||
company_id: number | null;
|
||||
}
|
||||
|
||||
interface MatchCandidate {
|
||||
configuration_item_id: number;
|
||||
link_confidence: LinkConfidence;
|
||||
}
|
||||
|
||||
export interface ReconcileResult {
|
||||
scanned: number;
|
||||
linked: number;
|
||||
conflicts: number;
|
||||
unmatched: number;
|
||||
byConfidence: Record<LinkConfidence, number>;
|
||||
}
|
||||
|
||||
const LINK_CONFIDENCE_RANK: Record<LinkConfidence, number> = {
|
||||
canonical: 100,
|
||||
exact_uid: 90,
|
||||
exact_serial: 80,
|
||||
mac: 70,
|
||||
hostname_in_company: 60,
|
||||
manual: 50,
|
||||
};
|
||||
|
||||
// Common BIOS/inventory placeholder serials that shouldn't be matched on —
|
||||
// hundreds of unrelated CIs share these and any link based on them is noise.
|
||||
const PLACEHOLDER_SERIALS = new Set([
|
||||
'', '0', '1', 'n/a', 'na', 'none', 'null', 'unknown', 'not listed',
|
||||
'not specified', 'not applicable', 'default string', 'to be filled by o.e.m.',
|
||||
'system serial number', 'chassis serial number',
|
||||
'0000000000', '00000000', 'ffffffffffff',
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
]);
|
||||
|
||||
function isPlaceholderSerial(serial: string): boolean {
|
||||
const s = serial.trim().toLowerCase();
|
||||
if (s.length < 4) return true;
|
||||
if (PLACEHOLDER_SERIALS.has(s)) return true;
|
||||
// Strings that are all the same character (e.g. "00000000", "FFFFFFFF").
|
||||
if (/^(.)\1+$/.test(s)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function findBySerial(serial: string): Promise<MatchCandidate[]> {
|
||||
if (isPlaceholderSerial(serial)) return [];
|
||||
const res = await postgresClient.query<{ id: string }>(
|
||||
`SELECT id::text FROM configuration_items
|
||||
WHERE serial_number IS NOT NULL
|
||||
AND serial_number = $1
|
||||
AND (is_deleted IS NULL OR is_deleted = false)`,
|
||||
[serial]
|
||||
);
|
||||
return res.rows.map((r) => ({
|
||||
configuration_item_id: Number(r.id),
|
||||
link_confidence: 'exact_serial' as const,
|
||||
}));
|
||||
}
|
||||
|
||||
async function findByMac(mac: string): Promise<MatchCandidate[]> {
|
||||
const res = await postgresClient.query<{ id: string }>(
|
||||
`SELECT id::text FROM configuration_items
|
||||
WHERE rmm_device_audit_mac_address IS NOT NULL
|
||||
AND LOWER(rmm_device_audit_mac_address) = LOWER($1)
|
||||
AND (is_deleted IS NULL OR is_deleted = false)`,
|
||||
[mac]
|
||||
);
|
||||
return res.rows.map((r) => ({
|
||||
configuration_item_id: Number(r.id),
|
||||
link_confidence: 'mac' as const,
|
||||
}));
|
||||
}
|
||||
|
||||
async function findByHostnameInCompany(
|
||||
hostname: string,
|
||||
companyId: number | null
|
||||
): Promise<MatchCandidate[]> {
|
||||
if (!companyId) return [];
|
||||
const res = await postgresClient.query<{ id: string }>(
|
||||
`SELECT id::text FROM configuration_items
|
||||
WHERE company_id = $1
|
||||
AND reference_title IS NOT NULL
|
||||
AND LOWER(reference_title) = LOWER($2)
|
||||
AND (is_deleted IS NULL OR is_deleted = false)`,
|
||||
[companyId, hostname]
|
||||
);
|
||||
return res.rows.map((r) => ({
|
||||
configuration_item_id: Number(r.id),
|
||||
link_confidence: 'hostname_in_company' as const,
|
||||
}));
|
||||
}
|
||||
|
||||
async function applyLink(
|
||||
rowId: number,
|
||||
configurationItemId: number,
|
||||
confidence: LinkConfidence
|
||||
): Promise<void> {
|
||||
await postgresClient.query(
|
||||
`UPDATE device_external_ids
|
||||
SET configuration_item_id = $2,
|
||||
link_confidence = $3,
|
||||
linked_at = NOW()
|
||||
WHERE id = $1
|
||||
AND configuration_item_id IS NULL`,
|
||||
[rowId, configurationItemId, confidence]
|
||||
);
|
||||
|
||||
// Propagate the new link into endpoint_audits / device_observations that
|
||||
// were anchored only on the tool-side ID (e.g. an IT Glue config) at the
|
||||
// time they were written. Without this they'd stay "unanchored" in the UI.
|
||||
const linked = await postgresClient.query<{
|
||||
source: string;
|
||||
source_id: string;
|
||||
}>(
|
||||
`SELECT source, source_id FROM device_external_ids WHERE id = $1`,
|
||||
[rowId]
|
||||
);
|
||||
const link = linked.rows[0];
|
||||
if (!link) return;
|
||||
|
||||
if (link.source === 'itglue') {
|
||||
await postgresClient.query(
|
||||
`UPDATE endpoint_audits
|
||||
SET configuration_item_id = $1
|
||||
WHERE configuration_item_id IS NULL
|
||||
AND itglue_configuration_id::text = $2`,
|
||||
[configurationItemId, link.source_id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function recordConflict(
|
||||
rowId: number,
|
||||
candidates: MatchCandidate[]
|
||||
): Promise<void> {
|
||||
// Order candidates highest-confidence-first so the admin UI sees the best
|
||||
// match at the top.
|
||||
const ordered = [...candidates].sort(
|
||||
(a, b) => LINK_CONFIDENCE_RANK[b.link_confidence] - LINK_CONFIDENCE_RANK[a.link_confidence]
|
||||
);
|
||||
const ciIds = ordered.map((c) => c.configuration_item_id);
|
||||
const confidences = ordered.map((c) => c.link_confidence);
|
||||
await postgresClient.query(
|
||||
`INSERT INTO device_link_review (device_external_id, candidate_ci_ids, match_confidences)
|
||||
VALUES ($1, $2::bigint[], $3::text[])
|
||||
ON CONFLICT (device_external_id) WHERE resolved_at IS NULL
|
||||
DO UPDATE SET candidate_ci_ids = EXCLUDED.candidate_ci_ids,
|
||||
match_confidences = EXCLUDED.match_confidences,
|
||||
detected_at = NOW()`,
|
||||
[rowId, ciIds, confidences]
|
||||
);
|
||||
}
|
||||
|
||||
function pickBestCandidate(candidates: MatchCandidate[]): MatchCandidate | null {
|
||||
if (candidates.length === 0) return null;
|
||||
const ids = new Set(candidates.map((c) => c.configuration_item_id));
|
||||
if (ids.size > 1) return null; // ambiguous — admin review
|
||||
return candidates.reduce((best, c) =>
|
||||
LINK_CONFIDENCE_RANK[c.link_confidence] > LINK_CONFIDENCE_RANK[best.link_confidence] ? c : best
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one reconciliation pass over unlinked rows. Idempotent — safe to run
|
||||
* repeatedly. Caller should schedule via sync-scheduler.
|
||||
*/
|
||||
export async function reconcileUnlinkedDevices(opts?: {
|
||||
limit?: number;
|
||||
dryRun?: boolean;
|
||||
}): Promise<ReconcileResult> {
|
||||
const limit = opts?.limit ?? 500;
|
||||
const dryRun = opts?.dryRun ?? false;
|
||||
const result: ReconcileResult = {
|
||||
scanned: 0,
|
||||
linked: 0,
|
||||
conflicts: 0,
|
||||
unmatched: 0,
|
||||
byConfidence: {
|
||||
canonical: 0,
|
||||
exact_uid: 0,
|
||||
exact_serial: 0,
|
||||
mac: 0,
|
||||
hostname_in_company: 0,
|
||||
manual: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const unlinked = await postgresClient.query<UnlinkedRow>(
|
||||
`SELECT id, source, source_id, hostname, serial, mac, company_id
|
||||
FROM device_external_ids
|
||||
WHERE configuration_item_id IS NULL
|
||||
ORDER BY last_seen_at DESC NULLS LAST
|
||||
LIMIT $1`,
|
||||
[limit]
|
||||
);
|
||||
|
||||
for (const row of unlinked.rows) {
|
||||
result.scanned += 1;
|
||||
const candidates: MatchCandidate[] = [];
|
||||
if (row.serial) candidates.push(...(await findBySerial(row.serial)));
|
||||
if (row.mac) candidates.push(...(await findByMac(row.mac)));
|
||||
if (row.hostname) candidates.push(...(await findByHostnameInCompany(row.hostname, row.company_id)));
|
||||
|
||||
if (candidates.length === 0) {
|
||||
result.unmatched += 1;
|
||||
continue;
|
||||
}
|
||||
const ids = new Set(candidates.map((c) => c.configuration_item_id));
|
||||
if (ids.size > 1) {
|
||||
result.conflicts += 1;
|
||||
if (!dryRun) {
|
||||
await recordConflict(row.id, candidates);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const best = pickBestCandidate(candidates);
|
||||
if (!best) {
|
||||
result.unmatched += 1;
|
||||
continue;
|
||||
}
|
||||
if (!dryRun) {
|
||||
await applyLink(row.id, best.configuration_item_id, best.link_confidence);
|
||||
}
|
||||
result.linked += 1;
|
||||
result.byConfidence[best.link_confidence] += 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import * as nodemailer from "nodemailer";
|
||||
import type { Gap } from "@/lib/types/analyzer";
|
||||
|
||||
// SMTP configuration from environment variables
|
||||
const smtpConfig = {
|
||||
|
|
@ -13,6 +14,10 @@ const smtpConfig = {
|
|||
|
||||
const fromAddress = process.env.SMTP_FROM || "noreply@example.com";
|
||||
|
||||
// Display-name + address for the From header so recipient mail clients show
|
||||
// "Pulse" rather than the bare mailbox.
|
||||
const FROM_HEADER = { name: "Pulse", address: fromAddress };
|
||||
|
||||
// Create reusable transporter
|
||||
let transporter: nodemailer.Transporter | null = null;
|
||||
|
||||
|
|
@ -23,7 +28,10 @@ function getTransporter(): nodemailer.Transporter {
|
|||
return transporter;
|
||||
}
|
||||
|
||||
// Email templates
|
||||
// =============================================================================
|
||||
// Magic-link sign-in
|
||||
// =============================================================================
|
||||
|
||||
interface MagicLinkEmailParams {
|
||||
email: string;
|
||||
url: string;
|
||||
|
|
@ -44,24 +52,27 @@ export async function sendMagicLinkEmail({
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sign in to Pulse</title>
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px 10px 0 0;">
|
||||
<h1 style="color: white; margin: 0; font-size: 28px;">Pulse</h1>
|
||||
</div>
|
||||
<div style="background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 10px 10px;">
|
||||
<h2 style="color: #333; margin-top: 0;">Sign in to your account</h2>
|
||||
<p>Click the button below to sign in to Pulse. This link will expire in 5 minutes.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${url}" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 14px 28px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block;">
|
||||
Sign in to Pulse
|
||||
</a>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #0f172a; background:#f1f5f9; margin:0; padding:24px;">
|
||||
<div style="max-width: 600px; margin: 0 auto;">
|
||||
<div style="background: #0f172a; padding: 24px 32px; border-radius: 8px 8px 0 0;">
|
||||
<div style="color: #ffffff; font-size: 22px; font-weight: 700; letter-spacing: -0.01em;">Pulse</div>
|
||||
<div style="color: #94a3b8; font-size: 13px; margin-top: 2px;">PSA Management System</div>
|
||||
</div>
|
||||
<div style="background: #ffffff; padding: 32px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;">
|
||||
<h2 style="color: #0f172a; margin: 0 0 12px; font-size: 18px;">Sign in to your account</h2>
|
||||
<p style="margin: 0 0 24px; color:#334155;">Click the button below to sign in. This link will expire in 5 minutes.</p>
|
||||
<div style="text-align: center; margin: 24px 0;">
|
||||
<a href="${url}" style="background: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block; font-size: 14px;">
|
||||
Sign in to Pulse
|
||||
</a>
|
||||
</div>
|
||||
<p style="color: #64748b; font-size: 13px; margin: 0;">If you didn't request this email, you can safely ignore it.</p>
|
||||
<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 24px 0;">
|
||||
<p style="color: #94a3b8; font-size: 12px; margin:0;">
|
||||
If the button doesn't work, copy and paste this link into your browser:<br>
|
||||
<a href="${url}" style="color: #2563eb; word-break: break-all;">${url}</a>
|
||||
</p>
|
||||
</div>
|
||||
<p style="color: #666; font-size: 14px;">If you didn't request this email, you can safely ignore it.</p>
|
||||
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 20px 0;">
|
||||
<p style="color: #999; font-size: 12px;">
|
||||
If the button doesn't work, copy and paste this link into your browser:<br>
|
||||
<a href="${url}" style="color: #667eea; word-break: break-all;">${url}</a>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -78,7 +89,7 @@ If you didn't request this email, you can safely ignore it.
|
|||
`;
|
||||
|
||||
await transport.sendMail({
|
||||
from: fromAddress,
|
||||
from: FROM_HEADER,
|
||||
to: email,
|
||||
subject: "Sign in to Pulse",
|
||||
text,
|
||||
|
|
@ -86,6 +97,10 @@ If you didn't request this email, you can safely ignore it.
|
|||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Invitation
|
||||
// =============================================================================
|
||||
|
||||
interface InvitationEmailParams {
|
||||
email: string;
|
||||
inviterName: string;
|
||||
|
|
@ -107,25 +122,28 @@ export async function sendInvitationEmail({
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>You're invited to Pulse</title>
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px 10px 0 0;">
|
||||
<h1 style="color: white; margin: 0; font-size: 28px;">Pulse</h1>
|
||||
</div>
|
||||
<div style="background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 10px 10px;">
|
||||
<h2 style="color: #333; margin-top: 0;">You're invited!</h2>
|
||||
<p><strong>${inviterName}</strong> has invited you to join Pulse.</p>
|
||||
<p>Click the button below to accept the invitation and set up your account.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${url}" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 14px 28px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block;">
|
||||
Accept Invitation
|
||||
</a>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #0f172a; background:#f1f5f9; margin:0; padding:24px;">
|
||||
<div style="max-width: 600px; margin: 0 auto;">
|
||||
<div style="background: #0f172a; padding: 24px 32px; border-radius: 8px 8px 0 0;">
|
||||
<div style="color: #ffffff; font-size: 22px; font-weight: 700; letter-spacing: -0.01em;">Pulse</div>
|
||||
<div style="color: #94a3b8; font-size: 13px; margin-top: 2px;">PSA Management System</div>
|
||||
</div>
|
||||
<div style="background: #ffffff; padding: 32px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;">
|
||||
<h2 style="color: #0f172a; margin: 0 0 12px; font-size: 18px;">You're invited!</h2>
|
||||
<p style="margin: 0 0 12px; color:#334155;"><strong>${escapeHtml(inviterName)}</strong> has invited you to join Pulse.</p>
|
||||
<p style="margin: 0 0 24px; color:#334155;">Click the button below to accept the invitation and set up your account.</p>
|
||||
<div style="text-align: center; margin: 24px 0;">
|
||||
<a href="${url}" style="background: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block; font-size: 14px;">
|
||||
Accept invitation
|
||||
</a>
|
||||
</div>
|
||||
<p style="color: #64748b; font-size: 13px; margin: 0;">If you weren't expecting this invitation, you can safely ignore this email.</p>
|
||||
<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 24px 0;">
|
||||
<p style="color: #94a3b8; font-size: 12px; margin: 0;">
|
||||
If the button doesn't work, copy and paste this link into your browser:<br>
|
||||
<a href="${url}" style="color: #2563eb; word-break: break-all;">${url}</a>
|
||||
</p>
|
||||
</div>
|
||||
<p style="color: #666; font-size: 14px;">If you weren't expecting this invitation, you can safely ignore this email.</p>
|
||||
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 20px 0;">
|
||||
<p style="color: #999; font-size: 12px;">
|
||||
If the button doesn't work, copy and paste this link into your browser:<br>
|
||||
<a href="${url}" style="color: #667eea; word-break: break-all;">${url}</a>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -144,7 +162,7 @@ If you weren't expecting this invitation, you can safely ignore this email.
|
|||
`;
|
||||
|
||||
await transport.sendMail({
|
||||
from: fromAddress,
|
||||
from: FROM_HEADER,
|
||||
to: email,
|
||||
subject: "You're invited to Pulse",
|
||||
text,
|
||||
|
|
@ -152,14 +170,25 @@ If you weren't expecting this invitation, you can safely ignore this email.
|
|||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Analysis share
|
||||
// =============================================================================
|
||||
|
||||
interface AnalysisShareEmailParams {
|
||||
recipientEmail: string;
|
||||
senderName: string;
|
||||
senderEmail: string;
|
||||
ticketNumber: string;
|
||||
ticketTitle?: string | null;
|
||||
analysisVersion: number;
|
||||
summary: string | null;
|
||||
nextStep: string | null;
|
||||
nextStepRationale?: string | null;
|
||||
whatWasDone?: string[] | null;
|
||||
whatShouldHaveBeenDone?: string[] | null;
|
||||
gaps?: Gap[] | null;
|
||||
confidenceScore?: number | null;
|
||||
modelTier?: "haiku" | "sonnet" | "opus" | null;
|
||||
analysisUrl: string;
|
||||
note?: string;
|
||||
}
|
||||
|
|
@ -173,14 +202,60 @@ function escapeHtml(s: string): string {
|
|||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
const GAP_TONES: Record<Gap["severity"], { border: string; bg: string; label: string }> = {
|
||||
high: { border: "#ef4444", bg: "#fef2f2", label: "HIGH" },
|
||||
medium: { border: "#f59e0b", bg: "#fffbeb", label: "MEDIUM" },
|
||||
low: { border: "#3b82f6", bg: "#eff6ff", label: "LOW" },
|
||||
};
|
||||
|
||||
const MODEL_LABEL: Record<NonNullable<AnalysisShareEmailParams["modelTier"]>, string> = {
|
||||
haiku: "Haiku",
|
||||
sonnet: "Haiku → Sonnet",
|
||||
opus: "Haiku → Sonnet → Opus",
|
||||
};
|
||||
|
||||
function bulletListHtml(items: string[]): string {
|
||||
return `<ul style="margin: 8px 0 16px; padding-left: 20px; color: #334155;">
|
||||
${items
|
||||
.map(
|
||||
(item) =>
|
||||
`<li style="margin: 4px 0;">${escapeHtml(item)}</li>`
|
||||
)
|
||||
.join("")}
|
||||
</ul>`;
|
||||
}
|
||||
|
||||
function gapsHtml(gaps: Gap[]): string {
|
||||
return gaps
|
||||
.map((g) => {
|
||||
const tone = GAP_TONES[g.severity] ?? GAP_TONES.low;
|
||||
return `<div style="border-left: 3px solid ${tone.border}; background: ${tone.bg}; padding: 10px 14px; margin: 8px 0; border-radius: 0 4px 4px 0;">
|
||||
<div style="font-size: 11px; font-weight: 700; letter-spacing: 0.05em; color: ${tone.border}; margin-bottom: 4px;">${tone.label}</div>
|
||||
<div style="color: #0f172a;">${escapeHtml(g.description)}</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function sectionHeaderHtml(title: string): string {
|
||||
return `<h3 style="font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #64748b; margin: 24px 0 8px; padding-bottom: 6px; border-bottom: 1px solid #e2e8f0;">${escapeHtml(title)}</h3>`;
|
||||
}
|
||||
|
||||
export async function sendAnalysisShareEmail({
|
||||
recipientEmail,
|
||||
senderName,
|
||||
senderEmail,
|
||||
ticketNumber,
|
||||
ticketTitle,
|
||||
analysisVersion,
|
||||
summary,
|
||||
nextStep,
|
||||
nextStepRationale,
|
||||
whatWasDone,
|
||||
whatShouldHaveBeenDone,
|
||||
gaps,
|
||||
confidenceScore,
|
||||
modelTier,
|
||||
analysisUrl,
|
||||
note,
|
||||
}: AnalysisShareEmailParams): Promise<void> {
|
||||
|
|
@ -190,19 +265,53 @@ export async function sendAnalysisShareEmail({
|
|||
`Pulse analysis · ${ticketNumber} v${analysisVersion}` +
|
||||
(summary ? ` — ${summary.slice(0, 80)}` : "");
|
||||
|
||||
const summaryHtml = summary
|
||||
? `<p>${escapeHtml(summary)}</p>`
|
||||
: `<p style="color:#999;font-style:italic;">No summary available.</p>`;
|
||||
const nextStepHtml = nextStep
|
||||
? `<h3 style="margin-bottom:4px;">Next step</h3><p>${escapeHtml(nextStep)}</p>`
|
||||
const titleLine = ticketTitle
|
||||
? `<div style="color: #475569; font-size: 14px; margin-top: 4px;">${escapeHtml(ticketTitle)}</div>`
|
||||
: "";
|
||||
const noteHtml = note
|
||||
? `<div style="background:#f6f8fa;border-left:3px solid #667eea;padding:12px 16px;margin:20px 0;">
|
||||
<strong>${escapeHtml(senderName)} added a note:</strong>
|
||||
<p style="margin:8px 0 0;white-space:pre-wrap;">${escapeHtml(note)}</p>
|
||||
|
||||
const summarySection = summary
|
||||
? `${sectionHeaderHtml("Summary")}<p style="color: #0f172a; margin: 0 0 8px; font-size: 15px; line-height: 1.55;">${escapeHtml(summary)}</p>`
|
||||
: "";
|
||||
|
||||
const nextStepSection = nextStep
|
||||
? `${sectionHeaderHtml("Next step")}<p style="color: #0f172a; margin: 0 0 8px; font-weight: 500;">${escapeHtml(nextStep)}</p>${
|
||||
nextStepRationale
|
||||
? `<p style="color: #475569; margin: 4px 0 16px; font-size: 14px;">${escapeHtml(nextStepRationale)}</p>`
|
||||
: ""
|
||||
}`
|
||||
: "";
|
||||
|
||||
const whatWasDoneSection =
|
||||
whatWasDone && whatWasDone.length > 0
|
||||
? `${sectionHeaderHtml("What was done")}${bulletListHtml(whatWasDone)}`
|
||||
: "";
|
||||
|
||||
const whatShouldHaveBeenDoneSection =
|
||||
whatShouldHaveBeenDone && whatShouldHaveBeenDone.length > 0
|
||||
? `${sectionHeaderHtml("What should have been done")}${bulletListHtml(whatShouldHaveBeenDone)}`
|
||||
: "";
|
||||
|
||||
const gapsSection =
|
||||
gaps && gaps.length > 0
|
||||
? `${sectionHeaderHtml("Gaps")}${gapsHtml(gaps)}`
|
||||
: "";
|
||||
|
||||
const noteSection = note
|
||||
? `<div style="background: #f8fafc; border-left: 3px solid #2563eb; padding: 12px 16px; margin: 0 0 24px; border-radius: 0 4px 4px 0;">
|
||||
<div style="font-size: 11px; font-weight: 700; letter-spacing: 0.05em; color: #2563eb; margin-bottom: 4px;">NOTE FROM ${escapeHtml(senderName).toUpperCase()}</div>
|
||||
<div style="color: #0f172a; white-space: pre-wrap;">${escapeHtml(note)}</div>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
const confidenceBadge =
|
||||
typeof confidenceScore === "number"
|
||||
? `<span style="display: inline-block; padding: 2px 8px; border-radius: 999px; background: #f1f5f9; color: #475569; font-size: 11px; font-weight: 600; letter-spacing: 0.02em;">${Math.round(confidenceScore * 100)}% confidence</span>`
|
||||
: "";
|
||||
|
||||
const modelBadge = modelTier
|
||||
? `<span style="color:#64748b; font-size: 12px;">${MODEL_LABEL[modelTier]}</span>`
|
||||
: "";
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
|
@ -211,52 +320,102 @@ export async function sendAnalysisShareEmail({
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${escapeHtml(subjectLine)}</title>
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px 10px 0 0;">
|
||||
<h1 style="color: white; margin: 0; font-size: 28px;">Pulse</h1>
|
||||
<p style="color: rgba(255,255,255,0.9); margin: 4px 0 0;">Ticket analysis · ${escapeHtml(ticketNumber)} · v${analysisVersion}</p>
|
||||
</div>
|
||||
<div style="background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 10px 10px;">
|
||||
<p>${escapeHtml(senderName)} (${escapeHtml(senderEmail)}) shared an analysis with you.</p>
|
||||
${noteHtml}
|
||||
<h3 style="margin-bottom:4px;">Summary</h3>
|
||||
${summaryHtml}
|
||||
${nextStepHtml}
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${analysisUrl}" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 14px 28px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block;">
|
||||
Open analysis in Pulse
|
||||
</a>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #0f172a; background:#f1f5f9; margin:0; padding:24px;">
|
||||
<div style="max-width: 640px; margin: 0 auto;">
|
||||
|
||||
<!-- Header -->
|
||||
<div style="background: #0f172a; padding: 24px 32px; border-radius: 8px 8px 0 0;">
|
||||
<div style="color: #ffffff; font-size: 22px; font-weight: 700; letter-spacing: -0.01em;">Pulse</div>
|
||||
<div style="color: #94a3b8; font-size: 12px; margin-top: 2px;">Ticket analysis</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div style="background: #ffffff; padding: 32px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;">
|
||||
|
||||
<!-- Ticket header -->
|
||||
<div style="margin-bottom: 4px;">
|
||||
<span style="font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 13px; color: #2563eb; font-weight: 600;">${escapeHtml(ticketNumber)}</span>
|
||||
<span style="color: #94a3b8; font-size: 12px; margin-left: 8px;">v${analysisVersion}</span>
|
||||
${confidenceBadge ? `<span style="margin-left: 8px;">${confidenceBadge}</span>` : ""}
|
||||
</div>
|
||||
${titleLine}
|
||||
|
||||
<!-- Sender -->
|
||||
<p style="color: #475569; font-size: 14px; margin: 16px 0 24px;">
|
||||
<strong style="color: #0f172a;">${escapeHtml(senderName)}</strong> shared this with you.
|
||||
</p>
|
||||
|
||||
${noteSection}
|
||||
${summarySection}
|
||||
${nextStepSection}
|
||||
${whatWasDoneSection}
|
||||
${whatShouldHaveBeenDoneSection}
|
||||
${gapsSection}
|
||||
|
||||
<!-- CTA -->
|
||||
<div style="text-align: center; margin: 32px 0 16px;">
|
||||
<a href="${analysisUrl}" style="background: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block; font-size: 14px;">
|
||||
Open full analysis in Pulse
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 24px 0 16px;">
|
||||
<table style="width: 100%; font-size: 12px; color: #94a3b8;">
|
||||
<tr>
|
||||
<td style="padding: 0;">${modelBadge}</td>
|
||||
<td style="padding: 0; text-align: right;">Reply to <a href="mailto:${escapeHtml(senderEmail)}" style="color: #64748b;">${escapeHtml(senderEmail)}</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="color: #94a3b8; font-size: 11px; margin: 12px 0 0; word-break: break-all;">
|
||||
<a href="${analysisUrl}" style="color: #94a3b8;">${analysisUrl}</a>
|
||||
</p>
|
||||
</div>
|
||||
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 20px 0;">
|
||||
<p style="color: #999; font-size: 12px;">
|
||||
If the button doesn't work, copy and paste this link into your browser:<br>
|
||||
<a href="${analysisUrl}" style="color: #667eea; word-break: break-all;">${analysisUrl}</a>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const textParts = [
|
||||
`${senderName} (${senderEmail}) shared a Pulse ticket analysis with you.`,
|
||||
`Ticket: ${ticketNumber} (analysis v${analysisVersion})`,
|
||||
"",
|
||||
];
|
||||
// Plain-text fallback — keep the same content sections so non-HTML clients
|
||||
// see the full analysis, not a truncated nag to "open in browser".
|
||||
const textParts: string[] = [];
|
||||
textParts.push(
|
||||
`${senderName} shared a Pulse ticket analysis with you.`,
|
||||
`Ticket: ${ticketNumber}${ticketTitle ? ` — ${ticketTitle}` : ""} (analysis v${analysisVersion})`,
|
||||
""
|
||||
);
|
||||
if (note) {
|
||||
textParts.push(`Note from ${senderName}:`, note, "");
|
||||
}
|
||||
textParts.push(
|
||||
"Summary:",
|
||||
summary ?? "(no summary available)",
|
||||
""
|
||||
);
|
||||
if (summary) {
|
||||
textParts.push("SUMMARY", summary, "");
|
||||
}
|
||||
if (nextStep) {
|
||||
textParts.push("Next step:", nextStep, "");
|
||||
textParts.push("NEXT STEP", nextStep);
|
||||
if (nextStepRationale) textParts.push(` Rationale: ${nextStepRationale}`);
|
||||
textParts.push("");
|
||||
}
|
||||
if (whatWasDone && whatWasDone.length > 0) {
|
||||
textParts.push("WHAT WAS DONE");
|
||||
whatWasDone.forEach((i) => textParts.push(` - ${i}`));
|
||||
textParts.push("");
|
||||
}
|
||||
if (whatShouldHaveBeenDone && whatShouldHaveBeenDone.length > 0) {
|
||||
textParts.push("WHAT SHOULD HAVE BEEN DONE");
|
||||
whatShouldHaveBeenDone.forEach((i) => textParts.push(` - ${i}`));
|
||||
textParts.push("");
|
||||
}
|
||||
if (gaps && gaps.length > 0) {
|
||||
textParts.push("GAPS");
|
||||
gaps.forEach((g) =>
|
||||
textParts.push(` [${g.severity.toUpperCase()}] ${g.description}`)
|
||||
);
|
||||
textParts.push("");
|
||||
}
|
||||
textParts.push(`Open in Pulse: ${analysisUrl}`);
|
||||
|
||||
await transport.sendMail({
|
||||
from: fromAddress,
|
||||
from: FROM_HEADER,
|
||||
to: recipientEmail,
|
||||
replyTo: senderEmail,
|
||||
subject: subjectLine,
|
||||
|
|
|
|||
159
lib/services/integration-health-alerts.ts
Normal file
159
lib/services/integration-health-alerts.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* Daily integration-health alert job.
|
||||
*
|
||||
* Calls checkIntegrationHealth(), summarizes, and posts an Adaptive Card to
|
||||
* morning-summary-webhooks ONLY when something needs attention. Quiet days
|
||||
* stay quiet — no spam.
|
||||
*/
|
||||
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import {
|
||||
checkIntegrationHealth,
|
||||
summarize,
|
||||
type IntegrationHealth,
|
||||
type HealthSummary,
|
||||
} from '@/lib/services/integration-health';
|
||||
|
||||
interface WebhookRow {
|
||||
id: number;
|
||||
label: string;
|
||||
webhook_url: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface HealthAlertResult {
|
||||
summary: HealthSummary;
|
||||
items: IntegrationHealth[];
|
||||
alertSent: boolean;
|
||||
webhooksDelivered: number;
|
||||
}
|
||||
|
||||
function buildHealthAdaptiveCard(items: IntegrationHealth[], summary: HealthSummary): object {
|
||||
const failed = items.filter((i) => i.status === 'auth_failed' || i.status === 'unreachable');
|
||||
const expired = items.filter(
|
||||
(i) => i.tokenExpiry && i.tokenExpiry.daysRemaining <= 0
|
||||
);
|
||||
const expiringSoon = items.filter(
|
||||
(i) => i.tokenExpiry && i.tokenExpiry.daysRemaining > 0 && i.tokenExpiry.daysRemaining <= 14
|
||||
);
|
||||
|
||||
const facts: Array<{ title: string; value: string }> = [];
|
||||
for (const i of failed) {
|
||||
facts.push({
|
||||
title: i.name,
|
||||
value: `${i.status === 'auth_failed' ? '⚠ AUTH FAILED' : '⚠ UNREACHABLE'} — ${i.error?.slice(0, 120) ?? 'no detail'}`,
|
||||
});
|
||||
}
|
||||
for (const i of expired) {
|
||||
facts.push({
|
||||
title: i.name,
|
||||
value: `🔑 token EXPIRED ${Math.abs(i.tokenExpiry!.daysRemaining).toFixed(0)} days ago (${i.tokenExpiry!.envVar})`,
|
||||
});
|
||||
}
|
||||
for (const i of expiringSoon) {
|
||||
facts.push({
|
||||
title: i.name,
|
||||
value: `🔑 token expires in ${i.tokenExpiry!.daysRemaining.toFixed(0)} days (${i.tokenExpiry!.envVar})`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'AdaptiveCard',
|
||||
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
||||
version: '1.4',
|
||||
body: [
|
||||
{
|
||||
type: 'TextBlock',
|
||||
size: 'Large',
|
||||
weight: 'Bolder',
|
||||
text: 'Pulse — Integration Health Alert',
|
||||
},
|
||||
{
|
||||
type: 'TextBlock',
|
||||
spacing: 'None',
|
||||
isSubtle: true,
|
||||
wrap: true,
|
||||
text: `${summary.failed} failing · ${summary.expired} expired · ${summary.expiringWithin14Days} expiring within 14 days`,
|
||||
},
|
||||
{
|
||||
type: 'FactSet',
|
||||
facts,
|
||||
},
|
||||
{
|
||||
type: 'TextBlock',
|
||||
spacing: 'Medium',
|
||||
isSubtle: true,
|
||||
wrap: true,
|
||||
text: `Generated ${new Date().toISOString()}. ${summary.ok}/${summary.total} integrations healthy.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function getEnabledWebhooks(): Promise<WebhookRow[]> {
|
||||
const r = await postgresClient.query<WebhookRow>(
|
||||
`SELECT id, label, webhook_url, enabled
|
||||
FROM morning_summary_webhooks
|
||||
WHERE enabled = true`
|
||||
);
|
||||
return r.rows;
|
||||
}
|
||||
|
||||
async function deliverCard(card: object, webhooks: WebhookRow[]): Promise<number> {
|
||||
const envelope = {
|
||||
type: 'message',
|
||||
attachments: [
|
||||
{
|
||||
contentType: 'application/vnd.microsoft.card.adaptive',
|
||||
contentUrl: null,
|
||||
content: card,
|
||||
},
|
||||
],
|
||||
};
|
||||
let delivered = 0;
|
||||
await Promise.all(
|
||||
webhooks.map(async (w) => {
|
||||
try {
|
||||
const res = await fetch(w.webhook_url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(envelope),
|
||||
});
|
||||
if (res.ok) delivered += 1;
|
||||
else console.warn(`[integration-health-alerts] ${w.label} responded ${res.status}`);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[integration-health-alerts] ${w.label} delivery failed:`,
|
||||
err instanceof Error ? err.message : err
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
return delivered;
|
||||
}
|
||||
|
||||
export async function runIntegrationHealthAlertJob(): Promise<HealthAlertResult> {
|
||||
const items = await checkIntegrationHealth({ skipCache: true });
|
||||
const summary = summarize(items);
|
||||
|
||||
if (!summary.hasIssues) {
|
||||
return { summary, items, alertSent: false, webhooksDelivered: 0 };
|
||||
}
|
||||
|
||||
const webhooks = await getEnabledWebhooks();
|
||||
if (webhooks.length === 0) {
|
||||
console.log(
|
||||
'[integration-health-alerts] issues found but no morning-summary-webhooks configured; skipping delivery'
|
||||
);
|
||||
return { summary, items, alertSent: false, webhooksDelivered: 0 };
|
||||
}
|
||||
|
||||
const card = buildHealthAdaptiveCard(items, summary);
|
||||
const delivered = await deliverCard(card, webhooks);
|
||||
return {
|
||||
summary,
|
||||
items,
|
||||
alertSent: delivered > 0,
|
||||
webhooksDelivered: delivered,
|
||||
};
|
||||
}
|
||||
316
lib/services/integration-health.ts
Normal file
316
lib/services/integration-health.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
/**
|
||||
* Integration auth health + token expiry checks.
|
||||
*
|
||||
* Lives outside any specific integration's client because the goal is to
|
||||
* surface "did anything just break silently" without forcing the dashboard
|
||||
* to depend on every per-tool client. Each check is a minimal authenticated
|
||||
* call against a cheap endpoint of the target API; results are cached
|
||||
* in-process for a few minutes so concurrent dashboard hits don't fan out
|
||||
* into a wave of API calls.
|
||||
*
|
||||
* Usage:
|
||||
* const results = await checkIntegrationHealth();
|
||||
*
|
||||
* Tools covered live: S1, Datto RMM, IT Glue, Autotask. Others report
|
||||
* configured / not_configured only — extending to live checks is mechanical.
|
||||
*/
|
||||
|
||||
export type HealthStatus =
|
||||
| 'ok' // configured, auth succeeded
|
||||
| 'auth_failed' // configured, server returned 401/403
|
||||
| 'unreachable' // configured, network/DNS/TLS error
|
||||
| 'not_configured' // env vars missing
|
||||
| 'unknown'; // configured, no live check implemented
|
||||
|
||||
export interface TokenExpiry {
|
||||
envVar: string;
|
||||
expiresAt: string; // ISO
|
||||
daysRemaining: number; // negative when already expired
|
||||
subject?: string | null;
|
||||
}
|
||||
|
||||
export interface IntegrationHealth {
|
||||
key: string;
|
||||
name: string;
|
||||
category: 'psa' | 'rmm' | 'docs' | 'security' | 'backup' | 'network' | 'identity' | 'mdm' | 'mail' | 'finance' | 'productivity' | 'llm';
|
||||
status: HealthStatus;
|
||||
configured: boolean;
|
||||
latencyMs?: number;
|
||||
error?: string | null;
|
||||
tokenExpiry?: TokenExpiry | null;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
expiresAt: number;
|
||||
data: IntegrationHealth[];
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
let cache: CacheEntry | null = null;
|
||||
|
||||
function decodeJwt(token: string, envVar: string): TokenExpiry | null {
|
||||
if (!token || !token.startsWith('eyJ')) return null;
|
||||
const parts = token.split('.');
|
||||
if (parts.length < 2) return null;
|
||||
try {
|
||||
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const pad = b64.length % 4 ? '='.repeat(4 - (b64.length % 4)) : '';
|
||||
const json = Buffer.from(b64 + pad, 'base64').toString('utf8');
|
||||
const claims = JSON.parse(json) as { exp?: number; sub?: string };
|
||||
if (!claims.exp) return null;
|
||||
const expiresMs = claims.exp * 1000;
|
||||
return {
|
||||
envVar,
|
||||
expiresAt: new Date(expiresMs).toISOString(),
|
||||
daysRemaining: (expiresMs - Date.now()) / 86400_000,
|
||||
subject: claims.sub ?? null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function timed<T>(fn: () => Promise<T>): Promise<{ result: T; latencyMs: number }> {
|
||||
const start = Date.now();
|
||||
const result = await fn();
|
||||
return { result, latencyMs: Date.now() - start };
|
||||
}
|
||||
|
||||
async function liveCheck(opts: {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
}): Promise<{ status: HealthStatus; error: string | null; latencyMs: number; httpStatus: number | null }> {
|
||||
const ctrl = new AbortController();
|
||||
const timeout = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? 8000);
|
||||
try {
|
||||
const { result, latencyMs } = await timed(() =>
|
||||
fetch(opts.url, { headers: { accept: 'application/json', ...opts.headers }, signal: ctrl.signal })
|
||||
);
|
||||
clearTimeout(timeout);
|
||||
if (result.ok) return { status: 'ok', error: null, latencyMs, httpStatus: result.status };
|
||||
if (result.status === 401 || result.status === 403) {
|
||||
const body = await result.text().catch(() => '');
|
||||
return {
|
||||
status: 'auth_failed',
|
||||
error: `${result.status}: ${body.slice(0, 200)}`,
|
||||
latencyMs,
|
||||
httpStatus: result.status,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'unknown',
|
||||
error: `${result.status} ${result.statusText}`,
|
||||
latencyMs,
|
||||
httpStatus: result.status,
|
||||
};
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
return {
|
||||
status: 'unreachable',
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
latencyMs: opts.timeoutMs ?? 8000,
|
||||
httpStatus: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function checkS1(): Promise<IntegrationHealth> {
|
||||
const url = process.env.S1_API_URL?.replace(/\/$/, '');
|
||||
const token = process.env.S1_API_TOKEN;
|
||||
const checkedAt = new Date().toISOString();
|
||||
if (!url || !token) {
|
||||
return { key: 's1', name: 'SentinelOne', category: 'security', status: 'not_configured', configured: false, checkedAt };
|
||||
}
|
||||
const tokenExpiry = decodeJwt(token, 'S1_API_TOKEN');
|
||||
const live = await liveCheck({
|
||||
url: `${url}/web/api/v2.1/system/info`,
|
||||
headers: { Authorization: `ApiToken ${token}` },
|
||||
});
|
||||
return {
|
||||
key: 's1',
|
||||
name: 'SentinelOne',
|
||||
category: 'security',
|
||||
status: live.status,
|
||||
configured: true,
|
||||
latencyMs: live.latencyMs,
|
||||
error: live.error,
|
||||
tokenExpiry,
|
||||
checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function checkDattoRmm(): Promise<IntegrationHealth> {
|
||||
const url = process.env.DATTO_RMM_API_URL?.replace(/\/$/, '');
|
||||
const key = process.env.DATTO_RMM_API_KEY;
|
||||
const secret = process.env.DATTO_RMM_API_SECRET;
|
||||
const checkedAt = new Date().toISOString();
|
||||
if (!url || !key || !secret) {
|
||||
return { key: 'datto_rmm', name: 'Datto RMM', category: 'rmm', status: 'not_configured', configured: false, checkedAt };
|
||||
}
|
||||
// OAuth password grant — same flow the client uses internally.
|
||||
const start = Date.now();
|
||||
try {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), 8000);
|
||||
const tokRes = await fetch(`${url}/auth/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
authorization: 'Basic ' + Buffer.from('public-client:public').toString('base64'),
|
||||
},
|
||||
body: `grant_type=password&username=${encodeURIComponent(key)}&password=${encodeURIComponent(secret)}`,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
clearTimeout(t);
|
||||
if (tokRes.ok) {
|
||||
return { key: 'datto_rmm', name: 'Datto RMM', category: 'rmm', status: 'ok', configured: true, latencyMs: Date.now() - start, checkedAt };
|
||||
}
|
||||
if (tokRes.status === 401 || tokRes.status === 403) {
|
||||
const body = await tokRes.text().catch(() => '');
|
||||
return {
|
||||
key: 'datto_rmm', name: 'Datto RMM', category: 'rmm', status: 'auth_failed',
|
||||
configured: true, latencyMs: Date.now() - start,
|
||||
error: `${tokRes.status}: ${body.slice(0, 200)}`, checkedAt,
|
||||
};
|
||||
}
|
||||
return {
|
||||
key: 'datto_rmm', name: 'Datto RMM', category: 'rmm', status: 'unknown',
|
||||
configured: true, latencyMs: Date.now() - start,
|
||||
error: `${tokRes.status} ${tokRes.statusText}`, checkedAt,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
key: 'datto_rmm', name: 'Datto RMM', category: 'rmm', status: 'unreachable',
|
||||
configured: true, latencyMs: Date.now() - start,
|
||||
error: err instanceof Error ? err.message : String(err), checkedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function checkItglue(): Promise<IntegrationHealth> {
|
||||
const apiKey = process.env.ITGLUE_API_KEY;
|
||||
const checkedAt = new Date().toISOString();
|
||||
if (!apiKey) {
|
||||
return { key: 'itglue', name: 'IT Glue', category: 'docs', status: 'not_configured', configured: false, checkedAt };
|
||||
}
|
||||
const live = await liveCheck({
|
||||
url: 'https://api.itglue.com/organizations?page[size]=1',
|
||||
headers: { 'x-api-key': apiKey },
|
||||
});
|
||||
return {
|
||||
key: 'itglue', name: 'IT Glue', category: 'docs',
|
||||
status: live.status, configured: true,
|
||||
latencyMs: live.latencyMs, error: live.error,
|
||||
checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function checkAutotask(): Promise<IntegrationHealth> {
|
||||
const url = process.env.AUTOTASK_API_URL?.replace(/\/$/, '');
|
||||
const user = process.env.AUTOTASK_USERNAME;
|
||||
const secret = process.env.AUTOTASK_SECRET;
|
||||
const code = process.env.AUTOTASK_API_INTEGRATION_CODE;
|
||||
const checkedAt = new Date().toISOString();
|
||||
if (!url || !user || !secret || !code) {
|
||||
return { key: 'autotask', name: 'Autotask', category: 'psa', status: 'not_configured', configured: false, checkedAt };
|
||||
}
|
||||
// Cheapest authenticated call — version endpoint (not behind auth at all
|
||||
// tenants, but failing here usually means URL/credential mismatch).
|
||||
const live = await liveCheck({
|
||||
url: `${url}/v1.0/Version`,
|
||||
headers: {
|
||||
ApiIntegrationCode: code,
|
||||
UserName: user,
|
||||
Secret: secret,
|
||||
},
|
||||
});
|
||||
return {
|
||||
key: 'autotask', name: 'Autotask', category: 'psa',
|
||||
status: live.status, configured: true,
|
||||
latencyMs: live.latencyMs, error: live.error,
|
||||
checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function checkConfigOnly(
|
||||
key: string,
|
||||
name: string,
|
||||
category: IntegrationHealth['category'],
|
||||
envVars: string[]
|
||||
): IntegrationHealth {
|
||||
const checkedAt = new Date().toISOString();
|
||||
const allSet = envVars.every((v) => !!process.env[v]);
|
||||
return {
|
||||
key, name, category,
|
||||
status: allSet ? 'unknown' : 'not_configured',
|
||||
configured: allSet,
|
||||
checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkIntegrationHealth(opts?: { skipCache?: boolean }): Promise<IntegrationHealth[]> {
|
||||
if (!opts?.skipCache && cache && cache.expiresAt > Date.now()) {
|
||||
return cache.data;
|
||||
}
|
||||
const results = await Promise.all([
|
||||
checkAutotask(),
|
||||
checkDattoRmm(),
|
||||
checkItglue(),
|
||||
checkS1(),
|
||||
Promise.resolve(checkConfigOnly('veeam', 'Veeam VSPC', 'backup',
|
||||
['VEEAM_VSPC_URL', 'VEEAM_VSPC_API_KEY'])),
|
||||
Promise.resolve(checkConfigOnly('msgraph', 'Microsoft Graph', 'productivity',
|
||||
['MSGRAPH_CLIENT_ID', 'MSGRAPH_CLIENT_SECRET', 'MSGRAPH_TENANT_ID'])),
|
||||
Promise.resolve(checkConfigOnly('auvik', 'Auvik', 'network',
|
||||
['AUVIK_API_URL', 'AUVIK_API_USER', 'AUVIK_API_KEY'])),
|
||||
Promise.resolve(checkConfigOnly('addigy', 'Addigy', 'mdm',
|
||||
['ADDIGY_API_URL', 'ADDIGY_API_TOKEN', 'ADDIGY_ORG_ID'])),
|
||||
Promise.resolve(checkConfigOnly('mimecast', 'Mimecast', 'mail',
|
||||
['MIMECAST_CLIENT_ID', 'MIMECAST_CLIENT_SECRET'])),
|
||||
Promise.resolve(checkConfigOnly('duo', 'Duo', 'identity',
|
||||
['DUO_API_HOST', 'DUO_INTEGRATION_KEY', 'DUO_SECRET_KEY'])),
|
||||
Promise.resolve(checkConfigOnly('zabbix', 'Zabbix', 'network',
|
||||
['ZABBIX_API_URL', 'ZABBIX_API_TOKEN'])),
|
||||
Promise.resolve(checkConfigOnly('qbo', 'QuickBooks Online', 'finance',
|
||||
['QBO_CLIENT_ID', 'QBO_CLIENT_SECRET'])),
|
||||
Promise.resolve(checkConfigOnly('anthropic', 'Anthropic', 'llm',
|
||||
['ANTHROPIC_API_KEY'])),
|
||||
]);
|
||||
cache = { expiresAt: Date.now() + CACHE_TTL_MS, data: results };
|
||||
return results;
|
||||
}
|
||||
|
||||
export function clearIntegrationHealthCache(): void {
|
||||
cache = null;
|
||||
}
|
||||
|
||||
export interface HealthSummary {
|
||||
total: number;
|
||||
ok: number;
|
||||
failed: number;
|
||||
notConfigured: number;
|
||||
expiringWithin14Days: number;
|
||||
expired: number;
|
||||
hasIssues: boolean;
|
||||
}
|
||||
|
||||
export function summarize(items: IntegrationHealth[]): HealthSummary {
|
||||
let ok = 0, failed = 0, notConfigured = 0, expiringWithin14Days = 0, expired = 0;
|
||||
for (const i of items) {
|
||||
if (i.status === 'ok' || i.status === 'unknown') ok += 1;
|
||||
else if (i.status === 'auth_failed' || i.status === 'unreachable') failed += 1;
|
||||
else if (i.status === 'not_configured') notConfigured += 1;
|
||||
if (i.tokenExpiry) {
|
||||
if (i.tokenExpiry.daysRemaining <= 0) expired += 1;
|
||||
else if (i.tokenExpiry.daysRemaining <= 14) expiringWithin14Days += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
total: items.length,
|
||||
ok, failed, notConfigured,
|
||||
expiringWithin14Days, expired,
|
||||
hasIssues: failed > 0 || expired > 0 || expiringWithin14Days > 0,
|
||||
};
|
||||
}
|
||||
|
|
@ -146,6 +146,26 @@ export class ITGlueClient {
|
|||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal PATCH helper. Used by the audit feature to update flexible assets.
|
||||
* Body should be a JSON:API resource object (e.g. `{ data: { type, attributes } }`).
|
||||
* Returns the parsed JSON response (typically `{ data: {...} }`).
|
||||
*/
|
||||
private async patch<T = unknown>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: 'PATCH',
|
||||
headers: this.headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`IT Glue PATCH ${path} → ${res.status} ${res.statusText}: ${text.slice(0, 500)}`
|
||||
);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
private async fetchAllPages<T>(
|
||||
path: string,
|
||||
params: Record<string, string | number> = {},
|
||||
|
|
@ -175,6 +195,20 @@ export class ITGlueClient {
|
|||
return data.data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw JSON:API resource for a single-resource endpoint
|
||||
* (`{ data: {...} }`). Used for per-record refresh after writes so callers
|
||||
* can read the un-mapped attributes (created-at, updated-at, etc.) for a
|
||||
* faithful upsert.
|
||||
*/
|
||||
async getRawSingle(
|
||||
path: string,
|
||||
params: Record<string, string | number> = {}
|
||||
): Promise<{ id: string; type: string; attributes: Record<string, unknown> } | null> {
|
||||
const data: any = await this.request(path, params);
|
||||
return data.data ?? null;
|
||||
}
|
||||
|
||||
/** Returns all raw JSON:API data items across all pages (used by sync service) */
|
||||
async getRawAllPages(path: string, params: Record<string, string | number> = {}): Promise<any[]> {
|
||||
const results: any[] = [];
|
||||
|
|
@ -274,6 +308,63 @@ export class ITGlueClient {
|
|||
return this.mapFlexibleAsset(data.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a flexible asset's traits. Sends PATCH /flexible_assets/:id with a
|
||||
* JSON:API body. `traits` is the merged trait map (IT Glue replaces the
|
||||
* trait set, so callers must include unchanged traits to preserve them; the
|
||||
* audit pipeline always reads then merges).
|
||||
*
|
||||
* Returns the updated asset as IT Glue returns it.
|
||||
*/
|
||||
async updateFlexibleAsset(
|
||||
id: string | number,
|
||||
traits: Record<string, unknown>
|
||||
): Promise<ITGlueFlexibleAsset> {
|
||||
const body = {
|
||||
data: {
|
||||
type: 'flexible_assets',
|
||||
id: String(id),
|
||||
attributes: { traits },
|
||||
},
|
||||
};
|
||||
const res = await this.patch<{ data: any }>(`/flexible_assets/${id}`, body);
|
||||
return this.mapFlexibleAsset(res.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetch a single flexible asset from IT Glue. Thin wrapper around
|
||||
* getFlexibleAsset; exists so callers naming "refresh" intent stays clear
|
||||
* separate from "read once".
|
||||
*/
|
||||
async refreshFlexibleAsset(id: string | number): Promise<ITGlueFlexibleAsset> {
|
||||
return this.getFlexibleAsset(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a configuration's editable attributes. Sends PATCH /configurations/:id
|
||||
* with a JSON:API body. Configurations have a flat attribute set (no traits
|
||||
* blob), so callers pass the partial map of fields to change — IT Glue
|
||||
* merges into the existing record.
|
||||
*/
|
||||
async updateConfiguration(
|
||||
id: string | number,
|
||||
attributes: Record<string, unknown>
|
||||
): Promise<ITGlueConfiguration> {
|
||||
const body = {
|
||||
data: {
|
||||
type: 'configurations',
|
||||
id: String(id),
|
||||
attributes,
|
||||
},
|
||||
};
|
||||
const res = await this.patch<{ data: any }>(`/configurations/${id}`, body);
|
||||
return this.mapConfiguration(res.data);
|
||||
}
|
||||
|
||||
async refreshConfiguration(id: string | number): Promise<ITGlueConfiguration> {
|
||||
return this.getConfiguration(id);
|
||||
}
|
||||
|
||||
async getFlexibleAssetTypes(): Promise<ITGlueFlexibleAssetType[]> {
|
||||
return this.fetchAllPages('/flexible_asset_types', {}, (item: any) => ({
|
||||
id: item.id,
|
||||
|
|
@ -474,3 +565,7 @@ export function getITGlueClient(): ITGlueClient {
|
|||
}
|
||||
return _client;
|
||||
}
|
||||
|
||||
export function isITGlueConfigured(): boolean {
|
||||
return !!process.env.ITGLUE_API_KEY;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -545,6 +545,115 @@ export class ITGlueSyncService {
|
|||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetch a single configuration from IT Glue and upsert the mirror row.
|
||||
* Mirrors the bulk syncConfigurations upsert. Used after a write so the
|
||||
* UI sees the new value immediately.
|
||||
*/
|
||||
async refreshConfigurationById(id: string | number): Promise<void> {
|
||||
const client = getITGlueClient();
|
||||
const item = await client.getRawSingle(`/configurations/${id}`);
|
||||
if (!item) {
|
||||
throw new Error(`IT Glue refreshConfigurationById ${id}: empty response`);
|
||||
}
|
||||
const a = item.attributes;
|
||||
await postgresClient.query(
|
||||
`INSERT INTO itg_configurations
|
||||
(id, organization_id, organization_name, name, hostname, primary_ip,
|
||||
mac_address, serial_number, asset_tag, position, installed_by, purchased_by,
|
||||
notes, operating_system_notes, warranty_expires_at, installed_at, purchased_at,
|
||||
end_of_life_at, configuration_type_id, configuration_type_name,
|
||||
configuration_status_id, configuration_status_name,
|
||||
manufacturer_id, manufacturer_name, model_id, model_name,
|
||||
operating_system_id, operating_system_name, location_id, contact_id,
|
||||
rmm_id, rmm_integration_type, created_at, updated_at, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
organization_id=$2, organization_name=$3, name=$4, hostname=$5, primary_ip=$6,
|
||||
mac_address=$7, serial_number=$8, asset_tag=$9, position=$10, installed_by=$11,
|
||||
purchased_by=$12, notes=$13, operating_system_notes=$14, warranty_expires_at=$15,
|
||||
installed_at=$16, purchased_at=$17, end_of_life_at=$18,
|
||||
configuration_type_id=$19, configuration_type_name=$20,
|
||||
configuration_status_id=$21, configuration_status_name=$22,
|
||||
manufacturer_id=$23, manufacturer_name=$24, model_id=$25, model_name=$26,
|
||||
operating_system_id=$27, operating_system_name=$28,
|
||||
location_id=$29, contact_id=$30, rmm_id=$31, rmm_integration_type=$32,
|
||||
updated_at=$34, synced_at=NOW()`,
|
||||
[
|
||||
item.id,
|
||||
a['organization-id'],
|
||||
a['organization-name'] || null,
|
||||
a.name,
|
||||
a.hostname || null,
|
||||
a['primary-ip'] || null,
|
||||
a['mac-address'] || null,
|
||||
a['serial-number'] || null,
|
||||
a['asset-tag'] || null,
|
||||
a.position || null,
|
||||
a['installed-by'] || null,
|
||||
a['purchased-by'] || null,
|
||||
a.notes || null,
|
||||
a['operating-system-notes'] || null,
|
||||
a['warranty-expires-at'] || null,
|
||||
a['installed-at'] || null,
|
||||
a['purchased-at'] || null,
|
||||
a['end-of-life-at'] || null,
|
||||
a['configuration-type-id'] || null,
|
||||
a['configuration-type-name'] || null,
|
||||
a['configuration-status-id'] || null,
|
||||
a['configuration-status-name'] || null,
|
||||
a['manufacturer-id'] || null,
|
||||
a['manufacturer-name'] || null,
|
||||
a['model-id'] || null,
|
||||
a['model-name'] || null,
|
||||
a['operating-system-id'] || null,
|
||||
a['operating-system-name'] || null,
|
||||
a['location-id'] || null,
|
||||
a['contact-id'] || null,
|
||||
a['rmm-id'] || null,
|
||||
a['rmm-integration-type'] || null,
|
||||
a['created-at'] || null,
|
||||
a['updated-at'] || null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetch a single flexible asset from IT Glue and upsert the mirror row.
|
||||
* Used after a write to keep itg_flexible_assets in sync without running
|
||||
* the full 27-entity sync.
|
||||
*/
|
||||
async refreshFlexibleAssetById(id: string | number): Promise<void> {
|
||||
const client = getITGlueClient();
|
||||
const item = await client.getRawSingle(`/flexible_assets/${id}`);
|
||||
if (!item) {
|
||||
throw new Error(`IT Glue refreshFlexibleAssetById ${id}: empty response`);
|
||||
}
|
||||
const a = item.attributes;
|
||||
await postgresClient.query(
|
||||
`INSERT INTO itg_flexible_assets
|
||||
(id, organization_id, organization_name, flexible_asset_type_id,
|
||||
flexible_asset_type_name, name, traits, archived, created_at, updated_at, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
organization_id=$2, organization_name=$3, flexible_asset_type_id=$4,
|
||||
flexible_asset_type_name=$5, name=$6, traits=$7, archived=$8,
|
||||
updated_at=$10, synced_at=NOW()`,
|
||||
[
|
||||
item.id,
|
||||
a['organization-id'],
|
||||
a['organization-name'] || null,
|
||||
a['flexible-asset-type-id'],
|
||||
a['flexible-asset-type-name'] || null,
|
||||
a.name || null,
|
||||
JSON.stringify(a.traits || {}),
|
||||
a.archived ?? false,
|
||||
a['created-at'] || null,
|
||||
a['updated-at'] || null,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let _instance: ITGlueSyncService | null = null;
|
||||
|
|
|
|||
|
|
@ -2,24 +2,31 @@
|
|||
* Generic LLM caller for the analyzer pipeline.
|
||||
*
|
||||
* One round-trip is:
|
||||
* 1. Send (system, user) to the chosen model.
|
||||
* 1. Send (system, user) to the chosen model (Anthropic or OpenRouter).
|
||||
* 2. Extract the assistant's text content.
|
||||
* 3. JSON.parse + Zod-validate against the caller's schema.
|
||||
* 4. On failure: retry ONCE with the prior raw response + parse error in a
|
||||
* follow-up user turn, then validate again.
|
||||
* 5. After two failures: throw.
|
||||
*
|
||||
* The system prompt is marked with `cache_control: ephemeral`. Anthropic
|
||||
* silently no-ops caching when the prefix is below the model's minimum
|
||||
* (~2-4K tokens) — for our short stage prompts this often won't fire, which
|
||||
* is fine; cost is unaffected when caching is skipped.
|
||||
* Provider dispatch:
|
||||
* - claude-* → Anthropic SDK (with prompt-cache hint on the system prefix)
|
||||
* - <vendor>/<model> → OpenRouter chat-completions (OpenAI-compatible)
|
||||
*
|
||||
* The retry logic, schema validation, and result shape are identical across
|
||||
* providers so callers stay provider-agnostic.
|
||||
*/
|
||||
|
||||
import type Anthropic from '@anthropic-ai/sdk';
|
||||
import type { ZodType } from 'zod';
|
||||
import { getAnthropicClient } from './client';
|
||||
import { callOpenRouterChat } from './openrouter-call';
|
||||
import { estimateCostUsd, type TokenUsage } from './pricing';
|
||||
import { type ModelId, OPUS } from './models';
|
||||
import {
|
||||
type ModelId,
|
||||
OPUS,
|
||||
providerForModel,
|
||||
} from './models';
|
||||
|
||||
export interface LLMCallOptions<T> {
|
||||
model: ModelId;
|
||||
|
|
@ -27,7 +34,7 @@ export interface LLMCallOptions<T> {
|
|||
user: string;
|
||||
schema: ZodType<T>;
|
||||
maxTokens: number;
|
||||
/** Override the singleton (test injection). */
|
||||
/** Override the Anthropic singleton (test injection). */
|
||||
client?: Anthropic;
|
||||
}
|
||||
|
||||
|
|
@ -41,10 +48,15 @@ export interface LLMCallResult<T> {
|
|||
raw_response: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate token usage from two calls (used to track total cost across
|
||||
* the original attempt + retry).
|
||||
*/
|
||||
interface RoundTripResult {
|
||||
text: string;
|
||||
usage: TokenUsage;
|
||||
}
|
||||
|
||||
type RoundTripFn = (
|
||||
history: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||
) => Promise<RoundTripResult>;
|
||||
|
||||
function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
|
||||
return {
|
||||
input_tokens: a.input_tokens + b.input_tokens,
|
||||
|
|
@ -56,7 +68,7 @@ function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
|
|||
};
|
||||
}
|
||||
|
||||
interface MessagesCreateBody {
|
||||
interface AnthropicMessagesCreateBody {
|
||||
model: string;
|
||||
max_tokens: number;
|
||||
system: Array<{
|
||||
|
|
@ -67,12 +79,12 @@ interface MessagesCreateBody {
|
|||
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
}
|
||||
|
||||
function buildBody(opts: {
|
||||
function buildAnthropicBody(opts: {
|
||||
model: ModelId;
|
||||
system: string;
|
||||
history: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
maxTokens: number;
|
||||
}): MessagesCreateBody {
|
||||
}): AnthropicMessagesCreateBody {
|
||||
return {
|
||||
model: opts.model,
|
||||
max_tokens: opts.maxTokens,
|
||||
|
|
@ -87,7 +99,7 @@ function buildBody(opts: {
|
|||
};
|
||||
}
|
||||
|
||||
function extractText(response: Anthropic.Message): string {
|
||||
function extractAnthropicText(response: Anthropic.Message): string {
|
||||
const parts: string[] = [];
|
||||
for (const block of response.content) {
|
||||
if (block.type === 'text') parts.push(block.text);
|
||||
|
|
@ -135,42 +147,67 @@ function tryParseValidate<T>(
|
|||
return { ok: true, value: result.data };
|
||||
}
|
||||
|
||||
function makeAnthropicRoundTrip(
|
||||
opts: LLMCallOptions<unknown>,
|
||||
client: Anthropic
|
||||
): RoundTripFn {
|
||||
return async (history) => {
|
||||
const body = buildAnthropicBody({
|
||||
model: opts.model,
|
||||
system: opts.system,
|
||||
history,
|
||||
maxTokens: opts.maxTokens,
|
||||
});
|
||||
void (body satisfies Anthropic.MessageCreateParamsNonStreaming);
|
||||
const response = await client.messages.create(body);
|
||||
return {
|
||||
text: extractAnthropicText(response),
|
||||
usage: response.usage as TokenUsage,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function makeOpenRouterRoundTrip(opts: LLMCallOptions<unknown>): RoundTripFn {
|
||||
return async (history) => {
|
||||
const result = await callOpenRouterChat({
|
||||
model: opts.model,
|
||||
system: opts.system,
|
||||
history,
|
||||
maxTokens: opts.maxTokens,
|
||||
});
|
||||
return { text: result.text, usage: result.usage };
|
||||
};
|
||||
}
|
||||
|
||||
export async function callLLMStage<T>(
|
||||
opts: LLMCallOptions<T>
|
||||
): Promise<LLMCallResult<T>> {
|
||||
const client = opts.client ?? getAnthropicClient();
|
||||
const provider = providerForModel(opts.model);
|
||||
const roundTrip: RoundTripFn =
|
||||
provider === 'anthropic'
|
||||
? makeAnthropicRoundTrip(opts, opts.client ?? getAnthropicClient())
|
||||
: makeOpenRouterRoundTrip(opts);
|
||||
|
||||
const history: Array<{ role: 'user' | 'assistant'; content: string }> = [
|
||||
{ role: 'user', content: opts.user },
|
||||
];
|
||||
|
||||
// Opus 4.7 rejects `temperature`, `top_p`, `top_k`. We don't pass any of
|
||||
// them, so the same body shape works on all three models.
|
||||
const firstBody = buildBody({
|
||||
model: opts.model,
|
||||
system: opts.system,
|
||||
history,
|
||||
maxTokens: opts.maxTokens,
|
||||
});
|
||||
void (firstBody satisfies Anthropic.MessageCreateParamsNonStreaming);
|
||||
|
||||
const first = await client.messages.create(firstBody);
|
||||
const firstText = extractText(first);
|
||||
const firstParse = tryParseValidate(firstText, opts.schema);
|
||||
const first = await roundTrip(history);
|
||||
const firstParse = tryParseValidate(first.text, opts.schema);
|
||||
|
||||
if (firstParse.ok) {
|
||||
const usage: TokenUsage = first.usage as TokenUsage;
|
||||
return {
|
||||
data: firstParse.value,
|
||||
usage,
|
||||
estimated_cost_usd: estimateCostUsd(opts.model, usage),
|
||||
usage: first.usage,
|
||||
estimated_cost_usd: estimateCostUsd(opts.model, first.usage),
|
||||
attempts: 1,
|
||||
raw_response: firstText,
|
||||
raw_response: first.text,
|
||||
};
|
||||
}
|
||||
|
||||
// Retry once. Append the model's previous (invalid) response and a follow-up
|
||||
// user turn explaining the parse error.
|
||||
history.push({ role: 'assistant', content: firstText });
|
||||
history.push({ role: 'assistant', content: first.text });
|
||||
history.push({
|
||||
role: 'user',
|
||||
content: [
|
||||
|
|
@ -182,26 +219,14 @@ export async function callLLMStage<T>(
|
|||
].join('\n'),
|
||||
});
|
||||
|
||||
const secondBody = buildBody({
|
||||
model: opts.model,
|
||||
system: opts.system,
|
||||
history,
|
||||
maxTokens: opts.maxTokens,
|
||||
});
|
||||
void (secondBody satisfies Anthropic.MessageCreateParamsNonStreaming);
|
||||
const second = await roundTrip(history);
|
||||
const secondParse = tryParseValidate(second.text, opts.schema);
|
||||
|
||||
const second = await client.messages.create(secondBody);
|
||||
const secondText = extractText(second);
|
||||
const secondParse = tryParseValidate(secondText, opts.schema);
|
||||
|
||||
const totalUsage = addUsage(
|
||||
first.usage as TokenUsage,
|
||||
second.usage as TokenUsage
|
||||
);
|
||||
const totalUsage = addUsage(first.usage, second.usage);
|
||||
|
||||
if (!secondParse.ok) {
|
||||
throw new Error(
|
||||
`LLM stage on ${opts.model} failed twice. First: ${firstParse.error}. Second: ${secondParse.error}. Last raw response: ${secondText.slice(0, 500)}`
|
||||
`LLM stage on ${opts.model} failed twice. First: ${firstParse.error}. Second: ${secondParse.error}. Last raw response: ${second.text.slice(0, 500)}`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -210,7 +235,7 @@ export async function callLLMStage<T>(
|
|||
usage: totalUsage,
|
||||
estimated_cost_usd: estimateCostUsd(opts.model, totalUsage),
|
||||
attempts: 2,
|
||||
raw_response: secondText,
|
||||
raw_response: second.text,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,73 @@
|
|||
* Use these constants — never hardcode the strings elsewhere.
|
||||
*
|
||||
* Verify quarterly against https://docs.claude.com/en/docs/about-claude/models
|
||||
* Model IDs change rarely but pricing and capability tiers can shift.
|
||||
* and OpenRouter's model list. Model IDs change rarely but pricing and
|
||||
* capability tiers can shift.
|
||||
*/
|
||||
|
||||
// Anthropic
|
||||
export const HAIKU = 'claude-haiku-4-5' as const;
|
||||
export const SONNET = 'claude-sonnet-4-6' as const;
|
||||
export const OPUS = 'claude-opus-4-7' as const;
|
||||
|
||||
export type ModelId = typeof HAIKU | typeof SONNET | typeof OPUS;
|
||||
// OpenRouter / DeepSeek
|
||||
export const DEEPSEEK_V4_FLASH = 'deepseek/deepseek-v4-flash' as const;
|
||||
export const DEEPSEEK_V4_PRO = 'deepseek/deepseek-v4-pro' as const;
|
||||
export const DEEPSEEK_R1 = 'deepseek/deepseek-r1-0528' as const;
|
||||
|
||||
export type AnthropicModelId = typeof HAIKU | typeof SONNET | typeof OPUS;
|
||||
export type OpenRouterModelId =
|
||||
| typeof DEEPSEEK_V4_FLASH
|
||||
| typeof DEEPSEEK_V4_PRO
|
||||
| typeof DEEPSEEK_R1;
|
||||
export type ModelId = AnthropicModelId | OpenRouterModelId;
|
||||
|
||||
export type Provider = 'anthropic' | 'openrouter';
|
||||
|
||||
/**
|
||||
* Per-stage model picks for each provider. The pipeline reads from this when
|
||||
* the user picks a provider — every stage knows what model to call without
|
||||
* the caller having to wire up four constants.
|
||||
*/
|
||||
export interface StageModels {
|
||||
triage: ModelId;
|
||||
deep_analysis: ModelId;
|
||||
deep_reasoning: ModelId;
|
||||
fingerprint: ModelId;
|
||||
/** Aggregate-reduce step (cross-ticket bundle reports). */
|
||||
aggregate_reduce: ModelId;
|
||||
/** Haiku-suggested-links arm in link-discovery. */
|
||||
link_suggest: ModelId;
|
||||
}
|
||||
|
||||
export const ANTHROPIC_STAGE_MODELS: StageModels = {
|
||||
triage: HAIKU,
|
||||
deep_analysis: SONNET,
|
||||
deep_reasoning: OPUS,
|
||||
fingerprint: HAIKU,
|
||||
aggregate_reduce: SONNET,
|
||||
link_suggest: HAIKU,
|
||||
};
|
||||
|
||||
export const OPENROUTER_STAGE_MODELS: StageModels = {
|
||||
triage: DEEPSEEK_V4_FLASH,
|
||||
deep_analysis: DEEPSEEK_V4_PRO,
|
||||
deep_reasoning: DEEPSEEK_R1,
|
||||
fingerprint: DEEPSEEK_V4_FLASH,
|
||||
aggregate_reduce: DEEPSEEK_V4_PRO,
|
||||
link_suggest: DEEPSEEK_V4_FLASH,
|
||||
};
|
||||
|
||||
export function stageModelsFor(provider: Provider): StageModels {
|
||||
return provider === 'openrouter'
|
||||
? OPENROUTER_STAGE_MODELS
|
||||
: ANTHROPIC_STAGE_MODELS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect provider from a model id. Anthropic ids start with `claude-`;
|
||||
* OpenRouter ids contain a `/`. Used by the LLM call layer to dispatch.
|
||||
*/
|
||||
export function providerForModel(model: ModelId): Provider {
|
||||
return model.includes('/') ? 'openrouter' : 'anthropic';
|
||||
}
|
||||
|
|
|
|||
126
lib/services/llm/openrouter-call.ts
Normal file
126
lib/services/llm/openrouter-call.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* OpenRouter chat-completions caller.
|
||||
*
|
||||
* Talks to https://openrouter.ai/api/v1/chat/completions in OpenAI-compatible
|
||||
* format. Used as the OpenRouter side of `callLLMStage` so the existing
|
||||
* Anthropic call path stays untouched.
|
||||
*
|
||||
* JSON adherence: requests `response_format: { type: 'json_object' }`. DeepSeek
|
||||
* supports this (the API tolerates it as a hint when not natively supported,
|
||||
* per OpenAI-compat). The retry-on-parse-fail logic in call.ts is the safety
|
||||
* net for stragglers.
|
||||
*/
|
||||
|
||||
import type { TokenUsage } from './pricing';
|
||||
import type { ModelId } from './models';
|
||||
|
||||
const OPENROUTER_BASE = 'https://openrouter.ai/api/v1';
|
||||
|
||||
export interface OpenRouterChatResponse {
|
||||
text: string;
|
||||
usage: TokenUsage;
|
||||
}
|
||||
|
||||
interface RawChatResponse {
|
||||
id: string;
|
||||
model: string;
|
||||
choices: Array<{
|
||||
index: number;
|
||||
message: { role: 'assistant'; content: string | null };
|
||||
finish_reason: string;
|
||||
}>;
|
||||
usage?: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens?: number;
|
||||
};
|
||||
error?: { message: string; code?: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a chat-completions request to OpenRouter and return the assistant's
|
||||
* text content + token usage. Throws on HTTP error or empty response.
|
||||
*/
|
||||
export async function callOpenRouterChat(opts: {
|
||||
model: ModelId;
|
||||
system: string;
|
||||
history: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
maxTokens: number;
|
||||
}): Promise<OpenRouterChatResponse> {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
'OPENROUTER_API_KEY is not set. The OpenRouter pipeline cannot run without it.'
|
||||
);
|
||||
}
|
||||
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: opts.system },
|
||||
...opts.history,
|
||||
];
|
||||
|
||||
const body = {
|
||||
model: opts.model,
|
||||
messages,
|
||||
max_tokens: opts.maxTokens,
|
||||
response_format: { type: 'json_object' as const },
|
||||
// Provider preferences:
|
||||
// - data_collection: 'deny' → refuse any inference provider whose
|
||||
// policy allows storing prompts/completions or training on them.
|
||||
// - sort: 'throughput' → among compliant providers, prefer the
|
||||
// fastest one. V4 Pro deep-analysis was ~3.5min without this hint;
|
||||
// with throughput sort it should land closer to ~1.5-2min.
|
||||
// - allow_fallbacks: true → still route across compliant providers
|
||||
// when the primary is down (default, made explicit).
|
||||
// OpenRouter publishes per-provider data policies; data_collection is
|
||||
// the documented way to enforce a privacy floor at the API call level.
|
||||
// The account-level "opt out of training" toggle is the belt; this is
|
||||
// the braces.
|
||||
provider: {
|
||||
data_collection: 'deny' as const,
|
||||
sort: 'throughput' as const,
|
||||
allow_fallbacks: true,
|
||||
},
|
||||
};
|
||||
|
||||
const res = await fetch(`${OPENROUTER_BASE}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
// OpenRouter uses these for analytics + their leaderboard.
|
||||
'HTTP-Referer':
|
||||
process.env.BETTER_AUTH_URL || 'https://pulse.wulfconsulting.cloud',
|
||||
'X-Title': 'Pulse Ticket Analyzer',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`OpenRouter HTTP ${res.status}: ${text.slice(0, 500)}`
|
||||
);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as RawChatResponse;
|
||||
if (json.error) {
|
||||
throw new Error(`OpenRouter error: ${json.error.message}`);
|
||||
}
|
||||
const choice = json.choices?.[0];
|
||||
if (!choice) {
|
||||
throw new Error('OpenRouter returned no choices');
|
||||
}
|
||||
const text = (choice.message.content ?? '').trim();
|
||||
|
||||
const usage: TokenUsage = {
|
||||
input_tokens: json.usage?.prompt_tokens ?? 0,
|
||||
output_tokens: json.usage?.completion_tokens ?? 0,
|
||||
};
|
||||
|
||||
return { text, usage };
|
||||
}
|
||||
|
||||
export function isOpenRouterConfigured(): boolean {
|
||||
return !!process.env.OPENROUTER_API_KEY;
|
||||
}
|
||||
|
|
@ -3,27 +3,46 @@
|
|||
*
|
||||
* Rates are USD per 1,000,000 tokens.
|
||||
*
|
||||
* VERIFY QUARTERLY against https://docs.claude.com/en/docs/about-claude/pricing
|
||||
* Last verified: 2026-04-15
|
||||
* VERIFY QUARTERLY against:
|
||||
* - https://docs.claude.com/en/docs/about-claude/pricing
|
||||
* - https://openrouter.ai/api/v1/models (deepseek/* entries)
|
||||
*
|
||||
* Last verified: 2026-05-02 (V4 Pro/Flash + R1-0528 added from OpenRouter live list)
|
||||
*/
|
||||
|
||||
import { HAIKU, SONNET, OPUS, type ModelId } from './models';
|
||||
import {
|
||||
HAIKU,
|
||||
SONNET,
|
||||
OPUS,
|
||||
DEEPSEEK_V4_FLASH,
|
||||
DEEPSEEK_V4_PRO,
|
||||
DEEPSEEK_R1,
|
||||
type ModelId,
|
||||
} from './models';
|
||||
|
||||
interface ModelRate {
|
||||
/** USD per 1M input tokens */
|
||||
input: number;
|
||||
/** USD per 1M output tokens */
|
||||
output: number;
|
||||
/** USD per 1M tokens read from prompt cache (~0.1× input) */
|
||||
/** USD per 1M tokens read from prompt cache (~0.1× input on Anthropic; not applicable on OpenRouter — set equal to input). */
|
||||
cacheRead: number;
|
||||
/** USD per 1M tokens written to 5-minute prompt cache (~1.25× input) */
|
||||
/** USD per 1M tokens written to 5-minute prompt cache (~1.25× input on Anthropic; not applicable on OpenRouter — set equal to input). */
|
||||
cacheWrite5m: number;
|
||||
}
|
||||
|
||||
export const PRICING: Record<ModelId, ModelRate> = {
|
||||
// Anthropic
|
||||
[HAIKU]: { input: 1.0, output: 5.0, cacheRead: 0.1, cacheWrite5m: 1.25 },
|
||||
[SONNET]: { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite5m: 3.75 },
|
||||
[OPUS]: { input: 5.0, output: 25.0, cacheRead: 0.5, cacheWrite5m: 6.25 },
|
||||
|
||||
// OpenRouter / DeepSeek (no prompt-cache discount surfaced via the OpenAI-
|
||||
// compatible API; we treat cacheRead/cacheWrite as the input rate so the
|
||||
// estimator stays additive even if those usage fields ever come back filled).
|
||||
[DEEPSEEK_V4_FLASH]: { input: 0.14, output: 0.28, cacheRead: 0.14, cacheWrite5m: 0.14 },
|
||||
[DEEPSEEK_V4_PRO]: { input: 0.435, output: 0.87, cacheRead: 0.435, cacheWrite5m: 0.435 },
|
||||
[DEEPSEEK_R1]: { input: 0.50, output: 2.15, cacheRead: 0.50, cacheWrite5m: 0.50 },
|
||||
};
|
||||
|
||||
export interface TokenUsage {
|
||||
|
|
|
|||
436
lib/services/rmm/executor.ts
Normal file
436
lib/services/rmm/executor.ts
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
/**
|
||||
* Dispatch a single RMM Overshell execution.
|
||||
*
|
||||
* 1. Validate script_id against the in-code registry.
|
||||
* 2. Resolve the target device.
|
||||
* 3. Per-user 24h rate limit (50 executions). Records every decision in
|
||||
* analyzer_cost_audit so admins see RMM activity alongside LLM activity.
|
||||
* 4. Insert pending row in rmm_executions.
|
||||
* 5. Resolve Overshell component_uid (discover-on-demand if cache empty).
|
||||
* 6. Call client.runQuickJob — store the returned jobUid + flip to
|
||||
* 'running'. The worker takes over from there.
|
||||
* 7. Best-effort generic audit_log entry.
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
|
||||
import { presignUpload } from '@/lib/services/b2/client';
|
||||
import {
|
||||
evaluateCost,
|
||||
recordCostAuditDecision,
|
||||
} from '@/lib/services/analyzer/cost-guard';
|
||||
import { audit } from '@/lib/services/audit';
|
||||
import {
|
||||
resolveOvershellComponent,
|
||||
resolveLogliftComponent,
|
||||
} from './settings';
|
||||
import { getScript } from './scripts';
|
||||
import {
|
||||
resolveAssetSelfTarget,
|
||||
resolveSiteAnchorTarget,
|
||||
} from './target-resolver';
|
||||
import {
|
||||
countUserExecutionsLast24h,
|
||||
createPendingExecution,
|
||||
markExecutionFailedToDispatch,
|
||||
markExecutionRunning,
|
||||
} from './persistence';
|
||||
|
||||
const RATE_LIMIT_PER_24H = 50;
|
||||
|
||||
export type ExecutionTarget =
|
||||
| { type: 'site_anchor'; companyId: number | string }
|
||||
| {
|
||||
type: 'asset_self';
|
||||
deviceUid: string;
|
||||
hostname?: string | null;
|
||||
companyId?: number | string | null;
|
||||
assetType?: 'flexible_asset' | 'configuration';
|
||||
assetId?: number | string;
|
||||
};
|
||||
|
||||
export interface QueueExecutionInput {
|
||||
scriptId: string;
|
||||
target: ExecutionTarget;
|
||||
performedByUserId: string | null;
|
||||
triggeredByAuditId?: string | null;
|
||||
}
|
||||
|
||||
export interface QueueExecutionResult {
|
||||
executionId: string;
|
||||
status: 'queued' | 'running' | 'failed';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function queueExecution(
|
||||
input: QueueExecutionInput
|
||||
): Promise<QueueExecutionResult> {
|
||||
const script = getScript(input.scriptId);
|
||||
if (!script) {
|
||||
throw new Error(`Unknown script_id: ${input.scriptId}`);
|
||||
}
|
||||
if (script.target_type !== input.target.type) {
|
||||
throw new Error(
|
||||
`Script "${script.id}" expects target_type=${script.target_type} but caller passed ${input.target.type}`
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve the target device.
|
||||
let deviceUid: string;
|
||||
let hostname: string | null;
|
||||
let companyId: number | string | null;
|
||||
let assetType: 'flexible_asset' | 'configuration' | null = null;
|
||||
let assetId: number | string | null = null;
|
||||
|
||||
if (input.target.type === 'site_anchor') {
|
||||
const resolved = await resolveSiteAnchorTarget(input.target.companyId);
|
||||
if (!resolved) {
|
||||
throw new Error(
|
||||
`No Wulf Nurse Production endpoint registered in Datto RMM for company ${input.target.companyId}.`
|
||||
);
|
||||
}
|
||||
deviceUid = resolved.device_uid;
|
||||
hostname = resolved.hostname;
|
||||
companyId = input.target.companyId;
|
||||
} else {
|
||||
const resolved = await resolveAssetSelfTarget(input.target.deviceUid);
|
||||
deviceUid = resolved.device_uid;
|
||||
hostname = resolved.hostname ?? input.target.hostname ?? null;
|
||||
companyId = input.target.companyId ?? null;
|
||||
assetType = input.target.assetType ?? null;
|
||||
assetId = input.target.assetId ?? null;
|
||||
}
|
||||
|
||||
// Rate-limit per user (24h rolling window).
|
||||
if (input.performedByUserId) {
|
||||
const recent = await countUserExecutionsLast24h(input.performedByUserId);
|
||||
if (recent >= RATE_LIMIT_PER_24H) {
|
||||
const evaluation = await evaluateCost({
|
||||
userId: input.performedByUserId,
|
||||
estimatedCost: 0,
|
||||
confirmedCost: false,
|
||||
});
|
||||
await recordCostAuditDecision({
|
||||
userId: input.performedByUserId,
|
||||
action: 'rmm_execute',
|
||||
evaluation: { ...evaluation, decision: 'blocked', decisionReason: `Rate-limited: ${recent} executions in last 24h (limit ${RATE_LIMIT_PER_24H})` },
|
||||
context: { scriptId: input.scriptId, recent24h: recent, limit: RATE_LIMIT_PER_24H },
|
||||
});
|
||||
throw new Error(
|
||||
`RMM execution rate limit reached (${recent}/${RATE_LIMIT_PER_24H} in 24h).`
|
||||
);
|
||||
}
|
||||
// Approved decision logged for telemetry.
|
||||
const evaluation = await evaluateCost({
|
||||
userId: input.performedByUserId,
|
||||
estimatedCost: 0,
|
||||
confirmedCost: false,
|
||||
});
|
||||
await recordCostAuditDecision({
|
||||
userId: input.performedByUserId,
|
||||
action: 'rmm_execute',
|
||||
evaluation,
|
||||
context: { scriptId: input.scriptId, recent24h: recent, deviceUid, hostname },
|
||||
});
|
||||
}
|
||||
|
||||
// Fork on transport — b2_upload uses the LogLift component + webhook flow.
|
||||
if (script.transport === 'b2_upload') {
|
||||
return dispatchB2Upload({
|
||||
script,
|
||||
deviceUid,
|
||||
hostname,
|
||||
companyId,
|
||||
assetType,
|
||||
assetId,
|
||||
performedByUserId: input.performedByUserId,
|
||||
triggeredByAuditId: input.triggeredByAuditId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve the Overshell component (discover-on-demand if cache empty).
|
||||
let componentUid: string;
|
||||
let variableName: string;
|
||||
try {
|
||||
const resolved = await resolveOvershellComponent();
|
||||
componentUid = resolved.componentUid;
|
||||
variableName = resolved.variableName;
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Could not resolve the Overshell component'
|
||||
);
|
||||
}
|
||||
|
||||
const jobName = `Pulse: ${script.name}`;
|
||||
const variables = [{ name: variableName, value: script.body }];
|
||||
|
||||
// Insert pending row first.
|
||||
const created = await createPendingExecution({
|
||||
scriptId: script.id,
|
||||
scriptVersion: script.version,
|
||||
targetType: input.target.type,
|
||||
targetDeviceUid: deviceUid,
|
||||
targetHostname: hostname,
|
||||
targetCompanyId: companyId,
|
||||
triggeredByAuditId: input.triggeredByAuditId ?? null,
|
||||
assetType,
|
||||
assetId,
|
||||
jobName,
|
||||
variables,
|
||||
performedByUserId: input.performedByUserId,
|
||||
});
|
||||
|
||||
// Dispatch.
|
||||
const client = getDattoRMMClient();
|
||||
let jobUid: string | null = null;
|
||||
try {
|
||||
const resp = await client.runQuickJob(deviceUid, {
|
||||
jobName,
|
||||
jobComponent: {
|
||||
componentUid,
|
||||
variables,
|
||||
},
|
||||
});
|
||||
// Datto's response is a {} on success per their convention; the job uid
|
||||
// sometimes comes back in different shapes depending on tenant config.
|
||||
// Accept either shape and persist whatever we can.
|
||||
jobUid =
|
||||
(resp?.uid as string | undefined) ??
|
||||
(resp?.jobUid as string | undefined) ??
|
||||
(resp?.id as string | undefined) ??
|
||||
(resp?.job?.uid as string | undefined) ??
|
||||
null;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await markExecutionFailedToDispatch(created.id, message);
|
||||
return { executionId: created.id, status: 'failed', error: message };
|
||||
}
|
||||
|
||||
if (!jobUid) {
|
||||
// Datto accepted the request but didn't return an id we can poll. Mark
|
||||
// failed-to-dispatch so we don't leave the row dangling. The next run
|
||||
// can be resubmitted.
|
||||
const message =
|
||||
'Datto RMM accepted the runQuickJob request but did not return a job uid.';
|
||||
await markExecutionFailedToDispatch(created.id, message);
|
||||
return { executionId: created.id, status: 'failed', error: message };
|
||||
}
|
||||
|
||||
await markExecutionRunning(created.id, jobUid);
|
||||
|
||||
// Generic admin-visible audit entry.
|
||||
await audit.log({
|
||||
userId: input.performedByUserId ?? undefined,
|
||||
action: 'rmm.execute',
|
||||
resource: 'datto_device',
|
||||
resourceId: deviceUid,
|
||||
details: {
|
||||
execution_id: created.id,
|
||||
script_id: script.id,
|
||||
target_type: input.target.type,
|
||||
job_uid: jobUid,
|
||||
hostname,
|
||||
},
|
||||
});
|
||||
|
||||
return { executionId: created.id, status: 'running' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a LogLift-style execution: the Datto component uploads gzipped
|
||||
* evidence to B2 and POSTs a metadata webhook back to Pulse. The execution
|
||||
* row stays `running` until the webhook lands (or the 5-minute timeout fires).
|
||||
*
|
||||
* Variables passed to the Datto component:
|
||||
* RunId — correlation token; the webhook handler uses it to find
|
||||
* this row.
|
||||
* ClientId — Datto-side site uuid (informational; matches CS_PROFILE_UID).
|
||||
* ObjectKey — full B2 object key the collector will PUT to.
|
||||
* UploadUrl — pre-presigned PUT URL (30-min TTL); collector uploads
|
||||
* the gzip directly to B2 with no presign-fetch round-trip.
|
||||
* WebhookUrl — Pulse's /api/rmm/loglift/upload endpoint.
|
||||
* WebhookSecret — OPENCLAW_API_KEY; collector sends it as x-openclaw-key.
|
||||
*
|
||||
* Optional IssueDescription / TicketNumber can be added later by extending
|
||||
* QueueExecutionInput.
|
||||
*/
|
||||
async function dispatchB2Upload(args: {
|
||||
script: NonNullable<ReturnType<typeof getScript>>;
|
||||
deviceUid: string;
|
||||
hostname: string | null;
|
||||
companyId: number | string | null;
|
||||
assetType: 'flexible_asset' | 'configuration' | null;
|
||||
assetId: number | string | null;
|
||||
performedByUserId: string | null;
|
||||
triggeredByAuditId: string | null;
|
||||
}): Promise<QueueExecutionResult> {
|
||||
const {
|
||||
script,
|
||||
deviceUid,
|
||||
hostname,
|
||||
companyId,
|
||||
assetType,
|
||||
assetId,
|
||||
performedByUserId,
|
||||
triggeredByAuditId,
|
||||
} = args;
|
||||
|
||||
// The collector needs the Datto site uuid (datto_rmm_sites.uid) — that's
|
||||
// what folds into the B2 object key as ClientId.
|
||||
const siteRes = await postgresClient.query<{ site_uid: string }>(
|
||||
`SELECT s.uid AS site_uid
|
||||
FROM datto_rmm_devices d
|
||||
JOIN datto_rmm_sites s ON s.id = d.site_id
|
||||
WHERE d.uid = $1
|
||||
LIMIT 1`,
|
||||
[deviceUid]
|
||||
);
|
||||
if (siteRes.rowCount === 0) {
|
||||
throw new Error(
|
||||
`Datto device ${deviceUid} has no associated site — cannot derive ClientId for LogLift.`
|
||||
);
|
||||
}
|
||||
const clientId = siteRes.rows[0].site_uid;
|
||||
|
||||
// Resolve LogLift component (discover-on-demand if cache empty).
|
||||
let logliftUid: string;
|
||||
try {
|
||||
const resolved = await resolveLogliftComponent();
|
||||
logliftUid = resolved.componentUid;
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
err instanceof Error ? err.message : 'Could not resolve the LogLift component'
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = (process.env.BETTER_AUTH_URL ?? '').replace(/\/$/, '');
|
||||
if (!baseUrl) {
|
||||
throw new Error(
|
||||
'BETTER_AUTH_URL must be set for LogLift dispatch (used as the webhook URL the collector POSTs to).'
|
||||
);
|
||||
}
|
||||
const webhookUrl = `${baseUrl}/api/rmm/loglift/upload`;
|
||||
const webhookSecret = process.env.OPENCLAW_API_KEY;
|
||||
if (!webhookSecret) {
|
||||
throw new Error(
|
||||
'OPENCLAW_API_KEY must be set for LogLift dispatch (collector authenticates with it as x-openclaw-key).'
|
||||
);
|
||||
}
|
||||
|
||||
const runId = `pulse_${randomBytes(6).toString('hex')}_${Date.now()}`;
|
||||
const jobName = `Pulse: ${script.name}`;
|
||||
|
||||
// Build the B2 object key + presigned PUT URL up-front. The collector
|
||||
// uploads directly with no presign-fetch round-trip. Hostname must be
|
||||
// present (asset_self LogLift dispatch always knows the target hostname).
|
||||
if (!hostname) {
|
||||
throw new Error(
|
||||
`LogLift dispatch requires a hostname for device ${deviceUid} (used as the B2 object-key path component).`
|
||||
);
|
||||
}
|
||||
const safeHostname = hostname.replace(/[^A-Za-z0-9_.-]/g, '_');
|
||||
const ts = formatObjectKeyTimestamp(new Date());
|
||||
const objectKey = `${clientId}/${safeHostname}/eventlogs_${ts}.json.gz`;
|
||||
const uploadUrl = presignUpload(objectKey, 1800); // 30-min TTL
|
||||
|
||||
const variables = [
|
||||
{ name: 'RunId', value: runId },
|
||||
{ name: 'ClientId', value: clientId },
|
||||
{ name: 'ObjectKey', value: objectKey },
|
||||
{ name: 'UploadUrl', value: uploadUrl },
|
||||
{ name: 'WebhookUrl', value: webhookUrl },
|
||||
{ name: 'WebhookSecret', value: webhookSecret },
|
||||
];
|
||||
|
||||
// Strip secrets/signed URLs before persisting — the row's `variables`
|
||||
// column is admin-readable. ObjectKey is fine to keep; the presigned URL
|
||||
// contains a SigV4 signature that lets anyone PUT to that key for 30 min.
|
||||
const persistedVariables = variables.filter(
|
||||
(v) => v.name !== 'WebhookSecret' && v.name !== 'UploadUrl'
|
||||
);
|
||||
|
||||
const created = await createPendingExecution({
|
||||
scriptId: script.id,
|
||||
scriptVersion: script.version,
|
||||
targetType: 'asset_self',
|
||||
targetDeviceUid: deviceUid,
|
||||
targetHostname: hostname,
|
||||
targetCompanyId: companyId,
|
||||
triggeredByAuditId,
|
||||
assetType,
|
||||
assetId,
|
||||
jobName,
|
||||
variables: persistedVariables,
|
||||
performedByUserId,
|
||||
transport: 'b2_upload',
|
||||
runId,
|
||||
});
|
||||
|
||||
const client = getDattoRMMClient();
|
||||
let jobUid: string | null = null;
|
||||
try {
|
||||
const resp = await client.runQuickJob(deviceUid, {
|
||||
jobName,
|
||||
jobComponent: {
|
||||
componentUid: logliftUid,
|
||||
variables,
|
||||
},
|
||||
});
|
||||
jobUid =
|
||||
(resp?.uid as string | undefined) ??
|
||||
(resp?.jobUid as string | undefined) ??
|
||||
(resp?.id as string | undefined) ??
|
||||
(resp?.job?.uid as string | undefined) ??
|
||||
null;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await markExecutionFailedToDispatch(created.id, message);
|
||||
return { executionId: created.id, status: 'failed', error: message };
|
||||
}
|
||||
|
||||
if (!jobUid) {
|
||||
const message =
|
||||
'Datto RMM accepted the runQuickJob request but did not return a job uid.';
|
||||
await markExecutionFailedToDispatch(created.id, message);
|
||||
return { executionId: created.id, status: 'failed', error: message };
|
||||
}
|
||||
|
||||
await markExecutionRunning(created.id, jobUid);
|
||||
|
||||
await audit.log({
|
||||
userId: performedByUserId ?? undefined,
|
||||
action: 'rmm.loglift.dispatched',
|
||||
resource: 'datto_device',
|
||||
resourceId: deviceUid,
|
||||
details: {
|
||||
execution_id: created.id,
|
||||
script_id: script.id,
|
||||
run_id: runId,
|
||||
client_id: clientId,
|
||||
object_key: objectKey,
|
||||
job_uid: jobUid,
|
||||
hostname,
|
||||
},
|
||||
});
|
||||
|
||||
return { executionId: created.id, status: 'running' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Date as `YYYYMMDD_HHMMSS` (UTC). Matches the file portion of
|
||||
* `OBJECT_KEY_REGEX` (`eventlogs_[0-9_]+\.json\.gz`).
|
||||
*/
|
||||
function formatObjectKeyTimestamp(d: Date): string {
|
||||
const y = d.getUTCFullYear();
|
||||
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getUTCDate()).padStart(2, '0');
|
||||
const h = String(d.getUTCHours()).padStart(2, '0');
|
||||
const mi = String(d.getUTCMinutes()).padStart(2, '0');
|
||||
const s = String(d.getUTCSeconds()).padStart(2, '0');
|
||||
return `${y}${m}${day}_${h}${mi}${s}`;
|
||||
}
|
||||
|
||||
export const _EXECUTOR_INTERNALS = { RATE_LIMIT_PER_24H, formatObjectKeyTimestamp };
|
||||
127
lib/services/rmm/loglift-matcher.ts
Normal file
127
lib/services/rmm/loglift-matcher.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
/**
|
||||
* Resolve linkages from a LogLift webhook payload to Pulse's identity model.
|
||||
*
|
||||
* clientId (Datto site uid) → datto_rmm_sites.id, autotask_company_id
|
||||
* computerName (hostname) → datto_rmm_devices.uid (the real Datto uid)
|
||||
* hostname + company → itg_configurations.id (single match only)
|
||||
*
|
||||
* Auto-audit only fires when a Configuration matches exactly one row —
|
||||
* multi-match is logged but skipped to avoid auditing the wrong asset.
|
||||
*/
|
||||
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
export interface DattoSiteResolution {
|
||||
datto_site_id: number;
|
||||
datto_site_uid: string;
|
||||
autotask_company_id: string | null;
|
||||
autotask_company_name: string | null;
|
||||
}
|
||||
|
||||
export async function resolveDattoSiteByUid(
|
||||
siteUid: string
|
||||
): Promise<DattoSiteResolution | null> {
|
||||
// datto_rmm_sites.uid is the Datto-side site uuid; the autotask_company_id
|
||||
// FK is sometimes null (only the company NAME is populated). Mirror the
|
||||
// Phase 4.2 fallback: match by company name if FK is null.
|
||||
const res = await postgresClient.query<{
|
||||
id: number;
|
||||
uid: string;
|
||||
autotask_company_id: string | null;
|
||||
autotask_company_name: string | null;
|
||||
by_name_company_id: string | null;
|
||||
}>(
|
||||
`SELECT s.id,
|
||||
s.uid,
|
||||
s.autotask_company_id::text AS autotask_company_id,
|
||||
s.autotask_company_name,
|
||||
(SELECT c.id::text FROM companies c
|
||||
WHERE LOWER(c.company_name) = LOWER(s.autotask_company_name)
|
||||
LIMIT 1) AS by_name_company_id
|
||||
FROM datto_rmm_sites s
|
||||
WHERE s.uid = $1
|
||||
LIMIT 1`,
|
||||
[siteUid]
|
||||
);
|
||||
if (res.rowCount === 0) return null;
|
||||
const r = res.rows[0];
|
||||
return {
|
||||
datto_site_id: r.id,
|
||||
datto_site_uid: r.uid,
|
||||
autotask_company_id: r.autotask_company_id ?? r.by_name_company_id,
|
||||
autotask_company_name: r.autotask_company_name,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveDattoDeviceByHostname(
|
||||
siteId: number,
|
||||
hostname: string
|
||||
): Promise<{ uid: string; hostname: string | null; online: boolean } | null> {
|
||||
const res = await postgresClient.query<{
|
||||
uid: string;
|
||||
hostname: string | null;
|
||||
online: boolean;
|
||||
}>(
|
||||
`SELECT uid, hostname, online
|
||||
FROM datto_rmm_devices
|
||||
WHERE site_id = $1
|
||||
AND LOWER(hostname) = LOWER($2)
|
||||
LIMIT 1`,
|
||||
[siteId, hostname]
|
||||
);
|
||||
return res.rowCount === 0 ? null : res.rows[0];
|
||||
}
|
||||
|
||||
export interface ConfigurationMatch {
|
||||
id: string;
|
||||
hostname: string | null;
|
||||
name: string;
|
||||
/** True when the company has exactly one Configuration with this hostname. */
|
||||
single_match: boolean;
|
||||
}
|
||||
|
||||
export async function resolveConfigurationByHostname(
|
||||
autotaskCompanyId: string | number,
|
||||
hostname: string
|
||||
): Promise<ConfigurationMatch | null> {
|
||||
// Two-pass: count + fetch. Avoids ambiguous auto-audits when multiple
|
||||
// Configurations share a hostname (rare but possible after a sync glitch).
|
||||
const countRes = await postgresClient.query<{ n: string }>(
|
||||
`SELECT COUNT(*)::text AS n
|
||||
FROM itg_configurations c
|
||||
JOIN companies comp ON LOWER(comp.company_name) = LOWER(c.organization_name)
|
||||
WHERE comp.id = $1
|
||||
AND (
|
||||
LOWER(c.hostname) = LOWER($2)
|
||||
OR LOWER(c.name) = LOWER($2)
|
||||
)`,
|
||||
[autotaskCompanyId, hostname]
|
||||
);
|
||||
const n = Number(countRes.rows[0]?.n ?? 0);
|
||||
if (n === 0) return null;
|
||||
|
||||
const fetchRes = await postgresClient.query<{
|
||||
id: string;
|
||||
hostname: string | null;
|
||||
name: string;
|
||||
}>(
|
||||
`SELECT c.id::text AS id, c.hostname, c.name
|
||||
FROM itg_configurations c
|
||||
JOIN companies comp ON LOWER(comp.company_name) = LOWER(c.organization_name)
|
||||
WHERE comp.id = $1
|
||||
AND (
|
||||
LOWER(c.hostname) = LOWER($2)
|
||||
OR LOWER(c.name) = LOWER($2)
|
||||
)
|
||||
LIMIT 1`,
|
||||
[autotaskCompanyId, hostname]
|
||||
);
|
||||
if (fetchRes.rowCount === 0) return null;
|
||||
const r = fetchRes.rows[0];
|
||||
return {
|
||||
id: r.id,
|
||||
hostname: r.hostname,
|
||||
name: r.name,
|
||||
single_match: n === 1,
|
||||
};
|
||||
}
|
||||
476
lib/services/rmm/loglift-receiver.ts
Normal file
476
lib/services/rmm/loglift-receiver.ts
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
/**
|
||||
* LogLift webhook receiver business logic.
|
||||
*
|
||||
* 1. Validate the webhook payload + object key shape.
|
||||
* 2. Resolve linkages: Datto site → Autotask company; hostname → Datto
|
||||
* device + IT Glue Configuration.
|
||||
* 3. Correlate to a Pulse-dispatched execution by run_id, or insert a
|
||||
* fresh out-of-band row.
|
||||
* 4. Download the gzipped JSON from B2 (25MB cap).
|
||||
* 5. Decompress with a zip-bomb guard (refuse if the inflated payload
|
||||
* claims > 100MB).
|
||||
* 6. Slim to a storage-friendly shape: system_context + summary + top 100
|
||||
* events by severity. The full gzip stays in B2 forever.
|
||||
* 7. Persist the row complete + redact for safety.
|
||||
* 8. Auto-fire an asset-first audit when the Configuration matched
|
||||
* uniquely.
|
||||
*
|
||||
* The full audit + correlation chain is intentionally synchronous so the
|
||||
* webhook response can include the executionId + matched_configuration_id.
|
||||
* The runAssetAudit() call is fire-and-forget so the agent doesn't wait
|
||||
* for an LLM call.
|
||||
*/
|
||||
|
||||
import { gunzipSync } from 'node:zlib';
|
||||
|
||||
import { downloadToBuffer, OBJECT_KEY_REGEX } from '@/lib/services/b2/client';
|
||||
import { redact } from '@/lib/services/analyzer/itglue-redact';
|
||||
import { runAssetAudit } from '@/lib/services/analyzer/asset-audit/runner';
|
||||
import { audit } from '@/lib/services/audit';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import {
|
||||
createOutOfBandUploadExecution,
|
||||
findExecutionByRunId,
|
||||
markExecutionFromB2Upload,
|
||||
} from './persistence';
|
||||
import {
|
||||
resolveConfigurationByHostname,
|
||||
resolveDattoDeviceByHostname,
|
||||
resolveDattoSiteByUid,
|
||||
} from './loglift-matcher';
|
||||
|
||||
/** Hard cap on the *inflated* payload size — defense against zip bombs. */
|
||||
const MAX_INFLATED_BYTES = 100 * 1024 * 1024; // 100 MB
|
||||
|
||||
/** Cap on how many events we copy into Postgres. The full set stays in B2. */
|
||||
const TOP_EVENTS_LIMIT = 100;
|
||||
|
||||
export interface LogliftWebhookPayload {
|
||||
runId: string;
|
||||
clientId: string; // Datto site uuid
|
||||
computerName: string;
|
||||
deviceUid?: string | null;
|
||||
summary: {
|
||||
totalEvents?: number | null;
|
||||
criticalEvents?: number | null;
|
||||
errorCount?: number | null;
|
||||
warningCount?: number | null;
|
||||
timeRange?: string | null;
|
||||
};
|
||||
objectKey: string;
|
||||
collectedAt: string;
|
||||
rmmContext?: {
|
||||
siteName?: string | null;
|
||||
siteUid?: string | null;
|
||||
accountUid?: string | null;
|
||||
} | null;
|
||||
issueDescription?: string | null;
|
||||
ticketNumber?: string | null;
|
||||
}
|
||||
|
||||
export interface LogliftReceiveResult {
|
||||
executionId: string;
|
||||
matched: {
|
||||
datto_site_id: number | null;
|
||||
datto_device_uid: string | null;
|
||||
autotask_company_id: string | null;
|
||||
configuration_id: string | null;
|
||||
configuration_single_match: boolean;
|
||||
};
|
||||
parsed: {
|
||||
total_events: number;
|
||||
critical_events: number;
|
||||
error_count: number | null;
|
||||
warning_count: number | null;
|
||||
time_range: string | null;
|
||||
};
|
||||
audit_id: string | null;
|
||||
}
|
||||
|
||||
interface LogliftRawPayload {
|
||||
metadata?: unknown;
|
||||
systemContext?: unknown;
|
||||
summary?: {
|
||||
TotalEvents?: number;
|
||||
CriticalEvents?: number;
|
||||
ByLevel?: Record<string, number>;
|
||||
TimeRange?: unknown;
|
||||
TopEventIds?: unknown;
|
||||
};
|
||||
events?: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Severity ranking. Critical > Error > Warning > Information > Verbose. The
|
||||
* Windows event log uses both the LevelDisplayName string and a numeric
|
||||
* Level (1=Critical, 2=Error, 3=Warning, 4=Information, 5=Verbose).
|
||||
*/
|
||||
function severityRank(ev: Record<string, unknown>): number {
|
||||
const lvlName = String(
|
||||
ev.LevelDisplayName ?? ev.levelDisplayName ?? ev.level ?? ''
|
||||
).toLowerCase();
|
||||
if (lvlName.includes('critical')) return 5;
|
||||
if (lvlName.includes('error')) return 4;
|
||||
if (lvlName.includes('warn')) return 3;
|
||||
if (lvlName.includes('info')) return 2;
|
||||
if (lvlName.includes('verbose')) return 1;
|
||||
const numLevel = Number(ev.Level ?? ev.level);
|
||||
if (Number.isFinite(numLevel)) {
|
||||
if (numLevel === 1) return 5;
|
||||
if (numLevel === 2) return 4;
|
||||
if (numLevel === 3) return 3;
|
||||
if (numLevel === 4) return 2;
|
||||
if (numLevel === 5) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function eventTimestamp(ev: Record<string, unknown>): number {
|
||||
const t = ev.TimeCreated ?? ev.timeCreated ?? ev.timestamp ?? null;
|
||||
if (typeof t !== 'string') return 0;
|
||||
const n = Date.parse(t);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slim the LogLift payload to a storage-friendly shape. Drops the full
|
||||
* `events` array (often thousands), keeps the top N by severity then
|
||||
* recency. The full gzip lives in B2 for forensic replay.
|
||||
*/
|
||||
export function slimLogliftPayload(raw: LogliftRawPayload): {
|
||||
metadata: unknown;
|
||||
system_context: unknown;
|
||||
summary: unknown;
|
||||
top_events: Array<Record<string, unknown>>;
|
||||
event_count_total: number;
|
||||
top_events_truncated: boolean;
|
||||
} {
|
||||
const events = Array.isArray(raw.events) ? raw.events : [];
|
||||
const total = events.length;
|
||||
|
||||
const ranked = events
|
||||
.map((e, i) => ({ e, rank: severityRank(e), ts: eventTimestamp(e), i }))
|
||||
.sort((a, b) => {
|
||||
if (a.rank !== b.rank) return b.rank - a.rank;
|
||||
if (a.ts !== b.ts) return b.ts - a.ts;
|
||||
return a.i - b.i;
|
||||
})
|
||||
.slice(0, TOP_EVENTS_LIMIT)
|
||||
.map((x) => x.e);
|
||||
|
||||
return {
|
||||
metadata: raw.metadata ?? null,
|
||||
system_context: raw.systemContext ?? null,
|
||||
summary: raw.summary ?? null,
|
||||
top_events: ranked,
|
||||
event_count_total: total,
|
||||
top_events_truncated: total > TOP_EVENTS_LIMIT,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompress the gzipped LogLift payload with a zip-bomb guard. The Node
|
||||
* zlib API doesn't expose a streaming size cap directly, so we check the
|
||||
* gzip's ISIZE trailer (last 4 bytes of the gzip stream — the modulo-2^32
|
||||
* inflated size). Refuse before decompressing if it claims > MAX_INFLATED_BYTES.
|
||||
*/
|
||||
export function decompressLogliftPayload(gz: Buffer): Buffer {
|
||||
if (gz.length < 18) {
|
||||
throw new Error('LogLift payload too small to be a valid gzip stream');
|
||||
}
|
||||
// gzip ISIZE: last 4 bytes, little-endian, = uncompressed size mod 2^32.
|
||||
// It's a rough hint — for streams > 4 GiB it wraps, but at our scale
|
||||
// (collector targets are < 25 MB compressed) it's a tight enough guard.
|
||||
const isize = gz.readUInt32LE(gz.length - 4);
|
||||
if (isize > MAX_INFLATED_BYTES) {
|
||||
throw new Error(
|
||||
`LogLift payload claims ${isize} bytes inflated; cap is ${MAX_INFLATED_BYTES}`
|
||||
);
|
||||
}
|
||||
const out = gunzipSync(gz);
|
||||
if (out.length > MAX_INFLATED_BYTES) {
|
||||
throw new Error(
|
||||
`LogLift payload inflated to ${out.length} bytes; cap is ${MAX_INFLATED_BYTES}`
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function processLogliftWebhook(
|
||||
payload: LogliftWebhookPayload
|
||||
): Promise<LogliftReceiveResult> {
|
||||
if (!OBJECT_KEY_REGEX.test(payload.objectKey)) {
|
||||
throw new Error(`Invalid objectKey shape: ${payload.objectKey.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
// 1. Resolve the Datto site → Autotask company.
|
||||
const site = await resolveDattoSiteByUid(payload.clientId);
|
||||
// 2. Resolve the device by hostname within that site (best-effort — we
|
||||
// fall back to the deviceUid the agent claimed).
|
||||
let deviceUid: string | null = null;
|
||||
let deviceHostname: string | null = payload.computerName;
|
||||
if (site) {
|
||||
const dev = await resolveDattoDeviceByHostname(site.datto_site_id, payload.computerName);
|
||||
if (dev) {
|
||||
deviceUid = dev.uid;
|
||||
deviceHostname = dev.hostname ?? deviceHostname;
|
||||
}
|
||||
}
|
||||
if (!deviceUid) {
|
||||
// Use the agent's claimed deviceUid as a last resort. The collector's
|
||||
// current PowerShell stuffs the hostname here — that's still useful for
|
||||
// joining downstream even if it's not the canonical Datto uid.
|
||||
deviceUid = payload.deviceUid ?? payload.computerName;
|
||||
}
|
||||
|
||||
// 3. Resolve the IT Glue Configuration when we have a company anchor.
|
||||
let configMatch: Awaited<ReturnType<typeof resolveConfigurationByHostname>> = null;
|
||||
if (site?.autotask_company_id) {
|
||||
configMatch = await resolveConfigurationByHostname(
|
||||
site.autotask_company_id,
|
||||
payload.computerName
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Correlate to a Pulse-dispatched execution by run_id.
|
||||
const correlated = await findExecutionByRunId(payload.runId);
|
||||
let executionId: string;
|
||||
if (correlated) {
|
||||
executionId = correlated.id;
|
||||
} else {
|
||||
const created = await createOutOfBandUploadExecution({
|
||||
scriptId: 'loglift-eventlogs',
|
||||
scriptVersion: 1,
|
||||
runId: payload.runId,
|
||||
jobName: 'LogLift event-log collection (out-of-band)',
|
||||
targetDeviceUid: deviceUid,
|
||||
targetHostname: deviceHostname,
|
||||
targetCompanyId: site?.autotask_company_id ?? null,
|
||||
assetType: configMatch ? 'configuration' : null,
|
||||
assetId: configMatch?.id ?? null,
|
||||
variables: {
|
||||
runId: payload.runId,
|
||||
clientId: payload.clientId,
|
||||
computerName: payload.computerName,
|
||||
objectKey: payload.objectKey,
|
||||
},
|
||||
});
|
||||
executionId = created.id;
|
||||
}
|
||||
|
||||
// 5. Download the gzipped payload from B2.
|
||||
const gz = await downloadToBuffer(payload.objectKey);
|
||||
// 6. Decompress with zip-bomb guard.
|
||||
const json = decompressLogliftPayload(gz);
|
||||
|
||||
// 7. Parse + slim.
|
||||
let raw: LogliftRawPayload;
|
||||
try {
|
||||
raw = JSON.parse(json.toString('utf8')) as LogliftRawPayload;
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`LogLift payload at ${payload.objectKey} is not valid JSON: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`
|
||||
);
|
||||
}
|
||||
const slim = slimLogliftPayload(raw);
|
||||
|
||||
// 8. Redact and persist. We keep the raw webhook summary alongside the
|
||||
// slimmed-from-payload one so the audit pipeline can rely on either.
|
||||
const persistedEvidence = redact({
|
||||
schema_version: 1,
|
||||
transport: 'b2_upload',
|
||||
object_key: payload.objectKey,
|
||||
collected_at: payload.collectedAt,
|
||||
rmm_context: payload.rmmContext ?? null,
|
||||
issue_description: payload.issueDescription ?? null,
|
||||
ticket_number: payload.ticketNumber ?? null,
|
||||
webhook_summary: payload.summary,
|
||||
metadata: slim.metadata,
|
||||
system_context: slim.system_context,
|
||||
summary: slim.summary,
|
||||
top_events: slim.top_events,
|
||||
event_count_total: slim.event_count_total,
|
||||
top_events_truncated: slim.top_events_truncated,
|
||||
});
|
||||
|
||||
await markExecutionFromB2Upload({
|
||||
id: executionId,
|
||||
evidenceObjectKey: payload.objectKey,
|
||||
parsedEvidence: persistedEvidence,
|
||||
targetCompanyId: site?.autotask_company_id ?? null,
|
||||
assetType: configMatch ? 'configuration' : null,
|
||||
assetId: configMatch?.id ?? null,
|
||||
});
|
||||
|
||||
// 8a. Dual-write into the new endpoint data model. Resolve to a configuration_item
|
||||
// via the device_external_ids xref — preferred by IT Glue config (when matched),
|
||||
// falling back to the Datto device UID.
|
||||
let configurationItemId: string | null = null;
|
||||
if (configMatch?.id) {
|
||||
const r = await postgresClient.query<{ configuration_item_id: string | null }>(
|
||||
`SELECT configuration_item_id::text
|
||||
FROM device_external_ids
|
||||
WHERE source = 'itglue' AND source_id = $1
|
||||
LIMIT 1`,
|
||||
[String(configMatch.id)]
|
||||
);
|
||||
configurationItemId = r.rows[0]?.configuration_item_id ?? null;
|
||||
}
|
||||
if (!configurationItemId && deviceUid) {
|
||||
const r = await postgresClient.query<{ configuration_item_id: string | null }>(
|
||||
`SELECT configuration_item_id::text
|
||||
FROM device_external_ids
|
||||
WHERE source = 'datto_rmm' AND source_id = $1
|
||||
LIMIT 1`,
|
||||
[deviceUid]
|
||||
);
|
||||
configurationItemId = r.rows[0]?.configuration_item_id ?? null;
|
||||
}
|
||||
|
||||
const obsRes = await postgresClient.query<{ id: string }>(
|
||||
`INSERT INTO device_observations
|
||||
(configuration_item_id, source, kind, collected_at, payload, evidence_object_key, run_id)
|
||||
VALUES ($1, 'loglift', 'loglift_eventlogs', $2::timestamptz, $3::jsonb, $4, $5)
|
||||
RETURNING id::text`,
|
||||
[
|
||||
configurationItemId,
|
||||
payload.collectedAt,
|
||||
JSON.stringify(persistedEvidence),
|
||||
payload.objectKey,
|
||||
payload.runId,
|
||||
]
|
||||
);
|
||||
const observationId = obsRes.rows[0]?.id ?? null;
|
||||
|
||||
await audit.log({
|
||||
action: 'rmm.loglift.received',
|
||||
resource: 'datto_device',
|
||||
resourceId: deviceUid,
|
||||
details: {
|
||||
execution_id: executionId,
|
||||
run_id: payload.runId,
|
||||
object_key: payload.objectKey,
|
||||
computer_name: payload.computerName,
|
||||
total_events: slim.event_count_total,
|
||||
critical_events: payload.summary.criticalEvents ?? null,
|
||||
datto_site_id: site?.datto_site_id ?? null,
|
||||
autotask_company_id: site?.autotask_company_id ?? null,
|
||||
configuration_id: configMatch?.id ?? null,
|
||||
configuration_single_match: configMatch?.single_match ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
if (configMatch && configMatch.single_match) {
|
||||
await audit.log({
|
||||
action: 'rmm.loglift.matched',
|
||||
resource: 'itg_configuration',
|
||||
resourceId: configMatch.id,
|
||||
details: {
|
||||
execution_id: executionId,
|
||||
run_id: payload.runId,
|
||||
autotask_company_id: site?.autotask_company_id ?? null,
|
||||
hostname: payload.computerName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 9. Auto-audit on a single-match Configuration. Fire-and-forget — the
|
||||
// webhook returns immediately. The audit row will appear on the
|
||||
// Configuration's audit page when the LLM call finishes.
|
||||
let auditId: string | null = null;
|
||||
if (configMatch && configMatch.single_match) {
|
||||
try {
|
||||
const result = await runAssetAudit({
|
||||
assetType: 'configuration',
|
||||
assetId: configMatch.id,
|
||||
generatedByUserId: null,
|
||||
provider: 'anthropic',
|
||||
});
|
||||
auditId = result.auditId;
|
||||
await audit.log({
|
||||
action: 'rmm.loglift.audit_triggered',
|
||||
resource: 'itg_configuration',
|
||||
resourceId: configMatch.id,
|
||||
details: {
|
||||
execution_id: executionId,
|
||||
audit_id: auditId,
|
||||
status: result.status,
|
||||
},
|
||||
});
|
||||
|
||||
// 9a. Mirror the resulting itglue_asset_audits row into endpoint_audits
|
||||
// so the new device-anchored model gets the same audit record. We
|
||||
// keep both during the cutover — endpoint_audits.legacy_itglue_audit_id
|
||||
// points back at the original.
|
||||
try {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO endpoint_audits (
|
||||
configuration_item_id, itglue_configuration_id, organization_id,
|
||||
generated_by_user_id, generated_at, provider, model_used,
|
||||
asset_snapshot, observations_consumed, ticket_count,
|
||||
field_gaps, notes_promotions, contradictions,
|
||||
overall_score, estimated_cost_usd, total_input_tokens, total_output_tokens,
|
||||
status, error_message, triggered_by_ticket_number, triggered_by_analysis_id,
|
||||
triggered_by_observation_id, legacy_itglue_audit_id
|
||||
)
|
||||
SELECT
|
||||
$2::bigint, ia.asset_id, ia.organization_id,
|
||||
ia.generated_by_user_id, ia.generated_at, ia.provider, ia.model_used,
|
||||
ia.asset_snapshot, $3::uuid[], ia.ticket_count,
|
||||
ia.field_gaps, ia.notes_promotions, ia.contradictions,
|
||||
ia.overall_score, ia.estimated_cost_usd, ia.total_input_tokens, ia.total_output_tokens,
|
||||
ia.status, ia.error_message, ia.triggered_by_ticket_number, ia.triggered_by_analysis_id,
|
||||
$4::uuid, ia.id
|
||||
FROM itglue_asset_audits ia
|
||||
WHERE ia.id = $1::uuid`,
|
||||
[
|
||||
auditId,
|
||||
configurationItemId,
|
||||
observationId ? [observationId] : [],
|
||||
observationId,
|
||||
]
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[LOGLIFT] endpoint_audits mirror failed for audit ${auditId}:`,
|
||||
err instanceof Error ? err.message : err
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Don't fail the webhook if the auto-audit blows up — the evidence
|
||||
// is already persisted; the user can re-run from the UI.
|
||||
console.warn(
|
||||
`[LOGLIFT] auto-audit failed for configuration ${configMatch.id}:`,
|
||||
err instanceof Error ? err.message : err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
executionId,
|
||||
matched: {
|
||||
datto_site_id: site?.datto_site_id ?? null,
|
||||
datto_device_uid: deviceUid,
|
||||
autotask_company_id: site?.autotask_company_id ?? null,
|
||||
configuration_id: configMatch?.id ?? null,
|
||||
configuration_single_match: configMatch?.single_match ?? false,
|
||||
},
|
||||
parsed: {
|
||||
total_events: slim.event_count_total,
|
||||
critical_events: payload.summary.criticalEvents ?? 0,
|
||||
error_count: payload.summary.errorCount ?? null,
|
||||
warning_count: payload.summary.warningCount ?? null,
|
||||
time_range: payload.summary.timeRange ?? null,
|
||||
},
|
||||
audit_id: auditId,
|
||||
};
|
||||
}
|
||||
|
||||
export const _LOGLIFT_INTERNALS = {
|
||||
severityRank,
|
||||
eventTimestamp,
|
||||
MAX_INFLATED_BYTES,
|
||||
TOP_EVENTS_LIMIT,
|
||||
};
|
||||
488
lib/services/rmm/persistence.ts
Normal file
488
lib/services/rmm/persistence.ts
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
/**
|
||||
* Read/write helpers for rmm_executions.
|
||||
*
|
||||
* Status lifecycle: queued (row inserted, runQuickJob not yet returned) →
|
||||
* running (job_uid stored, poller is watching) → complete | failed | timeout.
|
||||
*/
|
||||
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
// Hard timeout for a dispatched RMM job — covers Datto queue latency, agent
|
||||
// pickup, script runtime, and result postback. LogLift in particular can run
|
||||
// long on busy machines (event log collection + gzip + B2 upload). Configurable
|
||||
// via env so we can bump it without a code change.
|
||||
const EXECUTION_TIMEOUT_MINUTES = (() => {
|
||||
const raw = parseInt(process.env.RMM_EXECUTION_TIMEOUT_MINUTES ?? '', 10);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 10;
|
||||
})();
|
||||
|
||||
export type RmmExecutionStatus = 'queued' | 'running' | 'complete' | 'failed' | 'timeout';
|
||||
export type RmmTargetType = 'site_anchor' | 'asset_self';
|
||||
export type RmmTransport = 'overshell_stdout' | 'b2_upload';
|
||||
|
||||
export interface RmmExecutionRow {
|
||||
id: string;
|
||||
scriptId: string;
|
||||
scriptVersion: number;
|
||||
targetType: RmmTargetType;
|
||||
targetDeviceUid: string;
|
||||
targetHostname: string | null;
|
||||
targetCompanyId: string | null;
|
||||
triggeredByAuditId: string | null;
|
||||
assetType: 'flexible_asset' | 'configuration' | null;
|
||||
assetId: string | null;
|
||||
jobUid: string | null;
|
||||
jobName: string;
|
||||
variables: unknown;
|
||||
status: RmmExecutionStatus;
|
||||
exitCode: number | null;
|
||||
rawStdout: string | null;
|
||||
rawStderr: string | null;
|
||||
parsedEvidence: unknown;
|
||||
parseError: string | null;
|
||||
errorMessage: string | null;
|
||||
performedByUserId: string | null;
|
||||
transport: RmmTransport;
|
||||
evidenceObjectKey: string | null;
|
||||
runId: string | null;
|
||||
queuedAt: string;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
timeoutAt: string;
|
||||
}
|
||||
|
||||
interface RawRow {
|
||||
id: string;
|
||||
script_id: string;
|
||||
script_version: number;
|
||||
target_type: RmmTargetType;
|
||||
target_device_uid: string;
|
||||
target_hostname: string | null;
|
||||
target_company_id: string | null;
|
||||
triggered_by_audit_id: string | null;
|
||||
asset_type: 'flexible_asset' | 'configuration' | null;
|
||||
asset_id: string | null;
|
||||
job_uid: string | null;
|
||||
job_name: string;
|
||||
variables: unknown;
|
||||
status: RmmExecutionStatus;
|
||||
exit_code: number | null;
|
||||
raw_stdout: string | null;
|
||||
raw_stderr: string | null;
|
||||
parsed_evidence: unknown;
|
||||
parse_error: string | null;
|
||||
error_message: string | null;
|
||||
performed_by_user_id: string | null;
|
||||
transport: RmmTransport;
|
||||
evidence_object_key: string | null;
|
||||
run_id: string | null;
|
||||
queued_at: Date;
|
||||
started_at: Date | null;
|
||||
completed_at: Date | null;
|
||||
timeout_at: Date;
|
||||
}
|
||||
|
||||
const SELECT = `
|
||||
id::text AS id,
|
||||
script_id, script_version,
|
||||
target_type, target_device_uid, target_hostname,
|
||||
target_company_id::text AS target_company_id,
|
||||
triggered_by_audit_id::text AS triggered_by_audit_id,
|
||||
asset_type, asset_id::text AS asset_id,
|
||||
job_uid, job_name, variables,
|
||||
status, exit_code, raw_stdout, raw_stderr,
|
||||
parsed_evidence, parse_error, error_message,
|
||||
performed_by_user_id,
|
||||
transport, evidence_object_key, run_id,
|
||||
queued_at, started_at, completed_at, timeout_at
|
||||
`;
|
||||
|
||||
function toRow(r: RawRow): RmmExecutionRow {
|
||||
return {
|
||||
id: r.id,
|
||||
scriptId: r.script_id,
|
||||
scriptVersion: r.script_version,
|
||||
targetType: r.target_type,
|
||||
targetDeviceUid: r.target_device_uid,
|
||||
targetHostname: r.target_hostname,
|
||||
targetCompanyId: r.target_company_id,
|
||||
triggeredByAuditId: r.triggered_by_audit_id,
|
||||
assetType: r.asset_type,
|
||||
assetId: r.asset_id,
|
||||
jobUid: r.job_uid,
|
||||
jobName: r.job_name,
|
||||
variables: r.variables,
|
||||
status: r.status,
|
||||
exitCode: r.exit_code,
|
||||
rawStdout: r.raw_stdout,
|
||||
rawStderr: r.raw_stderr,
|
||||
parsedEvidence: r.parsed_evidence,
|
||||
parseError: r.parse_error,
|
||||
errorMessage: r.error_message,
|
||||
performedByUserId: r.performed_by_user_id,
|
||||
transport: r.transport,
|
||||
evidenceObjectKey: r.evidence_object_key,
|
||||
runId: r.run_id,
|
||||
queuedAt: r.queued_at.toISOString(),
|
||||
startedAt: r.started_at?.toISOString() ?? null,
|
||||
completedAt: r.completed_at?.toISOString() ?? null,
|
||||
timeoutAt: r.timeout_at.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateExecutionInput {
|
||||
scriptId: string;
|
||||
scriptVersion: number;
|
||||
targetType: RmmTargetType;
|
||||
targetDeviceUid: string;
|
||||
targetHostname: string | null;
|
||||
targetCompanyId: number | string | null;
|
||||
triggeredByAuditId?: string | null;
|
||||
assetType?: 'flexible_asset' | 'configuration' | null;
|
||||
assetId?: number | string | null;
|
||||
jobName: string;
|
||||
variables: unknown;
|
||||
performedByUserId: string | null;
|
||||
transport?: RmmTransport;
|
||||
runId?: string | null;
|
||||
}
|
||||
|
||||
export async function createPendingExecution(
|
||||
input: CreateExecutionInput
|
||||
): Promise<{ id: string }> {
|
||||
const res = await postgresClient.query<{ id: string }>(
|
||||
`INSERT INTO rmm_executions
|
||||
(script_id, script_version, target_type, target_device_uid,
|
||||
target_hostname, target_company_id,
|
||||
triggered_by_audit_id, asset_type, asset_id,
|
||||
job_name, variables, status, performed_by_user_id,
|
||||
transport, run_id, timeout_at)
|
||||
VALUES ($1, $2, $3, $4,
|
||||
$5, $6,
|
||||
$7, $8, $9,
|
||||
$10, $11::jsonb, 'queued', $12,
|
||||
$13, $14, NOW() + ($15::int || ' minutes')::interval)
|
||||
RETURNING id::text AS id`,
|
||||
[
|
||||
input.scriptId,
|
||||
input.scriptVersion,
|
||||
input.targetType,
|
||||
input.targetDeviceUid,
|
||||
input.targetHostname,
|
||||
input.targetCompanyId,
|
||||
input.triggeredByAuditId ?? null,
|
||||
input.assetType ?? null,
|
||||
input.assetId ?? null,
|
||||
input.jobName,
|
||||
JSON.stringify(input.variables ?? {}),
|
||||
input.performedByUserId,
|
||||
input.transport ?? 'overshell_stdout',
|
||||
input.runId ?? null,
|
||||
EXECUTION_TIMEOUT_MINUTES,
|
||||
]
|
||||
);
|
||||
return { id: res.rows[0].id };
|
||||
}
|
||||
|
||||
export async function markExecutionRunning(
|
||||
id: string,
|
||||
jobUid: string
|
||||
): Promise<void> {
|
||||
await postgresClient.query(
|
||||
`UPDATE rmm_executions
|
||||
SET status = 'running',
|
||||
job_uid = $2,
|
||||
started_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[id, jobUid]
|
||||
);
|
||||
}
|
||||
|
||||
export async function markExecutionFailedToDispatch(
|
||||
id: string,
|
||||
message: string
|
||||
): Promise<void> {
|
||||
await postgresClient.query(
|
||||
`UPDATE rmm_executions
|
||||
SET status = 'failed',
|
||||
error_message = $2,
|
||||
completed_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[id, message]
|
||||
);
|
||||
}
|
||||
|
||||
export async function markExecutionComplete(input: {
|
||||
id: string;
|
||||
exitCode: number | null;
|
||||
rawStdout: string | null;
|
||||
rawStderr: string | null;
|
||||
parsedEvidence: unknown;
|
||||
parseError: string | null;
|
||||
}): Promise<void> {
|
||||
const finalStatus = input.exitCode !== null && input.exitCode !== 0 ? 'failed' : 'complete';
|
||||
await postgresClient.query(
|
||||
`UPDATE rmm_executions
|
||||
SET status = $2,
|
||||
exit_code = $3,
|
||||
raw_stdout = $4,
|
||||
raw_stderr = $5,
|
||||
parsed_evidence = $6::jsonb,
|
||||
parse_error = $7,
|
||||
completed_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[
|
||||
input.id,
|
||||
finalStatus,
|
||||
input.exitCode,
|
||||
input.rawStdout,
|
||||
input.rawStderr,
|
||||
input.parsedEvidence === undefined ? null : JSON.stringify(input.parsedEvidence ?? null),
|
||||
input.parseError,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
export async function markExecutionTimeout(id: string): Promise<void> {
|
||||
await postgresClient.query(
|
||||
`UPDATE rmm_executions
|
||||
SET status = 'timeout',
|
||||
completed_at = NOW(),
|
||||
error_message = COALESCE(error_message,
|
||||
'Hard timeout reached — Datto RMM did not return job results within ' || $2::text || ' minutes.')
|
||||
WHERE id = $1`,
|
||||
[id, EXECUTION_TIMEOUT_MINUTES]
|
||||
);
|
||||
}
|
||||
|
||||
export async function getExecutionById(id: string): Promise<RmmExecutionRow | null> {
|
||||
const res = await postgresClient.query<RawRow>(
|
||||
`SELECT ${SELECT} FROM rmm_executions WHERE id = $1 LIMIT 1`,
|
||||
[id]
|
||||
);
|
||||
if (res.rowCount === 0) return null;
|
||||
return toRow(res.rows[0]);
|
||||
}
|
||||
|
||||
/** Pull rows the worker should poll (running, not yet timed out). */
|
||||
export async function listRunningExecutions(): Promise<RmmExecutionRow[]> {
|
||||
const res = await postgresClient.query<RawRow>(
|
||||
`SELECT ${SELECT} FROM rmm_executions
|
||||
WHERE status = 'running' AND timeout_at > NOW()
|
||||
ORDER BY queued_at`
|
||||
);
|
||||
return res.rows.map(toRow);
|
||||
}
|
||||
|
||||
/** Pull rows that have passed their timeout marker. */
|
||||
export async function listTimedOutExecutions(): Promise<RmmExecutionRow[]> {
|
||||
const res = await postgresClient.query<RawRow>(
|
||||
`SELECT ${SELECT} FROM rmm_executions
|
||||
WHERE status IN ('queued','running')
|
||||
AND timeout_at <= NOW()
|
||||
ORDER BY queued_at`
|
||||
);
|
||||
return res.rows.map(toRow);
|
||||
}
|
||||
|
||||
/** List recent executions. Filters: company, asset, script, status. */
|
||||
export async function listExecutions(opts: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
companyId?: number | string;
|
||||
scriptId?: string;
|
||||
status?: RmmExecutionStatus;
|
||||
assetType?: 'flexible_asset' | 'configuration';
|
||||
assetId?: number | string;
|
||||
}): Promise<RmmExecutionRow[]> {
|
||||
const limit = Math.min(opts.limit ?? 100, 500);
|
||||
const offset = opts.offset ?? 0;
|
||||
const params: unknown[] = [limit, offset];
|
||||
const where: string[] = [];
|
||||
if (opts.companyId !== undefined) {
|
||||
params.push(opts.companyId);
|
||||
where.push(`target_company_id = $${params.length}`);
|
||||
}
|
||||
if (opts.scriptId) {
|
||||
params.push(opts.scriptId);
|
||||
where.push(`script_id = $${params.length}`);
|
||||
}
|
||||
if (opts.status) {
|
||||
params.push(opts.status);
|
||||
where.push(`status = $${params.length}`);
|
||||
}
|
||||
if (opts.assetType) {
|
||||
params.push(opts.assetType);
|
||||
where.push(`asset_type = $${params.length}`);
|
||||
}
|
||||
if (opts.assetId !== undefined) {
|
||||
params.push(opts.assetId);
|
||||
where.push(`asset_id = $${params.length}`);
|
||||
}
|
||||
const whereSql = where.length === 0 ? '' : `WHERE ${where.join(' AND ')}`;
|
||||
const res = await postgresClient.query<RawRow>(
|
||||
`SELECT ${SELECT}
|
||||
FROM rmm_executions
|
||||
${whereSql}
|
||||
ORDER BY queued_at DESC
|
||||
LIMIT $1 OFFSET $2`,
|
||||
params
|
||||
);
|
||||
return res.rows.map(toRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* For the audit pipeline: for a given company, return the latest successful
|
||||
* execution per script_id within the last `since` days.
|
||||
*/
|
||||
export async function listLatestEvidenceForCompany(
|
||||
companyId: number | string,
|
||||
sinceDays = 7
|
||||
): Promise<RmmExecutionRow[]> {
|
||||
const res = await postgresClient.query<RawRow>(
|
||||
`SELECT DISTINCT ON (script_id) ${SELECT}
|
||||
FROM rmm_executions
|
||||
WHERE target_company_id = $1
|
||||
AND status = 'complete'
|
||||
AND completed_at >= NOW() - ($2::int || ' days')::interval
|
||||
ORDER BY script_id, completed_at DESC`,
|
||||
[companyId, sinceDays]
|
||||
);
|
||||
return res.rows.map(toRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* For the audit pipeline: for a specific asset, return the latest successful
|
||||
* asset-self execution per script_id (no time limit — asset-self evidence
|
||||
* stays relevant longer than site-anchored).
|
||||
*/
|
||||
export async function listLatestEvidenceForAsset(
|
||||
assetType: 'flexible_asset' | 'configuration',
|
||||
assetId: number | string
|
||||
): Promise<RmmExecutionRow[]> {
|
||||
const res = await postgresClient.query<RawRow>(
|
||||
`SELECT DISTINCT ON (script_id) ${SELECT}
|
||||
FROM rmm_executions
|
||||
WHERE asset_type = $1
|
||||
AND asset_id = $2
|
||||
AND target_type = 'asset_self'
|
||||
AND status = 'complete'
|
||||
ORDER BY script_id, completed_at DESC`,
|
||||
[assetType, assetId]
|
||||
);
|
||||
return res.rows.map(toRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook correlation: find a still-pending b2_upload execution dispatched
|
||||
* by Pulse with a given run_id. Caller updates this row instead of inserting
|
||||
* a fresh one when an inbound LogLift upload matches a Pulse-driven dispatch.
|
||||
*/
|
||||
export async function findExecutionByRunId(
|
||||
runId: string
|
||||
): Promise<RmmExecutionRow | null> {
|
||||
const res = await postgresClient.query<RawRow>(
|
||||
`SELECT ${SELECT}
|
||||
FROM rmm_executions
|
||||
WHERE run_id = $1
|
||||
ORDER BY queued_at DESC
|
||||
LIMIT 1`,
|
||||
[runId]
|
||||
);
|
||||
if (res.rowCount === 0) return null;
|
||||
return toRow(res.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a fresh row for an inbound LogLift webhook that didn't correlate to a
|
||||
* Pulse-dispatched execution (e.g. the collector ran on its own schedule).
|
||||
* Status starts at 'running' — the webhook is the completion event.
|
||||
*/
|
||||
export async function createOutOfBandUploadExecution(input: {
|
||||
scriptId: string;
|
||||
scriptVersion: number;
|
||||
runId: string;
|
||||
jobName: string;
|
||||
targetDeviceUid: string;
|
||||
targetHostname: string | null;
|
||||
targetCompanyId: number | string | null;
|
||||
assetType: 'flexible_asset' | 'configuration' | null;
|
||||
assetId: number | string | null;
|
||||
variables: unknown;
|
||||
}): Promise<{ id: string }> {
|
||||
const res = await postgresClient.query<{ id: string }>(
|
||||
`INSERT INTO rmm_executions
|
||||
(script_id, script_version, target_type, target_device_uid,
|
||||
target_hostname, target_company_id,
|
||||
asset_type, asset_id,
|
||||
job_name, variables, status, performed_by_user_id,
|
||||
transport, run_id, started_at)
|
||||
VALUES ($1, $2, 'asset_self', $3,
|
||||
$4, $5,
|
||||
$6, $7,
|
||||
$8, $9::jsonb, 'running', NULL,
|
||||
'b2_upload', $10, NOW())
|
||||
RETURNING id::text AS id`,
|
||||
[
|
||||
input.scriptId,
|
||||
input.scriptVersion,
|
||||
input.targetDeviceUid,
|
||||
input.targetHostname,
|
||||
input.targetCompanyId,
|
||||
input.assetType,
|
||||
input.assetId,
|
||||
input.jobName,
|
||||
JSON.stringify(input.variables ?? {}),
|
||||
input.runId,
|
||||
]
|
||||
);
|
||||
return { id: res.rows[0].id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a b2_upload execution complete with parsed evidence + the B2 object key
|
||||
* for forensic replay. Caller is responsible for resolving target_company_id
|
||||
* and asset linkage before calling (the webhook receiver does this from the
|
||||
* payload's clientId / computerName).
|
||||
*/
|
||||
export async function markExecutionFromB2Upload(input: {
|
||||
id: string;
|
||||
evidenceObjectKey: string;
|
||||
parsedEvidence: unknown;
|
||||
targetCompanyId?: number | string | null;
|
||||
assetType?: 'flexible_asset' | 'configuration' | null;
|
||||
assetId?: number | string | null;
|
||||
}): Promise<void> {
|
||||
await postgresClient.query(
|
||||
`UPDATE rmm_executions
|
||||
SET status = 'complete',
|
||||
exit_code = 0,
|
||||
evidence_object_key = $2,
|
||||
parsed_evidence = $3::jsonb,
|
||||
target_company_id = COALESCE($4, target_company_id),
|
||||
asset_type = COALESCE($5, asset_type),
|
||||
asset_id = COALESCE($6, asset_id),
|
||||
completed_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[
|
||||
input.id,
|
||||
input.evidenceObjectKey,
|
||||
JSON.stringify(input.parsedEvidence ?? null),
|
||||
input.targetCompanyId ?? null,
|
||||
input.assetType ?? null,
|
||||
input.assetId ?? null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/** Per-user 24-hour count for rate-limiting. */
|
||||
export async function countUserExecutionsLast24h(userId: string): Promise<number> {
|
||||
const res = await postgresClient.query<{ count: string }>(
|
||||
`SELECT COUNT(*)::text AS count
|
||||
FROM rmm_executions
|
||||
WHERE performed_by_user_id = $1
|
||||
AND queued_at >= NOW() - INTERVAL '24 hours'`,
|
||||
[userId]
|
||||
);
|
||||
return Number(res.rows[0]?.count ?? 0);
|
||||
}
|
||||
102
lib/services/rmm/scripts/get-ad-health.ts
Normal file
102
lib/services/rmm/scripts/get-ad-health.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/**
|
||||
* get-ad-health — site-anchored AD health summary. Mirrors what openclaw
|
||||
* produced via Overshell on the Hynes DCs around 2026-04-25.
|
||||
*
|
||||
* Runs from the WNP endpoint and uses native AD cmdlets to reach the DC(s)
|
||||
* over the network. Outputs structured JSON: replication state per DC,
|
||||
* critical service status, dcdiag pass/fail per test, recent System log
|
||||
* Netlogon/DNS errors.
|
||||
*/
|
||||
|
||||
import { type RmmScript, parseJsonOutput } from './types';
|
||||
|
||||
export const getAdHealth: RmmScript = {
|
||||
id: 'get-ad-health',
|
||||
name: 'AD health (DC replication, services, dcdiag)',
|
||||
description:
|
||||
'Per-DC replication state, critical service status, dcdiag summary, and recent Netlogon/DNS errors. Run from a Wulf Nurse endpoint at the site.',
|
||||
target_type: 'site_anchor',
|
||||
expected_runtime_seconds: 90,
|
||||
version: 1,
|
||||
body: `
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
Import-Module ActiveDirectory -ErrorAction SilentlyContinue
|
||||
|
||||
$forest = Get-ADForest -ErrorAction SilentlyContinue
|
||||
$domain = Get-ADDomain -ErrorAction SilentlyContinue
|
||||
$dcs = Get-ADDomainController -Filter * -ErrorAction SilentlyContinue |
|
||||
Select-Object Name, HostName, Site, OperatingSystem, IPv4Address
|
||||
|
||||
# Replication partner metadata per DC.
|
||||
$repl = foreach ($dc in $dcs) {
|
||||
$partners = Get-ADReplicationPartnerMetadata -Target $dc.HostName -PartnerType Both -ErrorAction SilentlyContinue
|
||||
foreach ($p in $partners) {
|
||||
[pscustomobject]@{
|
||||
dc = $dc.HostName
|
||||
partner = $p.Partner
|
||||
last_replication_success = if ($p.LastReplicationSuccess) { $p.LastReplicationSuccess.ToUniversalTime().ToString("o") } else { $null }
|
||||
last_replication_attempt = if ($p.LastReplicationAttempt) { $p.LastReplicationAttempt.ToUniversalTime().ToString("o") } else { $null }
|
||||
last_replication_result = $p.LastReplicationResult
|
||||
consecutive_failures = $p.ConsecutiveReplicationFailures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Critical services per DC.
|
||||
$svcNames = @('NTDS','DNS','Netlogon','W32Time','DFSR','RpcSs','Kdc')
|
||||
$services = foreach ($dc in $dcs) {
|
||||
foreach ($n in $svcNames) {
|
||||
$s = Get-Service -ComputerName $dc.HostName -Name $n -ErrorAction SilentlyContinue
|
||||
[pscustomobject]@{
|
||||
dc = $dc.HostName
|
||||
service = $n
|
||||
status = if ($s) { $s.Status.ToString() } else { 'Missing' }
|
||||
start_type = if ($s) { $s.StartType.ToString() } else { $null }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# dcdiag summary — capture pass/fail per test.
|
||||
$dcdiag = foreach ($dc in $dcs) {
|
||||
$raw = & dcdiag /s:$($dc.HostName) /q 2>&1
|
||||
$passed = @()
|
||||
$failed = @()
|
||||
foreach ($line in ($raw -split "\`n")) {
|
||||
if ($line -match 'passed test (\\w+)') { $passed += $Matches[1] }
|
||||
elseif ($line -match 'failed test (\\w+)') { $failed += $Matches[1] }
|
||||
}
|
||||
[pscustomobject]@{
|
||||
dc = $dc.HostName
|
||||
passed = $passed
|
||||
failed = $failed
|
||||
}
|
||||
}
|
||||
|
||||
# Recent Netlogon/DNS errors per DC.
|
||||
$recent = foreach ($dc in $dcs) {
|
||||
$events = Get-WinEvent -ComputerName $dc.HostName -FilterHashtable @{
|
||||
LogName = 'System'
|
||||
ProviderName = @('NETLOGON','Microsoft-Windows-DNS-Client','DNS-Server-Service')
|
||||
Level = @(1,2,3)
|
||||
StartTime = (Get-Date).AddHours(-24)
|
||||
} -MaxEvents 20 -ErrorAction SilentlyContinue |
|
||||
Select-Object @{n='time';e={$_.TimeCreated.ToUniversalTime().ToString("o")}},
|
||||
Id, LevelDisplayName, ProviderName,
|
||||
@{n='message';e={ ($_.Message -replace '\\s+',' ').Trim().Substring(0, [Math]::Min(400, $_.Message.Length)) }}
|
||||
[pscustomobject]@{ dc = $dc.HostName; events = $events }
|
||||
}
|
||||
|
||||
@{
|
||||
hostname = $env:COMPUTERNAME
|
||||
captured_at_utc = (Get-Date).ToUniversalTime().ToString("o")
|
||||
forest = if ($forest) { $forest.Name } else { $null }
|
||||
domain = if ($domain) { $domain.DNSRoot } else { $null }
|
||||
dcs = $dcs
|
||||
replication = $repl
|
||||
services = $services
|
||||
dcdiag = $dcdiag
|
||||
recent_errors = $recent
|
||||
} | ConvertTo-Json -Depth 6 -Compress
|
||||
`.trim(),
|
||||
parseOutput: (stdout) => parseJsonOutput(stdout),
|
||||
};
|
||||
57
lib/services/rmm/scripts/get-dhcp-scopes.ts
Normal file
57
lib/services/rmm/scripts/get-dhcp-scopes.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* get-dhcp-scopes — list DHCP scopes + lease statistics + reservations.
|
||||
* Site-anchored. Useful for documenting subnet plans + finding stale
|
||||
* reservations that should be retired.
|
||||
*/
|
||||
|
||||
import { type RmmScript, parseJsonOutput } from './types';
|
||||
|
||||
export const getDhcpScopes: RmmScript = {
|
||||
id: 'get-dhcp-scopes',
|
||||
name: 'DHCP scopes + reservations',
|
||||
description:
|
||||
'All authorized DHCP servers in the domain, their scopes, scope-level statistics, and reservations.',
|
||||
target_type: 'site_anchor',
|
||||
expected_runtime_seconds: 60,
|
||||
version: 1,
|
||||
body: `
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
Import-Module DhcpServer -ErrorAction SilentlyContinue
|
||||
|
||||
$servers = Get-DhcpServerInDC -ErrorAction SilentlyContinue
|
||||
|
||||
$result = foreach ($srv in $servers) {
|
||||
$scopes = Get-DhcpServerv4Scope -ComputerName $srv.DnsName -ErrorAction SilentlyContinue
|
||||
$detail = foreach ($sc in $scopes) {
|
||||
$stats = Get-DhcpServerv4ScopeStatistics -ComputerName $srv.DnsName -ScopeId $sc.ScopeId -ErrorAction SilentlyContinue
|
||||
$resv = Get-DhcpServerv4Reservation -ComputerName $srv.DnsName -ScopeId $sc.ScopeId -ErrorAction SilentlyContinue |
|
||||
Select-Object IPAddress, ClientId, Description, Name
|
||||
[pscustomobject]@{
|
||||
scope_id = $sc.ScopeId.ToString()
|
||||
name = $sc.Name
|
||||
subnet_mask = $sc.SubnetMask.ToString()
|
||||
start_range = $sc.StartRange.ToString()
|
||||
end_range = $sc.EndRange.ToString()
|
||||
state = $sc.State.ToString()
|
||||
lease_duration_hours = $sc.LeaseDuration.TotalHours
|
||||
addresses_in_use = if ($stats) { $stats.InUse } else { $null }
|
||||
addresses_free = if ($stats) { $stats.Free } else { $null }
|
||||
percentage_in_use = if ($stats) { $stats.PercentageInUse } else { $null }
|
||||
reservations = $resv
|
||||
}
|
||||
}
|
||||
[pscustomobject]@{
|
||||
dhcp_server = $srv.DnsName
|
||||
ip = $srv.IPAddress.ToString()
|
||||
scopes = $detail
|
||||
}
|
||||
}
|
||||
|
||||
@{
|
||||
hostname = $env:COMPUTERNAME
|
||||
captured_at_utc = (Get-Date).ToUniversalTime().ToString("o")
|
||||
servers = $result
|
||||
} | ConvertTo-Json -Depth 6 -Compress
|
||||
`.trim(),
|
||||
parseOutput: (stdout) => parseJsonOutput(stdout),
|
||||
};
|
||||
49
lib/services/rmm/scripts/get-dns-zones.ts
Normal file
49
lib/services/rmm/scripts/get-dns-zones.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* get-dns-zones — list DNS zones + forwarders + conditional forwarders +
|
||||
* recent zone-transfer status. Site-anchored.
|
||||
*
|
||||
* Useful for documenting which zones each DC is authoritative for and
|
||||
* verifying forwarders match what's recorded on the firewall Configuration.
|
||||
*/
|
||||
|
||||
import { type RmmScript, parseJsonOutput } from './types';
|
||||
|
||||
export const getDnsZones: RmmScript = {
|
||||
id: 'get-dns-zones',
|
||||
name: 'DNS zones + forwarders',
|
||||
description:
|
||||
'For every DC in the domain: zones, zone type, dynamic update mode, forwarders + conditional forwarders.',
|
||||
target_type: 'site_anchor',
|
||||
expected_runtime_seconds: 60,
|
||||
version: 1,
|
||||
body: `
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
Import-Module ActiveDirectory -ErrorAction SilentlyContinue
|
||||
Import-Module DnsServer -ErrorAction SilentlyContinue
|
||||
|
||||
$dcs = Get-ADDomainController -Filter * | Select-Object -ExpandProperty HostName
|
||||
|
||||
$result = foreach ($dc in $dcs) {
|
||||
$zones = Get-DnsServerZone -ComputerName $dc -ErrorAction SilentlyContinue |
|
||||
Select-Object ZoneName, ZoneType, IsDsIntegrated, DynamicUpdate, IsAutoCreated, IsReverseLookupZone
|
||||
$fwd = (Get-DnsServerForwarder -ComputerName $dc -ErrorAction SilentlyContinue) |
|
||||
Select-Object @{n='ip';e={$_.IPAddress -join ','}}, Timeout, UseRootHint
|
||||
$cfwd = Get-DnsServerZone -ComputerName $dc -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.ZoneType -eq 'Forwarder' } |
|
||||
Select-Object ZoneName, @{n='masters';e={ ($_.MasterServers | ForEach-Object { $_.IPAddressToString }) -join ',' }}
|
||||
[pscustomobject]@{
|
||||
dc = $dc
|
||||
zones = $zones
|
||||
forwarders = $fwd
|
||||
conditional_forwarders = $cfwd
|
||||
}
|
||||
}
|
||||
|
||||
@{
|
||||
hostname = $env:COMPUTERNAME
|
||||
captured_at_utc = (Get-Date).ToUniversalTime().ToString("o")
|
||||
dcs = $result
|
||||
} | ConvertTo-Json -Depth 6 -Compress
|
||||
`.trim(),
|
||||
parseOutput: (stdout) => parseJsonOutput(stdout),
|
||||
};
|
||||
39
lib/services/rmm/scripts/get-event-log-recent.ts
Normal file
39
lib/services/rmm/scripts/get-event-log-recent.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* get-event-log-recent — last 50 errors+warnings from System and
|
||||
* Application logs in the past 24h. Asset-self.
|
||||
*
|
||||
* Useful for retroactive diagnosis — "what was screaming on this server
|
||||
* around the time the ticket came in?"
|
||||
*/
|
||||
|
||||
import { type RmmScript, parseJsonOutput } from './types';
|
||||
|
||||
export const getEventLogRecent: RmmScript = {
|
||||
id: 'get-event-log-recent',
|
||||
name: 'Recent event-log errors',
|
||||
description:
|
||||
'Last 24h of Errors + Warnings from System + Application logs (capped at 50).',
|
||||
target_type: 'asset_self',
|
||||
expected_runtime_seconds: 25,
|
||||
version: 1,
|
||||
body: `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$since = (Get-Date).AddHours(-24)
|
||||
$entries = Get-WinEvent -FilterHashtable @{
|
||||
LogName = @('System','Application')
|
||||
Level = @(1,2,3) # 1=Critical, 2=Error, 3=Warning
|
||||
StartTime = $since
|
||||
} -MaxEvents 50 -ErrorAction SilentlyContinue |
|
||||
Select-Object @{n='time';e={$_.TimeCreated.ToUniversalTime().ToString("o")}},
|
||||
LogName, Id, LevelDisplayName, ProviderName,
|
||||
@{n='message';e={ ($_.Message -replace '\\s+',' ').Trim().Substring(0, [Math]::Min(500, $_.Message.Length)) }}
|
||||
@{
|
||||
hostname = $env:COMPUTERNAME
|
||||
captured_at_utc = (Get-Date).ToUniversalTime().ToString("o")
|
||||
since_utc = $since.ToUniversalTime().ToString("o")
|
||||
count = ($entries | Measure-Object).Count
|
||||
entries = $entries
|
||||
} | ConvertTo-Json -Depth 4 -Compress
|
||||
`.trim(),
|
||||
parseOutput: (stdout) => parseJsonOutput(stdout),
|
||||
};
|
||||
36
lib/services/rmm/scripts/get-installed-software.ts
Normal file
36
lib/services/rmm/scripts/get-installed-software.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* get-installed-software — list installed Win32 + WoW64 applications.
|
||||
* Asset-self. Useful for verifying app-version fields on Configuration
|
||||
* records and for spotting unexpected installs.
|
||||
*/
|
||||
|
||||
import { type RmmScript, parseJsonOutput } from './types';
|
||||
|
||||
export const getInstalledSoftware: RmmScript = {
|
||||
id: 'get-installed-software',
|
||||
name: 'Installed software',
|
||||
description:
|
||||
'Win32 + WoW64 uninstall registry — DisplayName, DisplayVersion, Publisher, InstallDate.',
|
||||
target_type: 'asset_self',
|
||||
expected_runtime_seconds: 20,
|
||||
version: 1,
|
||||
body: `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$paths = @(
|
||||
'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'
|
||||
'HKLM:\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'
|
||||
)
|
||||
$apps = foreach ($p in $paths) {
|
||||
Get-ItemProperty $p -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.DisplayName } |
|
||||
Select-Object DisplayName, DisplayVersion, Publisher, InstallDate
|
||||
}
|
||||
@{
|
||||
hostname = $env:COMPUTERNAME
|
||||
captured_at_utc = (Get-Date).ToUniversalTime().ToString("o")
|
||||
count = ($apps | Measure-Object).Count
|
||||
apps = $apps | Sort-Object DisplayName -Unique
|
||||
} | ConvertTo-Json -Depth 4 -Compress
|
||||
`.trim(),
|
||||
parseOutput: (stdout) => parseJsonOutput(stdout),
|
||||
};
|
||||
67
lib/services/rmm/scripts/get-network-discovery.ts
Normal file
67
lib/services/rmm/scripts/get-network-discovery.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* get-network-discovery — local IP config + ARP table from the WNP. Site-
|
||||
* anchored. Designed to catch the IP-conflict pattern (the proven test
|
||||
* case at Hynes had 10.10.110.215 / .253 fought over by two VMware MACs).
|
||||
*
|
||||
* Output: own NIC config + parsed ARP table (IP, MAC, type) + a flag for
|
||||
* any duplicate IPs seen in ARP responses.
|
||||
*/
|
||||
|
||||
import { type RmmScript, parseJsonOutput } from './types';
|
||||
|
||||
export const getNetworkDiscovery: RmmScript = {
|
||||
id: 'get-network-discovery',
|
||||
name: 'Network discovery (NIC config + ARP)',
|
||||
description:
|
||||
'Local NIC IPv4 configuration plus full ARP table (parsed). Flags duplicate IPs seen across MACs.',
|
||||
target_type: 'site_anchor',
|
||||
expected_runtime_seconds: 20,
|
||||
version: 1,
|
||||
body: `
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
$nics = Get-NetIPConfiguration |
|
||||
Where-Object { $_.NetAdapter.Status -eq 'Up' } |
|
||||
ForEach-Object {
|
||||
[pscustomobject]@{
|
||||
interface_alias = $_.InterfaceAlias
|
||||
ipv4_address = ($_.IPv4Address.IPAddress -join ',')
|
||||
ipv4_default_gateway = ($_.IPv4DefaultGateway.NextHop -join ',')
|
||||
dns_servers = ($_.DNSServer | Where-Object { $_.AddressFamily -eq 2 }).ServerAddresses -join ','
|
||||
mac = $_.NetAdapter.LinkLayerAddress
|
||||
link_speed = $_.NetAdapter.LinkSpeed
|
||||
}
|
||||
}
|
||||
|
||||
$arpRaw = & arp -a 2>&1
|
||||
$arp = @()
|
||||
$dup = @{}
|
||||
foreach ($line in $arpRaw) {
|
||||
if ($line -match '^\\s*(\\d+\\.\\d+\\.\\d+\\.\\d+)\\s+([a-fA-F0-9:-]{11,17})\\s+(\\w+)') {
|
||||
$ip = $Matches[1]
|
||||
$mac = $Matches[2].ToLower()
|
||||
$type = $Matches[3]
|
||||
$arp += [pscustomobject]@{ ip = $ip; mac = $mac; type = $type }
|
||||
if ($dup.ContainsKey($ip)) { $dup[$ip] = ($dup[$ip] + ',' + $mac) }
|
||||
else { $dup[$ip] = $mac }
|
||||
}
|
||||
}
|
||||
$conflicts = @()
|
||||
foreach ($k in $dup.Keys) {
|
||||
$macs = ($dup[$k] -split ',') | Sort-Object -Unique
|
||||
if ($macs.Count -gt 1) {
|
||||
$conflicts += [pscustomobject]@{ ip = $k; macs = $macs }
|
||||
}
|
||||
}
|
||||
|
||||
@{
|
||||
hostname = $env:COMPUTERNAME
|
||||
captured_at_utc = (Get-Date).ToUniversalTime().ToString("o")
|
||||
nics = $nics
|
||||
arp_count = $arp.Count
|
||||
arp = $arp
|
||||
ip_conflicts = $conflicts
|
||||
ip_conflict_count = $conflicts.Count
|
||||
} | ConvertTo-Json -Depth 5 -Compress
|
||||
`.trim(),
|
||||
parseOutput: (stdout) => parseJsonOutput(stdout),
|
||||
};
|
||||
34
lib/services/rmm/scripts/get-services.ts
Normal file
34
lib/services/rmm/scripts/get-services.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* get-services — list running services on the target. Asset-self.
|
||||
*
|
||||
* Audit purpose: when a ticket resolves by restarting/fixing a specific
|
||||
* Windows service, the next tech should be able to find that service in
|
||||
* the IT Glue Configuration record's operating_system_notes. The audit
|
||||
* pipeline cross-references the service inventory with ticket history to
|
||||
* propose notes_promotions like "Add 'BartenderProcessService' to
|
||||
* operating_system_notes — referenced in T20260502.0033."
|
||||
*/
|
||||
|
||||
import { type RmmScript, parseJsonOutput } from './types';
|
||||
|
||||
export const getServices: RmmScript = {
|
||||
id: 'get-services',
|
||||
name: 'Running services',
|
||||
description:
|
||||
'Snapshot of all running Windows services on the target — Name, DisplayName, StartType, ServiceType.',
|
||||
target_type: 'asset_self',
|
||||
expected_runtime_seconds: 15,
|
||||
version: 1,
|
||||
body: `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$services = Get-Service | Where-Object { $_.Status -eq 'Running' } |
|
||||
Select-Object Name, DisplayName, StartType, ServiceType
|
||||
@{
|
||||
hostname = $env:COMPUTERNAME
|
||||
captured_at_utc = (Get-Date).ToUniversalTime().ToString("o")
|
||||
count = $services.Count
|
||||
services = $services
|
||||
} | ConvertTo-Json -Depth 4 -Compress
|
||||
`.trim(),
|
||||
parseOutput: (stdout) => parseJsonOutput(stdout),
|
||||
};
|
||||
57
lib/services/rmm/scripts/index.ts
Normal file
57
lib/services/rmm/scripts/index.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* Registry of RMM Overshell scripts.
|
||||
*
|
||||
* Adding a new script:
|
||||
* 1. Create lib/services/rmm/scripts/<id>.ts exporting an RmmScript.
|
||||
* 2. Import + add to SCRIPTS below.
|
||||
* 3. Bump the script's `version` field if you change body or output shape.
|
||||
* 4. Run npm test — registry tests verify ids are unique + ids match
|
||||
* module exports.
|
||||
*
|
||||
* The DB never stores executable PowerShell. The runtime gates execution to
|
||||
* the ids in this registry only.
|
||||
*/
|
||||
|
||||
import type { RmmScript } from './types';
|
||||
import { getServices } from './get-services';
|
||||
import { getInstalledSoftware } from './get-installed-software';
|
||||
import { getEventLogRecent } from './get-event-log-recent';
|
||||
import { getAdHealth } from './get-ad-health';
|
||||
import { getDhcpScopes } from './get-dhcp-scopes';
|
||||
import { getDnsZones } from './get-dns-zones';
|
||||
import { getNetworkDiscovery } from './get-network-discovery';
|
||||
import { logliftEventLogs } from './loglift-eventlogs';
|
||||
|
||||
const _all: RmmScript[] = [
|
||||
getServices,
|
||||
getInstalledSoftware,
|
||||
getEventLogRecent,
|
||||
getAdHealth,
|
||||
getDhcpScopes,
|
||||
getDnsZones,
|
||||
getNetworkDiscovery,
|
||||
logliftEventLogs,
|
||||
];
|
||||
|
||||
// Build the lookup map and verify ids are unique at module-load time.
|
||||
export const SCRIPTS: Readonly<Record<string, RmmScript>> = (() => {
|
||||
const map: Record<string, RmmScript> = {};
|
||||
for (const s of _all) {
|
||||
if (map[s.id]) {
|
||||
throw new Error(`RMM script registry: duplicate id "${s.id}"`);
|
||||
}
|
||||
map[s.id] = s;
|
||||
}
|
||||
return Object.freeze(map);
|
||||
})();
|
||||
|
||||
export function listScripts(): RmmScript[] {
|
||||
return Object.values(SCRIPTS).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export function getScript(id: string): RmmScript | null {
|
||||
return SCRIPTS[id] ?? null;
|
||||
}
|
||||
|
||||
export type { RmmScript } from './types';
|
||||
export { parseJsonOutput } from './types';
|
||||
36
lib/services/rmm/scripts/loglift-eventlogs.ts
Normal file
36
lib/services/rmm/scripts/loglift-eventlogs.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* loglift-eventlogs — comprehensive Windows event log + system context
|
||||
* collection. Asset-self.
|
||||
*
|
||||
* The collector PowerShell is deployed as a separate Datto RMM component
|
||||
* (registered in the Wulf tenant; auto-discovered via /loglift/i pattern)
|
||||
* because the payload is too large for Overshell stdout — typically tens
|
||||
* of MB of gzipped JSON. The collector uploads to a Backblaze B2 bucket
|
||||
* and POSTs a metadata webhook to Pulse, which downloads, decompresses,
|
||||
* slims, and persists.
|
||||
*
|
||||
* This registry entry exists so:
|
||||
* 1. The picker UI can offer LogLift to admins on Configuration pages.
|
||||
* 2. The executor knows transport='b2_upload' and dispatches the
|
||||
* LogLift component (not Overshell) with the right variables.
|
||||
* 3. The audit-context loader can list latest LogLift evidence per
|
||||
* asset alongside other RMM scripts.
|
||||
*/
|
||||
|
||||
import type { RmmScript } from './types';
|
||||
|
||||
export const logliftEventLogs: RmmScript = {
|
||||
id: 'loglift-eventlogs',
|
||||
name: 'LogLift event-log collection',
|
||||
description:
|
||||
'Comprehensive Windows event-log + system-context capture. Uploads gzipped JSON to Backblaze B2; Pulse downloads, slims to top events + system context, and surfaces it as live audit evidence.',
|
||||
target_type: 'asset_self',
|
||||
transport: 'b2_upload',
|
||||
expected_runtime_seconds: 120,
|
||||
version: 1,
|
||||
// Deployed externally as the LogLift Datto component. Pulse never sends
|
||||
// PowerShell over Overshell for this script.
|
||||
body: '',
|
||||
// Payload arrives from B2 (parsed in the webhook handler), not stdout.
|
||||
parseOutput: () => ({}),
|
||||
};
|
||||
80
lib/services/rmm/scripts/registry.test.ts
Normal file
80
lib/services/rmm/scripts/registry.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
SCRIPTS,
|
||||
listScripts,
|
||||
getScript,
|
||||
parseJsonOutput,
|
||||
} from './index';
|
||||
|
||||
describe('RMM script registry', () => {
|
||||
it('contains the v1 + LogLift scripts', () => {
|
||||
expect(Object.keys(SCRIPTS).sort()).toEqual([
|
||||
'get-ad-health',
|
||||
'get-dhcp-scopes',
|
||||
'get-dns-zones',
|
||||
'get-event-log-recent',
|
||||
'get-installed-software',
|
||||
'get-network-discovery',
|
||||
'get-services',
|
||||
'loglift-eventlogs',
|
||||
]);
|
||||
});
|
||||
|
||||
it('all scripts have required fields', () => {
|
||||
for (const s of Object.values(SCRIPTS)) {
|
||||
expect(s.id).toBeTruthy();
|
||||
expect(s.name).toBeTruthy();
|
||||
expect(s.description).toBeTruthy();
|
||||
expect(['site_anchor', 'asset_self']).toContain(s.target_type);
|
||||
// Overshell scripts carry the body; b2_upload scripts deploy the
|
||||
// body externally as a Datto component, so we don't require it.
|
||||
if (s.transport === 'b2_upload') {
|
||||
expect(s.body).toBe('');
|
||||
} else {
|
||||
expect(s.body.length).toBeGreaterThan(50);
|
||||
}
|
||||
expect(typeof s.parseOutput).toBe('function');
|
||||
expect(s.expected_runtime_seconds).toBeGreaterThan(0);
|
||||
expect(s.expected_runtime_seconds).toBeLessThan(300); // safety check
|
||||
expect(s.version).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('listScripts returns sorted list', () => {
|
||||
const list = listScripts();
|
||||
const names = list.map((s) => s.name);
|
||||
expect(names).toEqual([...names].sort());
|
||||
});
|
||||
|
||||
it('getScript returns null on unknown id', () => {
|
||||
expect(getScript('does-not-exist')).toBeNull();
|
||||
expect(getScript('get-services')?.id).toBe('get-services');
|
||||
});
|
||||
|
||||
it('script bodies do not contain credential patterns', () => {
|
||||
// Heuristic: no script should reference plaintext password handling.
|
||||
for (const s of Object.values(SCRIPTS)) {
|
||||
const lower = s.body.toLowerCase();
|
||||
expect(lower).not.toMatch(/\$plain.*password/);
|
||||
expect(lower).not.toMatch(/-asplaintext/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseJsonOutput', () => {
|
||||
it('parses clean JSON', () => {
|
||||
expect(parseJsonOutput('{"ok":true}')).toEqual({ ok: true });
|
||||
expect(parseJsonOutput('[1,2,3]')).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('strips a leading banner before the JSON', () => {
|
||||
expect(
|
||||
parseJsonOutput('Datto Quick Job Output:\n{"ok":true,"value":42}')
|
||||
).toEqual({ ok: true, value: 42 });
|
||||
});
|
||||
|
||||
it('throws when there is no JSON', () => {
|
||||
expect(() => parseJsonOutput('')).toThrow();
|
||||
expect(() => parseJsonOutput('Just text, no braces.')).toThrow();
|
||||
});
|
||||
});
|
||||
78
lib/services/rmm/scripts/types.ts
Normal file
78
lib/services/rmm/scripts/types.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* Shared types for the Datto RMM Overshell script library.
|
||||
*
|
||||
* Each script is a TypeScript module exporting a typed `RmmScript`. The
|
||||
* registry in `index.ts` aggregates them. All script bodies are
|
||||
* version-controlled in this repo; nothing in the DB is treated as
|
||||
* executable PowerShell.
|
||||
*/
|
||||
|
||||
export type RmmTargetType = 'site_anchor' | 'asset_self';
|
||||
|
||||
/**
|
||||
* How the script returns its payload to Pulse.
|
||||
* - `overshell_stdout` (default) — payload arrives in Datto getJobResults
|
||||
* stdout. Suitable for small (<50KB) JSON payloads.
|
||||
* - `b2_upload` — collector uploads gzipped JSON to Backblaze B2 and
|
||||
* POSTs a webhook to /api/rmm/loglift/upload. Pulse downloads + parses
|
||||
* asynchronously. Used by the LogLift event-log pipeline.
|
||||
*/
|
||||
export type RmmTransport = 'overshell_stdout' | 'b2_upload';
|
||||
|
||||
export interface RmmScript {
|
||||
/** Stable id used in URLs + persistence. kebab-case. */
|
||||
id: string;
|
||||
/** Human-friendly display name. */
|
||||
name: string;
|
||||
/** One-line description for the picker UI. */
|
||||
description: string;
|
||||
/** Where this script runs:
|
||||
* - `site_anchor`: pick the WNP endpoint for the client.
|
||||
* - `asset_self`: caller supplies the device_uid (typically the audited asset). */
|
||||
target_type: RmmTargetType;
|
||||
/** How the payload comes back. Defaults to overshell_stdout. */
|
||||
transport?: RmmTransport;
|
||||
/**
|
||||
* The PowerShell body. Best practice: end with `ConvertTo-Json` so
|
||||
* `parseOutput` can `JSON.parse` cleanly. Use `-Depth 4` or higher when
|
||||
* the structure is nested.
|
||||
*
|
||||
* For b2_upload scripts, the body lives outside Pulse (deployed as a
|
||||
* Datto component) and this field is empty.
|
||||
*/
|
||||
body: string;
|
||||
/**
|
||||
* Best-effort parse of stdout into a structured object. May throw — the
|
||||
* worker catches and stores the error in rmm_executions.parse_error.
|
||||
*
|
||||
* For b2_upload scripts, the payload comes from B2 (parsed in the
|
||||
* webhook handler) so this is a no-op.
|
||||
*/
|
||||
parseOutput: (stdout: string) => unknown;
|
||||
/** Hint for the picker UI + sanity check against the 5-minute hard cap. */
|
||||
expected_runtime_seconds: number;
|
||||
/** Schema version. Bump when we change the body or output shape so
|
||||
* audit-context consumers can decide whether to use stored evidence. */
|
||||
version: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default JSON parser — strips any leading non-JSON noise (Datto sometimes
|
||||
* prefixes Overshell stdout with a banner) and parses the first balanced
|
||||
* JSON object/array it finds.
|
||||
*/
|
||||
export function parseJsonOutput(stdout: string): unknown {
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) throw new Error('Empty stdout');
|
||||
// Find the first { or [ and parse from there.
|
||||
const objStart = trimmed.indexOf('{');
|
||||
const arrStart = trimmed.indexOf('[');
|
||||
let start = -1;
|
||||
if (objStart === -1 && arrStart === -1) {
|
||||
throw new Error(`No JSON found in stdout: ${trimmed.slice(0, 120)}`);
|
||||
} else if (objStart === -1) start = arrStart;
|
||||
else if (arrStart === -1) start = objStart;
|
||||
else start = Math.min(objStart, arrStart);
|
||||
const candidate = trimmed.slice(start);
|
||||
return JSON.parse(candidate);
|
||||
}
|
||||
189
lib/services/rmm/settings.ts
Normal file
189
lib/services/rmm/settings.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
/**
|
||||
* Read/write the rmm_settings singleton row.
|
||||
*
|
||||
* Caches the discovered Overshell component_uid so we don't re-scan
|
||||
* components on every dispatch. The discover endpoint repopulates it.
|
||||
*/
|
||||
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
|
||||
|
||||
export interface RmmSettings {
|
||||
overshellComponentUid: string | null;
|
||||
overshellComponentName: string | null;
|
||||
overshellVariableName: string;
|
||||
discoveredAt: string | null;
|
||||
// Phase 4.3 — LogLift component cache.
|
||||
logliftComponentUid: string | null;
|
||||
logliftComponentName: string | null;
|
||||
logliftDiscoveredAt: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface RawSettingsRow {
|
||||
overshell_component_uid: string | null;
|
||||
overshell_component_name: string | null;
|
||||
overshell_variable_name: string;
|
||||
discovered_at: Date | null;
|
||||
loglift_component_uid: string | null;
|
||||
loglift_component_name: string | null;
|
||||
loglift_discovered_at: Date | null;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
function rowToSettings(r: RawSettingsRow): RmmSettings {
|
||||
return {
|
||||
overshellComponentUid: r.overshell_component_uid,
|
||||
overshellComponentName: r.overshell_component_name,
|
||||
overshellVariableName: r.overshell_variable_name,
|
||||
discoveredAt: r.discovered_at?.toISOString() ?? null,
|
||||
logliftComponentUid: r.loglift_component_uid,
|
||||
logliftComponentName: r.loglift_component_name,
|
||||
logliftDiscoveredAt: r.loglift_discovered_at?.toISOString() ?? null,
|
||||
updatedAt: r.updated_at.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getRmmSettings(): Promise<RmmSettings> {
|
||||
const res = await postgresClient.query<RawSettingsRow>(
|
||||
`SELECT overshell_component_uid, overshell_component_name,
|
||||
overshell_variable_name, discovered_at,
|
||||
loglift_component_uid, loglift_component_name, loglift_discovered_at,
|
||||
updated_at
|
||||
FROM rmm_settings WHERE id = true LIMIT 1`
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
// Defensive: re-seed if the singleton row vanished somehow.
|
||||
await postgresClient.query(
|
||||
`INSERT INTO rmm_settings (id) VALUES (true) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
return {
|
||||
overshellComponentUid: null,
|
||||
overshellComponentName: null,
|
||||
overshellVariableName: 'CommandLine',
|
||||
discoveredAt: null,
|
||||
logliftComponentUid: null,
|
||||
logliftComponentName: null,
|
||||
logliftDiscoveredAt: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
return rowToSettings(res.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-discover the Overshell component via the Datto RMM API and persist
|
||||
* the uid + name. Idempotent. Returns the updated settings.
|
||||
*/
|
||||
export async function discoverOvershellComponent(): Promise<{
|
||||
settings: RmmSettings;
|
||||
discovered: { uid: string; name: string } | null;
|
||||
}> {
|
||||
const client = getDattoRMMClient();
|
||||
const found = await client.findOvershellComponent();
|
||||
|
||||
if (!found) {
|
||||
return { settings: await getRmmSettings(), discovered: null };
|
||||
}
|
||||
|
||||
await postgresClient.query(
|
||||
`UPDATE rmm_settings
|
||||
SET overshell_component_uid = $1,
|
||||
overshell_component_name = $2,
|
||||
discovered_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = true`,
|
||||
[found.uid, found.name]
|
||||
);
|
||||
const settings = await getRmmSettings();
|
||||
return { settings, discovered: found };
|
||||
}
|
||||
|
||||
export async function updateOvershellVariableName(name: string): Promise<RmmSettings> {
|
||||
await postgresClient.query(
|
||||
`UPDATE rmm_settings
|
||||
SET overshell_variable_name = $1,
|
||||
updated_at = NOW()
|
||||
WHERE id = true`,
|
||||
[name]
|
||||
);
|
||||
return getRmmSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the component_uid Pulse should use, discovering on demand if the
|
||||
* cache is empty. Throws if the component cannot be found at all.
|
||||
*/
|
||||
export async function resolveOvershellComponent(): Promise<{
|
||||
componentUid: string;
|
||||
variableName: string;
|
||||
}> {
|
||||
let s = await getRmmSettings();
|
||||
if (!s.overshellComponentUid) {
|
||||
const result = await discoverOvershellComponent();
|
||||
if (!result.discovered) {
|
||||
throw new Error(
|
||||
'Overshell component not found in Datto RMM. Register a component whose name contains "overshell" or update the discovery pattern.'
|
||||
);
|
||||
}
|
||||
s = result.settings;
|
||||
}
|
||||
return {
|
||||
componentUid: s.overshellComponentUid as string,
|
||||
variableName: s.overshellVariableName,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Phase 4.3 — LogLift component discovery
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Match anything containing "loglift" or "eventlog" in the component
|
||||
* name. The user's existing tenant should have a registered Datto
|
||||
* component (matching one of these names) that owns the PowerShell
|
||||
* collector logic.
|
||||
*/
|
||||
const LOGLIFT_PATTERN = /loglift|eventlog/i;
|
||||
|
||||
export async function discoverLogliftComponent(): Promise<{
|
||||
settings: RmmSettings;
|
||||
discovered: { uid: string; name: string } | null;
|
||||
}> {
|
||||
const client = getDattoRMMClient();
|
||||
const found = await client.findComponentByName(LOGLIFT_PATTERN);
|
||||
|
||||
if (!found) {
|
||||
return { settings: await getRmmSettings(), discovered: null };
|
||||
}
|
||||
|
||||
await postgresClient.query(
|
||||
`UPDATE rmm_settings
|
||||
SET loglift_component_uid = $1,
|
||||
loglift_component_name = $2,
|
||||
loglift_discovered_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = true`,
|
||||
[found.uid, found.name]
|
||||
);
|
||||
const settings = await getRmmSettings();
|
||||
return { settings, discovered: found };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the LogLift component_uid Pulse should dispatch. Throws with a
|
||||
* helpful message if the component hasn't been discovered yet.
|
||||
*/
|
||||
export async function resolveLogliftComponent(): Promise<{ componentUid: string }> {
|
||||
let s = await getRmmSettings();
|
||||
if (!s.logliftComponentUid) {
|
||||
const result = await discoverLogliftComponent();
|
||||
if (!result.discovered) {
|
||||
throw new Error(
|
||||
'LogLift component not found in Datto RMM. Register a component whose name contains "loglift" or "eventlog" and re-run discovery from /admin/rmm-overshell.'
|
||||
);
|
||||
}
|
||||
s = result.settings;
|
||||
}
|
||||
return { componentUid: s.logliftComponentUid as string };
|
||||
}
|
||||
23
lib/services/rmm/target-resolver.test.ts
Normal file
23
lib/services/rmm/target-resolver.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { _TARGET_RESOLVER_INTERNALS } from './target-resolver';
|
||||
|
||||
describe('WNP_REGEX', () => {
|
||||
const re = _TARGET_RESOLVER_INTERNALS.WNP_REGEX;
|
||||
|
||||
it('matches the canonical 11-char Wulf Nurse pattern', () => {
|
||||
expect(re.test('YNGHYNWNP01')).toBe(true);
|
||||
expect(re.test('FOSSUPWNP02')).toBe(true);
|
||||
expect(re.test('TARBBPWNP09')).toBe(true);
|
||||
});
|
||||
|
||||
it('case-insensitive on the literal portion', () => {
|
||||
expect(re.test('ynghynwnp01')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects mismatched lengths or shapes', () => {
|
||||
expect(re.test('YNGHYNWNP1')).toBe(false); // 1 digit suffix
|
||||
expect(re.test('YNGHYNWNP123')).toBe(false); // 3 digit suffix
|
||||
expect(re.test('YNGHYNWN01')).toBe(false); // missing P
|
||||
expect(re.test('YNHYNWNP01')).toBe(false); // 5 letters before WNP
|
||||
});
|
||||
});
|
||||
192
lib/services/rmm/target-resolver.ts
Normal file
192
lib/services/rmm/target-resolver.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
/**
|
||||
* Resolve which Datto RMM device an RMM Overshell script should run against.
|
||||
*
|
||||
* For site-anchored scripts: pick the Wulf Nurse Production endpoint
|
||||
* (hostname matching `LLLCCCWNPNN` — 6 letters + WNP + 2 digits) for the
|
||||
* client. Prefer online devices and lowest numeric suffix (WNP01 over
|
||||
* WNP02 etc.) so we hit the canonical endpoint deterministically.
|
||||
*
|
||||
* For asset-self: caller supplies the device_uid; we look up hostname /
|
||||
* online state for telemetry only.
|
||||
*/
|
||||
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
export interface ResolvedTarget {
|
||||
device_uid: string;
|
||||
hostname: string | null;
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wulf Nurse Production endpoint pattern.
|
||||
* LLL = location code (3 letters)
|
||||
* CCC = client code (3 letters)
|
||||
* WNP = literal
|
||||
* NN = numeric suffix
|
||||
* e.g. YNGHYNWNP01 → Youngstown / Hynes / WNP / 01
|
||||
*/
|
||||
const WNP_REGEX = /^[A-Z]{3}[A-Z]{3}WNP\d{2}$/i;
|
||||
|
||||
interface SiteRow {
|
||||
id: number;
|
||||
name: string;
|
||||
device_count: number;
|
||||
}
|
||||
|
||||
interface DeviceRow {
|
||||
uid: string;
|
||||
hostname: string | null;
|
||||
online: boolean;
|
||||
site_id: number;
|
||||
site_device_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every Datto RMM site that maps to the given Autotask company.
|
||||
*
|
||||
* Two paths because the autotask_company_id FK on datto_rmm_sites isn't
|
||||
* always populated — many sites only carry the autotask_company_name. We
|
||||
* match on either the FK or a case-insensitive name match against
|
||||
* companies.company_name.
|
||||
*
|
||||
* Multi-site clients (e.g. Hynes Industries: Youngstown + Painesville +
|
||||
* Kokomo) return multiple rows; the caller combines WNPs across all of
|
||||
* them.
|
||||
*/
|
||||
async function findDattoSitesForCompany(
|
||||
companyId: string | number
|
||||
): Promise<SiteRow[]> {
|
||||
const res = await postgresClient.query<SiteRow>(
|
||||
`SELECT s.id, s.name, COALESCE(s.number_of_devices, 0) AS device_count
|
||||
FROM datto_rmm_sites s
|
||||
LEFT JOIN companies c ON c.id = $1::bigint
|
||||
WHERE s.autotask_company_id = $1::bigint
|
||||
OR (
|
||||
c.company_name IS NOT NULL
|
||||
AND s.autotask_company_name IS NOT NULL
|
||||
AND LOWER(s.autotask_company_name) = LOWER(c.company_name)
|
||||
)
|
||||
ORDER BY device_count DESC, s.name`,
|
||||
[companyId]
|
||||
);
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
async function findWnpDevicesForSites(siteIds: number[]): Promise<DeviceRow[]> {
|
||||
if (siteIds.length === 0) return [];
|
||||
const res = await postgresClient.query<DeviceRow>(
|
||||
`SELECT d.uid, d.hostname, d.online, d.site_id,
|
||||
COALESCE(s.number_of_devices, 0) AS site_device_count
|
||||
FROM datto_rmm_devices d
|
||||
JOIN datto_rmm_sites s ON s.id = d.site_id
|
||||
WHERE d.site_id = ANY($1::int[])
|
||||
AND d.hostname ~* '^[A-Z]{3}[A-Z]{3}WNP[0-9]{2}$'`,
|
||||
[siteIds]
|
||||
);
|
||||
return res.rows.filter((r) => r.hostname && WNP_REGEX.test(r.hostname));
|
||||
}
|
||||
|
||||
export async function resolveSiteAnchorTarget(
|
||||
companyId: string | number
|
||||
): Promise<ResolvedTarget | null> {
|
||||
const sites = await findDattoSitesForCompany(companyId);
|
||||
if (sites.length === 0) return null;
|
||||
const devices = await findWnpDevicesForSites(sites.map((s) => s.id));
|
||||
if (devices.length === 0) return null;
|
||||
// Pick: online > offline; site with more devices (proxy for "main
|
||||
// location"); lowest numeric suffix (WNP01 over WNP02).
|
||||
devices.sort((a, b) => {
|
||||
if (a.online !== b.online) return a.online ? -1 : 1;
|
||||
if (a.site_device_count !== b.site_device_count) {
|
||||
return b.site_device_count - a.site_device_count;
|
||||
}
|
||||
const suffixA = parseInt((a.hostname ?? '').slice(-2), 10);
|
||||
const suffixB = parseInt((b.hostname ?? '').slice(-2), 10);
|
||||
if (Number.isNaN(suffixA) || Number.isNaN(suffixB)) return 0;
|
||||
return suffixA - suffixB;
|
||||
});
|
||||
const pick = devices[0];
|
||||
return {
|
||||
device_uid: pick.uid,
|
||||
hostname: pick.hostname,
|
||||
online: pick.online,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return ALL matching WNPs across every Datto site for the company, with
|
||||
* their site context. The site-discovery page can show this so the user
|
||||
* sees every available endpoint — Hynes has Youngstown, Kokomo,
|
||||
* Painesville WNPs and they're all valid targets for some scripts.
|
||||
*/
|
||||
export async function listSiteAnchorTargets(
|
||||
companyId: string | number
|
||||
): Promise<
|
||||
Array<
|
||||
ResolvedTarget & { site_id: number; site_name: string; site_device_count: number }
|
||||
>
|
||||
> {
|
||||
const sites = await findDattoSitesForCompany(companyId);
|
||||
if (sites.length === 0) return [];
|
||||
const devices = await findWnpDevicesForSites(sites.map((s) => s.id));
|
||||
const siteNameById = new Map(sites.map((s) => [s.id, s.name]));
|
||||
return devices
|
||||
.sort((a, b) => {
|
||||
if (a.online !== b.online) return a.online ? -1 : 1;
|
||||
return b.site_device_count - a.site_device_count;
|
||||
})
|
||||
.map((d) => ({
|
||||
device_uid: d.uid,
|
||||
hostname: d.hostname,
|
||||
online: d.online,
|
||||
site_id: d.site_id,
|
||||
site_name: siteNameById.get(d.site_id) ?? '',
|
||||
site_device_count: d.site_device_count,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function resolveAssetSelfTarget(
|
||||
deviceUid: string
|
||||
): Promise<ResolvedTarget> {
|
||||
const res = await postgresClient.query<DeviceRow>(
|
||||
`SELECT uid, hostname, online
|
||||
FROM datto_rmm_devices
|
||||
WHERE uid = $1
|
||||
LIMIT 1`,
|
||||
[deviceUid]
|
||||
);
|
||||
if (res.rowCount === 0) {
|
||||
return { device_uid: deviceUid, hostname: null, online: false };
|
||||
}
|
||||
return {
|
||||
device_uid: res.rows[0].uid,
|
||||
hostname: res.rows[0].hostname,
|
||||
online: res.rows[0].online,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a Datto device by hostname (case-insensitive). Used when the
|
||||
* caller wants to target the audited Configuration's hostname rather than
|
||||
* a known device_uid.
|
||||
*/
|
||||
export async function resolveDeviceByHostname(
|
||||
hostname: string
|
||||
): Promise<ResolvedTarget | null> {
|
||||
const res = await postgresClient.query<DeviceRow>(
|
||||
`SELECT uid, hostname, online
|
||||
FROM datto_rmm_devices
|
||||
WHERE LOWER(hostname) = LOWER($1)
|
||||
LIMIT 1`,
|
||||
[hostname]
|
||||
);
|
||||
if (res.rowCount === 0) return null;
|
||||
return {
|
||||
device_uid: res.rows[0].uid,
|
||||
hostname: res.rows[0].hostname,
|
||||
online: res.rows[0].online,
|
||||
};
|
||||
}
|
||||
|
||||
export const _TARGET_RESOLVER_INTERNALS = { WNP_REGEX };
|
||||
59
lib/services/rmm/worker.test.ts
Normal file
59
lib/services/rmm/worker.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { _RMM_WORKER_INTERNALS } from './worker';
|
||||
|
||||
describe('extractResult', () => {
|
||||
const { extractResult } = _RMM_WORKER_INTERNALS;
|
||||
const deviceUid = 'dev-1';
|
||||
|
||||
it('returns done=false while jobStatus is running', () => {
|
||||
const r = extractResult({ jobStatus: 'running', stdOut: null }, deviceUid);
|
||||
expect(r.done).toBe(false);
|
||||
expect(r.exitCode).toBeNull();
|
||||
});
|
||||
|
||||
it('returns done=true with exitCode 0 on succeeded', () => {
|
||||
const r = extractResult(
|
||||
{ jobStatus: 'succeeded', stdOut: '{"ok":true}', stdErr: '', errorCode: 0 },
|
||||
deviceUid
|
||||
);
|
||||
expect(r.done).toBe(true);
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).toBe('{"ok":true}');
|
||||
});
|
||||
|
||||
it('returns done=true with exitCode 1 on failed (no errorCode given)', () => {
|
||||
const r = extractResult({ jobStatus: 'failed', stdErr: 'boom' }, deviceUid);
|
||||
expect(r.done).toBe(true);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(r.stderr).toBe('boom');
|
||||
});
|
||||
|
||||
it('uses per-device result when results array present', () => {
|
||||
const r = extractResult(
|
||||
{
|
||||
jobStatus: 'running',
|
||||
results: [
|
||||
{ deviceUid: 'dev-1', jobStatus: 'succeeded', stdOut: 'A', errorCode: 0 },
|
||||
{ deviceUid: 'dev-2', jobStatus: 'running', stdOut: null },
|
||||
],
|
||||
},
|
||||
'dev-1'
|
||||
);
|
||||
expect(r.done).toBe(true);
|
||||
expect(r.stdout).toBe('A');
|
||||
});
|
||||
|
||||
it('falls back to first result when device-specific not found', () => {
|
||||
const r = extractResult(
|
||||
{
|
||||
jobStatus: 'succeeded',
|
||||
results: [
|
||||
{ jobStatus: 'succeeded', stdOut: 'fallback', errorCode: 0 },
|
||||
],
|
||||
},
|
||||
'dev-X'
|
||||
);
|
||||
expect(r.done).toBe(true);
|
||||
expect(r.stdout).toBe('fallback');
|
||||
});
|
||||
});
|
||||
241
lib/services/rmm/worker.ts
Normal file
241
lib/services/rmm/worker.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
/**
|
||||
* RMM Overshell poller. Runs in-process alongside the analyzer worker.
|
||||
*
|
||||
* For each row in `running` status whose timeout_at hasn't passed:
|
||||
* 1. Call dattoClient.getJobResults(job_uid, target_device_uid).
|
||||
* 2. If Datto says still-running → leave alone, retry next tick.
|
||||
* 3. If Datto returns stdout/stderr → redact, run script.parseOutput,
|
||||
* persist via markExecutionComplete (status complete | failed
|
||||
* depending on exit_code).
|
||||
* 4. Catch all errors so a single bad job doesn't kill the loop.
|
||||
*
|
||||
* Stale-row sweep: any row past timeout_at gets marked timeout regardless
|
||||
* of whether Datto is responding. Stops the table from accumulating
|
||||
* orphaned 'running' rows.
|
||||
*/
|
||||
|
||||
import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
|
||||
import { redact } from '@/lib/services/analyzer/itglue-redact';
|
||||
import { getScript } from './scripts';
|
||||
import {
|
||||
listRunningExecutions,
|
||||
listTimedOutExecutions,
|
||||
markExecutionComplete,
|
||||
markExecutionTimeout,
|
||||
} from './persistence';
|
||||
import { audit } from '@/lib/services/audit';
|
||||
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
type DattoJobResults = {
|
||||
jobStatus?: string;
|
||||
status?: string;
|
||||
// Datto returns per-target stdout/stderr in different shapes depending on
|
||||
// tenant config; we accept both top-level and nested under
|
||||
// results/results[0]/stdOut etc.
|
||||
stdOut?: string | null;
|
||||
stdErr?: string | null;
|
||||
errorCode?: number | null;
|
||||
results?: Array<{
|
||||
deviceUid?: string;
|
||||
stdOut?: string | null;
|
||||
stdErr?: string | null;
|
||||
errorCode?: number | null;
|
||||
jobStatus?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
interface ExtractedResult {
|
||||
done: boolean;
|
||||
exitCode: number | null;
|
||||
stdout: string | null;
|
||||
stderr: string | null;
|
||||
}
|
||||
|
||||
function extractResult(payload: DattoJobResults, deviceUid: string): ExtractedResult {
|
||||
// Per-device result if available.
|
||||
const perDevice = payload.results?.find((r) => r.deviceUid === deviceUid)
|
||||
?? payload.results?.[0];
|
||||
|
||||
const candidate = perDevice ?? payload;
|
||||
const status = (candidate.jobStatus ?? payload.jobStatus ?? payload.status ?? '')
|
||||
.toString()
|
||||
.toLowerCase();
|
||||
|
||||
// Datto's terminal states: succeeded, failed, cancelled. Anything else =
|
||||
// not done yet.
|
||||
const done =
|
||||
status === 'succeeded' ||
|
||||
status === 'success' ||
|
||||
status === 'failed' ||
|
||||
status === 'cancelled' ||
|
||||
status === 'canceled';
|
||||
|
||||
return {
|
||||
done,
|
||||
exitCode:
|
||||
typeof candidate.errorCode === 'number'
|
||||
? candidate.errorCode
|
||||
: status === 'failed' || status === 'cancelled' || status === 'canceled'
|
||||
? 1
|
||||
: status === 'succeeded' || status === 'success'
|
||||
? 0
|
||||
: null,
|
||||
stdout: candidate.stdOut ?? null,
|
||||
stderr: candidate.stdErr ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
class RmmOvershellWorker {
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private running = false;
|
||||
private inFlight = false;
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
console.log('[RMM-WORKER] starting; polling every 5s');
|
||||
this.scheduleNextPoll(0);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.running = false;
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleNextPoll(delay: number): void {
|
||||
if (!this.running) return;
|
||||
this.timer = setTimeout(() => {
|
||||
void this.poll();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private async poll(): Promise<void> {
|
||||
if (!this.running) return;
|
||||
if (this.inFlight) {
|
||||
this.scheduleNextPoll(POLL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
this.inFlight = true;
|
||||
try {
|
||||
// First — sweep any rows that timed out.
|
||||
const timed = await listTimedOutExecutions();
|
||||
for (const row of timed) {
|
||||
try {
|
||||
await markExecutionTimeout(row.id);
|
||||
await audit.log({
|
||||
userId: row.performedByUserId ?? undefined,
|
||||
action: 'rmm.execute.timeout',
|
||||
resource: 'datto_device',
|
||||
resourceId: row.targetDeviceUid,
|
||||
details: { execution_id: row.id, script_id: row.scriptId },
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[RMM-WORKER] timeout-mark failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Then poll the running ones. Skip b2_upload rows — those are flipped
|
||||
// to complete by the LogLift webhook handler when the upload lands;
|
||||
// there's no stdout to poll for. The 5-minute timeout sweep above
|
||||
// still catches stuck rows.
|
||||
const running = (await listRunningExecutions()).filter(
|
||||
(r) => r.transport !== 'b2_upload'
|
||||
);
|
||||
if (running.length === 0) {
|
||||
return;
|
||||
}
|
||||
const client = getDattoRMMClient();
|
||||
for (const row of running) {
|
||||
if (!row.jobUid) continue;
|
||||
try {
|
||||
const payload = (await client.getJobResults(
|
||||
row.jobUid,
|
||||
row.targetDeviceUid
|
||||
)) as DattoJobResults;
|
||||
const r = extractResult(payload, row.targetDeviceUid);
|
||||
if (!r.done) continue; // still running, try next tick.
|
||||
|
||||
// Redact outputs before persistence — defensive against scripts
|
||||
// that might dump credentials. The script library is curated, but
|
||||
// belt + braces.
|
||||
const redactedStdout = r.stdout
|
||||
? (redact({ s: r.stdout }) as { s: string }).s
|
||||
: null;
|
||||
const redactedStderr = r.stderr
|
||||
? (redact({ s: r.stderr }) as { s: string }).s
|
||||
: null;
|
||||
|
||||
// Parse via the script's own parser. Failure is non-fatal — we
|
||||
// still persist the raw output.
|
||||
let parsed: unknown = null;
|
||||
let parseError: string | null = null;
|
||||
if (redactedStdout && r.exitCode === 0) {
|
||||
const script = getScript(row.scriptId);
|
||||
if (script) {
|
||||
try {
|
||||
parsed = script.parseOutput(redactedStdout);
|
||||
} catch (err) {
|
||||
parseError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await markExecutionComplete({
|
||||
id: row.id,
|
||||
exitCode: r.exitCode,
|
||||
rawStdout: redactedStdout,
|
||||
rawStderr: redactedStderr,
|
||||
parsedEvidence: parsed,
|
||||
parseError,
|
||||
});
|
||||
|
||||
await audit.log({
|
||||
userId: row.performedByUserId ?? undefined,
|
||||
action: r.exitCode === 0 ? 'rmm.execute.complete' : 'rmm.execute.failed',
|
||||
resource: 'datto_device',
|
||||
resourceId: row.targetDeviceUid,
|
||||
details: {
|
||||
execution_id: row.id,
|
||||
script_id: row.scriptId,
|
||||
exit_code: r.exitCode,
|
||||
parse_error: parseError,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[RMM-WORKER] poll error for execution ${row.id}:`,
|
||||
err instanceof Error ? err.message : err
|
||||
);
|
||||
// Don't mark failed yet — could be transient. timeout_at will
|
||||
// catch it eventually.
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[RMM-WORKER] poll loop error:', err);
|
||||
} finally {
|
||||
this.inFlight = false;
|
||||
this.scheduleNextPoll(POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const rmmOvershellWorker = new RmmOvershellWorker();
|
||||
|
||||
function shouldAutoStart(): boolean {
|
||||
if (typeof window !== 'undefined') return false;
|
||||
if (process.env.VITEST === 'true') return false;
|
||||
if (process.env.NODE_ENV === 'production') return true;
|
||||
return process.env.RMM_WORKER_AUTOSTART === '1';
|
||||
}
|
||||
|
||||
if (shouldAutoStart()) {
|
||||
rmmOvershellWorker.start().catch((err) => {
|
||||
console.error('[RMM-WORKER] failed to auto-start:', err);
|
||||
});
|
||||
}
|
||||
|
||||
export const _RMM_WORKER_INTERNALS = { POLL_INTERVAL_MS, extractResult };
|
||||
|
|
@ -22,7 +22,7 @@ export interface ScheduleConfig {
|
|||
name: string;
|
||||
description: string;
|
||||
cron_expression: string;
|
||||
sync_type: 'incremental' | 'full' | 'veeam-incremental' | 'veeam-full' | 'veeam-rpo-check' | 'contract-services' | 'engagement-daily' | 'zoom-daily' | 'morning-summary' | 'ticket-digest-daily' | 'ticket-digest-weekly' | 'ticket-digest-monthly';
|
||||
sync_type: 'incremental' | 'full' | 'veeam-incremental' | 'veeam-full' | 'veeam-rpo-check' | 'contract-services' | 'engagement-daily' | 'zoom-daily' | 'morning-summary' | 'ticket-digest-daily' | 'ticket-digest-weekly' | 'ticket-digest-monthly' | 'device-link-reconcile' | 'integration-health';
|
||||
years_back?: number;
|
||||
is_enabled: boolean;
|
||||
last_run?: Date;
|
||||
|
|
@ -275,6 +275,22 @@ class SyncScheduler {
|
|||
sync_type: 'ticket-digest-monthly',
|
||||
is_enabled: false,
|
||||
},
|
||||
{
|
||||
id: 'device-link-reconcile',
|
||||
name: 'Device-Link Reconciliation',
|
||||
description: 'Walks unlinked device_external_ids rows and links them to Autotask configuration_items via cascading match (serial → MAC → hostname-in-company). Conflicts are queued under /admin/device-link-conflicts. Hourly.',
|
||||
cron_expression: '15 * * * *',
|
||||
sync_type: 'device-link-reconcile',
|
||||
is_enabled: false,
|
||||
},
|
||||
{
|
||||
id: 'integration-health',
|
||||
name: 'Integration Health Check',
|
||||
description: 'Daily live auth check + token-expiry decode for each configured integration (S1, Datto RMM, IT Glue, Autotask, …). Posts an Adaptive Card to morning-summary webhooks when an integration is failing or a token expires within 14 days. Quiet on green days.',
|
||||
cron_expression: '30 7 * * *',
|
||||
sync_type: 'integration-health',
|
||||
is_enabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
for (const schedule of defaultSchedules) {
|
||||
|
|
@ -406,6 +422,18 @@ class SyncScheduler {
|
|||
await this.getTicketDigestService().run('weekly');
|
||||
} else if (config.sync_type === 'ticket-digest-monthly') {
|
||||
await this.getTicketDigestService().run('monthly');
|
||||
} else if (config.sync_type === 'device-link-reconcile') {
|
||||
const { reconcileUnlinkedDevices } = await import('@/lib/services/device-link-reconciler');
|
||||
const result = await reconcileUnlinkedDevices({ limit: 5000 });
|
||||
console.log(
|
||||
`[SCHEDULER] device-link-reconcile: scanned=${result.scanned} linked=${result.linked} conflicts=${result.conflicts} unmatched=${result.unmatched}`
|
||||
);
|
||||
} else if (config.sync_type === 'integration-health') {
|
||||
const { runIntegrationHealthAlertJob } = await import('@/lib/services/integration-health-alerts');
|
||||
const result = await runIntegrationHealthAlertJob();
|
||||
console.log(
|
||||
`[SCHEDULER] integration-health: failed=${result.summary.failed} expired=${result.summary.expired} expiringSoon=${result.summary.expiringWithin14Days} alertSent=${result.alertSent}`
|
||||
);
|
||||
} else if (config.sync_type === 'incremental') {
|
||||
await this.syncService.incrementalSync('scheduled');
|
||||
} else {
|
||||
|
|
@ -540,6 +568,26 @@ class SyncScheduler {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop every running cron task and re-load schedules from the DB. Use after
|
||||
* seeding new schedule rows directly via SQL (the constructor only runs the
|
||||
* default-seeding path on a virgin table).
|
||||
*/
|
||||
async reloadAllSchedules(): Promise<{ active: number; total: number }> {
|
||||
console.log('[SCHEDULER] Reloading all schedules from DB...');
|
||||
for (const id of Array.from(this.tasks.keys())) {
|
||||
this.stopSchedule(id);
|
||||
}
|
||||
await this.loadSchedules();
|
||||
const total = await postgresClient.query<{ count: string }>(
|
||||
'SELECT COUNT(*)::text AS count FROM sync_schedules'
|
||||
);
|
||||
return {
|
||||
active: this.tasks.size,
|
||||
total: parseInt(total.rows[0]?.count ?? '0', 10),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a schedule
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -213,6 +213,9 @@ export const PersistedAnalysis = z.object({
|
|||
|
||||
filteredNoiseCount: z.number().int().nonnegative(),
|
||||
errorMessage: z.string().nullable(),
|
||||
|
||||
/** Phase 3: which LLM provider produced this analysis. */
|
||||
provider: z.enum(['anthropic', 'openrouter']).default('anthropic'),
|
||||
});
|
||||
export type PersistedAnalysis = z.infer<typeof PersistedAnalysis>;
|
||||
|
||||
|
|
@ -440,6 +443,7 @@ export type AggregateReduceResponse = z.infer<typeof AggregateReduceResponse>;
|
|||
|
||||
export const AggregateReportStatus = z.enum([
|
||||
'pending',
|
||||
'pending_analyses',
|
||||
'running',
|
||||
'complete',
|
||||
'failed',
|
||||
|
|
@ -450,8 +454,12 @@ export type AggregateReportStatus = z.infer<typeof AggregateReportStatus>;
|
|||
// API request bodies.
|
||||
// =============================================================================
|
||||
|
||||
export const ProviderEnum = z.enum(['anthropic', 'openrouter']);
|
||||
export type ProviderEnum = z.infer<typeof ProviderEnum>;
|
||||
|
||||
export const AnalyzeTicketRequest = z.object({
|
||||
force: z.boolean().optional().default(false),
|
||||
provider: ProviderEnum.optional().default('anthropic'),
|
||||
});
|
||||
export type AnalyzeTicketRequest = z.infer<typeof AnalyzeTicketRequest>;
|
||||
|
||||
|
|
@ -461,6 +469,112 @@ export const ShareAnalysisRequest = z.object({
|
|||
});
|
||||
export type ShareAnalysisRequest = z.infer<typeof ShareAnalysisRequest>;
|
||||
|
||||
// =============================================================================
|
||||
// Phase 3 — link discovery & bundle analysis.
|
||||
// =============================================================================
|
||||
|
||||
export const LinkConfidence = z.enum(['high', 'medium']);
|
||||
export type LinkConfidence = z.infer<typeof LinkConfidence>;
|
||||
|
||||
export const LinkSource = z.enum([
|
||||
'related_tickets_section',
|
||||
'description_mention',
|
||||
'note_mention',
|
||||
'problem_ticket_id',
|
||||
'llm_suggested',
|
||||
]);
|
||||
export type LinkSource = z.infer<typeof LinkSource>;
|
||||
|
||||
export const TicketRef = z.object({
|
||||
ticket_number: z.string(),
|
||||
title: z.string().nullable(),
|
||||
status_label: z.string().nullable(),
|
||||
last_activity_date: z.string().datetime({ offset: true }).nullable(),
|
||||
source: LinkSource,
|
||||
confidence: LinkConfidence,
|
||||
reason: z.string().nullable(),
|
||||
});
|
||||
export type TicketRef = z.infer<typeof TicketRef>;
|
||||
|
||||
export const DiscoveredLinks = z.object({
|
||||
explicit: z.array(TicketRef),
|
||||
suggested: z.array(TicketRef),
|
||||
isProblemTicket: z.boolean(),
|
||||
problemTicketSignals: z.array(z.string()),
|
||||
});
|
||||
export type DiscoveredLinks = z.infer<typeof DiscoveredLinks>;
|
||||
|
||||
export const BundleAnalyzeRequest = z.object({
|
||||
linkedTicketNumbers: z.array(z.string()).max(50),
|
||||
includeItglueContext: z.boolean().optional().default(true),
|
||||
reportTitle: z.string().max(200).nullable().optional(),
|
||||
confirmedCost: z.boolean().optional().default(false),
|
||||
provider: ProviderEnum.optional().default('anthropic'),
|
||||
});
|
||||
export type BundleAnalyzeRequest = z.infer<typeof BundleAnalyzeRequest>;
|
||||
|
||||
export const SuggestLinksRequest = z.object({
|
||||
includeSuggested: z.boolean().optional().default(true),
|
||||
});
|
||||
export type SuggestLinksRequest = z.infer<typeof SuggestLinksRequest>;
|
||||
|
||||
// =============================================================================
|
||||
// Phase 4 — IT Glue asset audit (LLM-driven gap analysis vs ticket history).
|
||||
// =============================================================================
|
||||
|
||||
export const AuditConfidence = z.enum(['high', 'medium', 'low']);
|
||||
export type AuditConfidence = z.infer<typeof AuditConfidence>;
|
||||
|
||||
export const AssetAuditFieldGap = z.object({
|
||||
field_name: z.string(),
|
||||
why_missing_matters: z.string(),
|
||||
suggested_value: z.string().nullable(),
|
||||
evidence_ticket_numbers: z.array(z.string()),
|
||||
confidence: AuditConfidence,
|
||||
});
|
||||
export type AssetAuditFieldGap = z.infer<typeof AssetAuditFieldGap>;
|
||||
|
||||
export const AssetAuditNotePromotion = z.object({
|
||||
quoted_note_text: z.string(),
|
||||
target_field: z.string(),
|
||||
suggested_value: z.string(),
|
||||
confidence: AuditConfidence,
|
||||
});
|
||||
export type AssetAuditNotePromotion = z.infer<typeof AssetAuditNotePromotion>;
|
||||
|
||||
export const AssetAuditContradiction = z.object({
|
||||
description: z.string(),
|
||||
evidence: z.string(),
|
||||
});
|
||||
export type AssetAuditContradiction = z.infer<typeof AssetAuditContradiction>;
|
||||
|
||||
export const AssetAuditResponse = z.object({
|
||||
field_gaps: z.array(AssetAuditFieldGap),
|
||||
notes_promotions: z.array(AssetAuditNotePromotion),
|
||||
contradictions: z.array(AssetAuditContradiction),
|
||||
overall_score: z.number().min(0).max(1),
|
||||
});
|
||||
export type AssetAuditResponse = z.infer<typeof AssetAuditResponse>;
|
||||
|
||||
export const RunAssetAuditRequest = z.object({
|
||||
provider: ProviderEnum.optional().default('anthropic'),
|
||||
});
|
||||
export type RunAssetAuditRequest = z.infer<typeof RunAssetAuditRequest>;
|
||||
|
||||
export const ApplyAssetSuggestionRequest = z.object({
|
||||
auditId: z.string().uuid(),
|
||||
fieldName: z.string(),
|
||||
suggestedValue: z.unknown(),
|
||||
/** ticket_numbers + gap description for the source_evidence row. */
|
||||
sourceEvidence: z
|
||||
.object({
|
||||
ticket_numbers: z.array(z.string()).optional(),
|
||||
gap_description: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
export type ApplyAssetSuggestionRequest = z.infer<typeof ApplyAssetSuggestionRequest>;
|
||||
|
||||
// =============================================================================
|
||||
// Internal pipeline payload — passed between stages, NOT a wire format.
|
||||
// =============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue