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 <noreply@anthropic.com>
This commit is contained in:
1 parent
0e05039202
commit
57e492cceb
29 files changed
+2294
-125
No files matched your search
+33
-2
@@ -181,7 +181,7 @@ export interface FieldChange {
|
||||
}
|
||||
|
||||
export interface LineOp {
|
||||
op: 'same' | 'add' | 'del'
|
||||
op: 'same' | 'add' | 'remove'
|
||||
text: string
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@ export function lineDiff(a: string, b: string): { lines: LineOp[]; truncated: bo
|
||||
j++
|
||||
} else if (j < y.length && (i === x.length || table[i * w + j + 1] > table[(i + 1) * w + j])) {
|
||||
lines.push({ op: 'add', text: y[j++] })
|
||||
} else lines.push({ op: 'del', text: x[i++] })
|
||||
} else lines.push({ op: 'remove', text: x[i++] })
|
||||
}
|
||||
return { lines, truncated: false, changed: lines.filter((l) => l.op !== 'same').length }
|
||||
}
|
||||
@@ -257,6 +257,37 @@ export function importLocalValues(configs: Record<string, string | null>, paths:
|
||||
return out
|
||||
}
|
||||
|
||||
/*
|
||||
"Accept as baseline": the baseline becomes this server's config, except that the local fields keep
|
||||
the baseline's old values, and this server's own values for them move into the overrides.
|
||||
*/
|
||||
export function acceptServerConfig(
|
||||
serverText: string,
|
||||
oldBaselineText: string | null,
|
||||
overrides: Overrides,
|
||||
server: string,
|
||||
): { baselineText: string; overrides: Overrides } {
|
||||
if (overrides.paths.length === 0) return { baselineText: serverText, overrides }
|
||||
const parsed = parseJson(serverText)
|
||||
if (!parsed.ok || !looksLikeJson(serverText)) throw new Error('The config is plain text, so its local fields cannot be separated out.')
|
||||
const value = clone(parsed.value)
|
||||
const old = oldBaselineText === null ? null : parseJson(oldBaselineText)
|
||||
const mine: Record<string, Json> = {}
|
||||
for (const path of overrides.paths) {
|
||||
const segments = parsePath(path)
|
||||
if (segments === null) continue
|
||||
const here = getAt(value, segments)
|
||||
if (here.found) mine[path] = clone(here.value)
|
||||
const before = old !== null && old.ok ? getAt(old.value, segments) : { found: false }
|
||||
if (before.found) setAt(value, segments, clone(before.value))
|
||||
else removeAt(value, segments)
|
||||
}
|
||||
return {
|
||||
baselineText: JSON.stringify(value, null, 2),
|
||||
overrides: { paths: overrides.paths, values: { ...overrides.values, [server]: { ...(overrides.values[server] ?? {}), ...mine } } },
|
||||
}
|
||||
}
|
||||
|
||||
/* Local values for paths that are listed, from servers that exist; anything else is dropped. */
|
||||
export function cleanValues(values: unknown, paths: string[], servers: string[]): Overrides['values'] {
|
||||
if (values === null || typeof values !== 'object' || Array.isArray(values)) throw new Error('values must be an object of servers.')
|
||||
|
||||
Reference in new issue
Block a user