Skip to content

Commit 555c474

Browse files
committed
WIP
1 parent f12b9cf commit 555c474

File tree

26 files changed

+1031
-69
lines changed

26 files changed

+1031
-69
lines changed

.husky/pre-commit

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env sh
22
set -e
33

4-
pnpm format
5-
pnpm check:fix
6-
pnpm knip
4+
# pnpm format
5+
# pnpm check:fix
6+
# pnpm knip

AGENTS.md

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ export const createProject = createServerFn({ method: "POST" })
283283
.handler(async ({ data }) => {
284284
const { organizationId } = await requireSession()
285285
const client = getPostgresClient()
286-
286+
287287
const project = await Effect.runPromise(
288288
createProjectUseCase({...}).pipe(
289289
Effect.provide(ProjectRepositoryLive),
@@ -301,7 +301,7 @@ export const createProject = createServerFn({ method: "POST" })
301301
export const completeAuthIntentUseCase = (input) =>
302302
Effect.gen(function* () {
303303
const sqlClient = yield* SqlClient
304-
304+
305305
// Wraps multi-step operation in single transaction with RLS
306306
yield* sqlClient.transaction(handleIntentByType(intent, input.session))
307307
})
@@ -310,7 +310,7 @@ const handleSignup = (intent, session) =>
310310
Effect.gen(function* () {
311311
const users = yield* UserRepository
312312
const memberships = yield* MembershipRepository
313-
313+
314314
// All operations share the same transaction + RLS context
315315
const organization = yield* createOrganizationUseCase({...})
316316
yield* memberships.save(createMembership({...}))
@@ -326,13 +326,13 @@ export const ProjectRepositoryLive = Layer.effect(
326326
ProjectRepository,
327327
Effect.gen(function* () {
328328
const sqlClient = (yield* SqlClient) as SqlClientShape<Operator>
329-
329+
330330
return {
331331
findById: (id) =>
332332
sqlClient
333333
.query((db) => db.select().from(projects).where(eq(projects.id, id)))
334334
.pipe(Effect.flatMap(...)),
335-
335+
336336
save: (project) =>
337337
Effect.gen(function* () {
338338
yield* sqlClient.query((db) =>
@@ -684,7 +684,6 @@ describe("MyRepository", () => {
684684
it("does something", async () => {
685685
// pg.postgresDb is a real Drizzle instance backed by PGlite in-memory
686686
// pg.db is the lower-level Drizzle/PGlite instance for direct queries
687-
// pg.client is the raw PGlite handle (useful for createRlsMiddleware)
688687
})
689688
})
690689
```
@@ -693,8 +692,6 @@ describe("MyRepository", () => {
693692
- **beforeAll**: creates a PGlite instance, creates the `latitude_app` role, and runs all Drizzle migrations
694693
- **afterAll**: closes the PGlite connection
695694

696-
For Hono integration tests that need RLS enforcement, use `createRlsMiddleware(pg.client)` from `@platform/testkit`.
697-
698695
#### ClickHouse test setup (`@platform/testkit`)
699696

700697
```typescript
@@ -787,7 +784,7 @@ import { getPostgresClient } from "../../server/clients.ts"
787784
export const listProjects = createServerFn({ method: "GET" }).handler(async () => {
788785
const { organizationId } = await requireSession()
789786
const client = getPostgresClient()
790-
787+
791788
return await Effect.runPromise(
792789
Effect.gen(function* () {
793790
const repo = yield* ProjectRepository
@@ -805,7 +802,7 @@ export const createProject = createServerFn({ method: "POST" })
805802
.handler(async ({ data }) => {
806803
const { userId, organizationId } = await requireSession()
807804
const client = getPostgresClient()
808-
805+
809806
return await Effect.runPromise(
810807
createProjectUseCase({...}).pipe(
811808
Effect.provide(ProjectRepositoryLive),

PLAN-create-datasets-from-traces.md

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,13 @@ export const addTracesToDatasetMutation = createServerFn({ method: "POST" })
4848
traceIds: z.array(z.string()).min(1),
4949
}))
5050
.handler(async ({ data }) => {
51-
// 1. requireSession → organizationId
52-
// 2. Fetch trace details (with messages) via findByTraceIds
53-
// 3. Map each trace to a dataset row:
54-
// - input: { messages: trace.inputMessages }
55-
// - output: { messages: trace.outputMessages }
51+
// 1. Fetch trace details (with messages) via findByTraceIds
52+
// 2. Map each trace to a dataset row:
53+
// - input: trace.inputMessages
54+
// - output: trace.outputMessages
5655
// - metadata: { traceId, rootSpanName, model, ... }
57-
// 4. Call insertRows use-case with source: "traces"
58-
// 5. Return { versionId, version, rowIds }
56+
// 3. Call insertRows use-case with source: "traces"
57+
// 4. Return { versionId, version, rowIds }
5958
})
6059
```
6160

@@ -159,12 +158,9 @@ The `source` field on `dataset_versions` will be `"traces"` to distinguish from
159158

160159
## Questions / Decisions
161160

162-
1. **Message format in dataset rows**: Should `input`/`output` store the raw `GenAIMessage[]` array directly (e.g. `{ messages: [...] }`) or flatten to a simpler structure? Using `{ messages: GenAIMessage[] }` preserves fidelity and is consistent with the OTEL/GenAI format already used.
163-
164-
2. **Metadata enrichment**: How much trace metadata should be stored per row? The plan includes `traceId`, `rootSpanName`, `models`, `status`, duration, tokens, and cost — enough to trace provenance without bloating rows.
165-
166161
3. **Duplicate handling**: If a trace is already in the target dataset, should we skip it, upsert, or allow duplicates? The simplest approach is to allow duplicates (each insert is a new row with a new CUID). Deduplication could be a follow-up.
162+
HOW HARD is DE_DUPLICATION?
167163

168164
4. **Max selection limit**: Should we cap the number of traces that can be added at once? The `insertBatch` already batches in groups of 500, so large selections are handled. A reasonable UI cap (e.g. 1000) could prevent accidental bulk operations.
165+
1000 as CAP is fine
169166

170-
5. **Navigate after creation**: When creating a new dataset from traces, should the user be redirected to the new dataset detail page? This seems like good UX for discoverability.
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { Button, CloseTrigger, Input, Modal, Select, type SelectOption, Text, useToast } from "@repo/ui"
2+
import { useNavigate } from "@tanstack/react-router"
3+
import { Plus } from "lucide-react"
4+
import { useCallback, useMemo, useState } from "react"
5+
import { useDatasetsCollection } from "../../domains/datasets/datasets.collection.ts"
6+
import {
7+
addTracesToDatasetMutation,
8+
createDatasetFromTracesMutation,
9+
} from "../../domains/datasets/datasets.functions.ts"
10+
import { getQueryClient } from "../../lib/data/query-client.tsx"
11+
12+
interface AddToDatasetModalProps {
13+
open: boolean
14+
onOpenChange: (open: boolean) => void
15+
projectId: string
16+
traceIds: string[]
17+
onSuccess: () => void
18+
}
19+
20+
export function AddToDatasetModal({ open, onOpenChange, projectId, traceIds, onSuccess }: AddToDatasetModalProps) {
21+
const [selectedDatasetId, setSelectedDatasetId] = useState<string | null>(null)
22+
const [creatingNew, setCreatingNew] = useState(false)
23+
const [newDatasetName, setNewDatasetName] = useState("")
24+
const [submitting, setSubmitting] = useState(false)
25+
const { toast } = useToast()
26+
const navigate = useNavigate()
27+
const { data: datasets } = useDatasetsCollection(projectId)
28+
29+
const datasetOptions = useMemo<SelectOption<string>[]>(
30+
() => (datasets ?? []).map((ds) => ({ label: ds.name, value: ds.id })),
31+
[datasets],
32+
)
33+
34+
const handleSelectChange = useCallback((value: string) => {
35+
setSelectedDatasetId(value)
36+
setCreatingNew(false)
37+
}, [])
38+
39+
const handleCreateNew = useCallback(() => {
40+
setCreatingNew(true)
41+
setSelectedDatasetId(null)
42+
}, [])
43+
44+
const handleSubmit = useCallback(async () => {
45+
if (traceIds.length === 0) return
46+
setSubmitting(true)
47+
try {
48+
if (creatingNew) {
49+
if (!newDatasetName.trim()) return
50+
const result = await createDatasetFromTracesMutation({
51+
data: { projectId, name: newDatasetName.trim(), traceIds },
52+
})
53+
toast({
54+
title: "Dataset created",
55+
description: `"${newDatasetName.trim()}" created with ${result.rowCount} row${result.rowCount === 1 ? "" : "s"}.`,
56+
})
57+
getQueryClient().invalidateQueries({ queryKey: ["datasets", projectId] })
58+
onSuccess()
59+
onOpenChange(false)
60+
navigate({
61+
to: "/projects/$projectId/datasets/$datasetId",
62+
params: { projectId, datasetId: result.datasetId },
63+
})
64+
} else {
65+
if (!selectedDatasetId) return
66+
const result = await addTracesToDatasetMutation({
67+
data: { projectId, datasetId: selectedDatasetId, traceIds },
68+
})
69+
toast({
70+
title: "Traces added to dataset",
71+
description: `${result.rowCount} row${result.rowCount === 1 ? "" : "s"} added (version ${result.version}).`,
72+
})
73+
getQueryClient().invalidateQueries({ queryKey: ["datasets", projectId] })
74+
getQueryClient().invalidateQueries({ queryKey: ["datasetRows", selectedDatasetId] })
75+
onSuccess()
76+
onOpenChange(false)
77+
}
78+
} catch (error) {
79+
toast({
80+
variant: "destructive",
81+
title: "Error",
82+
description: error instanceof Error ? error.message : "Failed to add traces to dataset.",
83+
})
84+
} finally {
85+
setSubmitting(false)
86+
}
87+
}, [creatingNew, selectedDatasetId, newDatasetName, projectId, traceIds, toast, navigate, onSuccess, onOpenChange])
88+
89+
const canSubmit =
90+
traceIds.length > 0 && !submitting && (creatingNew ? newDatasetName.trim().length > 0 : !!selectedDatasetId)
91+
92+
return (
93+
<Modal
94+
open={open}
95+
onOpenChange={onOpenChange}
96+
title="Add traces to dataset"
97+
description={`${traceIds.length} trace${traceIds.length === 1 ? "" : "s"} selected`}
98+
dismissible
99+
footer={
100+
<div className="flex flex-row items-center gap-2">
101+
<CloseTrigger />
102+
<Button onClick={handleSubmit} disabled={!canSubmit} isLoading={submitting}>
103+
{!submitting && <Plus className="h-4 w-4" />}
104+
<Text.H5 color="white">{creatingNew ? "Create & add" : "Add to dataset"}</Text.H5>
105+
</Button>
106+
</div>
107+
}
108+
>
109+
<div className="flex flex-col gap-4">
110+
{creatingNew ? (
111+
<div className="flex flex-col gap-2">
112+
<Input
113+
label="New dataset name"
114+
placeholder="My dataset"
115+
value={newDatasetName}
116+
onChange={(e) => setNewDatasetName(e.target.value)}
117+
autoFocus
118+
/>
119+
<button type="button" onClick={() => setCreatingNew(false)} className="self-start">
120+
<Text.H6 color="primary">Back to existing datasets</Text.H6>
121+
</button>
122+
</div>
123+
) : (
124+
<Select<string>
125+
name="dataset"
126+
label="Dataset"
127+
placeholder="Select a dataset"
128+
options={datasetOptions}
129+
value={selectedDatasetId ?? undefined}
130+
onChange={handleSelectChange}
131+
searchable
132+
searchPlaceholder="Search datasets..."
133+
searchableEmptyMessage="No datasets found."
134+
side="bottom"
135+
footerAction={{
136+
label: "Create new dataset",
137+
icon: <Plus className="h-4 w-4" />,
138+
onClick: handleCreateNew,
139+
}}
140+
/>
141+
)}
142+
</div>
143+
</Modal>
144+
)
145+
}

apps/web/src/components/datasets/dataset-table.tsx

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,9 @@ import { Checkbox, Table, TableBody, TableCell, TableHead, TableHeader, TableRow
33
import { relativeTime } from "@repo/utils"
44
import type { DatasetRowRecord } from "../../domains/datasets/datasets.functions.ts"
55

6-
function truncateJson(data: string | Record<string, unknown>, maxLen = 30): string {
7-
if (typeof data === "string") return data.length > maxLen ? `${data.slice(0, maxLen)}…` : data
8-
const values = Object.values(data)
9-
if (values.length === 0) return "{}"
10-
const first = String(values[0])
11-
return first.length > maxLen ? `${first.slice(0, maxLen)}…` : first
6+
function formatCellValue(data: string | Record<string, unknown>): string {
7+
if (typeof data === "string") return data
8+
return JSON.stringify(data)
129
}
1310

1411
export function DatasetTable({
@@ -53,17 +50,16 @@ export function DatasetTable({
5350
checked={isRowSelected(row.rowId)}
5451
onCheckedChange={(checked) => onToggleRow(row.rowId, checked)}
5552
onClick={(e) => e.stopPropagation()}
56-
className="hit-area-3"
5753
/>
5854
</TableCell>
5955
<TableCell>
6056
<Text.H6 color="foregroundMuted">{relativeTime(row.createdAt)}</Text.H6>
6157
</TableCell>
62-
<TableCell>
63-
<Text.H6 className="font-mono truncate max-w-48">{truncateJson(row.input)}</Text.H6>
58+
<TableCell className="max-w-48">
59+
<Text.Mono ellipsis>{formatCellValue(row.input)}</Text.Mono>
6460
</TableCell>
65-
<TableCell>
66-
<Text.H6 className="font-mono truncate max-w-48">{truncateJson(row.output)}</Text.H6>
61+
<TableCell className="max-w-48">
62+
<Text.Mono ellipsis>{formatCellValue(row.output)}</Text.Mono>
6763
</TableCell>
6864
</TableRow>
6965
))}

apps/web/src/components/datasets/row-detail-panel.tsx

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
import { Button, RichTextEditor, Text } from "@repo/ui"
2-
import { safeStringifyJson } from "@repo/utils"
32
import { Loader2, Save, X } from "lucide-react"
43
import { useCallback, useEffect, useState } from "react"
54
import type { DatasetRowRecord } from "../../domains/datasets/datasets.functions.ts"
65

6+
function formatField(value: unknown): string {
7+
if (typeof value === "string") return value
8+
if (value === null || value === undefined) return ""
9+
if (typeof value === "object" && Object.keys(value as Record<string, unknown>).length === 0) return ""
10+
try {
11+
return JSON.stringify(value, null, 2)
12+
} catch {
13+
return String(value)
14+
}
15+
}
16+
717
function EditableSection({
818
title,
919
value,
@@ -43,14 +53,14 @@ export function RowDetailPanel({
4353
onSave?: (data: { input: string; output: string; metadata: string }) => void
4454
saving?: boolean
4555
}) {
46-
const [inputText, setInputText] = useState(() => safeStringifyJson(row.input))
47-
const [outputText, setOutputText] = useState(() => safeStringifyJson(row.output))
48-
const [metadataText, setMetadataText] = useState(() => safeStringifyJson(row.metadata))
56+
const [inputText, setInputText] = useState(() => formatField(row.input))
57+
const [outputText, setOutputText] = useState(() => formatField(row.output))
58+
const [metadataText, setMetadataText] = useState(() => formatField(row.metadata))
4959

5060
useEffect(() => {
51-
setInputText(safeStringifyJson(row.input))
52-
setOutputText(safeStringifyJson(row.output))
53-
setMetadataText(safeStringifyJson(row.metadata))
61+
setInputText(formatField(row.input))
62+
setOutputText(formatField(row.output))
63+
setMetadataText(formatField(row.metadata))
5464
}, [row.input, row.output, row.metadata])
5565

5666
const handleSave = useCallback(() => {

0 commit comments

Comments
 (0)