mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-08-07 01:13:05 +02:00
314 lines
8.6 KiB
TypeScript
314 lines
8.6 KiB
TypeScript
import { getAuthUserId } from '@convex-dev/auth/server'
|
|
import { v } from 'convex/values'
|
|
import { api } from './_generated/api'
|
|
import type { Id } from './_generated/dataModel'
|
|
import { internalMutation, mutation, query } from './_generated/server'
|
|
import modulesData from './modules.json'
|
|
|
|
type BuildUpdateData = {
|
|
status: string
|
|
completedAt?: number
|
|
}
|
|
|
|
/**
|
|
* Computes a stable SHA-256 hash from version, target, and flags.
|
|
* This hash uniquely identifies a build configuration based on what is actually executed.
|
|
*/
|
|
async function computeBuildHash(
|
|
version: string,
|
|
target: string,
|
|
flags: string
|
|
): Promise<string> {
|
|
// Input is now the exact parameters used for the build
|
|
const input = JSON.stringify({
|
|
version,
|
|
target,
|
|
flags,
|
|
})
|
|
|
|
// Use Web Crypto API for SHA-256 hashing
|
|
const encoder = new TextEncoder()
|
|
const data = encoder.encode(input)
|
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
|
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
|
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
|
|
return hashHex
|
|
}
|
|
|
|
/**
|
|
* Constructs the R2 artifact URL from a build.
|
|
* Uses artifactPath if available, otherwise falls back to buildHash.uf2
|
|
* Or custom domain if R2_PUBLIC_URL is set.
|
|
*/
|
|
export function getR2ArtifactUrl(build: {
|
|
buildHash: string
|
|
artifactPath?: string
|
|
}): string {
|
|
const r2PublicUrl = process.env.R2_PUBLIC_URL
|
|
if (!r2PublicUrl) {
|
|
throw new Error('R2_PUBLIC_URL is not set')
|
|
}
|
|
const path = build.artifactPath || `/${build.buildHash}.uf2`
|
|
// Ensure path starts with /
|
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
|
return `${r2PublicUrl}${normalizedPath}`
|
|
}
|
|
|
|
export const triggerBuildViaProfile = mutation({
|
|
args: {
|
|
profileId: v.id('profiles'),
|
|
target: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const profile = await ctx.db.get(args.profileId)
|
|
if (!profile) {
|
|
throw new Error('Profile not found')
|
|
}
|
|
|
|
// Convert config object to flags string
|
|
const flags: string[] = []
|
|
|
|
// Handle Modules (Inverted Logic: Default Excluded)
|
|
for (const module of modulesData.modules) {
|
|
// If config[id] is NOT false (explicitly included), we exclude it.
|
|
if (profile.config[module.id] !== false) {
|
|
flags.push(`-D${module.id}=1`)
|
|
}
|
|
}
|
|
|
|
const flagsString = flags.join(' ')
|
|
|
|
// Compute build hash
|
|
const buildHash = await computeBuildHash(
|
|
profile.version,
|
|
args.target,
|
|
flagsString
|
|
)
|
|
|
|
// Check if build already exists with this hash
|
|
let existingBuild = await ctx.db
|
|
.query('builds')
|
|
.withIndex('by_hash', (q) => q.eq('buildHash', buildHash))
|
|
.first()
|
|
|
|
let buildId: Id<'builds'>
|
|
let shouldDispatch = false
|
|
|
|
if (existingBuild) {
|
|
// Build already exists, use it
|
|
buildId = existingBuild._id
|
|
} else {
|
|
// Create new build and dispatch workflow
|
|
buildId = await ctx.db.insert('builds', {
|
|
target: args.target,
|
|
githubRunId: 0,
|
|
status: 'queued',
|
|
startedAt: Date.now(),
|
|
buildHash: buildHash,
|
|
})
|
|
shouldDispatch = true
|
|
|
|
// Handle race condition
|
|
existingBuild = await ctx.db
|
|
.query('builds')
|
|
.withIndex('by_hash', (q) => q.eq('buildHash', buildHash))
|
|
.first()
|
|
|
|
if (existingBuild && existingBuild._id !== buildId) {
|
|
await ctx.db.delete(buildId)
|
|
buildId = existingBuild._id
|
|
shouldDispatch = false
|
|
}
|
|
}
|
|
|
|
// Create or update profileBuild record
|
|
// Check if a profileBuild already exists for this profile+target combination
|
|
const profileBuilds = await ctx.db
|
|
.query('profileBuilds')
|
|
.withIndex('by_profile', (q) => q.eq('profileId', args.profileId))
|
|
.collect()
|
|
|
|
// Find existing profileBuild with matching target by checking the build
|
|
let foundExisting = null
|
|
for (const pb of profileBuilds) {
|
|
const build = await ctx.db.get(pb.buildId)
|
|
if (build?.target === args.target) {
|
|
foundExisting = pb
|
|
break
|
|
}
|
|
}
|
|
|
|
if (foundExisting) {
|
|
await ctx.db.patch(foundExisting._id, {
|
|
buildId: buildId,
|
|
})
|
|
} else {
|
|
await ctx.db.insert('profileBuilds', {
|
|
profileId: args.profileId,
|
|
buildId: buildId,
|
|
})
|
|
}
|
|
|
|
// Dispatch GitHub workflow if needed
|
|
if (shouldDispatch) {
|
|
await ctx.scheduler.runAfter(0, api.actions.dispatchGithubBuild, {
|
|
buildId: buildId,
|
|
target: args.target,
|
|
flags: flagsString,
|
|
version: profile.version,
|
|
buildHash: buildHash,
|
|
})
|
|
}
|
|
|
|
return buildId
|
|
},
|
|
})
|
|
|
|
export const listByProfile = query({
|
|
args: { profileId: v.id('profiles') },
|
|
handler: async (ctx, args) => {
|
|
// Query profileBuilds for this profile
|
|
const profileBuilds = await ctx.db
|
|
.query('profileBuilds')
|
|
.withIndex('by_profile', (q) => q.eq('profileId', args.profileId))
|
|
.collect()
|
|
|
|
// Get builds for each profileBuild
|
|
const builds = await Promise.all(
|
|
profileBuilds.map(async (pb) => {
|
|
const build = await ctx.db.get(pb.buildId)
|
|
if (!build) return null
|
|
// Return build with computed artifactUrl
|
|
return {
|
|
...build,
|
|
artifactUrl: getR2ArtifactUrl(build),
|
|
}
|
|
})
|
|
)
|
|
|
|
// Filter out nulls and sort by startedAt descending
|
|
return builds
|
|
.filter((b): b is NonNullable<typeof b> => b !== null)
|
|
.sort((a, b) => b.startedAt - a.startedAt)
|
|
.slice(0, 10)
|
|
},
|
|
})
|
|
|
|
export const get = query({
|
|
args: { buildId: v.id('builds') },
|
|
handler: async (ctx, args) => {
|
|
const userId = await getAuthUserId(ctx)
|
|
if (!userId) return null
|
|
|
|
const build = await ctx.db.get(args.buildId)
|
|
if (!build) return null
|
|
|
|
// Check if user has access via profileBuilds
|
|
// Get all profileBuilds for this build
|
|
const profileBuilds = await ctx.db
|
|
.query('profileBuilds')
|
|
.withIndex('by_build', (q) => q.eq('buildId', args.buildId))
|
|
.collect()
|
|
|
|
// Check if any of these profileBuilds link to a profile owned by the user
|
|
for (const pb of profileBuilds) {
|
|
const profile = await ctx.db.get(pb.profileId)
|
|
if (profile && profile.userId === userId) {
|
|
// Return build with computed artifactUrl
|
|
return {
|
|
...build,
|
|
artifactUrl: getR2ArtifactUrl(build),
|
|
}
|
|
}
|
|
}
|
|
|
|
return null
|
|
},
|
|
})
|
|
|
|
// Internal query to get build without auth checks (for webhooks)
|
|
export const getInternal = internalMutation({
|
|
args: { buildId: v.id('builds') },
|
|
handler: async (ctx, args) => {
|
|
return await ctx.db.get(args.buildId)
|
|
},
|
|
})
|
|
|
|
export const deleteBuild = mutation({
|
|
args: {
|
|
buildId: v.id('builds'),
|
|
profileId: v.id('profiles'),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const userId = await getAuthUserId(ctx)
|
|
if (!userId) throw new Error('Unauthorized')
|
|
|
|
// Verify profile belongs to user
|
|
const profile = await ctx.db.get(args.profileId)
|
|
if (!profile || profile.userId !== userId) {
|
|
throw new Error('Unauthorized')
|
|
}
|
|
|
|
// Find profileBuild linking this profile to this build
|
|
const profileBuilds = await ctx.db
|
|
.query('profileBuilds')
|
|
.withIndex('by_build', (q) => q.eq('buildId', args.buildId))
|
|
.collect()
|
|
|
|
const profileBuild = profileBuilds.find(
|
|
(pb) => pb.profileId === args.profileId
|
|
)
|
|
|
|
if (!profileBuild) {
|
|
throw new Error('ProfileBuild not found')
|
|
}
|
|
|
|
// Delete only the profileBuild record (not the underlying build)
|
|
await ctx.db.delete(profileBuild._id)
|
|
},
|
|
})
|
|
|
|
// Internal mutation to log errors from actions
|
|
export const logBuildError = internalMutation({
|
|
args: {
|
|
buildId: v.id('builds'),
|
|
error: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
await ctx.db.patch(args.buildId, {
|
|
status: 'failure',
|
|
completedAt: Date.now(),
|
|
})
|
|
},
|
|
})
|
|
|
|
// Internal mutation to update build status
|
|
export const updateBuildStatus = internalMutation({
|
|
args: {
|
|
buildId: v.id('builds'),
|
|
status: v.string(), // Accepts any status string value
|
|
artifactPath: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const build = await ctx.db.get(args.buildId)
|
|
if (!build) return
|
|
|
|
const updateData: BuildUpdateData & { artifactPath?: string } = {
|
|
status: args.status,
|
|
}
|
|
|
|
// Only set completedAt for final statuses
|
|
if (args.status === 'success' || args.status === 'failure') {
|
|
updateData.completedAt = Date.now()
|
|
}
|
|
|
|
// Set artifactPath if provided
|
|
if (args.artifactPath !== undefined) {
|
|
updateData.artifactPath = args.artifactPath
|
|
}
|
|
|
|
await ctx.db.patch(args.buildId, updateData)
|
|
},
|
|
})
|