Files
mesh-forge/convex/builds.ts

420 lines
13 KiB
TypeScript

import { pick } from "convex-helpers"
import { v } from "convex/values"
import { api, internal } from "./_generated/api"
import type { Doc, Id } from "./_generated/dataModel"
import { internalMutation, mutation, query } from "./_generated/server"
import { ArtifactType, getArtifactFilenameBase } from "./lib/filename"
import { computeFlagsFromConfig } from "./lib/flags"
import { generateSignedDownloadUrl } from "./lib/r2"
import { buildFields } from "./schema"
type BuildUpdateData = {
status: string
completedAt?: number
updatedAt?: number
}
export const get = query({
args: { id: v.id("builds") },
handler: async (ctx, args) => {
return await ctx.db.get(args.id)
},
})
export const getByHash = query({
args: { buildHash: v.string() },
handler: async (ctx, args) => {
const build = await ctx.db
.query("builds")
.withIndex("by_buildHash", q => q.eq("buildHash", args.buildHash))
.unique()
return build ?? null
},
})
// Re-export for backward compatibility
export { computeFlagsFromConfig } from "./lib/flags"
/**
* Encodes a byte array to base62 string.
* Uses characters: 0-9, a-z, A-Z (62 characters total)
*/
function base62Encode(bytes: Uint8Array): string {
const chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
// Convert bytes to a big-endian number
let num = BigInt(0)
for (let i = 0; i < bytes.length; i++) {
num = num * BigInt(256) + BigInt(bytes[i])
}
// Convert number to base62
if (num === BigInt(0)) return "0"
const result: string[] = []
while (num > BigInt(0)) {
result.push(chars[Number(num % BigInt(62))])
num = num / BigInt(62)
}
return result.reverse().join("")
}
/**
* Computes a stable SHA-256 hash from version, target, flags, and plugins.
* Internal helper for hash computation.
*/
async function computeBuildHashInternal(
version: string,
target: string,
flags: string,
plugins: string[],
pluginConfig?: Record<string, Record<string, boolean>>
): Promise<string> {
// Input is now the exact parameters used for the build
// Sort plugins array for consistent hashing
const sortedPlugins = [...plugins].sort()
// Sort plugin config for consistent hashing
const sortedPluginConfig = pluginConfig
? Object.keys(pluginConfig)
.sort()
.reduce(
(acc, pluginSlug) => {
const sortedOptions = Object.keys(pluginConfig[pluginSlug])
.sort()
.reduce(
(opts, optKey) => {
opts[optKey] = pluginConfig[pluginSlug][optKey]
return opts
},
{} as Record<string, boolean>
)
acc[pluginSlug] = sortedOptions
return acc
},
{} as Record<string, Record<string, boolean>>
)
: undefined
const input = JSON.stringify({
version,
target,
flags,
plugins: sortedPlugins,
pluginConfig: sortedPluginConfig,
})
// 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 hashBytes = new Uint8Array(hashBuffer)
// Encode to base62 instead of hex
return base62Encode(hashBytes)
}
/**
* Computes buildHash from build config.
* This is the single source of truth for build hash computation.
*/
export async function computeBuildHash(
config: Doc<"builds">["config"],
registryData?: Record<string, { configOptions?: Record<string, { define: string }> }>
): Promise<{ hash: string; flags: string }> {
const flags = computeFlagsFromConfig(config, registryData)
const plugins = config.pluginsEnabled ?? []
const hash = await computeBuildHashInternal(config.version, config.target, flags, plugins, config.pluginConfigs)
return { hash, flags }
}
/**
* Constructs the R2 artifact URL from a build.
* Uses {artifactType}-<buildHash>-<githubRunId>.tar.gz format.
*/
export function getR2ArtifactUrl(
build: Pick<Doc<"builds">, "buildHash" | "githubRunId">,
artifactType: ArtifactType
): string {
const r2PublicUrl = process.env.R2_PUBLIC_URL
if (!r2PublicUrl) {
throw new Error("R2_PUBLIC_URL is not set")
}
if (!build.githubRunId) {
throw new Error("githubRunId is required to construct artifact URL")
}
const artifactTypeStr = artifactType === ArtifactType.Source ? "source" : "firmware"
const path = `/${artifactTypeStr}-${build.buildHash}-${build.githubRunId}.tar.gz`
return `${r2PublicUrl}${path}`
}
// Internal mutation to upsert a build by buildHash
// This is the single source of truth for build creation
export const upsertBuild = internalMutation({
args: {
...pick(buildFields, ["buildHash", "config"]),
status: v.optional(v.string()),
flags: v.string(),
},
handler: async (ctx, args) => {
// Check if build already exists with this hash
const existingBuild = await ctx.db
.query("builds")
.withIndex("by_buildHash", q => q.eq("buildHash", args.buildHash))
.unique()
const { status, buildHash, config, flags } = args
if (existingBuild) {
await ctx.db.patch(existingBuild._id, {
status: status ?? existingBuild.status,
updatedAt: Date.now(),
})
return existingBuild._id
}
// Create new build (artifact paths are omitted, will be undefined)
const buildId = await ctx.db.insert("builds", {
status: "queued",
startedAt: Date.now(),
buildHash,
updatedAt: Date.now(),
config,
})
// Dispatch GitHub workflow if needed
await ctx.scheduler.runAfter(0, api.actions.dispatchGithubBuild, {
target: config.target,
version: config.version,
buildId,
flags,
buildHash,
plugins: config.pluginsEnabled ?? [],
})
return buildId
},
})
export const ensureBuildFromConfig = mutation({
args: {
target: v.string(),
version: v.string(),
modulesExcluded: v.optional(v.record(v.string(), v.boolean())),
pluginsEnabled: v.optional(v.array(v.string())),
pluginConfigs: v.optional(v.record(v.string(), v.record(v.string(), v.boolean()))),
registryData: v.optional(v.any()),
profileName: v.optional(v.string()),
profileDescription: v.optional(v.string()),
},
handler: async (ctx, args) => {
// Construct config for the build
const config: Doc<"builds">["config"] = {
version: args.version,
modulesExcluded: args.modulesExcluded ?? {},
target: args.target,
pluginsEnabled: args.pluginsEnabled,
pluginConfigs: args.pluginConfigs,
}
// Compute build hash (single source of truth)
// Registry data is optional - diagnostics works for all plugins without registry lookup
const registryData = args.registryData as
| Record<string, { configOptions?: Record<string, { define: string }> }>
| undefined
const { hash: buildHash, flags } = await computeBuildHash(config, registryData)
const existingBuild = await ctx.db
.query("builds")
.withIndex("by_buildHash", q => q.eq("buildHash", buildHash))
.unique()
if (existingBuild) {
return {
buildId: existingBuild._id,
existed: true,
buildHash,
}
}
const buildId: Id<"builds"> = await ctx.runMutation(internal.builds.upsertBuild, {
buildHash,
flags,
config,
})
return {
buildId,
existed: false,
buildHash,
}
},
})
// 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)
},
})
// 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: {
...pick(buildFields, ["status", "completedAt", "githubRunId", "firmwarePath", "sourcePath"]),
buildId: v.id("builds"),
},
handler: async (ctx, args) => {
const build = await ctx.db.get(args.buildId)
if (!build) return
const updateData: BuildUpdateData & {
githubRunId?: number
githubRunIdHistory?: number[]
firmwarePath?: string
sourcePath?: string
} = {
status: args.status,
updatedAt: Date.now(),
}
// Only set completedAt for final statuses
if (args.status === "success" || args.status === "failure") {
updateData.completedAt = Date.now()
}
// Clear artifact paths when build starts (queued status)
if (args.status === "queued") {
updateData.firmwarePath = undefined
updateData.sourcePath = undefined
}
// Set firmwarePath if provided
if (args.firmwarePath !== undefined) {
updateData.firmwarePath = args.firmwarePath
}
// Set sourcePath if provided
if (args.sourcePath !== undefined) {
updateData.sourcePath = args.sourcePath
}
// Set githubRunId if provided
// When a new run ID comes in, move the previous one to history
const existingHistory = [...new Set(build.githubRunIdHistory || [])]
if (args.githubRunId !== undefined) {
const existingRunId = build.githubRunId
// Only update if the run ID is actually changing
if (existingRunId !== undefined && existingRunId !== args.githubRunId) {
// Prepend existing run ID to history array, avoiding duplicates
existingHistory.unshift(existingRunId)
// Clear artifact paths when a new run starts
updateData.firmwarePath = undefined
updateData.sourcePath = undefined
}
updateData.githubRunId = args.githubRunId
}
updateData.githubRunIdHistory = [...new Set(existingHistory)].filter(id => id !== args.githubRunId)
await ctx.db.patch(args.buildId, updateData)
},
})
export const generateDownloadUrl = mutation({
args: {
buildId: v.id("builds"),
artifactType: v.union(v.literal("firmware"), v.literal("source")),
profileId: v.optional(v.id("profiles")),
},
handler: async (ctx, args) => {
const build = await ctx.db.get(args.buildId)
if (!build) throw new Error("Build not found")
if (!build.githubRunId) {
throw new Error("Build githubRunId is required for download")
}
const artifactTypeEnum = args.artifactType === "source" ? ArtifactType.Source : ArtifactType.Firmware
const isSource = artifactTypeEnum === ArtifactType.Source
const artifactTypeStr = artifactTypeEnum === ArtifactType.Source ? "source" : "firmware"
const contentType = isSource ? "application/gzip" : "application/octet-stream"
// Use stored path if available, otherwise construct from buildHash and githubRunId
const storedPath = isSource ? build.sourcePath : build.firmwarePath
const objectKey = storedPath
? storedPath.startsWith("/")
? storedPath.slice(1)
: storedPath
: `${artifactTypeStr}-${build.buildHash}-${build.githubRunId}.tar.gz`
// Fetch profile if profileId is provided
const profile = await (async () => {
if (!args.profileId) return
const profileDoc = await ctx.db.get(args.profileId)
if (!profileDoc) throw new Error("Profile not found")
return profileDoc
})()
// Slugify profile name for filename (if authenticated)
const profileSlug = profile
? profile.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)+/g, "")
: ""
// Increment profile flash count for firmware downloads
if (profile && !isSource) {
const nextCount = (profile.flashCount ?? 0) + 1
await ctx.db.patch(profile._id, {
flashCount: nextCount,
updatedAt: Date.now(),
})
}
// Increment plugin flash counts for firmware downloads (independent of profile)
if (!isSource && build.config.pluginsEnabled && build.config.pluginsEnabled.length > 0) {
await ctx.runMutation(internal.plugins.incrementFlashCount, {
slugs: build.config.pluginsEnabled,
})
}
// Generate base filename using shared utility function
const filenameBase = getArtifactFilenameBase(
build.config.version,
build.config.target,
build.buildHash,
build.githubRunId,
artifactTypeStr as "source" | "firmware"
)
// Add profile slug if present (inserted between version and target)
// Format: {os}-{version}-{profileSlug}-{target}-{last4hash}-{jobId}-{assetType}.tar.gz
const filename = profileSlug
? filenameBase.replace(
`meshtastic-${build.config.version}-`,
`meshtastic-${build.config.version}-${profileSlug}-`
) + ".tar.gz"
: filenameBase + ".tar.gz"
return await generateSignedDownloadUrl(objectKey, filename, contentType)
},
})