Skip to content

Add local campaign progress instrumentation and schema compliance - #225

Open
lumenstech wants to merge 52 commits into
trycompai:mainfrom
lumenstech:local-lead-exhibitor-source-units
Open

lumenstech wants to merge 52 commits into
trycompai:mainfrom
lumenstech:local-lead-exhibitor-source-units

Conversation

@lumenstech

@lumenstech lumenstech commented Sep 17, 2026

Copy link
Copy Markdown

Summary

  • Adds per-seed progress instrumentation for the local lead campaign runner.
  • Adds source-unit extraction diagnostics and failure flushing.
  • Adds stricter local lead extraction prompt/schema handling.
  • Adds one retry path for schema-invalid local Ollama output.
  • Improves oversized/thin source handling so bad inputs are rejected cleanly.
  • Keeps the runner local-only and dry-run safe.

Pilot Result

  • Pilot-003 staged 0 leads due to schema-invalid qwen2.5 output.
  • Pilot-004 used the same tiny 4-seed pilot.
  • Source units: 15.
  • Ollama calls: 3.
  • Accepted extractions: 3.
  • Staged leads: 1.
  • Duplicates: 0.
  • Oversized source rejections: 1.
  • Thin source-unit rejections: 2.
  • CRM/Neon/outreach writes: 0.

Safety

  • No CRM writes.
  • No Neon writes.
  • No migrations.
  • No launch-agent edits.
  • No outreach or email sending.
  • deploy/ excluded.
  • Local pilot artifacts excluded.

Tests

  • Focused tests: 43 passed.
  • Type check: passed.
  • Biome: passed, with existing agent/lib/tasks.ts warning.
  • git diff --check: passed.
  • Campaign validation: passed.
  • --sync-crm refusal: passed.
  • Forbidden import scan: passed.

Known Follow-ups

  • Add source-unit ranking before Ollama calls.
  • Prefer rows with company plus website/profile evidence.
  • Rerun the 24-seed PartWall pilot after ranking.
  • Evaluate smaller local models only if already installed or explicitly approved.

Summary by cubic

Adds a local-only campaign runner that turns seed pages into validated, scored, staged leads, plus an authenticated Intelligence inbox for reviewing commercial signals before CRM promotion. Local extraction now rejects malformed model output and unusable sources while remaining dry-run safe.

Local campaign workflow

  • Adds dry-run workers with page caching, source-unit diagnostics, per-seed progress, failure flushing, scoring, deduplication, and JSONL/CSV export.
  • Enforces strict schemas, retries schema-invalid Ollama output once, rejects oversized or thin sources, and keeps artifacts under apps/agent/var/local-leads without CRM, Neon, or outreach writes.

Signal workflow and migration

  • Adds idempotent signal ingestion, inbox queries, company resolution, qualification, and promotion endpoints, along with the Intelligence page and navigation entry.
  • Apply the included Prisma migration before using signal features; it adds business-unit, canonical-entity, signal, source-record, and qualification data structures.

Written for commit 1e53e61. Summary will update on new commits.

Review in cubic

carhartlewis and others added 30 commits August 7, 2026 11:38
@vercel

vercel Bot commented Sep 17, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the Comp AI - PoC Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions
github-actions Bot changed the base branch from release to main September 17, 2026 00:17
@github-actions

Copy link
Copy Markdown
Contributor

Retargeted this onto main.

release is the default branch so that a plain clone runs the last tagged release, but nothing merges into it — it is fast-forwarded onto the tag by the Release workflow and that is all. Changes go to main, and reach release when a release is cut.

Nothing is wrong with your branch. If the diff now shows commits that are already on main, rebase and force-push:

git fetch origin main
git rebase origin/main
git push --force-with-lease

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

40 issues found across 34 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/agent/agent/lib/local-campaign-staging.ts">

<violation number="1" location="apps/agent/agent/lib/local-campaign-staging.ts:67">
P1: When a source page contains multiple companies, every candidate shares `source_domain`, so `stageCampaignLead` marks all but the first company from that domain as `duplicate`. Remove the source domain as a standalone dedupe key and deduplicate using company, website/contact, or content identity instead.</violation>
</file>

<file name="apps/agent/agent/lib/local-lead-extraction.ts">

<violation number="1" location="apps/agent/agent/lib/local-lead-extraction.ts:105">
P1: The parser accepts fabricated leads because it never verifies `evidence_excerpt` against the source text. Pass `text` into the parser and reject excerpts absent from the source, as `local-campaign-extraction.ts` does.</violation>

<violation number="2" location="apps/agent/agent/lib/local-lead-extraction.ts:154">
P1: A public seed can redirect to a private host, bypassing the hostname check and allowing SSRF into internal services. Disable automatic redirects or revalidate and pin every redirect destination before fetching it.</violation>

<violation number="3" location="apps/agent/agent/lib/local-lead-extraction.ts:164">
P1: A server that sends headers and then stalls the body blocks `fetchLocalLeadSource` and the campaign run. Keep the abort timer active through body consumption and cancel the reader on timeout.</violation>
</file>

<file name="packages/db/prisma/schema.prisma">

<violation number="1" location="packages/db/prisma/schema.prisma:1700">
P1: A canonical opportunity or source record can reference a company or pipeline from another business unit because these foreign keys do not constrain the shared `businessUnitId`. Add composite business-unit-aware foreign keys or validate the association transactionally before writes.</violation>

<violation number="2" location="packages/db/prisma/schema.prisma:1735">
P1: When two business units ingest the same source key, this constraint makes them share one row and the ingest path reassigns the first unit's record to the second. Scope the source identity by `businessUnitId`, and update the ingest conflict target and lookup to match.</violation>

<violation number="3" location="packages/db/prisma/schema.prisma:1782">
P2: Campaign members and approvals accept target IDs that the database never validates. Add relations and foreign keys to the canonical company, person, and campaign-member records, or add explicit validation for the chosen polymorphic target design.</violation>

<violation number="4" location="packages/db/prisma/schema.prisma:1790">
P2: Because `companyId` and `personId` are nullable, PostgreSQL allows duplicate company-only and person-only members within one campaign. Enforce the two target shapes with partial unique indexes or a non-null target key.</violation>

<violation number="5" location="packages/db/prisma/schema.prisma:1829">
P2: `AgentRunContext` can reference a run that does not exist, and deleting a run leaves its context orphaned. Add an `AgentRun` relation and the matching foreign key with the intended delete behavior.</violation>
</file>

<file name="apps/agent/agent/lib/local-ollama.ts">

<violation number="1" location="apps/agent/agent/lib/local-ollama.ts:62">
P1: When Ollama sends headers but stalls while streaming the JSON body, `generate` clears its timer and waits indefinitely in `response.json()`, so the per-call timeout never fires. Keep the abort timer active through body consumption and clean it up afterward so the runner can record the failed seed.</violation>
</file>

<file name="apps/app/app/(app)/[slug]/intelligence/intelligence-inbox.tsx">

<violation number="1" location="apps/app/app/(app)/[slug]/intelligence/intelligence-inbox.tsx:82">
P2: When a project has no row in the current limited, status-filtered result, it never appears in the Project select. Load project options independently of the filtered signal rows, or expose project facets from the API.</violation>

<violation number="2" location="apps/app/app/(app)/[slug]/intelligence/intelligence-inbox.tsx:220">
P1: After a successful resolve or qualification, the sheet keeps the old `SignalRow`, so it can display stale state and leave promotion disabled. Update the selected row or make the sheet refetch and own the current signal state after mutations.</violation>

<violation number="3" location="apps/app/app/(app)/[slug]/intelligence/intelligence-inbox.tsx:237">
P1: When a user reviews another signal without unmounting the sheet, `score` retains the previous signal’s components and Save score submits them to the new signal. Reset score when `row?.sourceRecordId` changes.</violation>

<violation number="4" location="apps/app/app/(app)/[slug]/intelligence/intelligence-inbox.tsx:581">
P2: For canonical-only mappings, `row.mapped` is true while `mappedCompanyId` is null, so Create visible deal is enabled and the API rejects it. Gate this button on `mappedCompanyId` as well as the score and owner.</violation>
</file>

<file name="apps/agent/agent/lib/local-campaign-source-units.ts">

<violation number="1" location="apps/agent/agent/lib/local-campaign-source-units.ts:71">
P1: Ordinary same-origin links become source units because the normalized `href` itself satisfies the website evidence check. Exclude same-origin or non-company links before calling `buildSourceUnit`, otherwise navigation links consume the per-seed Ollama calls.</violation>

<violation number="2" location="apps/agent/agent/lib/local-campaign-source-units.ts:120">
P2: When a block contains a company's website only in an anchor attribute, `cleanText` removes the `href`, so Ollama never receives the website in `candidate_text`. Preserve valid anchor URLs before stripping HTML tags.</violation>
</file>

<file name="apps/api/src/ingest/signal-qualification.service.ts">

<violation number="1" location="apps/api/src/ingest/signal-qualification.service.ts:108">
P2: When `estimated_value_usd` is negative or exceeds the monetary column limit, `promote` bypasses the input amount validation and writes invalid money or fails with a database error. Validate the resolved amount against the supported nonnegative USD range before promotion.</violation>

<violation number="2" location="apps/api/src/ingest/signal-qualification.service.ts:113">
P1: Concurrent promotions for one source can both observe no opportunity mapping, create different opportunities and deals, and let the last mapping upsert win. Serialize promotion and create or reuse the mapping atomically.</violation>

<violation number="3" location="apps/api/src/ingest/signal-qualification.service.ts:149">
P2: When `createDeal` is true but its score, company link, or owner is invalid, `promote` has already inserted or updated the canonical opportunity before throwing. Validate these prerequisites before the canonical write.</violation>

<violation number="4" location="apps/api/src/ingest/signal-qualification.service.ts:184">
P2: If the final mapping write fails after deal creation, `promote` leaves the canonical opportunity and deal persisted without a source mapping. Run the promotion writes in one shared transaction or add explicit compensation.</violation>
</file>

<file name="apps/agent/agent/lib/local-campaign-runner.ts">

<violation number="1" location="apps/agent/agent/lib/local-campaign-runner.ts:396">
P2: The deadline check runs only before each candidate. If the final extraction or staging crosses `perSeedTimeoutMs`, the runner still stages the lead and reports `seed_completed`; enforce the deadline around awaited work and before final completion.</violation>

<violation number="2" location="apps/agent/agent/lib/local-campaign-runner.ts:409">
P2: Schema repair can make `extractLocalCampaignLead` call Ollama twice, but these counters increment once per extraction. Count every `client.generate` attempt so telemetry and both call limits include retries.</violation>
</file>

<file name="apps/agent/agent/lib/local-campaign-schema.ts">

<violation number="1" location="apps/agent/agent/lib/local-campaign-schema.ts:29">
P2: When a campaign includes an unknown `required_fields` entry, the runner treats that field as present because `lead[field]` is `undefined`, not `null`. Restrict `required_fields` to supported lead fields so invalid configurations cannot bypass required-field review.</violation>
</file>

<file name="apps/api/src/ingest/ingest.service.ts">

<violation number="1" location="apps/api/src/ingest/ingest.service.ts:117">
P2: Re-ingesting an already qualified signal replaces its payload and erases `qualification` and its saved evidence. Merge the existing payload with the new source payload so source refreshes preserve review state.</violation>

<violation number="2" location="apps/api/src/ingest/ingest.service.ts:140">
P2: When a valid signal contains nonnumeric text in `payload.metadata.fit_score`, `inbox` fails with a PostgreSQL cast error instead of returning the signal. Guard every numeric cast, or normalize numeric fields before storage, before applying `minScore` and ordering.</violation>

<violation number="3" location="apps/api/src/ingest/ingest.service.ts:157">
P2: When callers pass `source`, `inbox` ignores it and returns records from every source system. Add a `sourceSystem = input.source` predicate so the API's declared filter works.</violation>
</file>

<file name="apps/agent/agent/lib/local-campaign-extraction.ts">

<violation number="1" location="apps/agent/agent/lib/local-campaign-extraction.ts:89">
P2: When a non-null scalar is absent from `evidence_excerpt`, the parser skips the repair attempt and counts otherwise repairable model output as a failed extraction. Include this validation error in `needsSchemaRepair` so the one repair attempt handles it.</violation>

<violation number="2" location="apps/agent/agent/lib/local-campaign-extraction.ts:155">
P2: When the first response is repairable and a campaign call limit is one, this retry performs a second Ollama request despite the exhausted budget, while progress and result counters report one call. Charge retry attempts against the campaign budget and count or emit each underlying generation.</violation>
</file>

<file name="apps/agent/agent/cli/local-campaign-worker.ts">

<violation number="1" location="apps/agent/agent/cli/local-campaign-worker.ts:34">
P2: CLI overrides bypass campaign bounds and can round positive values to zero. Require integers within each schema limit, then validate the merged campaign before running.</violation>

<violation number="2" location="apps/agent/agent/cli/local-campaign-worker.ts:105">
P2: Export fails on a fresh checkout because it does not create `var/local-leads` before writing. Create the output directory before `writeFile`.</violation>
</file>

<file name="apps/api/src/app.module.ts">

<violation number="1" location="apps/api/src/app.module.ts:79">
P1: When `AppModule` imports `IngestModule`, its router is not attached to the tRPC host because `IngestModule` does not import `TrpcModule`. Add `TrpcModule` to `IngestModule` imports so the ingest tRPC procedures and REST bridge are registered.</violation>
</file>

<file name="apps/agent/agent/lib/local-lead-staging.ts">

<violation number="1" location="apps/agent/agent/lib/local-lead-staging.ts:17">
P2: When a staging file or parent directory is a symlink, this lexical check accepts it and `stageLocalLead` follows the link, so lead data can be read from or appended outside `var/local-leads`. Validate the real path or open the file and its parents with no-follow semantics before reading and appending.</violation>
</file>

<file name="apps/agent/agent/cli/local-lead-worker.ts">

<violation number="1" location="apps/agent/agent/cli/local-lead-worker.ts:51">
P2: When `--source-url` is not a URL, the worker spends an Ollama call before schema validation always fails. Validate and normalize `--source-url` before extraction.</violation>

<violation number="2" location="apps/agent/agent/cli/local-lead-worker.ts:52">
P2: When `--text` carries an oversized payload, this branch sends it directly to Ollama without the 512 KiB guard used for `--url`. Enforce the same byte limit before extraction.</violation>
</file>

<file name="apps/agent/test/local-campaign.spec.ts">

<violation number="1" location="apps/agent/test/local-campaign.spec.ts:66">
P2: This suite does not test the new per-seed progress instrumentation, so the callback can stop emitting or emit the wrong event sequence while all campaign tests pass. Add a test that records `onProgress` for successful and failed seeds and asserts the key lifecycle events and counters.</violation>

<violation number="2" location="apps/agent/test/local-campaign.spec.ts:623">
P2: This import-safety test misses `@crm/db/<subpath>` imports, so a forbidden CRM dependency can be added while the test still passes. Match the package prefix through the closing quote, including subpaths and other import forms.</violation>
</file>

<file name="apps/agent/agent/lib/local-campaign-lead-schema.ts">

<violation number="1" location="apps/agent/agent/lib/local-campaign-lead-schema.ts:6">
P2: This validator accepts non-web URI schemes as `website`, so values such as `mailto:` or `javascript:` can be staged as company websites. Restrict the website URL validator to `http` and `https` schemes.</violation>

<violation number="2" location="apps/agent/agent/lib/local-campaign-lead-schema.ts:6">
P2: When a source unit contains a bare `www.example.com` value, this validator rejects the copied website, causing a retry/failure or losing website data that source extraction identified. Normalize bare `www.` values to an HTTP(S) URL before parsing, or accept that source form consistently.</violation>
</file>

<file name="apps/agent/agent/lib/local-campaign-scoring.ts">

<violation number="1" location="apps/agent/agent/lib/local-campaign-scoring.ts:21">
P2: Because `project_fit_reason` is model-generated and not required to occur in `evidence_excerpt` or source text, this line lets Ollama inflate `fit_score` with unsupported keywords or trigger exclusions in its explanation. Score campaign fit from source-backed fields and evidence, or validate this rationale before including it.</violation>
</file>

<file name="apps/agent/test/local-lead-extraction.spec.ts">

<violation number="1" location="apps/agent/test/local-lead-extraction.spec.ts:85">
P2: This test does not wait for the rejection assertion, so the byte-limit regression can pass without being checked. Add `await` before `expect(...)` to make the test fail when `fetchLocalLeadSource` stops enforcing the limit.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic


function dedupeKeys(lead: LocalStagedLead): string[] {
return [
`domain:${normalizeDomain(lead.source_domain)}`,

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a source page contains multiple companies, every candidate shares source_domain, so stageCampaignLead marks all but the first company from that domain as duplicate. Remove the source domain as a standalone dedupe key and deduplicate using company, website/contact, or content identity instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/local-campaign-staging.ts, line 67:

<comment>When a source page contains multiple companies, every candidate shares `source_domain`, so `stageCampaignLead` marks all but the first company from that domain as `duplicate`. Remove the source domain as a standalone dedupe key and deduplicate using company, website/contact, or content identity instead.</comment>

<file context>
@@ -0,0 +1,116 @@
+
+function dedupeKeys(lead: LocalStagedLead): string[] {
+	return [
+		`domain:${normalizeDomain(lead.source_domain)}`,
+		lead.company_name ? `company:${normalizeText(lead.company_name)}` : null,
+		lead.email ? `email:${normalizeText(lead.email)}` : null,
</file context>
Fix with cubic

let response: Response;
try {
response = await Promise.race([
fetchImpl(url, {

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A public seed can redirect to a private host, bypassing the hostname check and allowing SSRF into internal services. Disable automatic redirects or revalidate and pin every redirect destination before fetching it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/local-lead-extraction.ts, line 154:

<comment>A public seed can redirect to a private host, bypassing the hostname check and allowing SSRF into internal services. Disable automatic redirects or revalidate and pin every redirect destination before fetching it.</comment>

<file context>
@@ -0,0 +1,301 @@
+	let response: Response;
+	try {
+		response = await Promise.race([
+			fetchImpl(url, {
+				signal: controller.signal,
+				headers: { accept: "text/html,text/plain;q=0.9" },
</file context>
Fix with cubic

opportunityId String?
opportunity CanonicalOpportunity? @relation(fields: [opportunityId], references: [id], onDelete: SetNull)

@@unique([sourceSystem, sourceType, sourceId])

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When two business units ingest the same source key, this constraint makes them share one row and the ingest path reassigns the first unit's record to the second. Scope the source identity by businessUnitId, and update the ingest conflict target and lookup to match.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/db/prisma/schema.prisma, line 1735:

<comment>When two business units ingest the same source key, this constraint makes them share one row and the ingest path reassigns the first unit's record to the second. Scope the source identity by `businessUnitId`, and update the ingest conflict target and lookup to match.</comment>

<file context>
@@ -1583,3 +1592,249 @@ model Apikey {
+  opportunityId  String?
+  opportunity    CanonicalOpportunity? @relation(fields: [opportunityId], references: [id], onDelete: SetNull)
+
+  @@unique([sourceSystem, sourceType, sourceId])
+  @@index([businessUnitId, sourceType])
+  @@map("source_record")
</file context>
Fix with cubic

throw new Error(`Ollama returned HTTP ${response.status}.`);
}

const body = (await response.json()) as { response?: unknown };

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When Ollama sends headers but stalls while streaming the JSON body, generate clears its timer and waits indefinitely in response.json(), so the per-call timeout never fires. Keep the abort timer active through body consumption and clean it up afterward so the runner can record the failed seed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/local-ollama.ts, line 62:

<comment>When Ollama sends headers but stalls while streaming the JSON body, `generate` clears its timer and waits indefinitely in `response.json()`, so the per-call timeout never fires. Keep the abort timer active through body consumption and clean it up afterward so the runner can record the failed seed.</comment>

<file context>
@@ -0,0 +1,73 @@
+				throw new Error(`Ollama returned HTTP ${response.status}.`);
+			}
+
+			const body = (await response.json()) as { response?: unknown };
+			if (typeof body.response !== "string") {
+				throw new Error("Ollama returned no text response.");
</file context>
Fix with cubic

row={selected}
onClose={() => setSelected(null)}
onChanged={async () => {
await signals.refetch();

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: After a successful resolve or qualification, the sheet keeps the old SignalRow, so it can display stale state and leave promotion disabled. Update the selected row or make the sheet refetch and own the current signal state after mutations.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/app/app/(app)/[slug]/intelligence/intelligence-inbox.tsx, line 220:

<comment>After a successful resolve or qualification, the sheet keeps the old `SignalRow`, so it can display stale state and leave promotion disabled. Update the selected row or make the sheet refetch and own the current signal state after mutations.</comment>

<file context>
@@ -0,0 +1,650 @@
+				row={selected}
+				onClose={() => setSelected(null)}
+				onChanged={async () => {
+					await signals.refetch();
+				}}
+			/>
</file context>
Fix with cubic

lead.company_name,
lead.website,
lead.location,
lead.project_fit_reason,

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Because project_fit_reason is model-generated and not required to occur in evidence_excerpt or source text, this line lets Ollama inflate fit_score with unsupported keywords or trigger exclusions in its explanation. Score campaign fit from source-backed fields and evidence, or validate this rationale before including it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/lib/local-campaign-scoring.ts, line 21:

<comment>Because `project_fit_reason` is model-generated and not required to occur in `evidence_excerpt` or source text, this line lets Ollama inflate `fit_score` with unsupported keywords or trigger exclusions in its explanation. Score campaign fit from source-backed fields and evidence, or validate this rationale before including it.</comment>

<file context>
@@ -0,0 +1,91 @@
+		lead.company_name,
+		lead.website,
+		lead.location,
+		lead.project_fit_reason,
+		lead.evidence_excerpt,
+	]
</file context>
Fix with cubic

missing_fields: ["contact_name", "role", "email", "phone"],
});

describe("local campaign runner", () => {

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This suite does not test the new per-seed progress instrumentation, so the callback can stop emitting or emit the wrong event sequence while all campaign tests pass. Add a test that records onProgress for successful and failed seeds and asserts the key lifecycle events and counters.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/test/local-campaign.spec.ts, line 66:

<comment>This suite does not test the new per-seed progress instrumentation, so the callback can stop emitting or emit the wrong event sequence while all campaign tests pass. Add a test that records `onProgress` for successful and failed seeds and asserts the key lifecycle events and counters.</comment>

<file context>
@@ -0,0 +1,646 @@
+	missing_fields: ["contact_name", "role", "email", "phone"],
+});
+
+describe("local campaign runner", () => {
+	it("validates campaign definitions", () => {
+		expect(localCampaignSchema.safeParse(campaign).success).toBe(true);
</file context>
Fix with cubic

Comment on lines +85 to +90
expect(
fetchLocalLeadSource("https://example.com", async () => response, {
maxBytes: 4,
lookup: async () => [{ address: "93.184.216.34", family: 4 }],
}),
).rejects.toThrow("maximum content size");

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This test does not wait for the rejection assertion, so the byte-limit regression can pass without being checked. Add await before expect(...) to make the test fail when fetchLocalLeadSource stops enforcing the limit.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/test/local-lead-extraction.spec.ts, line 85:

<comment>This test does not wait for the rejection assertion, so the byte-limit regression can pass without being checked. Add `await` before `expect(...)` to make the test fail when `fetchLocalLeadSource` stops enforcing the limit.</comment>

<file context>
@@ -0,0 +1,162 @@
+			status: 200,
+			headers: { "content-length": "20" },
+		});
+		expect(
+			fetchLocalLeadSource("https://example.com", async () => response, {
+				maxBytes: 4,
</file context>
Suggested change
expect(
fetchLocalLeadSource("https://example.com", async () => response, {
maxBytes: 4,
lookup: async () => [{ address: "93.184.216.34", family: 4 }],
}),
).rejects.toThrow("maximum content size");
await expect(
fetchLocalLeadSource("https://example.com", async () => response, {
maxBytes: 4,
lookup: async () => [{ address: "93.184.216.34", family: 4 }],
}),
).rejects.toThrow("maximum content size");
Fix with cubic

id String @id @default(uuid())
businessUnitId String
businessUnit BusinessUnit @relation(fields: [businessUnitId], references: [id], onDelete: Restrict)
agentRunId String

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: AgentRunContext can reference a run that does not exist, and deleting a run leaves its context orphaned. Add an AgentRun relation and the matching foreign key with the intended delete behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/db/prisma/schema.prisma, line 1829:

<comment>`AgentRunContext` can reference a run that does not exist, and deleting a run leaves its context orphaned. Add an `AgentRun` relation and the matching foreign key with the intended delete behavior.</comment>

<file context>
@@ -1583,3 +1592,249 @@ model Apikey {
+  id             String   @id @default(uuid())
+  businessUnitId String
+  businessUnit   BusinessUnit @relation(fields: [businessUnitId], references: [id], onDelete: Restrict)
+  agentRunId     String
+  budget         Int      @default(0)
+  fitScores      Json?
</file context>
Fix with cubic

if (!output) throw new Error("Export needs --out.");
const safeOutput = assertLocalCampaignPath(output);
if (format === "jsonl") {
await writeFile(safeOutput, localCampaignJsonl(leads), "utf8");

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Export fails on a fresh checkout because it does not create var/local-leads before writing. Create the output directory before writeFile.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/agent/agent/cli/local-campaign-worker.ts, line 105:

<comment>Export fails on a fresh checkout because it does not create `var/local-leads` before writing. Create the output directory before `writeFile`.</comment>

<file context>
@@ -0,0 +1,126 @@
+		if (!output) throw new Error("Export needs --out.");
+		const safeOutput = assertLocalCampaignPath(output);
+		if (format === "jsonl") {
+			await writeFile(safeOutput, localCampaignJsonl(leads), "utf8");
+		} else if (format === "csv") {
+			await writeFile(safeOutput, localCampaignCsv(leads), "utf8");
</file context>
Fix with cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants