feat(mcp-server): add org review follow-up hooks

This commit is contained in:
root 2026-03-15 09:56:04 +00:00
parent 7c5999ce5a
commit 8976caa966
4 changed files with 319 additions and 48 deletions

View File

@ -337,6 +337,29 @@ export async function diagnosticsListSessions(
);
}
// ── Reviews ──────────────────────────────────────────────────────────────────
export async function reviewsCreate(
body: {
title: string;
description: string;
category: string;
priority?: 'low' | 'normal' | 'high' | 'urgent';
scope?: 'org' | 'workspace';
orgId: string;
workspaceId?: string;
assignedTo?: string;
runId?: string;
source: string;
actionType: string;
metadata?: Record<string, unknown>;
dueAt?: string;
},
opts: PlatformClientOptions
): Promise<unknown> {
return platformFetch('/api/reviews', { method: 'POST', body: JSON.stringify(body) }, opts);
}
export async function diagnosticsCreateSession(
body: {
productId: string;

View File

@ -7,6 +7,7 @@ process.env.EXTRACTION_SERVICE_URL ??= 'http://localhost:4005';
vi.mock('../../lib/platform-client.js', () => ({
aiBudgetsRecordSpend: vi.fn(),
reviewsCreate: vi.fn(),
supportCasesCreate: vi.fn(),
runsCreate: vi.fn(),
runsUpdate: vi.fn(),
@ -24,12 +25,16 @@ vi.mock('../../lib/jarvis-client.js', () => ({
}));
vi.mock('../../lib/lysnrai-client.js', () => ({
lysnraiOrgGet: vi.fn(),
lysnraiApiTokensList: vi.fn(),
lysnraiSessionsList: vi.fn(),
lysnraiTranscriptsList: vi.fn(),
lysnraiTranscriptRunExtraction: vi.fn(),
}));
import {
aiBudgetsRecordSpend,
reviewsCreate,
supportCasesCreate,
runsCreate,
runsUpdate,
@ -39,6 +44,9 @@ import {
import { extractionRun } from '../../lib/extraction-client.js';
import { jarvisMarketplaceCertify, jarvisMarketplaceGetListing } from '../../lib/jarvis-client.js';
import {
lysnraiApiTokensList,
lysnraiOrgGet,
lysnraiSessionsList,
lysnraiTranscriptRunExtraction,
lysnraiTranscriptsList,
} from '../../lib/lysnrai-client.js';
@ -62,6 +70,7 @@ function buildReq(id = 'req_1'): McpToolRequest {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(aiBudgetsRecordSpend).mockResolvedValue({} as never);
vi.mocked(reviewsCreate).mockResolvedValue({} as never);
vi.mocked(supportCasesCreate).mockResolvedValue({} as never);
vi.mocked(runsCreate).mockResolvedValue({} as never);
vi.mocked(runsUpdate).mockResolvedValue({} as never);
@ -150,4 +159,23 @@ describe('A2A governance integration', () => {
expect.objectContaining({ productId: 'lysnrai' })
);
});
it('creates an org-scoped review when org provisioning needs follow-up', async () => {
const { runOrgProvisioningPipeline } = await import('./org-provisioning-pipeline.js');
vi.mocked(lysnraiOrgGet).mockResolvedValue({ id: 'org_1', name: 'Acme' } as never);
vi.mocked(lysnraiApiTokensList).mockResolvedValue({ tokens: [] } as never);
vi.mocked(lysnraiSessionsList).mockResolvedValue({ total: 0 } as never);
const report = await runOrgProvisioningPipeline('org_1', buildReq('req_org'));
expect(report.onboardingStatus).toBe('new_org');
expect(reviewsCreate).toHaveBeenCalledWith(
expect.objectContaining({
orgId: 'org_1',
category: 'org_onboarding',
actionType: 'org_onboarding_followup',
}),
expect.objectContaining({ productId: 'lysnrai' })
);
});
});

View File

@ -1,4 +1,8 @@
import { aiBudgetsRecordSpend, supportCasesCreate } from '../../lib/platform-client.js';
import {
aiBudgetsRecordSpend,
reviewsCreate,
supportCasesCreate,
} from '../../lib/platform-client.js';
import type { PlatformClientOptions } from '../../lib/platform-client.js';
interface BudgetSpendInput {
@ -26,6 +30,19 @@ interface SupportCaseInput {
tags?: string[];
}
interface ReviewInput {
productId: string;
orgId: string;
workspaceId?: string;
runId: string;
title: string;
description: string;
category: string;
priority?: 'low' | 'normal' | 'high' | 'urgent';
actionType: string;
metadata?: Record<string, unknown>;
}
export async function recordBudgetSpend(
input: BudgetSpendInput,
opts: PlatformClientOptions
@ -70,6 +87,30 @@ export async function createSupportCaseForRun(
);
}
export async function createReviewForRun(
input: ReviewInput,
opts: PlatformClientOptions
): Promise<void> {
await safeGovernanceCall(() =>
reviewsCreate(
{
title: input.title,
description: input.description,
category: input.category,
priority: input.priority ?? 'high',
scope: input.workspaceId ? 'workspace' : 'org',
orgId: input.orgId,
workspaceId: input.workspaceId,
runId: input.runId,
source: 'mcp.a2a',
actionType: input.actionType,
metadata: input.metadata,
},
withProduct(opts, input.productId)
)
);
}
function withProduct(opts: PlatformClientOptions, productId: string): PlatformClientOptions {
return {
token: opts.token,

View File

@ -21,6 +21,15 @@ import {
type OrgDoc,
type ApiTokenDoc,
} from '../../lib/lysnrai-client.js';
import {
trackRunCompleted,
trackRunFailed,
trackRunStarted,
trackStepCompleted,
trackStepFailed,
trackStepStarted,
} from './run-tracker.js';
import { createReviewForRun } from './governance.js';
// ── Types ──────────────────────────────────────────────────────────────────────
@ -117,65 +126,235 @@ function buildProvisioningActions(inspection: OrgInspection): {
// ── Pipeline runner ────────────────────────────────────────────────────────────
async function runOrgProvisioningPipeline(
export async function runOrgProvisioningPipeline(
orgId: string,
req: McpToolRequest
): Promise<OrgProvisioningReport> {
const runId = randomUUID();
const opts = { token: req.headers.authorization?.slice(7), requestId: req.id };
let currentStep: { stepName: 'inspect' | 'provision' | 'report'; order: number } | undefined;
req.log.info({ runId, stepId: 'inspect', orgId }, 'OrgInspectorAgent start');
const inspection = await inspectOrg(orgId, opts);
req.log.info({ runId, stepId: 'inspect', found: inspection !== null }, 'OrgInspectorAgent done');
if (!inspection) {
return {
await safeTrack(() =>
trackRunStarted({
runId,
productId: 'lysnrai',
orgId,
orgName: 'unknown',
existingTokens: 0,
defaultTokenNeeded: false,
defaultTokenNote: 'Organization not found or access denied.',
sessionCount: 0,
onboardingStatus: 'needs_attention',
recommendations: ['Verify the orgId is correct and the caller has admin access.'],
summary: `Failed to inspect org ${orgId}. Organization not found or inaccessible.`,
generatedAt: new Date().toISOString(),
};
}
req.log.info({ runId, stepId: 'provision' }, 'ProvisioningActionAgent start');
const { defaultTokenNote, recommendations } = buildProvisioningActions(inspection);
req.log.info(
{ runId, stepId: 'provision', recommendationCount: recommendations.length },
'ProvisioningActionAgent done'
name: 'org-provisioning',
requestId: req.id,
token: opts.token,
input: { orgId },
})
);
req.log.info({ runId, stepId: 'report' }, 'OrgReportAgent start');
const onboardingStatus = inspection.needsOnboarding
? 'new_org'
: inspection.sessionCount > 0 && inspection.existingTokenCount > 0
? 'complete'
: 'needs_attention';
try {
currentStep = { stepName: 'inspect', order: 1 };
const inspectStep = currentStep;
await safeTrack(() =>
trackStepStarted({
runId,
productId: 'lysnrai',
stepName: inspectStep.stepName,
order: inspectStep.order,
token: opts.token,
requestId: req.id,
input: { orgId },
})
);
req.log.info({ runId, stepId: 'inspect', orgId }, 'OrgInspectorAgent start');
const inspection = await inspectOrg(orgId, opts);
await safeTrack(() =>
trackStepCompleted({
runId,
productId: 'lysnrai',
stepName: inspectStep.stepName,
order: inspectStep.order,
token: opts.token,
requestId: req.id,
output: { found: inspection !== null },
})
);
req.log.info(
{ runId, stepId: 'inspect', found: inspection !== null },
'OrgInspectorAgent done'
);
const summary = `Org "${inspection.org.name}" (${orgId}): ${inspection.existingTokenCount} API token(s), ${inspection.sessionCount} session(s). Status: ${onboardingStatus}. ${recommendations.length} action(s) recommended.`;
req.log.info({ runId, stepId: 'report', summary }, 'OrgReportAgent done');
if (!inspection) {
const result = {
runId,
productId: 'lysnrai' as const,
orgId,
orgName: 'unknown',
existingTokens: 0,
defaultTokenNeeded: false,
defaultTokenNote: 'Organization not found or access denied.',
sessionCount: 0,
onboardingStatus: 'needs_attention' as const,
recommendations: ['Verify the orgId is correct and the caller has admin access.'],
summary: `Failed to inspect org ${orgId}. Organization not found or inaccessible.`,
generatedAt: new Date().toISOString(),
};
await safeTrack(() =>
trackRunCompleted({
runId,
productId: 'lysnrai',
name: 'org-provisioning',
requestId: req.id,
token: opts.token,
output: { onboardingStatus: result.onboardingStatus },
})
);
return result;
}
return {
runId,
productId: 'lysnrai',
orgId,
orgName: inspection.org.name,
existingTokens: inspection.existingTokenCount,
defaultTokenNeeded: inspection.needsDefaultToken,
defaultTokenNote,
sessionCount: inspection.sessionCount,
onboardingStatus,
recommendations,
summary,
generatedAt: new Date().toISOString(),
};
currentStep = { stepName: 'provision', order: 2 };
const provisionStep = currentStep;
await safeTrack(() =>
trackStepStarted({
runId,
productId: 'lysnrai',
stepName: provisionStep.stepName,
order: provisionStep.order,
token: opts.token,
requestId: req.id,
})
);
req.log.info({ runId, stepId: 'provision' }, 'ProvisioningActionAgent start');
const { defaultTokenNote, recommendations } = buildProvisioningActions(inspection);
await safeTrack(() =>
trackStepCompleted({
runId,
productId: 'lysnrai',
stepName: provisionStep.stepName,
order: provisionStep.order,
token: opts.token,
requestId: req.id,
output: { recommendationCount: recommendations.length },
})
);
req.log.info(
{ runId, stepId: 'provision', recommendationCount: recommendations.length },
'ProvisioningActionAgent done'
);
currentStep = { stepName: 'report', order: 3 };
const reportStep = currentStep;
await safeTrack(() =>
trackStepStarted({
runId,
productId: 'lysnrai',
stepName: reportStep.stepName,
order: reportStep.order,
token: opts.token,
requestId: req.id,
})
);
req.log.info({ runId, stepId: 'report' }, 'OrgReportAgent start');
const onboardingStatus: OrgProvisioningReport['onboardingStatus'] = inspection.needsOnboarding
? 'new_org'
: inspection.sessionCount > 0 && inspection.existingTokenCount > 0
? 'complete'
: 'needs_attention';
const summary = `Org "${inspection.org.name}" (${orgId}): ${inspection.existingTokenCount} API token(s), ${inspection.sessionCount} session(s). Status: ${onboardingStatus}. ${recommendations.length} action(s) recommended.`;
const report = {
runId,
productId: 'lysnrai' as const,
orgId,
orgName: inspection.org.name,
existingTokens: inspection.existingTokenCount,
defaultTokenNeeded: inspection.needsDefaultToken,
defaultTokenNote,
sessionCount: inspection.sessionCount,
onboardingStatus,
recommendations,
summary,
generatedAt: new Date().toISOString(),
};
await safeTrack(() =>
trackStepCompleted({
runId,
productId: 'lysnrai',
stepName: reportStep.stepName,
order: reportStep.order,
token: opts.token,
requestId: req.id,
output: {
onboardingStatus: report.onboardingStatus,
recommendationCount: recommendations.length,
},
})
);
await safeTrack(() =>
trackRunCompleted({
runId,
productId: 'lysnrai',
name: 'org-provisioning',
requestId: req.id,
token: opts.token,
output: {
onboardingStatus: report.onboardingStatus,
recommendationCount: recommendations.length,
},
})
);
if (report.onboardingStatus !== 'complete') {
await createReviewForRun(
{
productId: 'lysnrai',
orgId,
runId,
title: `Org provisioning follow-up for ${inspection.org.name}`,
description: summary,
category: 'org_onboarding',
priority: report.onboardingStatus === 'new_org' ? 'urgent' : 'high',
actionType: 'org_onboarding_followup',
metadata: {
recommendationCount: recommendations.length,
defaultTokenNeeded: inspection.needsDefaultToken,
sessionCount: inspection.sessionCount,
},
},
opts
);
}
req.log.info({ runId, stepId: 'report', summary }, 'OrgReportAgent done');
return report;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (currentStep) {
const failedStep = currentStep;
await safeTrack(() =>
trackStepFailed({
runId,
productId: 'lysnrai',
stepName: failedStep.stepName,
order: failedStep.order,
token: opts.token,
requestId: req.id,
error: message,
})
);
}
await safeTrack(() =>
trackRunFailed({
runId,
productId: 'lysnrai',
name: 'org-provisioning',
requestId: req.id,
token: opts.token,
error: message,
})
);
throw error;
}
}
async function safeTrack(fn: () => Promise<void>): Promise<void> {
try {
await fn();
} catch {
// Tracking must never fail the pipeline itself.
}
}
// ── MCP tool registration ─────────────────────────────────────────────────────