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
8 changes: 8 additions & 0 deletions apps/lifecycle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,11 @@ The app's Vercel project must use `apps/lifecycle` as its root directory, enable
Keep `LIFECYCLE_CRON_ENABLED` unset or set to anything other than the exact value `true` until the preview dogfood checklist in `docs/superpowers/runbooks/2026-08-31-growth-lifecycle-cutover.md` passes. In particular, verify outer auth on all Dawn surfaces, named-thread dispatch, duplicate invocation behavior, recovery pause/resume, cancellation/AbortSignal propagation, and Dawn persistence across fresh instances. Send findings to Dawn task `01a05e2f-7e93-7bd0-af74-f13d5a7719cd` for generalized backport.

Use [DOGFOOD.md](./DOGFOOD.md) for the provider-free setup, probe, and exact cleanup commands. The harness binds the growth target to a database-owned comment sentinel and binds each authenticated lifecycle health response to Vercel's `VERCEL_DEPLOYMENT_ID`; it also validates lifecycle origins in memory before making requests.

## Company evidence capture

`LIFECYCLE_COMPANY_CAPTURE_PROVIDER` defaults to `direct`, preserving the existing company-page fetch. To explicitly enable managed homepage capture, set it to exactly `firecrawl` and configure the server-only `FIRECRAWL_API_KEY`. Configuration is checked only when enrichment needs company evidence; it does not gate email delivery. Invalid configuration and provider failures use the existing enrichment retry handling, without a direct-fetch fallback.

Firecrawl capture makes one fresh homepage request with the basic proxy, a 10-second provider timeout, a 15-second total deadline, and a 2 MiB response limit. The existing HTML extractor produces the same bounded evidence schema. It accepts a changed final company hostname only when Firecrawl reports the requested source URL and a valid public HTTPS final URL. Local checks validate the input and final hostnames; Firecrawl owns remote DNS resolution and intermediate redirect safety. This is a provider trust boundary, not the direct fetcher's DNS-pinned transport.

Capture logs contain provider, outcome, status, byte count, and reported credits where available (direct capture also identifies the fixed requested path). They exclude page text, company URLs, and credentials. Keep the default provider until an account key is configured and an authenticated capture is verified; public keyless experiments do not verify the production account integration.
4 changes: 2 additions & 2 deletions apps/lifecycle/src/campaign/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import {
import { Resend } from 'resend';

import { generateEnrichmentArtifact } from '../enrichment/anthropic.js';
import { fetchCompanyEvidence } from '../enrichment/company-fetch.js';
import { createCompanyCapture } from '../enrichment/company-capture.js';
import { buildResearchInput } from '../enrichment/research-input.js';
import {
EnrichmentArtifactSchema,
Expand Down Expand Up @@ -886,7 +886,7 @@ export function createDefaultLifecycleJobDependencies(
claimInternalNotification: claimInternalNotificationSubmission,
markInternalNotificationUnknown,
failJob: failLeasedJob,
fetchCompanyEvidence,
fetchCompanyEvidence: createCompanyCapture(environment),
async readDeterministicScore(executor, contactId) {
const score = await recomputeContactScore(executor, {
contactId,
Expand Down
117 changes: 117 additions & 0 deletions apps/lifecycle/src/enrichment/company-capture.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const { direct, managed } = vi.hoisted(() => ({
direct: vi.fn(),
managed: vi.fn(),
}));
vi.mock('./company-fetch.js', () => ({ fetchCompanyEvidence: direct }));
vi.mock('./firecrawl.js', () => ({ fetchFirecrawlCompanyEvidence: managed }));

import { createCompanyCapture } from './company-capture.js';

beforeEach(() => {
direct.mockReset().mockResolvedValue([]);
managed.mockReset().mockResolvedValue([]);
});

describe('configured company capture', () => {
it('reports configuration failures without logging configuration values', async () => {
const log = vi.spyOn(console, 'info').mockImplementation(() => undefined);
try {
await expect(
createCompanyCapture({
LIFECYCLE_COMPANY_CAPTURE_PROVIDER: 'secret-invalid',
})('example.com', new AbortController().signal)
).rejects.toThrow('company_capture_invalid_provider');
expect(log).toHaveBeenCalledWith('company_capture', {
outcome: 'invalid_provider',
});
await expect(
createCompanyCapture({
LIFECYCLE_COMPANY_CAPTURE_PROVIDER: 'firecrawl',
})('example.com', new AbortController().signal)
).rejects.toThrow('company_capture_missing_key');
expect(log).toHaveBeenCalledWith('company_capture', {
provider: 'firecrawl',
outcome: 'missing_key',
});
} finally {
log.mockRestore();
}
});
it.each([undefined, 'direct'])(
'keeps %s on direct capture',
async (provider) => {
const signal = new AbortController().signal;
await createCompanyCapture({
LIFECYCLE_COMPANY_CAPTURE_PROVIDER: provider,
})('example.com', signal);
expect(direct).toHaveBeenCalledWith('example.com', signal, {
onDiagnostic: expect.any(Function),
});
expect(managed).not.toHaveBeenCalled();
}
);

it('selects Firecrawl only with explicit configuration', async () => {
const signal = new AbortController().signal;
await createCompanyCapture({
LIFECYCLE_COMPANY_CAPTURE_PROVIDER: 'firecrawl',
FIRECRAWL_API_KEY: 'fixture-key',
})('example.com', signal);
expect(managed).toHaveBeenCalledWith('example.com', signal, {
apiKey: 'fixture-key',
onDiagnostic: expect.any(Function),
});
expect(direct).not.toHaveBeenCalled();
});

it('validates lazily and never falls back on invalid configuration', async () => {
const capture = createCompanyCapture({
LIFECYCLE_COMPANY_CAPTURE_PROVIDER: 'invalid-secret-value',
});
await expect(
capture('example.com', new AbortController().signal)
).rejects.toThrow('company_capture_invalid_provider');
expect(direct).not.toHaveBeenCalled();
expect(managed).not.toHaveBeenCalled();
});

it.each([undefined, '', ' '])(
'requires a configured key before calling the provider: %s',
async (key) => {
const capture = createCompanyCapture({
LIFECYCLE_COMPANY_CAPTURE_PROVIDER: 'firecrawl',
FIRECRAWL_API_KEY: key,
});
await expect(
capture('example.com', new AbortController().signal)
).rejects.toThrow('company_capture_missing_key');
expect(managed).not.toHaveBeenCalled();
expect(direct).not.toHaveBeenCalled();
}
);

it('preserves provider failures without a second capture attempt', async () => {
managed.mockRejectedValue(new Error('firecrawl_provider_error'));
await expect(
createCompanyCapture({
LIFECYCLE_COMPANY_CAPTURE_PROVIDER: 'firecrawl',
FIRECRAWL_API_KEY: 'fixture-key',
})('example.com', new AbortController().signal)
).rejects.toThrow('firecrawl_provider_error');
expect(managed).toHaveBeenCalledTimes(1);
expect(direct).not.toHaveBeenCalled();
});

it('does not return evidence when cancellation arrives during capture', async () => {
const controller = new AbortController();
direct.mockImplementation(async () => {
controller.abort(new Error('cancelled'));
return [];
});
await expect(
createCompanyCapture({})('example.com', controller.signal)
).rejects.toThrow('cancelled');
});
});
44 changes: 44 additions & 0 deletions apps/lifecycle/src/enrichment/company-capture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { fetchCompanyEvidence } from './company-fetch.js';
import { fetchFirecrawlCompanyEvidence } from './firecrawl.js';
import type { CompanyPageEvidence } from './schema.js';

function report(diagnostic: object): void {
try {
console.info('company_capture', diagnostic);
} catch {
// Observability must not change capture behavior.
}
}

export function createCompanyCapture(
environment: Record<string, string | undefined>
): (domain: string, signal: AbortSignal) => Promise<CompanyPageEvidence[]> {
return async (domain, signal) => {
signal.throwIfAborted();
const provider = environment['LIFECYCLE_COMPANY_CAPTURE_PROVIDER'];
let evidence: CompanyPageEvidence[];
if (provider === undefined || provider === 'direct') {
evidence = await fetchCompanyEvidence(domain, signal, {
onDiagnostic: (diagnostic) =>
report({ provider: 'direct', ...diagnostic }),
});
} else if (provider === 'firecrawl') {
const apiKey = environment['FIRECRAWL_API_KEY']?.trim();
if (!apiKey) {
report({ provider: 'firecrawl', outcome: 'missing_key' });
signal.throwIfAborted();
throw new Error('company_capture_missing_key');
}
evidence = await fetchFirecrawlCompanyEvidence(domain, signal, {
apiKey,
onDiagnostic: report,
});
} else {
report({ outcome: 'invalid_provider' });
signal.throwIfAborted();
throw new Error('company_capture_invalid_provider');
}
signal.throwIfAborted();
return evidence;
};
}
16 changes: 14 additions & 2 deletions apps/lifecycle/src/enrichment/company-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ function isPublicAddress(address: string): boolean {
async function resolvePublicAddresses(
hostname: string,
signal: AbortSignal,
dependencies: CompanyFetchDependencies
dependencies: Pick<CompanyFetchDependencies, 'resolve'>
): Promise<readonly string[]> {
const addresses = await dependencies.resolve(hostname, signal);
signal.throwIfAborted();
Expand All @@ -396,6 +396,18 @@ async function resolvePublicAddresses(
return addresses;
}

/** Reuse the direct fetcher's hostname and public-address policy for providers. */
export async function validatePublicCompanyHostname(
domain: string,
signal: AbortSignal,
resolve: CompanyFetchDependencies['resolve'] = resolveWithNodeDns
): Promise<string> {
const hostname = validatedCompanyHostname(domain);
signal.throwIfAborted();
await resolvePublicAddresses(hostname, signal, { resolve });
return hostname;
}

function validatedRedirectUrl(
location: string,
current: URL,
Expand Down Expand Up @@ -572,7 +584,7 @@ function descriptionValues(
return values;
}

function extractEvidence(
export function extractEvidence(
body: Uint8Array
): Pick<CompanyPageEvidence, 'facts' | 'snippets'> {
const html = new TextDecoder('utf-8', { fatal: false }).decode(body);
Expand Down
Loading
Loading