feat(mcp-server): add org review follow-up hooks
This commit is contained in:
parent
7c5999ce5a
commit
8976caa966
@ -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(
|
export async function diagnosticsCreateSession(
|
||||||
body: {
|
body: {
|
||||||
productId: string;
|
productId: string;
|
||||||
|
|||||||
@ -7,6 +7,7 @@ process.env.EXTRACTION_SERVICE_URL ??= 'http://localhost:4005';
|
|||||||
|
|
||||||
vi.mock('../../lib/platform-client.js', () => ({
|
vi.mock('../../lib/platform-client.js', () => ({
|
||||||
aiBudgetsRecordSpend: vi.fn(),
|
aiBudgetsRecordSpend: vi.fn(),
|
||||||
|
reviewsCreate: vi.fn(),
|
||||||
supportCasesCreate: vi.fn(),
|
supportCasesCreate: vi.fn(),
|
||||||
runsCreate: vi.fn(),
|
runsCreate: vi.fn(),
|
||||||
runsUpdate: vi.fn(),
|
runsUpdate: vi.fn(),
|
||||||
@ -24,12 +25,16 @@ vi.mock('../../lib/jarvis-client.js', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../lib/lysnrai-client.js', () => ({
|
vi.mock('../../lib/lysnrai-client.js', () => ({
|
||||||
|
lysnraiOrgGet: vi.fn(),
|
||||||
|
lysnraiApiTokensList: vi.fn(),
|
||||||
|
lysnraiSessionsList: vi.fn(),
|
||||||
lysnraiTranscriptsList: vi.fn(),
|
lysnraiTranscriptsList: vi.fn(),
|
||||||
lysnraiTranscriptRunExtraction: vi.fn(),
|
lysnraiTranscriptRunExtraction: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
aiBudgetsRecordSpend,
|
aiBudgetsRecordSpend,
|
||||||
|
reviewsCreate,
|
||||||
supportCasesCreate,
|
supportCasesCreate,
|
||||||
runsCreate,
|
runsCreate,
|
||||||
runsUpdate,
|
runsUpdate,
|
||||||
@ -39,6 +44,9 @@ import {
|
|||||||
import { extractionRun } from '../../lib/extraction-client.js';
|
import { extractionRun } from '../../lib/extraction-client.js';
|
||||||
import { jarvisMarketplaceCertify, jarvisMarketplaceGetListing } from '../../lib/jarvis-client.js';
|
import { jarvisMarketplaceCertify, jarvisMarketplaceGetListing } from '../../lib/jarvis-client.js';
|
||||||
import {
|
import {
|
||||||
|
lysnraiApiTokensList,
|
||||||
|
lysnraiOrgGet,
|
||||||
|
lysnraiSessionsList,
|
||||||
lysnraiTranscriptRunExtraction,
|
lysnraiTranscriptRunExtraction,
|
||||||
lysnraiTranscriptsList,
|
lysnraiTranscriptsList,
|
||||||
} from '../../lib/lysnrai-client.js';
|
} from '../../lib/lysnrai-client.js';
|
||||||
@ -62,6 +70,7 @@ function buildReq(id = 'req_1'): McpToolRequest {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(aiBudgetsRecordSpend).mockResolvedValue({} as never);
|
vi.mocked(aiBudgetsRecordSpend).mockResolvedValue({} as never);
|
||||||
|
vi.mocked(reviewsCreate).mockResolvedValue({} as never);
|
||||||
vi.mocked(supportCasesCreate).mockResolvedValue({} as never);
|
vi.mocked(supportCasesCreate).mockResolvedValue({} as never);
|
||||||
vi.mocked(runsCreate).mockResolvedValue({} as never);
|
vi.mocked(runsCreate).mockResolvedValue({} as never);
|
||||||
vi.mocked(runsUpdate).mockResolvedValue({} as never);
|
vi.mocked(runsUpdate).mockResolvedValue({} as never);
|
||||||
@ -150,4 +159,23 @@ describe('A2A governance integration', () => {
|
|||||||
expect.objectContaining({ productId: 'lysnrai' })
|
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' })
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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';
|
import type { PlatformClientOptions } from '../../lib/platform-client.js';
|
||||||
|
|
||||||
interface BudgetSpendInput {
|
interface BudgetSpendInput {
|
||||||
@ -26,6 +30,19 @@ interface SupportCaseInput {
|
|||||||
tags?: string[];
|
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(
|
export async function recordBudgetSpend(
|
||||||
input: BudgetSpendInput,
|
input: BudgetSpendInput,
|
||||||
opts: PlatformClientOptions
|
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 {
|
function withProduct(opts: PlatformClientOptions, productId: string): PlatformClientOptions {
|
||||||
return {
|
return {
|
||||||
token: opts.token,
|
token: opts.token,
|
||||||
|
|||||||
@ -21,6 +21,15 @@ import {
|
|||||||
type OrgDoc,
|
type OrgDoc,
|
||||||
type ApiTokenDoc,
|
type ApiTokenDoc,
|
||||||
} from '../../lib/lysnrai-client.js';
|
} from '../../lib/lysnrai-client.js';
|
||||||
|
import {
|
||||||
|
trackRunCompleted,
|
||||||
|
trackRunFailed,
|
||||||
|
trackRunStarted,
|
||||||
|
trackStepCompleted,
|
||||||
|
trackStepFailed,
|
||||||
|
trackStepStarted,
|
||||||
|
} from './run-tracker.js';
|
||||||
|
import { createReviewForRun } from './governance.js';
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -117,65 +126,235 @@ function buildProvisioningActions(inspection: OrgInspection): {
|
|||||||
|
|
||||||
// ── Pipeline runner ────────────────────────────────────────────────────────────
|
// ── Pipeline runner ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function runOrgProvisioningPipeline(
|
export async function runOrgProvisioningPipeline(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
req: McpToolRequest
|
req: McpToolRequest
|
||||||
): Promise<OrgProvisioningReport> {
|
): Promise<OrgProvisioningReport> {
|
||||||
const runId = randomUUID();
|
const runId = randomUUID();
|
||||||
const opts = { token: req.headers.authorization?.slice(7), requestId: req.id };
|
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');
|
await safeTrack(() =>
|
||||||
const inspection = await inspectOrg(orgId, opts);
|
trackRunStarted({
|
||||||
req.log.info({ runId, stepId: 'inspect', found: inspection !== null }, 'OrgInspectorAgent done');
|
|
||||||
|
|
||||||
if (!inspection) {
|
|
||||||
return {
|
|
||||||
runId,
|
runId,
|
||||||
productId: 'lysnrai',
|
productId: 'lysnrai',
|
||||||
orgId,
|
name: 'org-provisioning',
|
||||||
orgName: 'unknown',
|
requestId: req.id,
|
||||||
existingTokens: 0,
|
token: opts.token,
|
||||||
defaultTokenNeeded: false,
|
input: { orgId },
|
||||||
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'
|
|
||||||
);
|
);
|
||||||
|
|
||||||
req.log.info({ runId, stepId: 'report' }, 'OrgReportAgent start');
|
try {
|
||||||
const onboardingStatus = inspection.needsOnboarding
|
currentStep = { stepName: 'inspect', order: 1 };
|
||||||
? 'new_org'
|
const inspectStep = currentStep;
|
||||||
: inspection.sessionCount > 0 && inspection.existingTokenCount > 0
|
await safeTrack(() =>
|
||||||
? 'complete'
|
trackStepStarted({
|
||||||
: 'needs_attention';
|
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.`;
|
if (!inspection) {
|
||||||
req.log.info({ runId, stepId: 'report', summary }, 'OrgReportAgent done');
|
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 {
|
currentStep = { stepName: 'provision', order: 2 };
|
||||||
runId,
|
const provisionStep = currentStep;
|
||||||
productId: 'lysnrai',
|
await safeTrack(() =>
|
||||||
orgId,
|
trackStepStarted({
|
||||||
orgName: inspection.org.name,
|
runId,
|
||||||
existingTokens: inspection.existingTokenCount,
|
productId: 'lysnrai',
|
||||||
defaultTokenNeeded: inspection.needsDefaultToken,
|
stepName: provisionStep.stepName,
|
||||||
defaultTokenNote,
|
order: provisionStep.order,
|
||||||
sessionCount: inspection.sessionCount,
|
token: opts.token,
|
||||||
onboardingStatus,
|
requestId: req.id,
|
||||||
recommendations,
|
})
|
||||||
summary,
|
);
|
||||||
generatedAt: new Date().toISOString(),
|
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 ─────────────────────────────────────────────────────
|
// ── MCP tool registration ─────────────────────────────────────────────────────
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user