feat(backend): return the created run id from manual triage

runTriage now resolves to { outcome, runId } so the admin API can hand the
run id back to the UI for deep-linking into the run detail.
This commit is contained in:
lakshit verma 2026-08-19 21:46:21 +05:30
parent 691980735a
commit 1ca37a6504
No known key found for this signature in database
4 changed files with 31 additions and 22 deletions

View file

@ -12,6 +12,11 @@ import type { ProviderMessage, ProviderResult } from './provider.js';
export type TriageOutcome = 'done' | 'skipped' | 'failed' | 'retry';
export interface TriageResult {
outcome: TriageOutcome;
runId: string | null;
}
type TriageProvider = typeof import('./provider.js');
const SYSTEM_PROMPT =
@ -38,33 +43,35 @@ export async function ensureTriageConfig(): Promise<void> {
}
}
export async function runTriage(payloadRef: string): Promise<TriageOutcome> {
if (!env.AI_ENABLED) return 'skipped';
export async function runTriage(payloadRef: string): Promise<TriageResult> {
if (!env.AI_ENABLED) return { outcome: 'skipped', runId: null };
let config = await agentRepo.getAgentConfig('triage');
if (!config) {
await ensureTriageConfig();
config = await agentRepo.getAgentConfig('triage');
}
if (!config || !config.enabled) return 'skipped';
if (!isWorkingHours(config.workingHours, new Date())) return 'skipped';
if (!config || !config.enabled) return { outcome: 'skipped', runId: null };
if (!isWorkingHours(config.workingHours, new Date())) return { outcome: 'skipped', runId: null };
const wo = await workOrderRepo.findById(payloadRef);
if (!wo) return 'skipped';
if (!wo) return { outcome: 'skipped', runId: null };
const owner = wo.owner as unknown as { _id?: { toString(): string }; name?: string; email?: string } | null;
const ownerId = owner?._id?.toString();
if (ownerId) {
const ownerUser = await userRepo.findById(ownerId);
if (ownerUser && ownerUser.aiEnabled === false) return 'skipped';
if (ownerUser && ownerUser.aiEnabled === false) return { outcome: 'skipped', runId: null };
}
const suggestionsToday = await agentRepo.countSuggestionsToday();
if (suggestionsToday >= config.dailyActionCap) return 'skipped';
if (suggestionsToday >= config.dailyActionCap) return { outcome: 'skipped', runId: null };
const agentSpend = await agentRepo.getSpend('agent:triage');
const globalSpend = await agentRepo.getSpend('global');
if (agentSpend >= env.AGENT_DAILY_SPEND_USD || globalSpend >= env.AI_GLOBAL_DAILY_SPEND_USD) return 'skipped';
if (agentSpend >= env.AGENT_DAILY_SPEND_USD || globalSpend >= env.AI_GLOBAL_DAILY_SPEND_USD) {
return { outcome: 'skipped', runId: null };
}
const provider = await import('./provider.js');
const run = await agentRepo.createRun({
@ -74,17 +81,19 @@ export async function runTriage(payloadRef: string): Promise<TriageOutcome> {
agentName: 'triage',
model: env.AI_MODEL,
});
const runId = run._id.toString();
try {
return await runAttempts(config, run._id.toString(), payloadRef, wo, provider);
const outcome = await runAttempts(config, runId, payloadRef, wo, provider);
return { outcome, runId };
} catch (err) {
const transient = err instanceof provider.ProviderError;
await agentRepo.finishRun(run._id.toString(), {
await agentRepo.finishRun(runId, {
status: 'error',
finishedAt: new Date(),
errorCode: transient ? 'AI_UNAVAILABLE' : 'INTERNAL',
});
return transient ? 'retry' : 'failed';
return { outcome: transient ? 'retry' : 'failed', runId };
}
}

View file

@ -94,8 +94,8 @@ export const agentAdminController = {
} else {
assertValidObjectId(workOrderId);
}
const outcome = await runTriage(workOrderId);
res.status(200).json({ success: true, data: { outcome } });
const { outcome, runId } = await runTriage(workOrderId);
res.status(200).json({ success: true, data: { outcome, runId } });
} catch (err) {
next(err);
}

View file

@ -37,7 +37,7 @@ async function main(): Promise<void> {
concurrency += 1;
void (async () => {
try {
const outcome = await runTriage(event.payloadRef);
const { outcome } = await runTriage(event.payloadRef);
if (outcome === 'done' || outcome === 'skipped') {
await agentRepo.completeOutbox(event._id.toString());
} else if (event.attempts >= env.AGENT_MAX_ATTEMPTS) {

View file

@ -73,7 +73,7 @@ describe('triage agent', () => {
providerResult({ content: proposal(), inputTokens: 100, outputTokens: 20 }),
);
const outcome = await runTriage(wo.id);
expect(outcome).toBe('done');
expect(outcome.outcome).toBe('done');
const suggestions = await TriageSuggestion.find({ workOrderId: wo.id });
expect(suggestions).toHaveLength(1);
expect(suggestions[0]).toMatchObject({ applied: false, suggestedPriority: 'high', flagForDispatcher: false });
@ -90,7 +90,7 @@ describe('triage agent', () => {
await agentRepo.updateAgentConfig('triage', { mode: 'auto-apply' }, 'admin');
providerMock.chatComplete.mockResolvedValue(providerResult({ content: proposal({ suggestedPriority: 'high' }) }));
const outcome = await runTriage(wo.id);
expect(outcome).toBe('done');
expect(outcome.outcome).toBe('done');
const fresh = await WorkOrder.findById(wo.id);
expect(fresh?.priority).toBe('high');
const suggestions = await TriageSuggestion.find({ workOrderId: wo.id });
@ -100,7 +100,7 @@ describe('triage agent', () => {
it('skips triage when the owner has AI disabled', async () => {
const { wo } = await makeWorkOrder({ aiEnabled: false });
const outcome = await runTriage(wo.id);
expect(outcome).toBe('skipped');
expect(outcome.outcome).toBe('skipped');
expect(providerMock.chatComplete).not.toHaveBeenCalled();
expect(await TriageSuggestion.countDocuments()).toBe(0);
});
@ -109,7 +109,7 @@ describe('triage agent', () => {
const { wo } = await makeWorkOrder();
providerMock.chatComplete.mockResolvedValue(providerResult({ content: 'definitely not json' }));
const outcome = await runTriage(wo.id);
expect(outcome).toBe('failed');
expect(outcome.outcome).toBe('failed');
expect(providerMock.chatComplete).toHaveBeenCalledTimes(3);
const runs = (await agentRepo.listAdminRuns(1, 10)).items.filter((run) => run.mode === 'autonomous');
expect(runs[0]).toMatchObject({ status: 'error', errorCode: 'AI_UNAVAILABLE' });
@ -122,7 +122,7 @@ describe('triage agent', () => {
const excluding = (hour + 2) % 24;
await agentRepo.updateAgentConfig('triage', { workingHours: `${excluding}-${excluding}` }, 'admin');
const outcome = await runTriage(wo.id);
expect(outcome).toBe('skipped');
expect(outcome.outcome).toBe('skipped');
expect(providerMock.chatComplete).not.toHaveBeenCalled();
});
@ -139,7 +139,7 @@ describe('triage agent', () => {
applied: false,
});
const outcome = await runTriage(wo.id);
expect(outcome).toBe('skipped');
expect(outcome.outcome).toBe('skipped');
expect(providerMock.chatComplete).not.toHaveBeenCalled();
});
@ -147,7 +147,7 @@ describe('triage agent', () => {
const { wo } = await makeWorkOrder();
await agentRepo.chargeSpend('agent:triage', 100);
const outcome = await runTriage(wo.id);
expect(outcome).toBe('skipped');
expect(outcome.outcome).toBe('skipped');
expect(providerMock.chatComplete).not.toHaveBeenCalled();
});
@ -155,7 +155,7 @@ describe('triage agent', () => {
const { wo } = await makeWorkOrder();
providerMock.chatComplete.mockRejectedValue(new providerMock.ProviderError('upstream down', 'network'));
const outcome = await runTriage(wo.id);
expect(outcome).toBe('retry');
expect(outcome.outcome).toBe('retry');
const runs = (await agentRepo.listAdminRuns(1, 10)).items.filter((run) => run.mode === 'autonomous');
expect(runs[0]).toMatchObject({ status: 'error', errorCode: 'AI_UNAVAILABLE' });
});