fix(preview): Status bleibt bei Runner-Builds, dev-Label, Abgleich mit dem Runner, Längenkürzung

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-09-30 21:54:01 +00:00
1 parent 19cd8f90ce
commit b7cbe3aa38
8 files changed
+396 -15

No files matched your search

+7
View File
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
import { config, assertServerConfig, WOMS_DATABASE_ID } from './config.js'
import { verifyDatabaseAccess } from './services/appwriteAdmin.js'
import { syncPreviewTlsOnStartup } from './services/previewDeploy.js'
import { runReconcile } from './services/previewReconcile.js'
import { requireSession, sessionMiddleware } from './middleware/session.js'
import authRoutes from './routes/auth.js'
import projectsRoutes from './routes/projects.js'
@@ -172,6 +173,12 @@ const server = app.listen(config.port, '0.0.0.0', () => {
)
setTimeout(repoAssign, 60 * 1000).unref()
setInterval(repoAssign, 10 * 60 * 1000).unref()
// Preview-Runner: Zustaende mit dem Runner abgleichen (alle 2 Minuten, nur wenn eingeschaltet)
if (config.previewRunner.enabled) {
setTimeout(runReconcile, 30 * 1000).unref()
setInterval(runReconcile, 2 * 60 * 1000).unref()
}
})
server.on('error', (err) => {
+4 -1
View File
@@ -6,6 +6,7 @@ import {
fetchPreviewConfig,
defaultPreviewConfig,
runDeploy,
deployKeepsStatus,
buildPreviewUrl,
} from '../../services/previewDeploy.js'
import { ensureRepoPreviewConfig } from '../../services/giteaAdmin.js'
@@ -143,7 +144,9 @@ router.post('/gitea', async (req, res) => {
if (subdomain) projectPayload.subdomain = subdomain
if (previewUrl) projectPayload.previewUrl = previewUrl
if (deployResult.deployed) {
if (deployKeepsStatus(deployResult) && hasPreview) {
// Preview-Runner (C1): Status/Bereitschaft bleiben unveraendert, der Verfolger setzt sie
} else if (deployResult.deployed) {
projectPayload.status = 'deployed'
projectPayload.provisioningStatus = 'ready'
} else if (hasPreview) {
+2
View File
@@ -263,6 +263,8 @@ export async function upsertWebsiteProjectByRepo(repoFullName, data) {
...payload,
customerId: payload.customerId ?? '',
ticketId: payload.ticketId ?? '',
status: payload.status ?? 'pending',
provisioningStatus: payload.provisioningStatus ?? 'pending',
projectName: payload.projectName || repoFullName.split('/').pop() || repoFullName,
createdAt: now,
documentId: ID.unique(),
+17 -8
View File
@@ -8,6 +8,7 @@ import {
} from './appwriteAdmin.js'
import {
runDeploy,
deployKeepsStatus,
buildPreviewUrl,
removeDeploy,
syncPreviewTls,
@@ -334,10 +335,14 @@ export async function createProjectFromTemplate({
subdomain: safeSubdomain,
previewUrl,
projectType: hasWebsite ? 'website' : 'repo',
status: hasWebsite ? (deployResult.deployed ? 'deployed' : 'pending') : 'created',
provisioningStatus: hasWebsite
? (deployResult.deployed ? 'ready' : 'deploy_failed')
: 'ready',
...(hasWebsite && deployKeepsStatus(deployResult)
? {} // Preview-Runner (C1): Status setzt der Verfolger
: {
status: hasWebsite ? (deployResult.deployed ? 'deployed' : 'pending') : 'created',
provisioningStatus: hasWebsite
? (deployResult.deployed ? 'ready' : 'deploy_failed')
: 'ready',
}),
})
return {
@@ -494,10 +499,14 @@ export async function updateWebsiteProject(projectId, { projectName, subdomain,
projectType: nextType,
isPublic,
isTemplate,
status: hasWebsite ? (deployResult.deployed ? 'deployed' : 'pending') : 'created',
provisioningStatus: hasWebsite
? (deployResult.deployed ? 'ready' : (deployResult.error ? 'deploy_failed' : 'pending'))
: 'ready',
...(hasWebsite && deployKeepsStatus(deployResult)
? {} // Preview-Runner (C1): Status setzt der Verfolger
: {
status: hasWebsite ? (deployResult.deployed ? 'deployed' : 'pending') : 'created',
provisioningStatus: hasWebsite
? (deployResult.deployed ? 'ready' : (deployResult.error ? 'deploy_failed' : 'pending'))
: 'ready',
}),
updatedAt: new Date().toISOString(),
})
+31 -3
View File
@@ -84,7 +84,7 @@ export function devSubdomainsFromHostsFile(content) {
async function getDevProjects() {
try {
const docs = await listDocuments(config.collections.websiteProjects, [
Query.equal('previewState', ['sleeping', 'starting', 'online']),
Query.equal('previewState', ['sleeping', 'starting', 'online', 'building', 'failed']),
Query.limit(500),
])
return docs.filter(
@@ -463,7 +463,7 @@ export async function waitForDevRegistration() {
let pendingProjects = null
async function registerDevRound(projects) {
export async function registerDevRound(projects) {
const key = envKey()
await Promise.allSettled(
projects.map(async (project) => {
@@ -541,14 +541,27 @@ const SHA_RE = /^[0-9a-f]{7,40}$/
const KEY_MISSING = 'Preview-Variablen nicht entschlüsselbar (PREVIEW_ENV_KEY fehlt)'
const KEY_BROKEN = 'Preview-Variablen nicht entschlüsselbar'
/** Gibt es fuer das Repo einen laufenden Verfolger in diesem Prozess? (Abgleich, R18) */
export const hasLiveFollower = (repoFullName) => latestRun.has(repoFullName)
/** Wartet auf alle laufenden Hintergrund-Verfolger (Tests, geordnetes Beenden). */
export async function waitForRunFollowers() {
while (followers.size) await Promise.allSettled([...followers])
}
/**
* C1: Mit aktivem Runner liefert runDeploy nur "queued"/"skipped"/Fehler des Auftrags - das ist
* kein Deploy-Ergebnis. Aufrufer duerfen dann status/provisioningStatus NICHT anfassen
* (sonst sperrt auth.js "not_ready" ein bereits bereites Projekt aus). Der Verfolger setzt
* bei Erfolg 'deployed'/'ready'.
*/
export function deployKeepsStatus(deployResult) {
return Boolean(config.previewRunner.enabled && deployResult && !deployResult.deployed)
}
// previewDetected: static | build:<pm> | dev:<pm> (der Runner liefert kein Framework)
export function detectedLabel(detected, result) {
const kind = detected?.kind || result
const kind = result || detected?.kind
if (kind === 'static') return 'static'
const pm = detected?.pm || 'npm'
return kind === 'dev' ? `dev:${pm}` : `build:${pm}`
@@ -567,7 +580,19 @@ async function findProject(repoFullName) {
}
}
// Attributgroessen (Appwrite lehnt laengere Werte ab und der Patch ginge verloren)
const ATTR_MAX = { previewError: 500, previewDetected: 40, previewBranch: 100 }
function clampAttrs(data) {
const out = { ...data }
for (const [key, max] of Object.entries(ATTR_MAX)) {
if (typeof out[key] === 'string' && out[key].length > max) out[key] = out[key].slice(0, max)
}
return out
}
async function patchProject(repoFullName, project, data) {
data = clampAttrs(data)
const doc = project?.$id ? project : await findProject(repoFullName)
if (!doc?.$id) return null
try {
@@ -630,6 +655,9 @@ async function followRun({ repoFullName, project, token, runId, subdomain, job,
previewError: warning || '',
previewBranch: job.branch,
previewBuiltSha: run.sha || job.sha,
// Wie der Legacy-Erfolg: Projekt ist bereit (C1). Ein Fehlschlag senkt nie herab.
status: 'deployed',
provisioningStatus: 'ready',
}
if (run.result === 'dev') {
await patchProject(repoFullName, project, { ...base, previewState: 'sleeping' })
+77
View File
@@ -0,0 +1,77 @@
// R18: Abgleich der Preview-Zustaende mit dem Runner (alle 2 Minuten, nur mit PREVIEW_RUNNER=on).
// (a) 'building' ohne laufenden Verfolger und aelter als 70 Minuten => 'failed'
// (b) Dev-Projekte: Zustand laut Runner (laeuft => online/starting, sonst sleeping); Modus 'off' bleibt unberuehrt
// (c) Dev-Spezifikationen, die der Runner nicht kennt (z. B. nach Neustart), erneut hinterlegen
// Wirft nie: Fehler werden protokolliert und im Ergebnis gemeldet.
import { config } from '../config.js'
import { listDocuments, updateDocument, Query } from './appwriteAdmin.js'
import * as runnerClient from './previewRunnerClient.js'
import { hasLiveFollower, registerDevRound } from './previewDeploy.js'
export const STALE_BUILD_MS = 70 * 60 * 1000
const STALE_TEXT = 'Status unbekannt – bitte neu bauen'
const isDev = (d) => String(d.previewDetected || '').startsWith('dev')
export async function reconcilePreviews({ now = Date.now() } = {}) {
if (!config.previewRunner.enabled) return { skipped: true }
const result = { staleFailed: 0, stateUpdated: 0, reregistered: 0 }
let docs
try {
docs = await listDocuments(config.collections.websiteProjects, [
Query.equal('previewState', ['building', 'sleeping', 'starting', 'online']),
Query.limit(500),
])
} catch (err) {
console.error('[previewReconcile] Projekte nicht lesbar:', err.message)
return { ...result, error: err.message }
}
const patch = async (doc, data) => {
try { await updateDocument(config.collections.websiteProjects, doc.$id, data); return true } catch (err) {
console.error('[previewReconcile] Update fehlgeschlagen:', err.message)
return false
}
}
// (a)
for (const doc of docs) {
if (doc.previewState !== 'building' || !doc.repoFullName) continue
if (hasLiveFollower(doc.repoFullName)) continue
const since = Date.parse(doc.$updatedAt || doc.$createdAt || '')
if (!Number.isFinite(since) || now - since <= STALE_BUILD_MS) continue
if (await patch(doc, { previewState: 'failed', previewError: STALE_TEXT })) result.staleFailed++
}
// (b) + (c)
const devDocs = docs.filter((d) => isDev(d) && d.previewMode !== 'off' && d.previewState !== 'building' && d.subdomain)
if (!devDocs.length) return result
let st
try { st = await runnerClient.status() } catch (err) {
console.error('[previewReconcile] Runner-Status nicht abrufbar:', err.message)
return { ...result, error: err.message }
}
const entries = new Map((st?.dev || []).map((e) => [e.sub, e]))
const known = new Set([...(st?.known || []), ...entries.keys()])
const unknown = []
for (const doc of devDocs) {
const entry = entries.get(doc.subdomain)
const want = entry?.state === 'online' ? 'online' : entry?.state === 'starting' ? 'starting' : 'sleeping'
if (doc.previewState !== want && (await patch(doc, { previewState: want }))) result.stateUpdated++
if (!known.has(doc.subdomain)) unknown.push(doc)
}
if (unknown.length) {
try { await registerDevRound(unknown); result.reregistered = unknown.length } catch (err) {
console.error('[previewReconcile] Registrierung fehlgeschlagen:', err.message)
}
}
return result
}
/** Fuer setInterval: nie werfen. */
export function runReconcile() {
return reconcilePreviews().catch((err) => {
console.error('[previewReconcile] Lauf fehlgeschlagen:', err.message)
return { error: err.message }
})
}