Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -647,10 +647,11 @@ async function tick(): Promise<void> {
for (const workspaceId of await workspacesWithInternalBacklog(db)) {
const cleared = await autoApproveInternal(db, { workspaceId });

if (cleared.approved > 0) {
if (cleared.approved > 0 || cleared.cooledDown > 0) {
console.log(
`auto-approved ${cleared.approved} internal card(s) in ${workspaceId}` +
`${cleared.queuedCrawls > 0 ? `, queued ${cleared.queuedCrawls} crawl(s)` : ''}` +
`${cleared.cooledDown > 0 ? `, ${cleared.cooledDown} already researched today` : ''}` +
`${cleared.refused > 0 ? `, ${cleared.refused} refused by policy` : ''}`,
);
}
Expand Down
119 changes: 118 additions & 1 deletion packages/pipeline/src/auto-approve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,9 +194,126 @@ describe('autoApproveInternal', () => {
);
});

test('does not re-research somebody researched today', async () => {
// The loop this closes. A research card exists because we had nothing to
// say; approving it re-crawls; if that yields nothing the card returns.
// Automating the click turned a visible backlog into invisible crawl
// traffic — one person reached three cards within minutes.
seeded = await seedDatabase('auto-cooldown');
await withDomain(seeded.db);
await card(seeded.db, 'refresh_research');

const first = await autoApproveInternal(seeded.db, { workspaceId: SEED.workspaceId });
expect(first.approved).toBe(1);
expect(first.queuedCrawls).toBe(1);

// The next pass proposes it again, as the pipeline does.
const again = await card(seeded.db, 'refresh_research');
const second = await autoApproveInternal(seeded.db, { workspaceId: SEED.workspaceId });

expect(second.approved).toBe(0);
expect(second.cooledDown).toBe(1);
// Crucially: no second crawl. That is what stops the loop.
expect(second.queuedCrawls).toBe(0);

const rec = await queryOne<{ status: string }>(
seeded.db,
'SELECT status FROM recommendations WHERE id = ?',
[again],
);
// Closed, not approved — approving would claim we went and looked.
expect(rec?.status).toBe('skipped');
});

test('a cooled-down card leaves an auditable reason', async () => {
seeded = await seedDatabase('auto-cooldown-audit');
await withDomain(seeded.db);
await card(seeded.db, 'refresh_research');
await autoApproveInternal(seeded.db, { workspaceId: SEED.workspaceId });

const id = await card(seeded.db, 'refresh_research');
await autoApproveInternal(seeded.db, { workspaceId: SEED.workspaceId });

const audit = await queryOne<{ event_type: string }>(
seeded.db,
`SELECT event_type FROM audit_events WHERE entity_id = ?
AND event_type = 'recommendation.research_cooldown'`,
[id],
);

expect(audit?.event_type).toBe('recommendation.research_cooldown');
});

test('research outside the cooldown runs again', async () => {
// The cooldown must expire, or a company that genuinely changed is never
// re-read.
seeded = await seedDatabase('auto-cooldown-expired');
await withDomain(seeded.db);
await card(seeded.db, 'refresh_research');
await autoApproveInternal(seeded.db, { workspaceId: SEED.workspaceId });

// Age the recorded research past the window.
await seeded.db.execute({
sql: `UPDATE actions SET created_at = ? WHERE kind = 'refresh_research'`,
args: [new Date(Date.now() - 48 * 3_600_000).toISOString()],
});

await card(seeded.db, 'refresh_research');
const result = await autoApproveInternal(seeded.db, { workspaceId: SEED.workspaceId });

expect(result.approved).toBe(1);
expect(result.cooledDown).toBe(0);
});

test('a human approval also suppresses the automatic re-run', async () => {
// The cooldown counts actions, not crawl jobs, so it does not matter who
// approved the card an hour ago.
seeded = await seedDatabase('auto-cooldown-human');
await withDomain(seeded.db);

// Stands in for a card a person clicked an hour ago: the action row is
// what both paths write, and is what the cooldown counts.
const clicked = await card(seeded.db, 'refresh_research');
await seeded.db.execute({
sql: `INSERT INTO actions (id, workspace_id, recommendation_id, person_id, kind, network,
mode, status, created_at)
VALUES (?, ?, ?, ?, 'refresh_research', 'website', 'manual', 'queued', ?)`,
args: [newId('action'), SEED.workspaceId, clicked, SEED.personId, now()],
});
await seeded.db.execute({
sql: `UPDATE recommendations SET status = 'approved' WHERE id = ?`,
args: [clicked],
});

await card(seeded.db, 'refresh_research');
const result = await autoApproveInternal(seeded.db, { workspaceId: SEED.workspaceId });

expect(result.cooledDown).toBe(1);
expect(result.queuedCrawls).toBe(0);
});

test('honours the limit so one workspace cannot hold the tick', async () => {
seeded = await seedDatabase('auto-limit');
for (let index = 0; index < 5; index += 1) await card(seeded.db, 'refresh_research');

// Distinct people, because the cooldown is per person: five cards for one
// person is one approval and four cooldowns, which is correct and is not
// what this test is about.
for (let index = 0; index < 5; index += 1) {
const personId = `per_limit_${index}`;
await seeded.db.execute({
sql: `INSERT INTO people (id, display_name, status, identity_confidence, created_at,
updated_at) VALUES (?, ?, 'qualified', 0.9, ?, ?)`,
args: [personId, `Limit ${index}`, now(), now()],
});
await seeded.db.execute({
sql: `INSERT INTO recommendations (id, workspace_id, campaign_id, person_id, action,
network, priority, reason, policy_status, policy_version, expected_goal, status,
created_at)
VALUES (?, ?, ?, ?, 'refresh_research', 'website', 50, 'because',
'allow_with_approval', '2026-08-11', 'qualify', 'pending', ?)`,
args: [newId('recommendation'), SEED.workspaceId, SEED.campaignId, personId, now()],
});
}

const result = await autoApproveInternal(seeded.db, {
workspaceId: SEED.workspaceId,
Expand Down
92 changes: 90 additions & 2 deletions packages/pipeline/src/auto-approve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,32 @@ import { enqueue } from './queue';
*/
const AUTO_APPROVED: readonly ActionKind[] = ['refresh_research', 'observe', 'wait'];

/**
* How long a person is left alone after their research has been run.
*
* This exists because automating the approval turned a visible backlog into
* an invisible loop. A `refresh_research` card is proposed *because* we have
* nothing to say about someone; approving it re-reads their company's site;
* if that still yields nothing, the next pass proposes the same card again.
* While a human had to click, the cards simply piled up — production held 179
* — and the waste was at least in plain sight. Approving them automatically
* closed the loop and turned it into crawl traffic nobody was watching:
* within minutes of shipping, one person had three cards and 195 crawls were
* queued.
*
* A day is chosen because that is the shortest interval over which a company
* website plausibly changes. Shorter re-reads the same bytes; much longer
* would delay picking up a genuine change.
*/
const RESEARCH_COOLDOWN_HOURS = 24;

export interface AutoApproveResult {
readonly considered: number;
readonly approved: number;
readonly refused: number;
readonly queuedCrawls: number;
/** Closed without re-crawling, because the answer would not have changed. */
readonly cooledDown: number;
}

interface PendingRow {
Expand Down Expand Up @@ -82,7 +103,7 @@ export async function autoApproveInternal(
// Absent workspace or the setting turned off. Not an error: a workspace that
// wants to watch its own research go by is entitled to.
if (!workspace || workspace.auto_approve_internal !== 1) {
return { considered: 0, approved: 0, refused: 0, queuedCrawls: 0 };
return { considered: 0, approved: 0, refused: 0, queuedCrawls: 0, cooledDown: 0 };
}

const placeholders = AUTO_APPROVED.map(() => '?').join(', ');
Expand All @@ -105,8 +126,18 @@ export async function autoApproveInternal(
let approved = 0;
let refused = 0;
let queuedCrawls = 0;
let cooledDown = 0;

for (const row of rows) {
// Research we have already done for this person, recently enough that
// doing it again would read the same page. Closed rather than approved:
// approving it would enqueue the crawl that creates the next card.
if (row.action === 'refresh_research' && (await researchedRecently(db, row.person_id))) {
await closeWithoutCrawling(db, input.workspaceId, row.id, row.action);
cooledDown += 1;
continue;
}

const decision = evaluatePolicy({
network: row.network as Network,
action: row.action as ActionKind,
Expand Down Expand Up @@ -209,7 +240,64 @@ export async function autoApproveInternal(
}
}

return { considered: rows.length, approved, refused, queuedCrawls };
return { considered: rows.length, approved, refused, queuedCrawls, cooledDown };
}

/**
* Whether this person's research has already run inside the cooldown.
*
* Counted from `actions`, which is written on every approval — automatic or
* clicked — so a human who approved the card an hour ago also suppresses the
* automatic re-run. The alternative, looking at crawl jobs, would miss a
* person whose company has no domain and whose card therefore never produced
* one.
*/
async function researchedRecently(db: Client, personId: string): Promise<boolean> {
const since = new Date(Date.now() - RESEARCH_COOLDOWN_HOURS * 3_600_000).toISOString();

const row = await queryOne<{ n: number }>(
db,
`SELECT count(*) AS n FROM actions
WHERE person_id = ? AND kind = 'refresh_research' AND created_at >= ?`,
[personId, since],
);

return Number(row?.n ?? 0) > 0;
}

/**
* Retires a card whose answer is already known, without spending a crawl.
*
* `skipped` rather than `approved`, because approving would claim we went and
* looked. The status already exists for exactly this — a card that is over
* without having been acted on — and it keeps the card out of the queue
* without inventing a fourth outcome.
*/
async function closeWithoutCrawling(
db: Client,
workspaceId: string,
recommendationId: string,
action: string,
): Promise<void> {
await db.batch([
{
sql: `UPDATE recommendations SET status = 'skipped' WHERE id = ?`,
args: [recommendationId],
},
{
sql: `INSERT INTO audit_events (id, workspace_id, actor_kind, actor_id, event_type,
entity_kind, entity_id, detail_json, occurred_at)
VALUES (?, ?, 'system', ?, 'recommendation.research_cooldown', 'recommendation', ?, ?, ?)`,
args: [
newId('auditEvent'),
workspaceId,
AUTO_APPROVE_ACTOR,
recommendationId,
JSON.stringify({ action, cooldownHours: RESEARCH_COOLDOWN_HOURS }),
now(),
],
},
]);
}

/** Who the audit trail credits for an unattended approval. */
Expand Down
Loading