mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-03-28 17:42:55 +01:00
420 lines
13 KiB
TypeScript
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)
|
|
},
|
|
})
|