Skip to content
← Public packages

@kentcdodds/friction-log

Qualify and file friction papercuts to GitHub (any owner/repo) or wake Patch for Kody packages; daily sweep of kody-hq/kody friction issues.

FollowFork

src/sweep.ts

190 lines · 4.3 KB · TypeScript
Raw
import { createAgent, listAgents } from 'kody:@kentcdodds/cursor/agents'
import { createRun } from 'kody:@kentcdodds/cursor/runs'
import { buildFrictionAgentPrompt } from './agent-prompt.ts'
import { eligibleIssues, isEligibleIssue } from './eligibility.ts'
import { loadFrictionIssues } from './issues.ts'
import {
	agentModel,
	agentUrlOf,
	denverDayKey,
	maxIssuesInPrompt,
	ref,
	repository,
	type FrictionIssue,
} from './shared.ts'
import {
	getSweep,
	isEnabled,
	latestLiveSweep,
	listRecentOutcomes,
	upsertSweep,
} from './storage.ts'

export type SweepInput = {
	dryRun?: boolean
	force?: boolean
}

function isLiveStatus(status: unknown) {
	const value = String(status || '').toUpperCase()
	return value === 'ACTIVE' || value === 'CREATING' || value === 'RUNNING'
}

async function findLiveHunterAgent() {
	try {
		const { items } = await listAgents({ limit: 50 })
		return (
			(items || []).find((agent) => {
				const name = String(agent.name || '')
				return /friction-log/i.test(name) && isLiveStatus(agent.status)
			}) || null
		)
	} catch {
		return null
	}
}

function compactIssues(issues: Array<FrictionIssue>) {
	return issues.slice(0, maxIssuesInPrompt).map((issue) => ({
		number: issue.number,
		title: issue.title,
		htmlUrl: issue.htmlUrl,
		labels: issue.labels,
		eligibility: isEligibleIssue(issue).reason,
	}))
}

/**
 * List open friction issues and spawn one Cloud Agent when any are eligible.
 */
export default async function sweep(input: SweepInput = {}) {
	const dryRun = input.dryRun === true
	const force = input.force === true
	const day = denverDayKey()

	if (!(await isEnabled()) && !dryRun) {
		return { ok: true, skipped: 'paused', day }
	}

	const existing = await getSweep(day)
	if (existing && existing.spawned === 1 && existing.dry_run === 0 && !dryRun && !force) {
		return {
			ok: true,
			skipped: 'already-spawned-today',
			day,
			agentId: existing.agent_id,
			agentUrl: existing.agent_url,
		}
	}

	const liveSweep = await latestLiveSweep()
	if (liveSweep?.agent_id && !dryRun && !force) {
		const outcomes = await listRecentOutcomes(5)
		const finished = outcomes.some((row) => row.agent_id === liveSweep.agent_id)
		if (!finished) {
			const live = await findLiveHunterAgent()
			if (live) {
				return {
					ok: true,
					skipped: 'live-agent',
					day,
					agentId: live.id,
					agentUrl: agentUrlOf(live),
				}
			}
		}
	}

	const loaded = await loadFrictionIssues()
	const eligible = eligibleIssues(loaded)
	const queued = eligible.slice(0, maxIssuesInPrompt)
	const issueNumbers = queued.map((issue) => issue.number)

	if (queued.length === 0) {
		if (!dryRun) {
			await upsertSweep({
				day,
				scanned_at: new Date().toISOString(),
				eligible_count: 0,
				issue_numbers_json: '[]',
				spawned: 0,
				agent_id: null,
				agent_url: null,
				skipped: 'no-eligible-issues',
				dry_run: 0,
			})
		}
		return {
			ok: true,
			day,
			dryRun,
			openCount: loaded.length,
			eligibleCount: 0,
			spawned: false,
		}
	}

	const prompt = buildFrictionAgentPrompt({ issues: queued, day })

	if (dryRun) {
		return {
			ok: true,
			day,
			dryRun: true,
			openCount: loaded.length,
			eligibleCount: eligible.length,
			issues: compactIssues(queued),
			wouldSpawn: true,
			promptChars: prompt.length,
		}
	}

	const created = await createAgent({
		prompt: { text: prompt },
		model: agentModel,
		name: `friction-log ${day}`.slice(0, 60),
		repository,
		ref,
	})
	const agent =
		(created as { agent?: { id?: string; url?: string } })?.agent ??
		(created as { id?: string; url?: string })
	const agentId = agent?.id || null
	const agentUrl = agentUrlOf(agent)

	if (agentId && agentUrl) {
		try {
			await createRun({
				agentId,
				prompt: {
					text: `Your public agent URL is ${agentUrl}. Continue the original friction-log investigation.`,
				},
			})
		} catch {
			// The initial prompt already tells the agent how to recover the URL.
		}
	}

	await upsertSweep({
		day,
		scanned_at: new Date().toISOString(),
		eligible_count: queued.length,
		issue_numbers_json: JSON.stringify(issueNumbers),
		spawned: 1,
		agent_id: agentId,
		agent_url: agentUrl,
		skipped: null,
		dry_run: 0,
	})

	return {
		ok: true,
		day,
		openCount: loaded.length,
		eligibleCount: eligible.length,
		issues: compactIssues(queued),
		spawned: true,
		agentId,
		agentUrl,
	}
}