Skip to content

Commit 654eb3a

Browse files
committed
Wire Mastra populate through self-healing
1 parent 72bb0ba commit 654eb3a

14 files changed

Lines changed: 834 additions & 30 deletions

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ OPENROUTER_API_KEY=sk-or-...
1414
# Used by the backend container to call internal Convex functions.
1515
CONVEX_SELF_HOSTED_ADMIN_KEY=
1616

17+
# Durable store for self-healing populate recipe manifests.
18+
# Docker dev overrides this to /app/.bigset/populate-recipes on a named volume.
19+
POPULATE_RECIPE_STORE_DIR=.bigset/populate-recipes
20+
1721
# PostHog (optional — leave blank to disable analytics entirely in local dev).
1822
# Get from https://us.posthog.com/project/settings/general.
1923
NEXT_PUBLIC_POSTHOG_KEY=

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
.DS_Store
22
node_modules/
3+
backend/node_modules
34
.env
45
.env.local
56
Project_BigSet_brief.md
@@ -22,6 +23,7 @@ tmp/
2223
temp/
2324

2425
.mastra
26+
.bigset/
2527

2628
# Local tarballs
2729
*.tgz

backend/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
CLIENT_ORIGIN=http://localhost:3500
22
CONVEX_URL=http://localhost:3210
33
PORT=3501
4+
POPULATE_RECIPE_STORE_DIR=.bigset/populate-recipes
45

56
# Required once the backend starts writing rows via internal Convex mutations.
67
# Generate with: docker compose exec convex ./generate_admin_key.sh

backend/src/env.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,9 @@ export const env = {
2424
CLERK_PUBLISHABLE_KEY: process.env.CLERK_PUBLISHABLE_KEY,
2525

2626
OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY,
27+
28+
// Durable recipe manifests for the self-healing populate layer. In Docker
29+
// dev this points at a named volume; locally it defaults under the repo.
30+
POPULATE_RECIPE_STORE_DIR:
31+
process.env.POPULATE_RECIPE_STORE_DIR || ".bigset/populate-recipes",
2732
};

backend/src/index.ts

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import { env } from "./env.js";
55
import clerkAuthPlugin, { requireAuth } from "./clerk-auth.js";
66
import { inferSchema } from "./pipeline/schema-inference.js";
77
import { datasetContextSchema } from "./pipeline/populate.js";
8-
import { populateWorkflow } from "./mastra/workflows/populate.js";
8+
import { ConvexPopulateDatasetRowWriter } from "./pipeline/populate-convex-writer.js";
9+
import { runSelfHealingPopulate } from "./pipeline/populate-self-healing-runner.js";
910
import { convex, api } from "./convex.js";
1011

1112
const fastify = Fastify({ logger: true });
13+
const populateRowWriter = new ConvexPopulateDatasetRowWriter();
1214

1315
await fastify.register(fastifyCors, {
1416
origin: env.CLIENT_ORIGIN,
@@ -72,17 +74,37 @@ await fastify.register(async (instance) => {
7274
if (dataset.ownerId !== authenticatedUserId) {
7375
return reply.code(403).send({ error: "Not authorized to populate this dataset" });
7476
}
77+
if (!env.CONVEX_ADMIN_KEY) {
78+
return reply.code(500).send({
79+
error: "Backend is missing the Convex admin key required for row writes.",
80+
});
81+
}
7582

76-
const run = await populateWorkflow.createRun();
77-
const result = await run.start({ inputData: parsed.data });
78-
79-
req.log.info({ workflowStatus: result.status, steps: JSON.stringify(result.steps).slice(0, 2000) }, "Populate workflow completed");
83+
const result = await runSelfHealingPopulate({
84+
context: parsed.data,
85+
recipeStoreDirectory: env.POPULATE_RECIPE_STORE_DIR,
86+
rowWriter: populateRowWriter,
87+
shouldCommitRows: true,
88+
});
8089

81-
if (result.status !== "success") {
82-
throw new Error(`Workflow ended with status: ${result.status}`);
90+
req.log.info({
91+
action: result.action,
92+
datasetId: result.datasetId,
93+
committedRows: result.committedRows?.insertedRowCount ?? 0,
94+
validationIssues: result.validationIssues.slice(0, 5),
95+
}, "Self-healing populate completed");
96+
97+
if (!result.success) {
98+
return reply.code(422).send({
99+
error: "Self-healing populate failed validation.",
100+
result: responseSafePopulateResult(result),
101+
});
83102
}
84103

85-
return { success: true, result: result.result };
104+
return {
105+
success: true,
106+
result: responseSafePopulateResult(result),
107+
};
86108
} catch (err) {
87109
const msg = err instanceof Error ? err.message : String(err);
88110
if (msg.includes("validator") || msg.includes("Invalid")) {
@@ -100,3 +122,20 @@ try {
100122
fastify.log.error(err);
101123
process.exit(1);
102124
}
125+
126+
function responseSafePopulateResult(
127+
result: Awaited<ReturnType<typeof runSelfHealingPopulate>>
128+
) {
129+
const diagnosticRun = result.selectedRun ?? result.diagnosticRun;
130+
return {
131+
action: result.action,
132+
datasetId: result.datasetId,
133+
success: result.success,
134+
committedRows: result.committedRows,
135+
rejectionReasons: result.rejectionReasons,
136+
validationIssues: result.validationIssues,
137+
productionValidation: diagnosticRun?.productionValidation,
138+
metrics: diagnosticRun?.metrics,
139+
rowCount: diagnosticRun?.rows.length ?? 0,
140+
};
141+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { env } from "../env.js";
2+
import { convex, internal } from "../convex.js";
3+
import type {
4+
PopulateDatasetRowWriter,
5+
PopulateDatasetWriteResult,
6+
} from "./populate-self-healing-runner.js";
7+
8+
interface ConvexMutationClient {
9+
mutation(functionReference: unknown, args: unknown): Promise<unknown>;
10+
}
11+
12+
export class ConvexPopulateDatasetRowWriter implements PopulateDatasetRowWriter {
13+
constructor(
14+
private readonly input: {
15+
convexClient?: ConvexMutationClient;
16+
internalApi?: typeof internal;
17+
} = {}
18+
) {}
19+
20+
async replaceRows(input: Parameters<PopulateDatasetRowWriter["replaceRows"]>[0]):
21+
Promise<PopulateDatasetWriteResult> {
22+
if (!env.CONVEX_ADMIN_KEY) {
23+
throw new Error(
24+
"CONVEX_SELF_HOSTED_ADMIN_KEY is required to commit self-healed populate rows."
25+
);
26+
}
27+
28+
const convexClient = this.input.convexClient ?? convex;
29+
const internalApi = this.input.internalApi ?? internal;
30+
const replacement = await convexClient.mutation(
31+
internalApi.datasetRows.replaceByDataset,
32+
{
33+
datasetId: input.datasetId,
34+
rows: input.rows.map((row) => ({
35+
data: row.cells,
36+
sources: row.sourceUrls,
37+
})),
38+
}
39+
);
40+
41+
return normalizeReplacementResult(replacement, input.rows.length);
42+
}
43+
}
44+
45+
function normalizeReplacementResult(
46+
value: unknown,
47+
fallbackInsertedRowCount: number
48+
): PopulateDatasetWriteResult {
49+
if (
50+
typeof value === "object" &&
51+
value !== null &&
52+
"insertedRowCount" in value
53+
) {
54+
const replacement = value as {
55+
clearedRowCount?: unknown;
56+
insertedRowCount?: unknown;
57+
};
58+
return {
59+
clearedRowCount: typeof replacement.clearedRowCount === "number"
60+
? replacement.clearedRowCount
61+
: undefined,
62+
insertedRowCount: typeof replacement.insertedRowCount === "number"
63+
? replacement.insertedRowCount
64+
: fallbackInsertedRowCount,
65+
};
66+
}
67+
68+
return {
69+
insertedRowCount: fallbackInsertedRowCount,
70+
};
71+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { join } from "node:path";
2+
3+
import type { DatasetContext } from "./populate.js";
4+
import {
5+
DefaultPopulateRecipeAuthor,
6+
FileSystemPopulateRecipeStore,
7+
MastraPopulateRecipeRuntime,
8+
SelfHealingPopulateRecipeService,
9+
type PopulateRecipeAuthor,
10+
type PopulateRecipeRunResult,
11+
type PopulateRecipeRuntime,
12+
type PopulateRecipeStore,
13+
type SelfHealingPopulateTickResult,
14+
} from "./populate-self-healing.js";
15+
16+
export interface PopulateDatasetRowWriter {
17+
replaceRows(input: {
18+
datasetId: string;
19+
rows: PopulateRecipeRunResult["rows"];
20+
}): Promise<PopulateDatasetWriteResult>;
21+
}
22+
23+
export interface PopulateDatasetWriteResult {
24+
clearedRowCount?: number;
25+
insertedRowCount: number;
26+
}
27+
28+
export interface RunSelfHealingPopulateInput {
29+
context: DatasetContext;
30+
store?: PopulateRecipeStore;
31+
runtime?: PopulateRecipeRuntime;
32+
author?: PopulateRecipeAuthor;
33+
rowWriter?: PopulateDatasetRowWriter;
34+
shouldCommitRows?: boolean;
35+
recipeStoreDirectory?: string;
36+
}
37+
38+
export interface RunSelfHealingPopulateResult {
39+
success: boolean;
40+
action: SelfHealingPopulateTickResult["action"];
41+
datasetId: string;
42+
selectedRun?: PopulateRecipeRunResult;
43+
diagnosticRun?: PopulateRecipeRunResult;
44+
committedRows?: PopulateDatasetWriteResult;
45+
rejectionReasons: string[];
46+
validationIssues: string[];
47+
tick: SelfHealingPopulateTickResult;
48+
}
49+
50+
export async function runSelfHealingPopulate(
51+
input: RunSelfHealingPopulateInput
52+
): Promise<RunSelfHealingPopulateResult> {
53+
if (input.shouldCommitRows && !input.rowWriter) {
54+
throw new Error("rowWriter is required when shouldCommitRows is true.");
55+
}
56+
const rowWriter = input.rowWriter;
57+
58+
const store = input.store ?? new FileSystemPopulateRecipeStore(
59+
input.recipeStoreDirectory ?? defaultPopulateRecipeStoreDirectory()
60+
);
61+
const service = new SelfHealingPopulateRecipeService({
62+
store,
63+
runtime: input.runtime ?? new MastraPopulateRecipeRuntime(),
64+
author: input.author ?? new DefaultPopulateRecipeAuthor(),
65+
});
66+
const tick = await service.tick({
67+
datasetId: input.context.datasetId,
68+
context: input.context,
69+
});
70+
const selectedRun = successfulRunForTick(tick);
71+
const diagnosticRun = diagnosticRunForTick(tick);
72+
let committedRows: PopulateDatasetWriteResult | undefined;
73+
74+
if (input.shouldCommitRows && selectedRun && rowWriter) {
75+
committedRows = await rowWriter.replaceRows({
76+
datasetId: input.context.datasetId,
77+
rows: selectedRun.rows,
78+
});
79+
}
80+
81+
return {
82+
success: Boolean(selectedRun),
83+
action: tick.action,
84+
datasetId: input.context.datasetId,
85+
selectedRun,
86+
diagnosticRun,
87+
committedRows,
88+
rejectionReasons: tick.rejectionReasons,
89+
validationIssues: validationIssuesForSelfHealingTick(tick),
90+
tick,
91+
};
92+
}
93+
94+
export function successfulRunForTick(
95+
tick: SelfHealingPopulateTickResult
96+
): PopulateRecipeRunResult | undefined {
97+
if (tick.action === "active_rerun_succeeded") {
98+
return tick.activeRun;
99+
}
100+
if (
101+
tick.action === "generated_initial_recipe" ||
102+
tick.action === "repaired_active_recipe"
103+
) {
104+
return tick.candidateRun;
105+
}
106+
return undefined;
107+
}
108+
109+
export function diagnosticRunForTick(
110+
tick: SelfHealingPopulateTickResult
111+
): PopulateRecipeRunResult | undefined {
112+
return successfulRunForTick(tick) ?? tick.candidateRun ?? tick.activeRun;
113+
}
114+
115+
export function validationIssuesForSelfHealingTick(
116+
tick: SelfHealingPopulateTickResult
117+
): string[] {
118+
const run = diagnosticRunForTick(tick);
119+
return Array.from(new Set([
120+
...(run?.validationIssues ?? []),
121+
...(run?.productionValidation.criticalIssues ?? []),
122+
...tick.rejectionReasons,
123+
]));
124+
}
125+
126+
function defaultPopulateRecipeStoreDirectory(): string {
127+
return join(process.cwd(), ".bigset", "populate-recipes");
128+
}

0 commit comments

Comments
 (0)