Skip to content

Commit 89989aa

Browse files
committed
Audit and repair intent workflow runtime
1 parent 58c349c commit 89989aa

10 files changed

Lines changed: 682 additions & 106 deletions

src/routes/admin/intent.tsx

Lines changed: 196 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,18 @@ import {
1212
AlertTriangle,
1313
CheckCircle2,
1414
Clock,
15+
Wrench,
1516
} from 'lucide-react'
1617
import { Button } from '~/ui'
1718
import { Card } from '~/components/Card'
1819
import { formatDistanceToNow } from '~/utils/dates'
1920
import {
2021
getIntentAdminStats,
22+
getIntentWorkflowHealth,
2123
listIntentPackages,
2224
listFailedVersions,
2325
listIntentWorkflowRuns,
26+
repairIntentWorkflowStore,
2427
triggerIntentDiscover,
2528
triggerIntentProcess,
2629
retryIntentVersion,
@@ -42,6 +45,7 @@ const QK = {
4245
packages: ['admin', 'intent', 'packages'] as const,
4346
failed: ['admin', 'intent', 'failed'] as const,
4447
workflows: ['admin', 'intent', 'workflows'] as const,
48+
health: ['admin', 'intent', 'workflow-health'] as const,
4549
}
4650

4751
// ---------------------------------------------------------------------------
@@ -77,13 +81,19 @@ function IntentAdminPage() {
7781
refetchInterval: 10_000,
7882
})
7983

84+
const healthQuery = useQuery({
85+
queryKey: QK.health,
86+
queryFn: () => getIntentWorkflowHealth(),
87+
refetchInterval: 10_000,
88+
})
89+
8090
const discoverMutation = useMutation({
8191
mutationFn: () => triggerIntentDiscover(),
8292
onSuccess: invalidateAll,
8393
})
8494

8595
const processMutation = useMutation({
86-
mutationFn: (limit: number) => triggerIntentProcess({ data: { limit } }),
96+
mutationFn: () => triggerIntentProcess(),
8797
onSuccess: invalidateAll,
8898
})
8999

@@ -92,6 +102,11 @@ function IntentAdminPage() {
92102
onSuccess: invalidateAll,
93103
})
94104

105+
const repairWorkflowMutation = useMutation({
106+
mutationFn: () => repairIntentWorkflowStore(),
107+
onSuccess: invalidateAll,
108+
})
109+
95110
const seedMutation = useMutation({
96111
mutationFn: (name: string) => seedIntentPackage({ data: { name } }),
97112
onSuccess: () => {
@@ -160,19 +175,19 @@ function IntentAdminPage() {
160175
<Button
161176
size="sm"
162177
color="green"
163-
onClick={() => processMutation.mutate(10)}
178+
onClick={() => processMutation.mutate()}
164179
disabled={
165180
processMutation.isPending ||
166181
(stats?.pendingVersions ?? 0) + (stats?.failedVersions ?? 0) === 0
167182
}
168-
title="Download tarballs and extract skills for up to 10 pending versions"
183+
title="Download tarballs and extract skills until the workflow nears its time budget"
169184
>
170185
<Play
171186
className={
172187
processMutation.isPending ? 'animate-pulse w-4 h-4' : 'w-4 h-4'
173188
}
174189
/>
175-
{processMutation.isPending ? 'Processing...' : 'Process 10 Pending'}
190+
{processMutation.isPending ? 'Processing...' : 'Process Queue'}
176191
</Button>
177192
{(stats?.failedVersions ?? 0) > 0 && (
178193
<Button
@@ -235,10 +250,17 @@ function IntentAdminPage() {
235250
{processMutation.data && (
236251
<ResultBanner
237252
title={`Processed ${processMutation.data.processed} version(s)`}
238-
items={processMutation.data.results.map(
239-
(r) =>
240-
`${r.packageName}@${r.version}: ${r.status === 'synced' ? `${r.skillCount} skills` : `FAILED — ${r.error}`}`,
241-
)}
253+
items={[
254+
...(processMutation.data.deferred > 0
255+
? [
256+
`${processMutation.data.deferred} version(s) deferred by the time budget`,
257+
]
258+
: []),
259+
...processMutation.data.results.map(
260+
(r) =>
261+
`${r.packageName}@${r.version}: ${r.status === 'synced' ? `${r.skillCount} skills` : `FAILED — ${r.error}`}`,
262+
),
263+
]}
242264
errors={processMutation.data.results
243265
.filter((r) => r.status === 'failed')
244266
.map((r) => `${r.packageName}@${r.version}: ${r.error}`)}
@@ -253,6 +275,17 @@ function IntentAdminPage() {
253275
onDismiss={() => resetFailedMutation.reset()}
254276
/>
255277
)}
278+
{repairWorkflowMutation.data && (
279+
<ResultBanner
280+
title="Workflow store repaired"
281+
items={[
282+
`${repairWorkflowMutation.data.staleRunsMarkedErrored} stale run(s) marked errored`,
283+
`${repairWorkflowMutation.data.unregisteredSchedulesDeleted} obsolete schedule(s) deleted`,
284+
]}
285+
errors={[]}
286+
onDismiss={() => repairWorkflowMutation.reset()}
287+
/>
288+
)}
256289
{githubDiscoverMutation.data && (
257290
<ResultBanner
258291
title="GitHub discovery complete"
@@ -332,6 +365,13 @@ function IntentAdminPage() {
332365
/>
333366
</div>
334367

368+
<WorkflowHealthSection
369+
health={healthQuery.data}
370+
loading={healthQuery.isLoading}
371+
repairing={repairWorkflowMutation.isPending}
372+
onRepair={() => repairWorkflowMutation.mutate()}
373+
/>
374+
335375
<WorkflowRunsSection
336376
runs={workflowsQuery.data ?? []}
337377
loading={workflowsQuery.isLoading}
@@ -393,6 +433,154 @@ function StatCard({
393433
)
394434
}
395435

436+
function WorkflowHealthSection({
437+
health,
438+
loading,
439+
repairing,
440+
onRepair,
441+
}: {
442+
readonly health:
443+
| {
444+
checkedAt: Date
445+
staleRunMs: number
446+
staleRuns: Array<{
447+
runId: string
448+
workflowId: string
449+
status: string
450+
updatedAt: Date
451+
leaseExpiresAt: Date | null
452+
}>
453+
unregisteredSchedules: Array<{
454+
scheduleId: string
455+
workflowId: string
456+
enabled: boolean
457+
nextFireAt: Date | null
458+
updatedAt: Date
459+
}>
460+
schedules: Array<{
461+
scheduleId: string
462+
workflowId: string
463+
enabled: boolean
464+
nextFireAt: Date | null
465+
updatedAt: Date
466+
}>
467+
latestRuns: Array<{
468+
runId: string
469+
workflowId: string
470+
status: string
471+
updatedAt: Date
472+
}>
473+
statusCounts: Array<{ status: string; count: number }>
474+
}
475+
| undefined
476+
readonly loading: boolean
477+
readonly repairing: boolean
478+
readonly onRepair: () => void
479+
}) {
480+
const staleRunCount = health?.staleRuns.length ?? 0
481+
const obsoleteScheduleCount = health?.unregisteredSchedules.length ?? 0
482+
const needsRepair = staleRunCount > 0 || obsoleteScheduleCount > 0
483+
484+
return (
485+
<div className="mb-6">
486+
<div className="mb-2 flex items-center justify-between gap-3">
487+
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-300 flex items-center gap-1.5">
488+
<CheckCircle2 className="w-4 h-4" />
489+
Workflow Health
490+
</h2>
491+
<Button
492+
size="xs"
493+
variant="secondary"
494+
onClick={onRepair}
495+
disabled={repairing || loading || !needsRepair}
496+
title="Mark stale runs as errored and delete schedules for workflows that are no longer registered"
497+
>
498+
<Wrench
499+
className={repairing ? 'w-3.5 h-3.5 animate-pulse' : 'w-3.5 h-3.5'}
500+
/>
501+
{repairing ? 'Repairing...' : 'Repair Store'}
502+
</Button>
503+
</div>
504+
{loading ? (
505+
<div className="h-28 rounded-xl bg-gray-100 dark:bg-gray-800 animate-pulse" />
506+
) : (
507+
<div className="rounded-xl border border-gray-200 dark:border-gray-800 overflow-hidden">
508+
<div className="grid grid-cols-2 md:grid-cols-4 divide-x divide-y md:divide-y-0 divide-gray-100 dark:divide-gray-800">
509+
<HealthMetric
510+
label="Stale runs"
511+
value={staleRunCount}
512+
tone={staleRunCount > 0 ? 'red' : 'green'}
513+
/>
514+
<HealthMetric
515+
label="Obsolete schedules"
516+
value={obsoleteScheduleCount}
517+
tone={obsoleteScheduleCount > 0 ? 'amber' : 'green'}
518+
/>
519+
<HealthMetric
520+
label="Registered schedules"
521+
value={health?.schedules.length ?? 0}
522+
tone="default"
523+
/>
524+
<HealthMetric
525+
label="Tracked runs"
526+
value={
527+
health?.statusCounts.reduce(
528+
(total, row) => total + row.count,
529+
0,
530+
) ?? 0
531+
}
532+
tone="default"
533+
/>
534+
</div>
535+
{health && needsRepair && (
536+
<div className="border-t border-gray-100 dark:border-gray-800 bg-amber-50/60 dark:bg-amber-950/20 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
537+
{[...health.staleRuns, ...health.unregisteredSchedules]
538+
.slice(0, 4)
539+
.map((item) =>
540+
'runId' in item
541+
? `${item.workflowId}: ${item.status} since ${formatDistanceToNow(
542+
item.updatedAt,
543+
{ addSuffix: true },
544+
)}`
545+
: `${item.workflowId}: obsolete schedule ${item.scheduleId}`,
546+
)
547+
.join(' | ')}
548+
</div>
549+
)}
550+
</div>
551+
)}
552+
</div>
553+
)
554+
}
555+
556+
function HealthMetric({
557+
label,
558+
value,
559+
tone,
560+
}: {
561+
readonly label: string
562+
readonly value: number
563+
readonly tone: 'default' | 'green' | 'amber' | 'red'
564+
}) {
565+
const valueClass = {
566+
default: 'text-gray-900 dark:text-white',
567+
green: 'text-emerald-600 dark:text-emerald-400',
568+
amber: 'text-amber-600 dark:text-amber-400',
569+
red: 'text-red-600 dark:text-red-400',
570+
}[tone]
571+
572+
return (
573+
<div className="bg-white dark:bg-gray-900 px-3 py-2">
574+
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
575+
{label}
576+
</div>
577+
<div className={`text-xl font-semibold tabular-nums ${valueClass}`}>
578+
{value.toLocaleString()}
579+
</div>
580+
</div>
581+
)
582+
}
583+
396584
function WorkflowRunsSection({
397585
runs,
398586
loading,

src/server/scheduled.server.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,28 @@ import { materializeWorkflowSchedules } from '@tanstack/workflow-runtime'
22
import { pruneStaleCacheRows } from '~/utils/github-content-cache.server'
33
import { refreshHomepageNpmStatsSummary } from '~/utils/homepage-npm-stats.server'
44
import { refreshGitHubOrgStats } from '~/utils/stats.functions'
5-
import { workflowRuntime } from '~/utils/workflow-runtime.server'
5+
import {
6+
reconcileWorkflowRuntimeStore,
7+
workflowRuntime,
8+
} from '~/utils/workflow-runtime.server'
69

710
const CONTENT_CACHE_PRUNE_CRON = '0 9 * * *'
8-
const STATS_AND_INTENT_DISCOVER_CRON = '0 */6 * * *'
9-
const INTENT_PROCESS_CRON = '*/15 * * * *'
11+
const STATS_REFRESH_CRON = '0 */6 * * *'
12+
const WORKFLOW_SWEEP_CRON = '* * * * *'
1013
const WORKFLOW_SWEEP_MAX_DURATION_MS = 25_000
1114

1215
export async function runScheduledTasks(cron: string, scheduledTime: number) {
1316
switch (cron) {
1417
case CONTENT_CACHE_PRUNE_CRON:
1518
await runContentCachePrune(scheduledTime)
1619
return
17-
case STATS_AND_INTENT_DISCOVER_CRON:
20+
case STATS_REFRESH_CRON:
1821
await Promise.all([
1922
runGitHubStatsRefresh(scheduledTime),
2023
runHomepageNpmStatsSummaryRefresh(scheduledTime),
21-
runWorkflowSweep(cron, scheduledTime),
2224
])
2325
return
24-
case INTENT_PROCESS_CRON:
26+
case WORKFLOW_SWEEP_CRON:
2527
await runWorkflowSweep(cron, scheduledTime)
2628
return
2729
default:
@@ -34,6 +36,7 @@ async function runWorkflowSweep(cron: string, scheduledTime: number) {
3436
console.log('[workflow-sweep] Starting workflow sweep...')
3537

3638
try {
39+
const reconciliation = await reconcileWorkflowRuntimeStore()
3740
const materialized = await materializeWorkflowSchedules(workflowRuntime, {
3841
now: scheduledTime,
3942
})
@@ -48,7 +51,7 @@ async function runWorkflowSweep(cron: string, scheduledTime: number) {
4851
const duration = Date.now() - startTime
4952

5053
console.log(
51-
`[workflow-sweep] Completed in ${duration}ms - materialized: ${materialized.length}, scheduled: ${JSON.stringify(sweep.summary.scheduled)}, timers: ${JSON.stringify(sweep.summary.timers)}, remaining: ${sweep.remainingMayExist}`,
54+
`[workflow-sweep] Completed in ${duration}ms - staleRuns: ${reconciliation.staleRunsMarkedErrored}, prunedSchedules: ${reconciliation.unregisteredSchedulesDeleted}, materialized: ${materialized.length}, scheduled: ${JSON.stringify(sweep.summary.scheduled)}, timers: ${JSON.stringify(sweep.summary.timers)}, remaining: ${sweep.remainingMayExist}`,
5255
)
5356
console.log(
5457
'[workflow-sweep] Scheduled time:',

src/utils/intent-admin.functions.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,17 @@ import {
44
deleteIntentPackage as deleteIntentPackageServer,
55
discoverViaGitHub as discoverViaGitHubServer,
66
getIntentAdminStats as getIntentAdminStatsServer,
7+
getIntentWorkflowHealth as getIntentWorkflowHealthServer,
78
listFailedVersions as listFailedVersionsServer,
89
listIntentPackages as listIntentPackagesServer,
910
listIntentWorkflowRuns as listIntentWorkflowRunsServer,
11+
repairIntentWorkflowStore as repairIntentWorkflowStoreServer,
1012
resetFailedVersions as resetFailedVersionsServer,
1113
retryIntentVersion as retryIntentVersionServer,
1214
seedIntentPackage as seedIntentPackageServer,
1315
triggerIntentDiscover as triggerIntentDiscoverServer,
1416
triggerIntentProcess as triggerIntentProcessServer,
1517
} from '~/utils/intent-admin.server'
16-
import { pageSizeSchema } from './schemas'
1718

1819
export const getIntentAdminStats = createServerFn({ method: 'GET' }).handler(
1920
async () => getIntentAdminStatsServer(),
@@ -31,17 +32,21 @@ export const listIntentWorkflowRuns = createServerFn({ method: 'GET' }).handler(
3132
async () => listIntentWorkflowRunsServer(),
3233
)
3334

35+
export const getIntentWorkflowHealth = createServerFn({
36+
method: 'GET',
37+
}).handler(async () => getIntentWorkflowHealthServer())
38+
39+
export const repairIntentWorkflowStore = createServerFn({
40+
method: 'POST',
41+
}).handler(async () => repairIntentWorkflowStoreServer())
42+
3443
export const triggerIntentDiscover = createServerFn({ method: 'POST' }).handler(
3544
async () => triggerIntentDiscoverServer(),
3645
)
3746

38-
export const triggerIntentProcess = createServerFn({ method: 'POST' })
39-
.validator(
40-
v.object({
41-
limit: v.optional(pageSizeSchema, 10),
42-
}),
43-
)
44-
.handler(async ({ data }) => triggerIntentProcessServer({ data }))
47+
export const triggerIntentProcess = createServerFn({ method: 'POST' }).handler(
48+
async () => triggerIntentProcessServer(),
49+
)
4550

4651
export const retryIntentVersion = createServerFn({ method: 'POST' })
4752
.validator(

0 commit comments

Comments
 (0)