Files
technitium-console/hub/drift.ts
T
DenozordecandClaude Opus 5.5 57e492cceb Hub stage 4: config history, rollback, drift check, baseline export and import
Every saved config, and every earlier baseline, is listed per app. You
can view it, diff it against a server's current config, and roll it back
through preview and apply. Earlier baselines can be restored.

A drift check (HUB_DRIFT_MINUTES, default 15) compares each server with
its effective config. Drift shows in the header and the matrix, with
Accept as baseline and Revert to baseline. All baselines export to one
file. Importing that file shows a dry run before it writes.

Rolling back preselects, and diffs against, the server the version came
from. The line-diff op "del" is renamed to "remove" for the language lint.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:57:33 +07:00

135 lines
5.0 KiB
TypeScript

/*
Drift: a server whose config is no longer what the hub says it should be (the baseline plus the
server's own local values), usually because someone changed it in that server's own console.
`check` reads every server's config for every app that has a baseline, with the read token, and
compares it with the effective config as the sync does. The result is kept in memory and in
`${HUB_DATA}/drift.json`; `since` is the first check that saw the same problem, and it is dropped
once the problem is gone. Two checks never run at once. The timer lives in server.ts, so the
tests drive `check` themselves.
*/
import { existsSync, readFileSync } from 'node:fs'
import path from 'node:path'
import type { HubConfig } from './config.ts'
import { effectiveConfig, sameConfig } from './apps-sync.ts'
import { readConfigText } from './apps-config.ts'
import { listBaselineApps, readBaseline, readOverrides, writeAtomic } from './store.ts'
import type { Upstream } from './upstream.ts'
export type DriftKind = 'differs' | 'unreadable' | 'not-installed'
export interface DriftEntry {
app: string
server: string
since: string
kind: DriftKind
}
export interface DriftState {
checkedAt: string | null
apps: DriftEntry[]
}
const KINDS: DriftKind[] = ['differs', 'unreadable', 'not-installed']
export class Drift {
#config: HubConfig
#upstream: Upstream
#now: () => number
#state: DriftState
#running: Promise<DriftState> | null = null
constructor(config: HubConfig, upstream: Upstream, now: () => number = Date.now) {
this.#config = config
this.#upstream = upstream
this.#now = now
this.#state = this.#load()
}
#file(): string {
return path.join(this.#config.dataDir, 'drift.json')
}
#load(): DriftState {
try {
if (!existsSync(this.#file())) return { checkedAt: null, apps: [] }
const data = JSON.parse(readFileSync(this.#file(), 'utf8')) as Partial<DriftState>
const apps = (Array.isArray(data.apps) ? data.apps : []).filter(
(e): e is DriftEntry => typeof e?.app === 'string' && typeof e.server === 'string' && typeof e.since === 'string' && KINDS.includes(e.kind),
)
return { checkedAt: typeof data.checkedAt === 'string' ? data.checkedAt : null, apps }
} catch {
return { checkedAt: null, apps: [] }
}
}
get(): DriftState & { checking: boolean } {
return { ...this.#state, checking: this.#running !== null }
}
/** How many servers differ from their baseline (the other kinds are listed, not counted). */
count(): number {
return this.#state.apps.filter((e) => e.kind === 'differs').length
}
/* Runs a check, or joins the one that is already running. */
check(): Promise<DriftState> {
if (this.#running !== null) return this.#running
const run = this.#run().finally(() => {
this.#running = null
})
this.#running = run
return run
}
async #run(): Promise<DriftState> {
const apps = listBaselineApps(this.#config.dataDir).map((app) => ({
app,
baseline: readBaseline(this.#config.dataDir, app),
overrides: readOverrides(this.#config.dataDir, app),
}))
const found: { app: string; server: string; kind: DriftKind }[] = []
await Promise.all(
this.#config.servers.map(async (server) => {
if (apps.length === 0) return
this.#upstream.invalidate(server.id)
let installed: Set<string>
try {
const list = (await this.#upstream.call(server, 'apps/list')) as { apps?: { name: string }[] } | null
installed = new Set((list?.apps ?? []).map((a) => a.name))
} catch {
for (const a of apps) found.push({ app: a.app, server: server.id, kind: 'unreadable' })
return
}
await Promise.all(
apps.map(async (a) => {
if (a.baseline === null) return
if (!installed.has(a.app)) return void found.push({ app: a.app, server: server.id, kind: 'not-installed' })
try {
const text = (await readConfigText(this.#upstream, server, a.app)) ?? ''
const effective = effectiveConfig(a.baseline.text, a.overrides, server.id).text
if (!sameConfig(text, effective)) found.push({ app: a.app, server: server.id, kind: 'differs' })
} catch {
found.push({ app: a.app, server: server.id, kind: 'unreadable' })
}
}),
)
}),
)
const at = new Date(this.#now()).toISOString()
const before = new Map(this.#state.apps.map((e) => [`${e.app}\n${e.server}\n${e.kind}`, e.since]))
const entries = found
.map((f): DriftEntry => ({ ...f, since: before.get(`${f.app}\n${f.server}\n${f.kind}`) ?? at }))
.sort((x, y) => x.app.localeCompare(y.app) || x.server.localeCompare(y.server))
this.#state = { checkedAt: at, apps: entries }
try {
writeAtomic(this.#file(), JSON.stringify(this.#state, null, 2) + '\n')
} catch (e) {
console.error(`hub: drift.json could not be written (${e instanceof Error ? e.message : String(e)}).`)
}
return this.#state
}
}