feat: migrate task jobs to workflow notifications + timeline

This commit is contained in:
2026-03-02 14:29:31 -05:00
parent 36c4ed2ee2
commit d81a681905
33 changed files with 2437 additions and 292 deletions

View File

@@ -1,4 +1,5 @@
import { Elysia, t } from 'elysia';
import { getWorld } from 'workflow/runtime';
import type {
Filing,
FinancialHistoryWindow,
@@ -31,9 +32,12 @@ import {
import { getPriceHistory, getQuote } from '@/lib/server/prices';
import {
enqueueTask,
findInFlightTask,
getTaskById,
getTaskTimeline,
getTaskQueueSnapshot,
listRecentTasks
listRecentTasks,
updateTaskNotification
} from '@/lib/server/tasks';
const ALLOWED_STATUSES: TaskStatus[] = ['queued', 'running', 'completed', 'failed'];
@@ -120,7 +124,8 @@ async function queueAutoFilingSync(userId: string, ticker: string) {
ticker,
limit: AUTO_FILING_SYNC_LIMIT
},
priority: 90
priority: 90,
resourceKey: `sync_filings:${ticker}`
});
return true;
@@ -132,18 +137,63 @@ async function queueAutoFilingSync(userId: string, ticker: string) {
const authHandler = ({ request }: { request: Request }) => auth.handler(request);
async function checkWorkflowBackend() {
try {
const world = getWorld();
await world.runs.list({
pagination: { limit: 1 },
resolveData: 'none'
});
return { ok: true } as const;
} catch (error) {
return {
ok: false,
reason: asErrorMessage(error, 'Workflow backend unavailable')
} as const;
}
}
export const app = new Elysia({ prefix: '/api' })
.all('/auth', authHandler)
.all('/auth/*', authHandler)
.get('/health', async () => {
const queue = await getTaskQueueSnapshot();
try {
const [queue, workflowBackend] = await Promise.all([
getTaskQueueSnapshot(),
checkWorkflowBackend()
]);
return Response.json({
status: 'ok',
version: '4.0.0',
timestamp: new Date().toISOString(),
queue
});
if (!workflowBackend.ok) {
return Response.json({
status: 'degraded',
version: '4.0.0',
timestamp: new Date().toISOString(),
queue,
workflow: {
ok: false,
reason: workflowBackend.reason
}
}, { status: 503 });
}
return Response.json({
status: 'ok',
version: '4.0.0',
timestamp: new Date().toISOString(),
queue,
workflow: {
ok: true
}
});
} catch (error) {
return Response.json({
status: 'degraded',
version: '4.0.0',
timestamp: new Date().toISOString(),
error: asErrorMessage(error, 'Health check failed')
}, { status: 503 });
}
})
.get('/me', async () => {
const { session, response } = await requireAuthenticatedSession();
@@ -375,7 +425,8 @@ export const app = new Elysia({ prefix: '/api' })
userId: session.user.id,
taskType: 'refresh_prices',
payload: {},
priority: 80
priority: 80,
resourceKey: 'refresh_prices:portfolio'
});
return Response.json({ task });
@@ -394,7 +445,8 @@ export const app = new Elysia({ prefix: '/api' })
userId: session.user.id,
taskType: 'portfolio_insights',
payload: {},
priority: 70
priority: 70,
resourceKey: 'portfolio_insights:portfolio'
});
return Response.json({ task });
@@ -543,7 +595,8 @@ export const app = new Elysia({ prefix: '/api' })
ticker,
limit: defaultFinancialSyncLimit(window)
},
priority: 88
priority: 88,
resourceKey: `sync_filings:${ticker}`
});
queuedSync = true;
} catch (error) {
@@ -668,7 +721,8 @@ export const app = new Elysia({ prefix: '/api' })
ticker,
limit: Number.isFinite(limit) ? limit : 20
},
priority: 90
priority: 90,
resourceKey: `sync_filings:${ticker}`
});
return Response.json({ task });
@@ -693,11 +747,23 @@ export const app = new Elysia({ prefix: '/api' })
}
try {
const resourceKey = `analyze_filing:${accessionNumber}`;
const existing = await findInFlightTask(
session.user.id,
'analyze_filing',
resourceKey
);
if (existing) {
return Response.json({ task: existing });
}
const task = await enqueueTask({
userId: session.user.id,
taskType: 'analyze_filing',
payload: { accessionNumber },
priority: 65
priority: 65,
resourceKey
});
return Response.json({ task });
@@ -760,6 +826,56 @@ export const app = new Elysia({ prefix: '/api' })
params: t.Object({
taskId: t.String({ minLength: 1 })
})
})
.get('/tasks/:taskId/timeline', async ({ params }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const timeline = await getTaskTimeline(params.taskId, session.user.id);
if (!timeline) {
return jsonError('Task not found', 404);
}
return Response.json(timeline);
}, {
params: t.Object({
taskId: t.String({ minLength: 1 })
})
})
.patch('/tasks/:taskId/notification', async ({ params, body }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const payload = asRecord(body);
const read = typeof payload.read === 'boolean' ? payload.read : undefined;
const silenced = typeof payload.silenced === 'boolean' ? payload.silenced : undefined;
if (read === undefined && silenced === undefined) {
return jsonError('read or silenced must be provided');
}
const task = await updateTaskNotification(session.user.id, params.taskId, {
read,
silenced
});
if (!task) {
return jsonError('Task not found', 404);
}
return Response.json({ task });
}, {
params: t.Object({
taskId: t.String({ minLength: 1 })
}),
body: t.Object({
read: t.Optional(t.Boolean()),
silenced: t.Optional(t.Boolean())
})
});
export type App = typeof app;