diff --git a/packages/hub/README.md b/packages/hub/README.md index da70607fd8..6464989d03 100644 --- a/packages/hub/README.md +++ b/packages/hub/README.md @@ -79,3 +79,13 @@ Squiggle Hub is deployed on [Vercel](https://vercel.com/) automatically when the The production database is migrated by [this GitHub Action](https://github.com/quantified-uncertainty/squiggle/blob/main/.github/workflows/prisma-migrate-prod.yml). **Important: it should be invoked _before_ merging any PR that changes the schema.** + +## Debugging + +If you get an error like: + +``` +PothosSchemaError [GraphQLError]: Ref ObjectRef has not been implemented +``` + +Make sure that any new files in `src/graphql/types` have been added to `src/schema.ts`, or something that references that. diff --git a/packages/hub/package.json b/packages/hub/package.json index 37073c472d..cb4f9b6268 100644 --- a/packages/hub/package.json +++ b/packages/hub/package.json @@ -20,7 +20,8 @@ "build": "pnpm gen && __NEXT_PRIVATE_PREBUNDLED_REACT=next next build", "lint": "prettier --check . && next lint", "format": "prettier --write .", - "test:manual": "dotenv -e .env.test -- jest -i" + "test:manual": "dotenv -e .env.test -- jest -i", + "build-last-revision": "tsx src/scripts/buildRecentModelRevision/main.ts" }, "dependencies": { "@next-auth/prisma-adapter": "^1.0.7", @@ -74,6 +75,7 @@ "@types/invariant": "^2.2.37", "@types/jest": "^29.5.12", "@types/lodash": "^4.14.202", + "@types/node": "^20.11.24", "@types/pako": "^2.0.3", "@types/react": "^18.2.52", "@types/react-relay": "^16.0.6", diff --git a/packages/hub/prisma/migrations/20240407144130_model_revision_build/migration.sql b/packages/hub/prisma/migrations/20240407144130_model_revision_build/migration.sql new file mode 100644 index 0000000000..0fe367f26b --- /dev/null +++ b/packages/hub/prisma/migrations/20240407144130_model_revision_build/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "ModelRevisionBuild" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "modelRevisionId" TEXT NOT NULL, + "runSeconds" DOUBLE PRECISION NOT NULL, + "errors" TEXT[], + + CONSTRAINT "ModelRevisionBuild_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "ModelRevisionBuild_modelRevisionId_idx" ON "ModelRevisionBuild"("modelRevisionId"); + +-- AddForeignKey +ALTER TABLE "ModelRevisionBuild" ADD CONSTRAINT "ModelRevisionBuild_modelRevisionId_fkey" FOREIGN KEY ("modelRevisionId") REFERENCES "ModelRevision"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/hub/prisma/schema.prisma b/packages/hub/prisma/schema.prisma index a94bf40e55..13bbeb8812 100644 --- a/packages/hub/prisma/schema.prisma +++ b/packages/hub/prisma/schema.prisma @@ -222,11 +222,23 @@ model ModelRevision { exports ModelExport[] // required by Prisma, but unused since `model` field should point at the same entity - currentRevisionModel Model? @relation("CurrentRevision") + currentRevisionModel Model? @relation("CurrentRevision") + builds ModelRevisionBuild[] @@index([modelId]) } +model ModelRevisionBuild { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + modelRevision ModelRevision @relation(fields: [modelRevisionId], references: [id], onDelete: Cascade) + modelRevisionId String + runSeconds Float + errors String[] + + @@index([modelRevisionId]) +} + model SquiggleSnippet { id String @id @default(cuid()) diff --git a/packages/hub/schema.graphql b/packages/hub/schema.graphql index 1a1dc4b79a..f75ff7249b 100644 --- a/packages/hub/schema.graphql +++ b/packages/hub/schema.graphql @@ -157,6 +157,7 @@ type Model implements Node { id: ID! isEditable: Boolean! isPrivate: Boolean! + lastRevisionWithBuild: ModelRevision owner: Owner! revision(id: ID!): ModelRevision! revisions(after: String, before: String, first: Int, last: Int): ModelRevisionConnection! @@ -215,16 +216,34 @@ type ModelExportRevisionsConnectionEdge { type ModelRevision implements Node { author: User + buildStatus: ModelRevisionBuildStatus! comment: String! content: ModelContent! createdAtTimestamp: Float! + exportNames: [String!]! exports: [ModelExport!]! forRelativeValues(input: ModelRevisionForRelativeValuesInput!): ModelRevisionForRelativeValuesResult! id: ID! + lastBuild: ModelRevisionBuild model: Model! relativeValuesExports: [RelativeValuesExport!]! } +type ModelRevisionBuild implements Node { + createdAtTimestamp: Float! + errors: [String!]! + id: ID! + modelRevision: ModelRevision! + runSeconds: Float! +} + +enum ModelRevisionBuildStatus { + Failure + Pending + Skipped + Success +} + type ModelRevisionConnection { edges: [ModelRevisionEdge!]! pageInfo: PageInfo! @@ -483,7 +502,6 @@ union MutationUpdateRelativeValuesDefinitionResult = BaseError | UpdateRelativeV input MutationUpdateSquiggleSnippetModelInput { comment: String content: SquiggleSnippetContentInput! - exports: [SquiggleModelExportInput!] owner: String! relativeValuesExports: [RelativeValuesExportInput!] slug: String! @@ -533,7 +551,7 @@ type Query { nodes(ids: [ID!]!): [Node]! relativeValuesDefinition(input: QueryRelativeValuesDefinitionInput!): QueryRelativeValuesDefinitionResult! relativeValuesDefinitions(after: String, before: String, first: Int, input: RelativeValuesDefinitionsQueryInput, last: Int): RelativeValuesDefinitionConnection! - runSquiggle(code: String!): SquiggleOutput! + runSquiggle(code: String!, seed: String): SquiggleOutput! search(after: String, before: String, first: Int, last: Int, text: String!): QuerySearchResult! userByUsername(username: String!): QueryUserByUsernameResult! users(after: String, before: String, first: Int, input: UsersQueryInput, last: Int): QueryUsersConnection! @@ -681,13 +699,6 @@ type SquiggleErrorOutput implements SquiggleOutput { isCached: Boolean! } -input SquiggleModelExportInput { - docstring: String - title: String - variableName: String! - variableType: String! -} - type SquiggleOkOutput implements SquiggleOutput { bindingsJSON: String! isCached: Boolean! diff --git a/packages/hub/src/app/api/runSquiggle/route.ts b/packages/hub/src/app/api/runSquiggle/route.ts index 54280244e3..3cc422e217 100644 --- a/packages/hub/src/app/api/runSquiggle/route.ts +++ b/packages/hub/src/app/api/runSquiggle/route.ts @@ -1,13 +1,16 @@ import { NextRequest, NextResponse } from "next/server"; -import { runSquiggle } from "@/graphql/queries/runSquiggle"; +import { runSquiggleWithCache } from "@/graphql/queries/runSquiggle"; export async function POST(req: NextRequest) { // Assuming 'code' is sent in the request body and is a string try { const body = await req.json(); if (body.code) { - let response = await runSquiggle(body.code); + let response = await runSquiggleWithCache( + body.code, + body.seed || "DEFAULT_SEED" + ); if (response.isOk) { return new NextResponse( JSON.stringify({ diff --git a/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx b/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx index 40e0d89bb0..b6340bf481 100644 --- a/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx +++ b/packages/hub/src/app/models/[owner]/[slug]/EditSquiggleSnippetModel.tsx @@ -54,13 +54,11 @@ import { EditSquiggleSnippetModel$key } from "@/__generated__/EditSquiggleSnippe import { EditSquiggleSnippetModelMutation, RelativeValuesExportInput, - SquiggleModelExportInput, } from "@/__generated__/EditSquiggleSnippetModelMutation.graphql"; export type SquiggleSnippetFormShape = { code: string; relativeValuesExports: RelativeValuesExportInput[]; - exports: SquiggleModelExportInput[]; }; type OnSubmit = ( @@ -144,6 +142,11 @@ export const EditSquiggleSnippetModel: FC = ({ owner { slug } + lastRevisionWithBuild { + lastBuild { + runSeconds + } + } currentRevision { id content { @@ -158,6 +161,7 @@ export const EditSquiggleSnippetModel: FC = ({ xyPointLength } } + exportNames exports { id variableName @@ -188,6 +192,8 @@ export const EditSquiggleSnippetModel: FC = ({ "SquiggleSnippet" ); + const lastBuildSpeed = model.lastRevisionWithBuild?.lastBuild?.runSeconds; + const seed = content.seed; const initialFormValues: SquiggleSnippetFormShape = useMemo(() => { @@ -200,14 +206,8 @@ export const EditSquiggleSnippetModel: FC = ({ slug: item.definition.slug, }, })), - exports: revision.exports.map((item) => ({ - title: item.title, - variableName: item.variableName, - variableType: item.variableType, - docstring: item.docstring, - })), }; - }, [content, revision.relativeValuesExports, revision.exports]); + }, [content, revision.relativeValuesExports]); const { form, onSubmit, inFlight } = useMutationForm< SquiggleSnippetFormShape, @@ -245,7 +245,6 @@ export const EditSquiggleSnippetModel: FC = ({ xyPointLength: content.xyPointLength, }, relativeValuesExports: formData.relativeValuesExports, - exports: formData.exports, comment: extraData?.comment, slug: model.slug, owner: model.owner.slug, @@ -312,13 +311,18 @@ export const EditSquiggleSnippetModel: FC = ({ const squiggle = use(versionedSquigglePackages(checkedVersion)); + // Automatically turn off autorun, if the last build speed was > 5s. Note that this does not stop in the case of memory errors or similar. + const autorunMode = + content.autorunMode || + (lastBuildSpeed ? (lastBuildSpeed > 5 ? false : true) : true); + // Build props for versioned SquigglePlayground first, since they might depend on the version we use, // and we want to populate them incrementally. const playgroundProps: Parameters< typeof squiggle.components.SquigglePlayground >[0] = { defaultCode, - autorunMode: content.autorunMode ?? true, + autorunMode: autorunMode, sourceId: serializeSourceId({ owner: model.owner.slug, slug: model.slug, @@ -406,9 +410,6 @@ export const EditSquiggleSnippetModel: FC = ({ playgroundProps ) ) { - playgroundProps.onExportsChange = (exports) => { - form.setValue("exports", exports); - }; } playgroundProps.environment = { diff --git a/packages/hub/src/app/models/[owner]/[slug]/ModelLayout.tsx b/packages/hub/src/app/models/[owner]/[slug]/ModelLayout.tsx index bbb2d319b1..a3b87f73c7 100644 --- a/packages/hub/src/app/models/[owner]/[slug]/ModelLayout.tsx +++ b/packages/hub/src/app/models/[owner]/[slug]/ModelLayout.tsx @@ -7,7 +7,11 @@ import { CodeBracketIcon, RectangleStackIcon, ShareIcon } from "@quri/ui"; import { EntityLayout } from "@/components/EntityLayout"; import { EntityTab } from "@/components/ui/EntityTab"; -import { ExportsDropdown, totalImportLength } from "@/lib/ExportsDropdown"; +import { + ExportsDropdown, + type ModelExport, + totalImportLength, +} from "@/lib/ExportsDropdown"; import { extractFromGraphqlErrorUnion } from "@/lib/graphqlHelpers"; import { SerializablePreloadedQuery } from "@/relay/loadPageQuery"; import { usePageQuery } from "@/relay/usePageQuery"; @@ -45,6 +49,7 @@ const Query = graphql` currentRevision { id # for length; TODO - "hasExports" field? + exportNames exports { id variableName @@ -81,12 +86,18 @@ export const ModelLayout: FC< slug: model.slug, }); - const modelExports = model.currentRevision.exports.map( - ({ variableName, variableType, title }) => ({ - variableName, - variableType, - title: title || undefined, - }) + const modelExports: ModelExport[] = model.currentRevision.exportNames.map( + (name) => { + const matchingExport = model.currentRevision.exports.find( + (e) => e.variableName === name + ); + + return { + variableName: name, + variableType: matchingExport?.variableType || undefined, + title: matchingExport?.title || undefined, + }; + } ); const relativeValuesExports = model.currentRevision.relativeValuesExports.map( diff --git a/packages/hub/src/app/models/[owner]/[slug]/revisions/ModelRevisionsList.tsx b/packages/hub/src/app/models/[owner]/[slug]/revisions/ModelRevisionsList.tsx index 6038cb7a3c..f84d26b03e 100644 --- a/packages/hub/src/app/models/[owner]/[slug]/revisions/ModelRevisionsList.tsx +++ b/packages/hub/src/app/models/[owner]/[slug]/revisions/ModelRevisionsList.tsx @@ -27,6 +27,7 @@ const ModelRevisionItem: FC<{ fragment ModelRevisionsList_revision on ModelRevision { id createdAtTimestamp + buildStatus author { username } @@ -36,6 +37,10 @@ const ModelRevisionItem: FC<{ variableName title } + lastBuild { + errors + runSeconds + } } `, revisionRef @@ -76,6 +81,11 @@ const ModelRevisionItem: FC<{ {revision.comment ? (
{revision.comment}
) : null} + +
{`Build Status: ${revision.buildStatus}`}
+ {revision.lastBuild && ( +
{`Build Time: ${revision.lastBuild.runSeconds.toFixed(2)}s`}
+ )} {revision.exports.length > 0 ? (
{`${revision.exports.length} exports `} @@ -124,11 +134,16 @@ export const ModelRevisionsList: FC<{ ...ModelRevisionsList_revision id createdAtTimestamp + buildStatus exports { id title variableName } + lastBuild { + errors + runSeconds + } } } pageInfo { diff --git a/packages/hub/src/graphql/mutations/updateSquiggleSnippetModel.ts b/packages/hub/src/graphql/mutations/updateSquiggleSnippetModel.ts index bb29cc8791..424a9df770 100644 --- a/packages/hub/src/graphql/mutations/updateSquiggleSnippetModel.ts +++ b/packages/hub/src/graphql/mutations/updateSquiggleSnippetModel.ts @@ -43,15 +43,6 @@ const SquiggleSnippetContentInput = builder.inputType( } ); -const SquiggleModelExportInput = builder.inputType("SquiggleModelExportInput", { - fields: (t) => ({ - variableName: t.string({ required: true }), - variableType: t.string({ required: true }), - docstring: t.string({ required: false }), - title: t.string({ required: false }), - }), -}); - builder.mutationField("updateSquiggleSnippetModel", (t) => t.withAuth({ signedIn: true }).fieldWithInput({ type: builder.simpleObject("UpdateSquiggleSnippetResult", { @@ -66,9 +57,6 @@ builder.mutationField("updateSquiggleSnippetModel", (t) => relativeValuesExports: t.input.field({ type: [RelativeValuesExportInput], }), - exports: t.input.field({ - type: [SquiggleModelExportInput], - }), content: t.input.field({ type: SquiggleSnippetContentInput, required: true, @@ -178,19 +166,6 @@ builder.mutationField("updateSquiggleSnippetModel", (t) => data: relativeValuesExportsToInsert, }, }, - exports: { - createMany: { - data: (input.exports ?? []).map( - ({ variableName, variableType, docstring, title }) => ({ - variableName, - variableType, - docstring: docstring ?? undefined, - title: title ?? null, - isCurrent: true, - }) - ), - }, - }, }, include: { model: { diff --git a/packages/hub/src/graphql/queries/runSquiggle.ts b/packages/hub/src/graphql/queries/runSquiggle.ts index dd4a590f03..f7462fda16 100644 --- a/packages/hub/src/graphql/queries/runSquiggle.ts +++ b/packages/hub/src/graphql/queries/runSquiggle.ts @@ -1,14 +1,25 @@ import { Prisma } from "@prisma/client"; import crypto from "crypto"; -import { SqProject, SqValue } from "@quri/squiggle-lang"; - -import { DEFAULT_SEED, SAMPLE_COUNT_DEFAULT } from "@/constants"; +import { + SqLinker, + SqOutputResult, + SqProject, + SqValue, +} from "@quri/squiggle-lang"; + +import { SAMPLE_COUNT_DEFAULT, XY_POINT_LENGTH_DEFAULT } from "@/constants"; import { builder } from "@/graphql/builder"; import { prisma } from "@/prisma"; +import { parseSourceId } from "@/squiggle/components/linker"; + +import { NotFoundError } from "../errors/NotFoundError"; -function getKey(code: string): string { - return crypto.createHash("md5").update(code).digest("base64"); +function getKey(code: string, seed: string): string { + return crypto + .createHash("sha256") + .update(JSON.stringify({ code, seed })) + .digest("base64"); } export const squiggleValueToJSON = (value: SqValue): any => { @@ -77,23 +88,85 @@ builder.objectType( } ); -export async function runSquiggle(code: string): Promise { +export const squiggleLinker: SqLinker = { + resolve(name) { + return name; + }, + async loadSource(sourceId: string) { + const { owner, slug } = parseSourceId(sourceId); + const model = await prisma.model.findFirst({ + where: { + slug, + owner: { slug: owner }, + }, + include: { + currentRevision: { + include: { + squiggleSnippet: true, + }, + }, + }, + }); + + if (!model) { + throw new NotFoundError(); + } + + const content = model?.currentRevision?.squiggleSnippet; + if (content) { + return content.code; + } else { + throw new NotFoundError(); + } + }, +}; + +export async function runSquiggle( + code: string, + seed: string +): Promise { const MAIN = "main"; const env = { sampleCount: SAMPLE_COUNT_DEFAULT, // int - xyPointLength: 1000, // int - seed: DEFAULT_SEED, + xyPointLength: XY_POINT_LENGTH_DEFAULT, // int + seed: seed, }; - const project = SqProject.create({ environment: env }); + const project = SqProject.create({ + environment: env, + linker: squiggleLinker, + }); project.setSource(MAIN, code); await project.run(MAIN); - const outputR = project.getOutput(MAIN); + return project.getOutput(MAIN); +} + +//Warning: Caching will break if any imports change. It would be good to track this. Maybe we could compile the import tree, then store that as well, and recalculate whenever either that or the code changes. +export async function runSquiggleWithCache( + code: string, + seed: string +): Promise { + const key = getKey(code, seed); + const cached = await prisma.squiggleCache.findUnique({ + where: { id: key }, + }); + + if (cached) { + return { + isCached: true, + isOk: cached.ok, + errorString: cached.error, + resultJSON: cached.result, + bindingsJSON: cached.bindings, + } as unknown as SquiggleOutput; + } - return outputR.ok + const outputR = await runSquiggle(code, seed); + + const result: SquiggleOutput = outputR.ok ? { isCached: false, isOk: true, @@ -105,6 +178,25 @@ export async function runSquiggle(code: string): Promise { isOk: false, errorString: outputR.value.toString(), }; + + await prisma.squiggleCache.upsert({ + where: { id: key }, + create: { + id: key, + ok: result.isOk, + result: result.resultJSON ?? undefined, + bindings: result.bindingsJSON ?? undefined, + error: result.errorString, + }, + update: { + ok: result.isOk, + result: result.resultJSON ?? undefined, + bindings: result.bindingsJSON ?? undefined, + error: result.errorString, + }, + }); + + return result; } builder.queryField("runSquiggle", (t) => @@ -112,39 +204,10 @@ builder.queryField("runSquiggle", (t) => type: SquiggleOutputObj, args: { code: t.arg.string({ required: true }), + seed: t.arg.string({ required: false }), }, - async resolve(_, { code }) { - const key = getKey(code); - - const cached = await prisma.squiggleCache.findUnique({ - where: { id: key }, - }); - if (cached) { - return { - isCached: true, - isOk: cached.ok, - errorString: cached.error, - resultJSON: cached.result, - bindingsJSON: cached.bindings, - } as unknown as SquiggleOutput; // cache is less strictly typed than SquiggleOutput, so we have to force-cast it - } - const result = await runSquiggle(code); - await prisma.squiggleCache.upsert({ - where: { id: key }, - create: { - id: key, - ok: result.isOk, - result: result.resultJSON ?? undefined, - bindings: result.bindingsJSON ?? undefined, - error: result.errorString, - }, - update: { - ok: result.isOk, - result: result.resultJSON ?? undefined, - bindings: result.bindingsJSON ?? undefined, - error: result.errorString, - }, - }); + async resolve(_, { code, seed }) { + const result = await runSquiggleWithCache(code, seed || "DEFAULT_SEED"); return result; }, }) diff --git a/packages/hub/src/graphql/types/Model.ts b/packages/hub/src/graphql/types/Model.ts index 7da25cb967..4e506074ab 100644 --- a/packages/hub/src/graphql/types/Model.ts +++ b/packages/hub/src/graphql/types/Model.ts @@ -112,6 +112,30 @@ export const Model = builder.prismaNode("Model", { resolve: (model, args, ctx) => exportRevisionConnectionHelpers.resolve(model.revisions, args, ctx), }), + lastRevisionWithBuild: t.field({ + type: ModelRevision, + nullable: true, + select: { + revisions: { + orderBy: { + createdAt: "desc", + }, + where: { + builds: { + some: { + id: { + not: undefined, + }, + }, + }, + }, + take: 1, + }, + }, + async resolve(model) { + return model.revisions[0]; + }, + }), }), }); diff --git a/packages/hub/src/graphql/types/ModelRevision.ts b/packages/hub/src/graphql/types/ModelRevision.ts index b1babe5781..aacbcb4123 100644 --- a/packages/hub/src/graphql/types/ModelRevision.ts +++ b/packages/hub/src/graphql/types/ModelRevision.ts @@ -1,9 +1,12 @@ import { UnionRef } from "@pothos/core"; +import { ASTNode, parse } from "@quri/squiggle-lang"; + import { builder } from "@/graphql/builder"; import { prisma } from "@/prisma"; import { NotFoundError } from "../errors/NotFoundError"; +import { ModelRevisionBuild } from "./ModelRevisionBuild"; import { RelativeValuesExport } from "./RelativeValuesExport"; import { SquiggleSnippet } from "./SquiggleSnippet"; @@ -24,6 +27,28 @@ const ModelContent: UnionRef< resolveType: () => SquiggleSnippet, }); +const ModelRevisionBuildStatus = builder.enumType("ModelRevisionBuildStatus", { + values: ["Skipped", "Pending", "Success", "Failure"], +}); + +function getExportedVariableNames(ast: ASTNode): string[] { + const exportedVariableNames: string[] = []; + + if (ast.type === "Program") { + ast.statements.forEach((statement) => { + while (statement.type === "DecoratedStatement") + statement = statement.statement; + if (statement.type === "LetStatement" && statement.exported) { + exportedVariableNames.push(statement.variable.value); + } else if (statement.type === "DefunStatement" && statement.exported) { + exportedVariableNames.push(statement.variable.value); + } + }); + } + + return exportedVariableNames; +} + export const ModelRevision = builder.prismaNode("ModelRevision", { id: { field: "id" }, fields: (t) => ({ @@ -35,8 +60,54 @@ export const ModelRevision = builder.prismaNode("ModelRevision", { relativeValuesExports: t.relation("relativeValuesExports"), exports: t.relation("exports"), model: t.relation("model"), + + lastBuild: t.field({ + type: ModelRevisionBuild, + nullable: true, + select: { + builds: { + orderBy: { + createdAt: "desc", + }, + take: 1, + }, + }, + async resolve(revision) { + return revision.builds[0]; + }, + }), author: t.relation("author", { nullable: true }), comment: t.exposeString("comment"), + buildStatus: t.field({ + type: ModelRevisionBuildStatus, + select: { + builds: { + select: { + errors: true, + }, + orderBy: { + createdAt: "desc", + }, + take: 1, + }, + model: { + select: { + currentRevisionId: true, + }, + }, + }, + async resolve(revision) { + const lastBuild = revision.builds[0]; + + if (lastBuild) { + return lastBuild.errors.length === 0 ? "Success" : "Failure"; + } + + return revision.model.currentRevisionId === revision.id + ? "Pending" + : "Skipped"; + }, + }), content: t.field({ type: ModelContent, select: { squiggleSnippet: true }, @@ -47,6 +118,22 @@ export const ModelRevision = builder.prismaNode("ModelRevision", { } }, }), + exportNames: t.field({ + type: ["String"], + select: { squiggleSnippet: true }, + async resolve(revision) { + if (revision.contentType === "SquiggleSnippet") { + const ast = parse(revision.squiggleSnippet!.code); + if (ast.ok) { + return getExportedVariableNames(ast.value); + } else { + return []; + } + } else { + return []; + } + }, + }), forRelativeValues: t.fieldWithInput({ type: RelativeValuesExport, errors: { diff --git a/packages/hub/src/graphql/types/ModelRevisionBuild.ts b/packages/hub/src/graphql/types/ModelRevisionBuild.ts new file mode 100644 index 0000000000..aa77c3138a --- /dev/null +++ b/packages/hub/src/graphql/types/ModelRevisionBuild.ts @@ -0,0 +1,13 @@ +import { builder } from "@/graphql/builder"; + +export const ModelRevisionBuild = builder.prismaNode("ModelRevisionBuild", { + id: { field: "id" }, + fields: (t) => ({ + createdAtTimestamp: t.float({ + resolve: (build) => build.createdAt.getTime(), + }), + modelRevision: t.relation("modelRevision"), + errors: t.exposeStringList("errors"), + runSeconds: t.exposeFloat("runSeconds"), + }), +}); diff --git a/packages/hub/src/lib/ExportsDropdown.tsx b/packages/hub/src/lib/ExportsDropdown.tsx index 757b3f0e1e..d680b91132 100644 --- a/packages/hub/src/lib/ExportsDropdown.tsx +++ b/packages/hub/src/lib/ExportsDropdown.tsx @@ -12,11 +12,13 @@ import { modelExportRoute, modelForRelativeValuesExportRoute } from "@/routes"; import { exportTypeIcon } from "./typeIcon"; -type ModelExport = { +export type ModelExport = { title?: string; variableName: string; - variableType: string; + variableType?: string; + docstring?: string; }; + type RelativeValuesExport = { slug: string; variableName: string }; const nonRelativeValuesExports = ( @@ -64,7 +66,7 @@ export const ExportsDropdown: FC< variableName: exportItem.variableName, })} title={`${exportItem.title || exportItem.variableName}`} - icon={exportTypeIcon(exportItem.variableType)} + icon={exportTypeIcon(exportItem.variableType || "")} close={close} /> ))}{" "} diff --git a/packages/hub/src/scripts/buildRecentModelRevision/main.ts b/packages/hub/src/scripts/buildRecentModelRevision/main.ts new file mode 100644 index 0000000000..e44272e201 --- /dev/null +++ b/packages/hub/src/scripts/buildRecentModelRevision/main.ts @@ -0,0 +1,209 @@ +import { PrismaClient } from "@prisma/client"; +import { spawn } from "child_process"; + +import { NotFoundError } from "../../graphql/errors/NotFoundError"; +import { WorkerOutput, WorkerRunMessage } from "./worker"; + +const TIMEOUT_SECONDS = 60; // 60 seconds + +const prisma = new PrismaClient(); + +async function runWorker( + revisionId: string, + code: string, + seed: string, + timeoutSeconds: number +): Promise { + return new Promise((resolve, _) => { + console.log("Spawning worker process for Revision ID: " + revisionId); + const worker = spawn( + "tsx", + ["src/scripts/buildRecentModelRevision/worker.ts"], + { + stdio: ["pipe", "pipe", "pipe", "ipc"], + } + ); + + const timeoutId = setTimeout(() => { + worker.kill(); + resolve({ errors: `Timeout Error, at ${timeoutSeconds}s`, exports: [] }); + }, timeoutSeconds * 1000); + + worker.stdout?.on("data", (data) => { + console.log(`Worker output: ${data}`); + }); + + worker.stderr?.on("data", (data) => { + console.error(`Worker error: ${data}`); + }); + + worker.on( + "message", + async (message: { type: string; data: WorkerOutput }) => { + resolve(message.data); + } + ); + + worker.on("exit", (code) => { + clearTimeout(timeoutId); + if (code === 0) { + console.log("Worker completed successfully"); + } else { + console.error(`Worker process exited with error code ${code}`); + resolve({ + errors: "Computation error, with code: " + code, + exports: [], + }); + } + }); + + worker.send({ + type: "run", + data: { code, seed }, + } satisfies WorkerRunMessage); + }); +} + +async function oldestModelRevisionWithoutBuilds() { + const modelRevision = await prisma.modelRevision.findFirst({ + where: { + currentRevisionModel: { + isNot: null, + }, + builds: { + none: {}, + }, + contentType: "SquiggleSnippet", + }, + orderBy: { + createdAt: "asc", + }, + include: { + model: { + include: { + currentRevision: { + include: { + squiggleSnippet: true, + }, + }, + }, + }, + }, + }); + return modelRevision?.model; +} + +async function buildRecentModelVersion(): Promise { + try { + const model = await oldestModelRevisionWithoutBuilds(); + + if (!model) { + console.log("No remaining unbuilt model revisions"); + return; + } + + if (!model?.currentRevisionId || !model.currentRevision?.squiggleSnippet) { + throw new NotFoundError( + `Unexpected Error: Model revision didn't have needed information. This should never happen.` + ); + } + + const { code, seed } = model.currentRevision.squiggleSnippet; + + const startTime = performance.now(); + let response = await runWorker( + model.currentRevisionId, + code, + seed, + TIMEOUT_SECONDS + ); + const endTime = performance.now(); + + await prisma.$transaction(async (tx) => { + // For some reason, Typescript becomes unsure if `model.currentRevisionId` is null or not, even though it's checked above. + const revisionId = model.currentRevisionId!; + + await tx.modelRevisionBuild.create({ + data: { + modelRevision: { connect: { id: revisionId } }, + runSeconds: (endTime - startTime) / 1000, + errors: response.errors === undefined ? [] : [response.errors], + }, + }); + + for (const e of response.exports) { + await tx.modelExport.upsert({ + where: { + uniqueKey: { + modelRevisionId: revisionId, + variableName: e.variableName, + }, + }, + update: { + variableType: e.variableType, + title: e.title, + docstring: e.docstring, + }, + create: { + modelRevision: { connect: { id: revisionId } }, + variableName: e.variableName, + variableType: e.variableType, + title: e.title, + docstring: e.docstring, + }, + }); + } + }); + console.log( + `Build created for model revision ID: ${model.currentRevisionId}, in ${endTime - startTime}ms. Created ${response.exports.length} exports.` + ); + } catch (error) { + console.error("Error building model revision:", error); + throw error; + } +} + +async function countItemsRemaining() { + const remaining = await prisma.modelRevision.count({ + where: { + currentRevisionModel: { + isNot: null, + }, + builds: { + none: {}, + }, + }, + }); + + console.log("Model Revisions Remaining:", remaining); +} + +async function main(): Promise { + try { + await buildRecentModelVersion(); + await countItemsRemaining(); + } catch (error) { + console.error(error); + process.exit(1); + } finally { + await prisma.$disconnect(); + } +} + +async function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function runContinuously() { + while (true) { + try { + await main(); + await new Promise((resolve) => process.nextTick(resolve)); + await delay(500); // Delay for approximately .5s + } catch (error) { + console.error("An error occurred during continuous execution:", error); + } + } +} + +runContinuously(); diff --git a/packages/hub/src/scripts/buildRecentModelRevision/worker.ts b/packages/hub/src/scripts/buildRecentModelRevision/worker.ts new file mode 100644 index 0000000000..30347ed95f --- /dev/null +++ b/packages/hub/src/scripts/buildRecentModelRevision/worker.ts @@ -0,0 +1,64 @@ +import { runSquiggle } from "@/graphql/queries/runSquiggle"; +import { ModelExport } from "@/lib/ExportsDropdown"; +import { prisma } from "@/prisma"; + +export type WorkerRunMessage = { + type: "run"; + data: { + code: string; + seed: string; + }; +}; + +export type WorkerOutput = { + errors: string; + exports: ModelExport[]; +}; + +export async function runSquiggleCode( + code: string, + seed: string +): Promise { + const outputR = await runSquiggle(code, seed); + + let exports: ModelExport[] = []; + + if (outputR.ok) { + // I Imagine it would be nice to move this out of this worker file, but this would require exporting a lot more information. It seems wise to instead wait for the Serialization PR to go in and then refactor this. + exports = outputR.value.exports.entries().map((e) => ({ + variableName: e[0], + variableType: e[1].tag, + title: e[1].tags.name() ? e[1].tags.name() : e[1].title() || "", + docstring: e[1].tags.doc() || "", + })); + } + + return { + errors: outputR.ok ? "" : outputR.value.toString(), + exports, + }; +} + +process.on("message", async (message: WorkerRunMessage) => { + if (message.type === "run") { + try { + const { code, seed } = message.data; + const buildOutput = await runSquiggleCode(code, seed); + process?.send?.({ + type: "result", + data: buildOutput, + }); + } catch (error) { + console.error("An error occurred in the worker process:", error); + process?.send?.({ + type: "result", + data: { + errors: "An unknown error occurred in the worker process.", + }, + }); + } finally { + await prisma.$disconnect(); + process.exit(0); + } + } +}); diff --git a/packages/squiggle-lang/src/index.ts b/packages/squiggle-lang/src/index.ts index ee10e08adf..dc9089e744 100644 --- a/packages/squiggle-lang/src/index.ts +++ b/packages/squiggle-lang/src/index.ts @@ -89,6 +89,7 @@ export { export { type AST, type ASTNode } from "./ast/parse.js"; export { type ASTCommentNode } from "./ast/peggyHelpers.js"; export { type SqLinker } from "./public/SqLinker.js"; +export { type SqOutput, type SqOutputResult } from "./public/types.js"; export async function run( code: string, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aaf2d9a890..b4b3e03d92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -411,7 +411,7 @@ importers: devDependencies: '@graphql-codegen/cli': specifier: ^5.0.2 - version: 5.0.2(@parcel/watcher@2.4.1)(graphql@16.8.1)(typescript@5.3.3) + version: 5.0.2(@parcel/watcher@2.4.1)(@types/node@20.11.24)(graphql@16.8.1)(typescript@5.3.3) '@graphql-codegen/client-preset': specifier: ^4.1.0 version: 4.2.4(graphql@16.8.1) @@ -436,6 +436,9 @@ importers: '@types/lodash': specifier: ^4.14.202 version: 4.14.202 + '@types/node': + specifier: ^20.11.24 + version: 20.11.24 '@types/pako': specifier: ^2.0.3 version: 2.0.3 @@ -3920,7 +3923,7 @@ packages: tslib: 2.6.2 dev: true - /@graphql-codegen/cli@5.0.2(@parcel/watcher@2.4.1)(graphql@16.8.1)(typescript@5.3.3): + /@graphql-codegen/cli@5.0.2(@parcel/watcher@2.4.1)(@types/node@20.11.24)(graphql@16.8.1)(typescript@5.3.3): resolution: {integrity: sha512-MBIaFqDiLKuO4ojN6xxG9/xL9wmfD3ZjZ7RsPjwQnSHBCUXnEkdKvX+JVpx87Pq29Ycn8wTJUguXnTZ7Di0Mlw==} hasBin: true peerDependencies: @@ -3939,12 +3942,12 @@ packages: '@graphql-tools/apollo-engine-loader': 8.0.0(graphql@16.8.1) '@graphql-tools/code-file-loader': 8.0.2(graphql@16.8.1) '@graphql-tools/git-loader': 8.0.2(graphql@16.8.1) - '@graphql-tools/github-loader': 8.0.0(graphql@16.8.1) + '@graphql-tools/github-loader': 8.0.0(@types/node@20.11.24)(graphql@16.8.1) '@graphql-tools/graphql-file-loader': 8.0.0(graphql@16.8.1) '@graphql-tools/json-file-loader': 8.0.0(graphql@16.8.1) '@graphql-tools/load': 8.0.0(graphql@16.8.1) - '@graphql-tools/prisma-loader': 8.0.1(graphql@16.8.1) - '@graphql-tools/url-loader': 8.0.0(graphql@16.8.1) + '@graphql-tools/prisma-loader': 8.0.1(@types/node@20.11.24)(graphql@16.8.1) + '@graphql-tools/url-loader': 8.0.0(@types/node@20.11.24)(graphql@16.8.1) '@graphql-tools/utils': 10.0.6(graphql@16.8.1) '@parcel/watcher': 2.4.1 '@whatwg-node/fetch': 0.8.8 @@ -3953,7 +3956,7 @@ packages: debounce: 1.2.1 detect-indent: 6.1.0 graphql: 16.8.1 - graphql-config: 5.0.2(graphql@16.8.1)(typescript@5.3.3) + graphql-config: 5.0.2(@types/node@20.11.24)(graphql@16.8.1)(typescript@5.3.3) inquirer: 8.2.6 is-glob: 4.0.3 jiti: 1.20.0 @@ -4212,7 +4215,7 @@ packages: - utf-8-validate dev: true - /@graphql-tools/executor-http@1.0.2(graphql@16.8.1): + /@graphql-tools/executor-http@1.0.2(@types/node@20.11.24)(graphql@16.8.1): resolution: {integrity: sha512-JKTB4E3kdQM2/1NEcyrVPyQ8057ZVthCV5dFJiKktqY9IdmF00M8gupFcW3jlbM/Udn78ickeUBsUzA3EouqpA==} engines: {node: '>=16.0.0'} peerDependencies: @@ -4223,7 +4226,7 @@ packages: '@whatwg-node/fetch': 0.9.7 extract-files: 11.0.0 graphql: 16.8.1 - meros: 1.3.0 + meros: 1.3.0(@types/node@20.11.24) tslib: 2.6.2 value-or-promise: 1.0.12 transitivePeerDependencies: @@ -4277,14 +4280,14 @@ packages: - supports-color dev: true - /@graphql-tools/github-loader@8.0.0(graphql@16.8.1): + /@graphql-tools/github-loader@8.0.0(@types/node@20.11.24)(graphql@16.8.1): resolution: {integrity: sha512-VuroArWKcG4yaOWzV0r19ElVIV6iH6UKDQn1MXemND0xu5TzrFme0kf3U9o0YwNo0kUYEk9CyFM0BYg4he17FA==} engines: {node: '>=16.0.0'} peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 dependencies: '@ardatan/sync-fetch': 0.0.1 - '@graphql-tools/executor-http': 1.0.2(graphql@16.8.1) + '@graphql-tools/executor-http': 1.0.2(@types/node@20.11.24)(graphql@16.8.1) '@graphql-tools/graphql-tag-pluck': 8.0.2(graphql@16.8.1) '@graphql-tools/utils': 10.0.6(graphql@16.8.1) '@whatwg-node/fetch': 0.9.7 @@ -4387,13 +4390,13 @@ packages: tslib: 2.6.2 dev: true - /@graphql-tools/prisma-loader@8.0.1(graphql@16.8.1): + /@graphql-tools/prisma-loader@8.0.1(@types/node@20.11.24)(graphql@16.8.1): resolution: {integrity: sha512-bl6e5sAYe35Z6fEbgKXNrqRhXlCJYeWKBkarohgYA338/SD9eEhXtg3Cedj7fut3WyRLoQFpHzfiwxKs7XrgXg==} engines: {node: '>=16.0.0'} peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 dependencies: - '@graphql-tools/url-loader': 8.0.0(graphql@16.8.1) + '@graphql-tools/url-loader': 8.0.0(@types/node@20.11.24)(graphql@16.8.1) '@graphql-tools/utils': 10.0.6(graphql@16.8.1) '@types/js-yaml': 4.0.5 '@types/json-stable-stringify': 1.0.34 @@ -4447,7 +4450,7 @@ packages: tslib: 2.6.2 value-or-promise: 1.0.12 - /@graphql-tools/url-loader@8.0.0(graphql@16.8.1): + /@graphql-tools/url-loader@8.0.0(@types/node@20.11.24)(graphql@16.8.1): resolution: {integrity: sha512-rPc9oDzMnycvz+X+wrN3PLrhMBQkG4+sd8EzaFN6dypcssiefgWKToXtRKI8HHK68n2xEq1PyrOpkjHFJB+GwA==} engines: {node: '>=16.0.0'} peerDependencies: @@ -4456,7 +4459,7 @@ packages: '@ardatan/sync-fetch': 0.0.1 '@graphql-tools/delegate': 10.0.3(graphql@16.8.1) '@graphql-tools/executor-graphql-ws': 1.1.0(graphql@16.8.1) - '@graphql-tools/executor-http': 1.0.2(graphql@16.8.1) + '@graphql-tools/executor-http': 1.0.2(@types/node@20.11.24)(graphql@16.8.1) '@graphql-tools/executor-legacy-ws': 1.0.3(graphql@16.8.1) '@graphql-tools/utils': 10.0.6(graphql@16.8.1) '@graphql-tools/wrap': 10.0.1(graphql@16.8.1) @@ -12593,7 +12596,7 @@ packages: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} dev: true - /graphql-config@5.0.2(graphql@16.8.1)(typescript@5.3.3): + /graphql-config@5.0.2(@types/node@20.11.24)(graphql@16.8.1)(typescript@5.3.3): resolution: {integrity: sha512-7TPxOrlbiG0JplSZYCyxn2XQtqVhXomEjXUmWJVSS5ET1nPhOJSsIb/WTwqWhcYX6G0RlHXSj9PLtGTKmxLNGg==} engines: {node: '>= 16.0.0'} peerDependencies: @@ -12607,7 +12610,7 @@ packages: '@graphql-tools/json-file-loader': 8.0.0(graphql@16.8.1) '@graphql-tools/load': 8.0.0(graphql@16.8.1) '@graphql-tools/merge': 9.0.0(graphql@16.8.1) - '@graphql-tools/url-loader': 8.0.0(graphql@16.8.1) + '@graphql-tools/url-loader': 8.0.0(@types/node@20.11.24)(graphql@16.8.1) '@graphql-tools/utils': 10.0.6(graphql@16.8.1) cosmiconfig: 8.3.6(typescript@5.3.3) graphql: 16.8.1 @@ -15350,7 +15353,7 @@ packages: - supports-color dev: false - /meros@1.3.0: + /meros@1.3.0(@types/node@20.11.24): resolution: {integrity: sha512-2BNGOimxEz5hmjUG2FwoxCt5HN7BXdaWyFqEwxPTrJzVdABtrL4TiHTcsWSFAxPQ/tOnEaQEJh3qWq71QRMY+w==} engines: {node: '>=13'} peerDependencies: @@ -15358,6 +15361,8 @@ packages: peerDependenciesMeta: '@types/node': optional: true + dependencies: + '@types/node': 20.11.24 dev: true /methods@1.1.2: