Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
448 lines
27 KiB
JavaScript
448 lines
27 KiB
JavaScript
const fs = require('node:fs')
|
|
const path = require('node:path')
|
|
const { execFileSync } = require('node:child_process')
|
|
const { parseEnvironment } = require('./workbench_acceptance_runtime.cjs')
|
|
const { preserveFrontendAssets } = require('./preserve_frontend_assets.cjs')
|
|
const { stagePublicAPIContext } = require('./stage_public_api_context.cjs')
|
|
|
|
const root = path.resolve(__dirname, '../..')
|
|
const database = 'yawan_native_acceptance_20260915'
|
|
const apiName = 'yawan-native-acceptance'
|
|
const network = 'weknora_WeKnora-network'
|
|
const publicVerificationReports = ['public-release-smoke', 'public-navigation', 'public-access-control', 'public-resource-isolation', 'public-resource-files', 'public-tenant-isolation', 'public-indexed-retrieval-isolation']
|
|
const fixtureReconciliationCounts = ['active_users', 'active_memberships', 'roles', 'assignments', 'grants',
|
|
'unrevoked_tokens', 'active_agents', 'active_skills', 'active_digital_humans', 'workbench_resources',
|
|
'active_sessions', 'synthetic_messages', 'file_resources', 'file_bindings', 'file_access_grants',
|
|
'active_knowledge_bases', 'active_documents', 'active_chunks', 'tags', 'tag_relations', 'publications',
|
|
'secondary_tenant', 'secondary_memberships', 'memory_item_embeddings', 'memory_items', 'memory_tombstones',
|
|
'memory_topic_stats', 'memory_doc_affinity', 'memory_subjects']
|
|
const docker = (...args) => execFileSync('docker', ['--context', 'colima-yawan', ...args], {
|
|
encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
|
|
}).trim()
|
|
const writeJSON = (file, value) => fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 })
|
|
|
|
// Include every native queue, plus queues discovered in Redis. Read all states
|
|
// in one read-only Lua snapshot: an active task can otherwise move to retry
|
|
// between separate commands and appear idle. Archived/completed history does
|
|
// not block publication; waiting aggregation work does.
|
|
const nativeQueues = ['default', 'chat_attachment', 'postprocess', 'summary', 'multimodal',
|
|
'graph', 'question', 'sync', 'low', 'wiki', 'memory']
|
|
const queueStates = ['active', 'pending', 'scheduled', 'retry', 'aggregating', 'aggregation_sets']
|
|
const queueSnapshotScript = `
|
|
local names = {}
|
|
for _, name in ipairs(ARGV) do names[name] = true end
|
|
for _, name in ipairs(redis.call('SMEMBERS', KEYS[1])) do names[name] = true end
|
|
local result = {}
|
|
for name, _ in pairs(names) do
|
|
local prefix = 'asynq:{' .. name .. '}:'
|
|
local aggregating = 0
|
|
for _, group in ipairs(redis.call('SMEMBERS', prefix .. 'groups')) do
|
|
aggregating = aggregating + redis.call('ZCARD', prefix .. 'g:' .. group)
|
|
end
|
|
result[name] = {
|
|
active = redis.call('LLEN', prefix .. 'active'),
|
|
pending = redis.call('LLEN', prefix .. 'pending'),
|
|
scheduled = redis.call('ZCARD', prefix .. 'scheduled'),
|
|
retry = redis.call('ZCARD', prefix .. 'retry'),
|
|
aggregating = aggregating,
|
|
aggregation_sets = redis.call('ZCARD', prefix .. 'aggregation_sets')
|
|
}
|
|
end
|
|
return cjson.encode(result)
|
|
`
|
|
|
|
function idleCount(value) {
|
|
if (!Number.isSafeInteger(value) || value < 0) throw new Error('Cannot verify background task counts')
|
|
return value
|
|
}
|
|
|
|
function assertIdleSnapshot(snapshot) {
|
|
if (!snapshot || idleCount(snapshot.workbench) !== 0) throw new Error('Background jobs, uploads or reserved balances are still active')
|
|
if (idleCount(snapshot.pending_ops) !== 0) throw new Error('Wiki or other persisted background operations are still pending')
|
|
const queues = snapshot.queues
|
|
if (!queues || typeof queues !== 'object' || Array.isArray(queues)
|
|
|| nativeQueues.some(name => !Object.hasOwn(queues, name))) throw new Error('Cannot verify native background queues')
|
|
for (const state of Object.values(queues)) {
|
|
if (!state || typeof state !== 'object' || queueStates.some(name => idleCount(state[name]) !== 0)) {
|
|
throw new Error('Native background queues still have active or waiting tasks')
|
|
}
|
|
}
|
|
}
|
|
|
|
function idle(run = execFileSync) {
|
|
const invoke = (args, env = process.env) => run('docker', ['--context', 'colima-yawan', ...args], {
|
|
encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, timeout: 15000,
|
|
env, stdio: ['ignore', 'pipe', 'pipe'],
|
|
}).trim()
|
|
let snapshot
|
|
try {
|
|
snapshot = JSON.parse(invoke(['exec', 'WeKnora-postgres', 'psql', '-X', '-qAt',
|
|
'-v', 'ON_ERROR_STOP=1', '-U', 'postgres', '-d', database, '-c',
|
|
"SELECT json_build_object('workbench', (SELECT count(*) FROM workbench_jobs WHERE status IN ('queued','running')) + (SELECT count(*) FROM digital_human_jobs WHERE status IN ('queued','running')) + (SELECT count(*) FROM workbench_knowledge_version_jobs WHERE status IN ('queued','running')) + (SELECT count(*) FROM knowledge_classification_jobs WHERE status='running') + (SELECT count(*) FROM resumable_uploads WHERE state NOT IN ('completed','cancelled','failed','expired')) + (SELECT count(*) FROM workbench_balance_holds WHERE status='reserved'), 'pending_ops', (SELECT count(*) FROM task_pending_ops));"]))
|
|
const environment = JSON.parse(invoke(['inspect', apiName, '--format', '{{json .Config.Env}}']))
|
|
if (!Array.isArray(environment) || environment.some(value => typeof value !== 'string')) throw new Error()
|
|
const runtime = parseEnvironment(environment.join('\n'))
|
|
// Use the running API's Redis host, ACL identity and database, not the old
|
|
// deployment or the redis-cli defaults. Secrets are passed only in env.
|
|
const address = /^(?:\[([^\]]+)\]|([^:]+)):(\d+)$/.exec(runtime.REDIS_ADDR || '')
|
|
if (!address || !/^\d+$/.test(runtime.REDIS_DB || '') || runtime.DB_NAME !== database) throw new Error()
|
|
const redisArgs = ['redis-cli', '--raw', '--no-auth-warning', '-h', address[1] || address[2],
|
|
'-p', address[3], '-n', runtime.REDIS_DB]
|
|
if (runtime.REDIS_USERNAME) redisArgs.push('--user', runtime.REDIS_USERNAME)
|
|
if (/^true$/i.test((runtime.REDIS_USE_TLS || '').trim())) {
|
|
redisArgs.push('--tls')
|
|
if (runtime.REDIS_TLS_SERVER_NAME) redisArgs.push('--sni', runtime.REDIS_TLS_SERVER_NAME)
|
|
if (/^true$/i.test((runtime.REDIS_TLS_INSECURE_SKIP_VERIFY || '').trim())) redisArgs.push('--insecure')
|
|
}
|
|
redisArgs.push('EVAL_RO', queueSnapshotScript, '1', 'asynq:queues', ...nativeQueues)
|
|
snapshot.queues = JSON.parse(invoke(['exec', '-e', 'REDISCLI_AUTH', 'WeKnora-redis', ...redisArgs],
|
|
{ ...process.env, REDISCLI_AUTH: runtime.REDIS_PASSWORD || '' }))
|
|
} catch {
|
|
// Docker, Redis and JSON errors may embed credentials or server output.
|
|
// An unavailable or unparseable queue is never considered empty.
|
|
throw new Error('Cannot verify background task idleness; publication stopped')
|
|
}
|
|
assertIdleSnapshot(snapshot)
|
|
return snapshot
|
|
}
|
|
|
|
function prepare(release) {
|
|
if (!/^public-\d{8}-\d{6}$/.test(release || '')) throw new Error('Expected public-YYYYMMDD-HHMMSS release identifier')
|
|
const directory = path.join(root, 'releases', release)
|
|
if (fs.existsSync(directory)) throw new Error('Release already exists; do not overwrite immutable artifacts')
|
|
fs.mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
const env = parseEnvironment(fs.readFileSync(path.join(root, 'acceptance/runtime.env'), 'utf8'))
|
|
if (env.DB_NAME !== database) throw new Error('Unexpected source database')
|
|
Object.assign(env, {
|
|
GIN_MODE: 'release', FRONTEND_BASE_URL: 'https://agent.pducv.cn',
|
|
DISABLE_REGISTRATION: 'true', WEKNORA_AUTH_REGISTRATION_MODE: 'invite_only',
|
|
WEKNORA_TENANT_ENABLE_RBAC: 'true', WEKNORA_TENANT_ENABLE_CROSS_TENANT_ACCESS: 'false',
|
|
WEKNORA_TENANT_SELF_SERVICE_CREATION_ENABLED: 'false',
|
|
WEKNORA_TRUSTED_PROXIES: '127.0.0.1,::1,172.19.0.0/16',
|
|
})
|
|
fs.writeFileSync(path.join(directory, 'runtime.env'), Object.entries(env).map(([key, value]) => `${key}=${value}`).join('\n') + '\n', { mode: 0o600 })
|
|
fs.copyFileSync(path.join(root, 'acceptance/runtime.env'), path.join(directory, 'previous-runtime.env'))
|
|
fs.copyFileSync('/Users/macclaw/.cloudflared/config.yml', path.join(directory, 'previous-tunnel.yml'))
|
|
for (const db of [database, 'WeKnora']) {
|
|
const fd = fs.openSync(path.join(directory, `${db}.dump`), 'wx', 0o600)
|
|
try { execFileSync('docker', ['--context', 'colima-yawan', 'exec', 'WeKnora-postgres', 'pg_dump', '-U', 'postgres', '-Fc', db], { stdio: ['ignore', fd, 'pipe'] }) }
|
|
finally { fs.closeSync(fd) }
|
|
}
|
|
const manifest = { release, prepared_at: new Date().toISOString(), database, api_image: `yawan-api:${release}`, frontend_image: `yawan-frontend:${release}`, previous_api: `yawan-native-before-${release}`, public_origin: 'http://127.0.0.1:18088', url: 'https://agent.pducv.cn', stage: 'prepared' }
|
|
writeJSON(path.join(directory, 'manifest.json'), manifest)
|
|
manifest.retained_assets = preserveFrontendAssets(root, release)
|
|
fs.writeFileSync(path.join(root, 'src/frontend/dist/release.json'), JSON.stringify({ release, built_at: manifest.prepared_at }))
|
|
const apiContext = stagePublicAPIContext(root, release)
|
|
docker('build', '-t', manifest.api_image, apiContext)
|
|
docker('build', '-t', manifest.frontend_image, path.join(root, 'src/frontend'))
|
|
for (const [key, image] of [['api_image_id', manifest.api_image], ['frontend_image_id', manifest.frontend_image]]) {
|
|
manifest[key] = docker('image', 'inspect', image, '--format', '{{.Id}}')
|
|
}
|
|
writeJSON(path.join(directory, 'manifest.json'), manifest)
|
|
console.log(JSON.stringify({ release, stage: 'prepared', backups: 2, immutable_images: true }))
|
|
}
|
|
|
|
function isActivatedRelease(runtime, manifest) {
|
|
return !!manifest.release && !!manifest.api_image_id
|
|
&& runtime.image === manifest.api_image_id && runtime.release === manifest.release
|
|
}
|
|
|
|
async function activate(release) {
|
|
if (!/^public-\d{8}-\d{6}$/.test(release || '')) throw new Error('Invalid release identifier')
|
|
const directory = path.join(root, 'releases', release)
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'manifest.json'), 'utf8'))
|
|
if (manifest.stage !== 'prepared') throw new Error('Release has already been activated')
|
|
idle()
|
|
const frontendName = 'yawan-public-frontend'
|
|
const previousFrontend = `${frontendName}-before-${release}`
|
|
const hasFrontend = docker('ps', '-a', '--filter', `name=^/${frontendName}$`, '--format', '{{.Names}}') === frontendName
|
|
const runtime = JSON.parse(docker('inspect', apiName, '--format', '{"image":{{json .Image}},"release":{{json (index .Config.Labels "yawan.release")}}}'))
|
|
const apiAlreadyStarted = isActivatedRelease(runtime, manifest)
|
|
if (apiAlreadyStarted && docker('ps', '-a', '--filter', `name=^/${manifest.previous_api}$`, '--format', '{{.Names}}') !== manifest.previous_api) {
|
|
throw new Error('Cannot resume activation without the previous API rollback container')
|
|
}
|
|
let frontendRenamed = false
|
|
let frontendCreated = false
|
|
if (!apiAlreadyStarted) {
|
|
docker('stop', '--time', '40', apiName)
|
|
docker('rename', apiName, manifest.previous_api)
|
|
}
|
|
try {
|
|
if (!apiAlreadyStarted) docker('run', '-d', '--name', apiName, '--restart', 'unless-stopped', '--network', network,
|
|
'--env-file', path.join(directory, 'runtime.env'), '-p', '127.0.0.1:18080:8080',
|
|
'-e', `WEKNORA_REVIEWED_RELEASE=${release}`, '-e', `WEKNORA_REVIEWED_API_IMAGE=${manifest.api_image_id}`,
|
|
'--label', `yawan.release=${release}`,
|
|
'-v', `${path.join(root, 'acceptance/files')}:/data/files`,
|
|
'-v', `${path.join(root, 'acceptance/resumable-uploads')}:/data/uploads`, manifest.api_image_id)
|
|
let healthy = false
|
|
for (let attempt = 0; attempt < 45; attempt++) {
|
|
try { healthy = (await fetch('http://127.0.0.1:18080/health', { signal: AbortSignal.timeout(1500) })).ok } catch {}
|
|
if (healthy) break
|
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
}
|
|
if (!healthy) throw new Error('New API did not become healthy')
|
|
if (hasFrontend) {
|
|
docker('stop', '--time', '20', frontendName)
|
|
docker('rename', frontendName, previousFrontend)
|
|
frontendRenamed = true
|
|
}
|
|
docker('run', '-d', '--name', 'yawan-public-frontend', '--restart', 'unless-stopped', '--network', network,
|
|
'-p', '127.0.0.1:18088:80', '-e', `APP_HOST=${apiName}`, '-e', 'APP_PORT=8080',
|
|
'-e', 'DEFAULT_LOCALE=zh-CN', '-e', 'MAX_FILE_SIZE_MB=50', '--label', `yawan.release=${release}`, manifest.frontend_image_id)
|
|
frontendCreated = true
|
|
docker('exec', 'yawan-public-frontend', 'nginx', '-t')
|
|
const frontendHealth = await fetch('http://127.0.0.1:18088/api/v1/access/me', { signal: AbortSignal.timeout(10000) })
|
|
if (frontendHealth.status !== 401) throw new Error('Frontend proxy health failed')
|
|
} catch (error) {
|
|
if (frontendCreated || frontendRenamed) {
|
|
try { docker('rm', '-f', frontendName) } catch {}
|
|
}
|
|
try { docker('rm', '-f', apiName) } catch {}
|
|
docker('rename', manifest.previous_api, apiName)
|
|
docker('start', apiName)
|
|
if (frontendRenamed) {
|
|
docker('rename', previousFrontend, frontendName)
|
|
docker('start', frontendName)
|
|
} else if (hasFrontend) {
|
|
docker('restart', frontendName)
|
|
}
|
|
throw error
|
|
}
|
|
if (frontendRenamed) manifest.previous_frontend = previousFrontend
|
|
manifest.stage = 'origin-ready'
|
|
manifest.activated_at = new Date().toISOString()
|
|
writeJSON(path.join(directory, 'manifest.json'), manifest)
|
|
writeJSON(path.join(root, 'public-release.json'), manifest)
|
|
console.log(JSON.stringify({ release, stage: manifest.stage, origin: manifest.public_origin, tunnel_changed: false }))
|
|
}
|
|
|
|
async function refreshFrontend(release) {
|
|
if (!/^public-\d{8}-\d{6}$/.test(release || '')) throw new Error('Invalid release identifier')
|
|
const directory = path.join(root, 'releases', release)
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'manifest.json'), 'utf8'))
|
|
if (manifest.stage !== 'origin-ready') throw new Error('Only refresh an unpublished origin')
|
|
const builtAt = new Date().toISOString()
|
|
const revision = builtAt.replace(/[^0-9]/g, '')
|
|
const image = `yawan-frontend:${release}-ui-${revision}`
|
|
manifest.retained_assets = preserveFrontendAssets(root, `${release}-ui-${revision}`)
|
|
fs.writeFileSync(path.join(root, 'src/frontend/dist/release.json'), JSON.stringify({ release, built_at: builtAt, frontend_revision: revision }))
|
|
docker('build', '-t', image, path.join(root, 'src/frontend'))
|
|
const imageID = docker('image', 'inspect', image, '--format', '{{.Id}}')
|
|
const previous = `yawan-public-frontend-before-${revision}`
|
|
docker('stop', '--time', '20', 'yawan-public-frontend')
|
|
docker('rename', 'yawan-public-frontend', previous)
|
|
try {
|
|
docker('run', '-d', '--name', 'yawan-public-frontend', '--restart', 'unless-stopped', '--network', network,
|
|
'-p', '127.0.0.1:18088:80', '-e', `APP_HOST=${apiName}`, '-e', 'APP_PORT=8080',
|
|
'-e', 'DEFAULT_LOCALE=zh-CN', '-e', 'MAX_FILE_SIZE_MB=50', '--label', `yawan.release=${release}`, imageID)
|
|
docker('exec', 'yawan-public-frontend', 'nginx', '-t')
|
|
if (!(await fetch('http://127.0.0.1:18088/login', { signal: AbortSignal.timeout(10000) })).ok) throw new Error('Frontend health failed')
|
|
} catch (error) {
|
|
try { docker('rm', '-f', 'yawan-public-frontend') } catch {}
|
|
docker('rename', previous, 'yawan-public-frontend')
|
|
docker('start', 'yawan-public-frontend')
|
|
throw error
|
|
}
|
|
manifest.previous_frontends = [...(manifest.previous_frontends || []), { image: manifest.frontend_image, image_id: manifest.frontend_image_id, container: previous }]
|
|
manifest.frontend_image = image
|
|
manifest.frontend_image_id = imageID
|
|
manifest.frontend_built_at = builtAt
|
|
manifest.frontend_activated_at = new Date().toISOString()
|
|
writeJSON(path.join(directory, 'manifest.json'), manifest)
|
|
writeJSON(path.join(root, 'public-release.json'), manifest)
|
|
console.log(JSON.stringify({ release, stage: manifest.stage, frontend_revision: revision, tunnel_changed: false }))
|
|
}
|
|
|
|
async function refreshAPI(release) {
|
|
if (!/^public-\d{8}-\d{6}$/.test(release || '')) throw new Error('Invalid release identifier')
|
|
const directory = path.join(root, 'releases', release)
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'manifest.json'), 'utf8'))
|
|
if (manifest.stage !== 'origin-ready') throw new Error('Only refresh an unverified origin')
|
|
idle()
|
|
const revision = new Date().toISOString().replace(/[^0-9]/g, '')
|
|
const image = `yawan-api:${release}-api-${revision}`
|
|
const apiContext = stagePublicAPIContext(root, release, revision)
|
|
docker('build', '-t', image, apiContext)
|
|
const imageID = docker('image', 'inspect', image, '--format', '{{.Id}}')
|
|
idle()
|
|
const previous = `${apiName}-before-${revision}`
|
|
docker('stop', '--time', '40', apiName)
|
|
docker('rename', apiName, previous)
|
|
try {
|
|
docker('run', '-d', '--name', apiName, '--restart', 'unless-stopped', '--network', network,
|
|
'--env-file', path.join(directory, 'runtime.env'), '-p', '127.0.0.1:18080:8080',
|
|
'-e', `WEKNORA_REVIEWED_RELEASE=${release}`, '-e', `WEKNORA_REVIEWED_API_IMAGE=${imageID}`,
|
|
'--label', `yawan.release=${release}`,
|
|
'-v', `${path.join(root, 'acceptance/files')}:/data/files`,
|
|
'-v', `${path.join(root, 'acceptance/resumable-uploads')}:/data/uploads`, imageID)
|
|
let healthy = false
|
|
for (let attempt = 0; attempt < 45; attempt++) {
|
|
try { healthy = (await fetch('http://127.0.0.1:18080/health', { signal: AbortSignal.timeout(1500) })).ok } catch {}
|
|
if (healthy) break
|
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
}
|
|
if (!healthy) throw new Error('Refreshed API health failed')
|
|
docker('exec', 'yawan-public-frontend', 'nginx', '-s', 'reload')
|
|
} catch (error) {
|
|
try { docker('rm', '-f', apiName) } catch {}
|
|
docker('rename', previous, apiName)
|
|
docker('start', apiName)
|
|
docker('exec', 'yawan-public-frontend', 'nginx', '-s', 'reload')
|
|
throw error
|
|
}
|
|
manifest.previous_apis = [...(manifest.previous_apis || []), {image: manifest.api_image, image_id: manifest.api_image_id, container: previous}]
|
|
manifest.api_image = image
|
|
manifest.api_image_id = imageID
|
|
manifest.api_activated_at = new Date().toISOString()
|
|
writeJSON(path.join(directory, 'manifest.json'), manifest)
|
|
writeJSON(path.join(root, 'public-release.json'), manifest)
|
|
console.log(JSON.stringify({release, stage: manifest.stage, api_revision: revision}))
|
|
}
|
|
|
|
function publicationTimestamp(value, field, now) {
|
|
const parsed = typeof value === 'string' ? Date.parse(value) : NaN
|
|
if (!Number.isFinite(parsed) || new Date(parsed).toISOString() !== value || parsed > now) {
|
|
throw new Error(`Invalid publication timestamp: ${field}`)
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
function assertSuccessfulVerification(result, report) {
|
|
if (!result || result.passed !== true || !Array.isArray(result.checks) || !result.checks.length
|
|
|| result.checks.some(check => check?.passed !== true)
|
|
|| !Array.isArray(result.failures) || result.failures.length) {
|
|
throw new Error(`Public verification did not pass: ${report}`)
|
|
}
|
|
if (Object.hasOwn(result, 'cleanup') && (!Array.isArray(result.cleanup) || !result.cleanup.length
|
|
|| result.cleanup.some(item => !item || typeof item !== 'object' || Array.isArray(item)
|
|
|| typeof item.name !== 'string' || !item.name.trim() || item.passed !== true
|
|
|| (Object.hasOwn(item, 'error') && item.error !== undefined && item.error !== null && item.error !== '')))) {
|
|
throw new Error(`Public verification cleanup did not pass: ${report}`)
|
|
}
|
|
}
|
|
|
|
function assertPublicVerification(manifest, result, report, now = Date.now()) {
|
|
const activated = publicationTimestamp(manifest.activated_at, 'activated_at', now)
|
|
const changes = [activated]
|
|
for (const field of ['api_activated_at', 'frontend_built_at', 'frontend_activated_at']) {
|
|
if (Object.hasOwn(manifest, field)) {
|
|
const changed = publicationTimestamp(manifest[field], field, now)
|
|
if (changed < activated) throw new Error(`Publication timestamp predates activation: ${field}`)
|
|
changes.push(changed)
|
|
}
|
|
}
|
|
if (manifest.previous_apis?.length && !Object.hasOwn(manifest, 'api_activated_at')) {
|
|
throw new Error('Missing API activation timestamp')
|
|
}
|
|
const frontendRefreshed = Object.hasOwn(manifest, 'frontend_built_at')
|
|
|| Object.hasOwn(manifest, 'frontend_activated_at') || manifest.previous_frontends?.length
|
|
if (frontendRefreshed) {
|
|
const built = publicationTimestamp(manifest.frontend_built_at, 'frontend_built_at', now)
|
|
const enabled = publicationTimestamp(manifest.frontend_activated_at, 'frontend_activated_at', now)
|
|
if (enabled < built) throw new Error('Frontend activation predates its build')
|
|
}
|
|
assertSuccessfulVerification(result, report)
|
|
if (report === 'public-indexed-retrieval-isolation') {
|
|
const knowledgeIDs = result.fixtures?.kbs
|
|
if (!Array.isArray(knowledgeIDs) || !knowledgeIDs.length
|
|
|| knowledgeIDs.some(id => typeof id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id))
|
|
|| new Set(knowledgeIDs).size !== knowledgeIDs.length) {
|
|
throw new Error(`Invalid indexed knowledge fixtures: ${report}`)
|
|
}
|
|
// The final SQL reconciliation relies on these entries to attest Milvus cleanup.
|
|
const knowledgeCleanup = result.cleanup?.filter(item => item.name.startsWith('knowledge '))
|
|
if (!knowledgeCleanup || knowledgeCleanup.length !== knowledgeIDs.length
|
|
|| knowledgeIDs.some(id => knowledgeCleanup.filter(item => item.name === `knowledge ${id}`).length !== 1)) {
|
|
throw new Error(`Incomplete indexed knowledge cleanup: ${report}`)
|
|
}
|
|
}
|
|
const started = publicationTimestamp(result.started_at, `${report}.started_at`, now)
|
|
const completedField = Object.hasOwn(result, 'checked_at') ? 'checked_at' : 'time'
|
|
const completed = publicationTimestamp(result[completedField], `${report}.${completedField}`, now)
|
|
if (completed < started) throw new Error(`Public verification finished before it started: ${report}`)
|
|
if (started < Math.max(...changes)) throw new Error(`Public verification started before this runtime: ${report}`)
|
|
if (new URL(result.origin || result.base).origin !== manifest.url) throw new Error(`Wrong verification origin: ${report}`)
|
|
if (['public-navigation', 'public-access-control'].includes(report)) {
|
|
let apiOrigin
|
|
try { apiOrigin = new URL(result.api_origin).origin } catch {}
|
|
if (apiOrigin !== manifest.url) throw new Error(`Wrong verification API origin: ${report}`)
|
|
}
|
|
}
|
|
|
|
function assertFixtureReconciliation(manifest, result, reports, now = Date.now()) {
|
|
const name = 'public-fixture-reconciliation'
|
|
assertSuccessfulVerification(result, name)
|
|
if (result.release !== manifest.release || result.origin !== manifest.url || result.read_only !== true) {
|
|
throw new Error('Fixture reconciliation identity does not match this release')
|
|
}
|
|
const checked = publicationTimestamp(result.checked_at, `${name}.checked_at`, now)
|
|
if (!result.reports || Array.isArray(result.reports)
|
|
|| Object.keys(result.reports).length !== publicVerificationReports.length) {
|
|
throw new Error('Fixture reconciliation must cover all seven source reports')
|
|
}
|
|
for (const sourceName of publicVerificationReports) {
|
|
const source = reports?.[sourceName]
|
|
assertPublicVerification(manifest, source, sourceName, now)
|
|
const completed = Object.hasOwn(source, 'checked_at') ? source.checked_at : source.time
|
|
const reconciled = result.reports[sourceName]
|
|
if (!reconciled || reconciled.started_at !== source.started_at || reconciled.completed_at !== completed) {
|
|
throw new Error(`Fixture reconciliation does not match current report: ${sourceName}`)
|
|
}
|
|
if (checked < Date.parse(completed)) throw new Error(`Fixture reconciliation predates source report: ${sourceName}`)
|
|
}
|
|
const expected = [...fixtureReconciliationCounts, 'file_fixture_directory_removed']
|
|
const byName = new Map(result.checks.map(check => [check.name, check]))
|
|
if (result.checks.length !== expected.length || byName.size !== expected.length
|
|
|| expected.some(check => !byName.has(check))) {
|
|
throw new Error('Fixture reconciliation is missing required residual checks')
|
|
}
|
|
for (const check of fixtureReconciliationCounts) {
|
|
if (byName.get(check).count !== 0) throw new Error(`Fixture reconciliation has residual rows: ${check}`)
|
|
}
|
|
const prefix = reports['public-resource-files'].prefix
|
|
if (!/^file-isolation-[0-9]+$/.test(prefix || '')
|
|
|| byName.get('file_fixture_directory_removed').path !== path.join(root, 'acceptance', 'files', '10059', 'exports', prefix)) {
|
|
throw new Error('Fixture reconciliation checked the wrong attachment directory')
|
|
}
|
|
}
|
|
|
|
async function markPublished(release) {
|
|
if (!/^public-\d{8}-\d{6}$/.test(release || '')) throw new Error('Invalid release identifier')
|
|
const directory = path.join(root, 'releases', release)
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'manifest.json'), 'utf8'))
|
|
if (manifest.release !== release) throw new Error('Manifest release does not match the requested release')
|
|
if (manifest.stage !== 'origin-ready') throw new Error('Expected verified origin')
|
|
const response = await fetch(`${manifest.url}/release.json`, { signal: AbortSignal.timeout(30000) })
|
|
if (!response.ok || (await response.json()).release !== release) throw new Error('Public origin does not serve this release')
|
|
const reports = Object.fromEntries(publicVerificationReports.map(name => [name,
|
|
JSON.parse(fs.readFileSync(path.join(root, 'acceptance', `${name}.json`), 'utf8'))]))
|
|
const reconciliation = JSON.parse(fs.readFileSync(path.join(root, 'acceptance', 'public-fixture-reconciliation.json'), 'utf8'))
|
|
assertFixtureReconciliation(manifest, reconciliation, reports)
|
|
manifest.stage = 'published'
|
|
manifest.published_at = new Date().toISOString()
|
|
manifest.public_verification_reports = publicVerificationReports
|
|
manifest.fixture_reconciliation_checked_at = reconciliation.checked_at
|
|
writeJSON(path.join(directory, 'manifest.json'), manifest)
|
|
writeJSON(path.join(root, 'public-release.json'), manifest)
|
|
console.log(JSON.stringify({ release, stage: manifest.stage, url: manifest.url }))
|
|
}
|
|
|
|
module.exports = { idle, assertIdleSnapshot, nativeQueues, queueStates, queueSnapshotScript, isActivatedRelease, assertPublicVerification,
|
|
assertFixtureReconciliation, publicVerificationReports, fixtureReconciliationCounts }
|
|
|
|
if (require.main === module) {
|
|
const [action, release] = process.argv.slice(2)
|
|
Promise.resolve().then(() => {
|
|
if (action === 'prepare') return prepare(release)
|
|
if (action === 'activate') return activate(release)
|
|
if (action === 'refresh-frontend') return refreshFrontend(release)
|
|
if (action === 'refresh-api') return refreshAPI(release)
|
|
if (action === 'mark-published') return markPublished(release)
|
|
throw new Error('Use prepare, activate, refresh-api, refresh-frontend or mark-published, followed by release identifier')
|
|
}).catch(() => {
|
|
// Docker diagnostics may contain environment secrets; keep console output bounded.
|
|
console.error('Public release command failed; inspect the private release directory and container health. This command does not change the tunnel.')
|
|
process.exitCode = 1
|
|
})
|
|
}
|