feat(mcp-server): add team provisioning follow-up hooks

This commit is contained in:
root 2026-03-15 09:59:35 +00:00
parent 8976caa966
commit e996962b64
2 changed files with 235 additions and 27 deletions

View File

@ -22,6 +22,9 @@ vi.mock('../../lib/extraction-client.js', () => ({
vi.mock('../../lib/jarvis-client.js', () => ({
jarvisMarketplaceGetListing: vi.fn(),
jarvisMarketplaceCertify: vi.fn(),
jarvisTeamsListMembers: vi.fn(),
jarvisAgentsList: vi.fn(),
jarvisMemoryCreate: vi.fn(),
}));
vi.mock('../../lib/lysnrai-client.js', () => ({
@ -178,4 +181,43 @@ describe('A2A governance integration', () => {
expect.objectContaining({ productId: 'lysnrai' })
);
});
it('creates a support case when team provisioning is only partially successful', async () => {
const { runTeamProvisioningPipeline } = await import('./team-provisioning-pipeline.js');
const { jarvisTeamsListMembers, jarvisAgentsList, jarvisMemoryCreate } =
await import('../../lib/jarvis-client.js');
vi.mocked(jarvisTeamsListMembers).mockResolvedValue({
members: [
{
userId: 'u1',
teamId: 'team_1',
role: 'member',
status: 'invited',
joinedAt: new Date().toISOString(),
},
],
total: 1,
} as never);
vi.mocked(jarvisAgentsList).mockResolvedValue({
agents: [
{
id: 'agt_1',
role: 'productivity_coach',
},
],
total: 1,
} as never);
vi.mocked(jarvisMemoryCreate).mockRejectedValue(new Error('memory down'));
const report = await runTeamProvisioningPipeline('team_1', 48, buildReq('req_team'));
expect(report.failed).toBe(1);
expect(supportCasesCreate).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Team provisioning follow-up for team_1',
}),
expect.objectContaining({ productId: 'jarvisjr' })
);
});
});

View File

@ -21,6 +21,15 @@ import {
type JarvisTeamMemberDoc,
type JarvisAgentDoc,
} from '../../lib/jarvis-client.js';
import {
trackRunCompleted,
trackRunFailed,
trackRunStarted,
trackStepCompleted,
trackStepFailed,
trackStepStarted,
} from './run-tracker.js';
import { createSupportCaseForRun } from './governance.js';
// ── Types ──────────────────────────────────────────────────────────────────────
@ -180,7 +189,7 @@ function assembleProvisioningReport(
// ── Pipeline runner ────────────────────────────────────────────────────────────
async function runTeamProvisioningPipeline(
export async function runTeamProvisioningPipeline(
teamId: string,
sinceHours: number,
req: McpToolRequest
@ -188,37 +197,194 @@ async function runTeamProvisioningPipeline(
const runId = randomUUID();
const opts = { token: req.headers.authorization?.slice(7), requestId: req.id };
const sinceMs = sinceHours * 60 * 60 * 1000;
let currentStep: { stepName: 'detect' | 'onboard' | 'report'; order: number } | undefined;
req.log.info({ runId, stepId: 'detect', teamId, sinceHours }, 'NewMemberDetectorAgent start');
const { all, newMembers } = await detectNewMembers(teamId, sinceMs, opts);
req.log.info(
{ runId, stepId: 'detect', total: all.length, newCount: newMembers.length },
'NewMemberDetectorAgent done'
await safeTrack(() =>
trackRunStarted({
runId,
productId: 'jarvisjr',
name: 'team-provisioning',
requestId: req.id,
token: opts.token,
input: { teamId, sinceHours },
})
);
req.log.info({ runId, stepId: 'onboard', newCount: newMembers.length }, 'OnboardingAgent start');
const agentsResult = await jarvisAgentsList({ limit: 20 }, opts).catch(() => ({
agents: [] as JarvisAgentDoc[],
total: 0,
}));
const results: MemberOnboardingResult[] = [];
for (const member of newMembers) {
const result = await onboardMember(member, agentsResult.agents, opts);
results.push(result);
try {
currentStep = { stepName: 'detect', order: 1 };
const detectStep = currentStep;
await safeTrack(() =>
trackStepStarted({
runId,
productId: 'jarvisjr',
stepName: detectStep.stepName,
order: detectStep.order,
token: opts.token,
requestId: req.id,
input: { teamId, sinceHours },
})
);
req.log.info({ runId, stepId: 'detect', teamId, sinceHours }, 'NewMemberDetectorAgent start');
const { all, newMembers } = await detectNewMembers(teamId, sinceMs, opts);
await safeTrack(() =>
trackStepCompleted({
runId,
productId: 'jarvisjr',
stepName: detectStep.stepName,
order: detectStep.order,
token: opts.token,
requestId: req.id,
output: { totalMembers: all.length, newMembers: newMembers.length },
})
);
req.log.info(
{ runId, stepId: 'detect', total: all.length, newCount: newMembers.length },
'NewMemberDetectorAgent done'
);
currentStep = { stepName: 'onboard', order: 2 };
const onboardStep = currentStep;
await safeTrack(() =>
trackStepStarted({
runId,
productId: 'jarvisjr',
stepName: onboardStep.stepName,
order: onboardStep.order,
token: opts.token,
requestId: req.id,
input: { newMembers: newMembers.length },
})
);
req.log.info(
{ runId, stepId: 'onboard', newCount: newMembers.length },
'OnboardingAgent start'
);
const agentsResult = await jarvisAgentsList({ limit: 20 }, opts).catch(() => ({
agents: [] as JarvisAgentDoc[],
total: 0,
}));
const results: MemberOnboardingResult[] = [];
for (const member of newMembers) {
const result = await onboardMember(member, agentsResult.agents, opts);
results.push(result);
}
await safeTrack(() =>
trackStepCompleted({
runId,
productId: 'jarvisjr',
stepName: onboardStep.stepName,
order: onboardStep.order,
token: opts.token,
requestId: req.id,
output: {
seeded: results.filter(r => r.memorySeeded).length,
failed: results.filter(r => r.errors.length > 0).length,
},
})
);
req.log.info(
{ runId, stepId: 'onboard', seeded: results.filter(r => r.memorySeeded).length },
'OnboardingAgent done'
);
currentStep = { stepName: 'report', order: 3 };
const reportStep = currentStep;
await safeTrack(() =>
trackStepStarted({
runId,
productId: 'jarvisjr',
stepName: reportStep.stepName,
order: reportStep.order,
token: opts.token,
requestId: req.id,
})
);
req.log.info({ runId, stepId: 'report' }, 'ProvisioningReportAgent start');
const report = assembleProvisioningReport(runId, teamId, all.length, results);
await safeTrack(() =>
trackStepCompleted({
runId,
productId: 'jarvisjr',
stepName: reportStep.stepName,
order: reportStep.order,
token: opts.token,
requestId: req.id,
output: {
fullyOnboarded: report.fullyOnboarded,
partiallyOnboarded: report.partiallyOnboarded,
failed: report.failed,
},
})
);
await safeTrack(() =>
trackRunCompleted({
runId,
productId: 'jarvisjr',
name: 'team-provisioning',
requestId: req.id,
token: opts.token,
output: {
fullyOnboarded: report.fullyOnboarded,
partiallyOnboarded: report.partiallyOnboarded,
failed: report.failed,
},
})
);
if (report.failed > 0 || report.partiallyOnboarded > 0) {
await createSupportCaseForRun(
{
productId: 'jarvisjr',
runId,
title: `Team provisioning follow-up for ${teamId}`,
description: report.summary,
priority: report.failed > 0 ? 'high' : 'medium',
tags: ['a2a', 'team-provisioning', 'onboarding'],
},
opts
);
}
req.log.info(
{ runId, stepId: 'report', summary: report.summary },
'ProvisioningReportAgent done'
);
return report;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (currentStep) {
const failedStep = currentStep;
await safeTrack(() =>
trackStepFailed({
runId,
productId: 'jarvisjr',
stepName: failedStep.stepName,
order: failedStep.order,
token: opts.token,
requestId: req.id,
error: message,
})
);
}
await safeTrack(() =>
trackRunFailed({
runId,
productId: 'jarvisjr',
name: 'team-provisioning',
requestId: req.id,
token: opts.token,
error: message,
})
);
throw error;
}
req.log.info(
{ runId, stepId: 'onboard', seeded: results.filter(r => r.memorySeeded).length },
'OnboardingAgent done'
);
}
req.log.info({ runId, stepId: 'report' }, 'ProvisioningReportAgent start');
const report = assembleProvisioningReport(runId, teamId, all.length, results);
req.log.info(
{ runId, stepId: 'report', summary: report.summary },
'ProvisioningReportAgent done'
);
return report;
async function safeTrack(fn: () => Promise<void>): Promise<void> {
try {
await fn();
} catch {
// Tracking must never fail the pipeline itself.
}
}
// ── MCP tool registration ─────────────────────────────────────────────────────