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
387 changes: 235 additions & 152 deletions backend/src/index.ts

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion backend/src/mastra/workflows/populate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ const agentStep = createStep({
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[populate-agent] agent.generate failed: ${msg}`);
return { text: `Agent failed: ${msg}` };
throw err;
}
},
});
Expand Down
45 changes: 34 additions & 11 deletions frontend/app/dataset/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export default function DatasetPage() {
}

async function handleUpdate() {
if (!dataset || updating) return;
if (!dataset || updating || dataset.status === "building") return;
setUpdating(true);
try {
const token = await getToken();
Expand All @@ -118,22 +118,23 @@ export default function DatasetPage() {
}

async function handlePopulate() {
if (!dataset || populating) return;
if (!dataset || populating || dataset.status === "building") return;
setPopulating(true);
try {
const token = await getToken();
if (!token) throw new Error("Not authenticated");

await populate(
const startedRun = await populate(
dataset._id,
dataset.name,
dataset.description,
dataset.columns,
token,
);
track(EVENTS.DATASET_POPULATED, {
track(EVENTS.DATASET_POPULATE_STARTED, {
datasetId: dataset._id,
column_count: dataset.columns.length,
runId: startedRun.runId,
});
} catch (err) {
console.error("[populate] failed", err);
Expand All @@ -159,6 +160,21 @@ export default function DatasetPage() {
// the "Dataset not found" UI.

const exportDisabled = exporting !== null || rows.length === 0;
const isDatasetBuilding = dataset.status === "building";
const updateDisabled = updating || isDatasetBuilding;
const populateDisabled = populating || isDatasetBuilding;
const updateLabel = isDatasetBuilding
? "Building…"
: updating
? "Updating…"
: "Update Dataset";
const populateLabel = isDatasetBuilding
? "Building…"
: populating
? "Starting…"
: dataset.status === "failed"
? "Retry Populate"
: "Clear & Populate";
const csvLabel =
exporting === "csv"
? "Exporting…"
Expand Down Expand Up @@ -216,27 +232,34 @@ export default function DatasetPage() {
</button>
<button
onClick={handleUpdate}
disabled={updating}
disabled={updateDisabled}
className="border border-border px-3 py-1.5 text-xs font-medium text-foreground hover:bg-foreground/[0.03] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{updating ? "Updating…" : "Update Dataset"}
{updateLabel}
</button>
<button
onClick={handlePopulate}
disabled={populating}
disabled={populateDisabled}
className="border border-border px-3 py-1.5 text-xs font-medium text-foreground hover:bg-foreground/[0.03] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{populating ? "Populating…" : "Clear & Populate"}
{populateLabel}
</button>
<div className="w-px h-4 bg-border mx-1" />
<ThemeToggle />
</div>
</header>

<div className="border-b border-border px-5 py-2.5 flex items-center gap-4 bg-surface/50 shrink-0">
<p className="text-xs text-muted truncate max-w-2xl">
{dataset.description}
</p>
<div className="min-w-0 flex-1">
<p className="text-xs text-muted truncate">
{dataset.description}
</p>
{dataset.status === "failed" && dataset.lastStatusError && (
<p role="status" className="mt-1 truncate text-xs font-medium text-red-600 dark:text-red-400">
Last populate failed: {dataset.lastStatusError}
</p>
)}
</div>
<div className="ml-auto flex items-center gap-4 text-[11px] text-muted shrink-0">
{selectedCount > 0 && (
<>
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/dataset/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ export default function NewDatasetPage() {
Create a new dataset
</h2>
<p className="mt-2 text-sm text-muted">
Describe what data you want to collect. Our agents will figure out the schema and start populating it.
Describe what data you want to collect. Our agents will figure out the schema; you can start populating it from the dataset page.
</p>
</div>

Expand Down
7 changes: 6 additions & 1 deletion frontend/components/dataset/StatusBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
export type DatasetStatus = "live" | "paused" | "building";
export type DatasetStatus = "live" | "paused" | "building" | "failed";

const STYLES: Record<DatasetStatus, string> = {
live: "border-emerald-600/20 bg-emerald-600/5 text-emerald-700 dark:text-emerald-400",
paused: "border-border bg-background text-muted",
building: "border-amber-600/20 bg-amber-600/5 text-amber-700 dark:text-amber-400",
failed: "border-red-600/20 bg-red-600/5 text-red-700 dark:text-red-400",
};

const LABELS: Record<DatasetStatus, string> = {
live: "Live",
paused: "Paused",
building: "Building...",
failed: "Failed",
};

export function StatusBadge({ status }: { status: DatasetStatus }) {
Expand All @@ -23,6 +25,9 @@ export function StatusBadge({ status }: { status: DatasetStatus }) {
{status === "building" && (
<span className="h-1.5 w-1.5 rounded-full bg-amber-600 animate-pulse" />
)}
{status === "failed" && (
<span className="h-1.5 w-1.5 rounded-full bg-red-600" />
)}
{LABELS[status]}
</span>
);
Expand Down
3 changes: 2 additions & 1 deletion frontend/components/table/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export interface DatasetMeta {
_id: string;
name: string;
description: string;
status: "live" | "paused" | "building";
status: "live" | "paused" | "building" | "failed";
lastStatusError?: string;
cadence: string;
columns: DatasetColumn[];
}
Expand Down
55 changes: 43 additions & 12 deletions frontend/convex/datasets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,38 @@ export const getInternal = internalQuery({
},
});

/**
* Atomically claims a user-requested populate run for a dataset.
*
* This is the concurrency gate for backend /populate calls. The workflow
* starts by clearing existing rows, so duplicate background runs for the same
* dataset must be rejected before either one reaches the row-clearing step.
*/
export const beginPopulateInternal = internalMutation({
args: {
id: v.id("datasets"),
ownerId: v.string(),
},
handler: async (ctx, args) => {
const dataset = await ctx.db.get(args.id);
if (!dataset) {
return { outcome: "not_found" as const };
}
if (dataset.ownerId !== args.ownerId) {
return { outcome: "forbidden" as const };
}
if (dataset.status === "building") {
return { outcome: "already_building" as const };
}

await ctx.db.patch(dataset._id, {
status: "building",
lastStatusError: undefined,
});
return { outcome: "started" as const };
},
});

/**
* Admin-only status transition. Used by the backend orchestration layer
* to move a dataset between lifecycle states after a workflow completes.
Expand All @@ -123,16 +155,10 @@ export const getInternal = internalQuery({
* run). This mutation is purely a controlled patch on the `status` field.
*
* Lifecycle today:
* - "building" : set by `datasets.create`, before any rows exist
* - "live" : set by /populate handler after successful population
* - "paused" : reserved for the future user-facing Pause/Resume UI
*
* Future statuses (extend the schema's `status` union when they land —
* the validator below auto-picks up new values since it points at the
* same union):
* - "refreshing" : scheduled refresh in progress (Inngest / cron)
* - "failed" : last populate / refresh failed
* - "quota_exceeded" : last attempt blocked by quota
* - "paused" : default for newly created datasets before first run
* - "building" : set by beginPopulateInternal after ownership passes
* - "live" : set by background populate after rows exist
* - "failed" : set by background populate on workflow failure
*
* NOTE: the public `datasets.updateStatus` mutation still exists for
* user-initiated transitions (Pause/Resume) — that one goes through
Expand All @@ -145,10 +171,15 @@ export const setStatusInternal = internalMutation({
v.literal("live"),
v.literal("paused"),
v.literal("building"),
v.literal("failed"),
),
lastStatusError: v.optional(v.string()),
},
handler: async (ctx, args) => {
await ctx.db.patch(args.id, { status: args.status });
await ctx.db.patch(args.id, {
status: args.status,
lastStatusError: args.status === "failed" ? args.lastStatusError : undefined,
});
},
});

Expand All @@ -170,7 +201,7 @@ export const create = mutation({
return await ctx.db.insert("datasets", {
...args,
ownerId: identity.subject,
status: "building",
status: "paused",
visibility: "private",
rowCount: 0,
});
Expand Down
4 changes: 3 additions & 1 deletion frontend/convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ export default defineSchema({
status: v.union(
v.literal("live"),
v.literal("paused"),
v.literal("building")
v.literal("building"),
v.literal("failed")
),
lastStatusError: v.optional(v.string()),
cadence: v.string(),
// Optional for backward compat with rows seeded before this field existed.
// Treat undefined as "private" in authorization helpers.
Expand Down
2 changes: 1 addition & 1 deletion frontend/lib/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const EVENTS = {
// Dataset interaction
DATASET_OPENED: "dataset_opened",
DATASET_EXPORTED: "dataset_exported",
DATASET_POPULATED: "dataset_populated",
DATASET_POPULATE_STARTED: "dataset_populate_started",

// Creation flow
DATASET_CREATION_STARTED: "dataset_creation_started",
Expand Down
11 changes: 8 additions & 3 deletions frontend/lib/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ export interface PopulateColumn {
description?: string;
}

export interface PopulateResult {
export interface PopulateStartResult {
success: boolean;
runId: string;
}

export interface WorkflowResult {
success: boolean;
result: unknown;
}
Expand Down Expand Up @@ -59,7 +64,7 @@ export async function populate(
description: string,
columns: PopulateColumn[],
token: string,
): Promise<PopulateResult> {
): Promise<PopulateStartResult> {
const res = await fetch(`${BACKEND_URL}/populate`, {
method: "POST",
headers: {
Expand All @@ -84,7 +89,7 @@ export async function update(
description: string,
columns: PopulateColumn[],
token: string,
): Promise<PopulateResult> {
): Promise<WorkflowResult> {
const res = await fetch(`${BACKEND_URL}/update`, {
method: "POST",
headers: {
Expand Down