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(
|
||||
body: {
|
||||
productId: string;
|
||||
|
||||
@ -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' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
Loading…
Reference in New Issue
Block a user