shraga 0.1.34 → 0.1.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -81,6 +81,26 @@ bun install && bun run build && sudo systemctl restart "$APP_NAME"
81
81
  ```
82
82
  List checkpoints with `git -C "$APP_DIR" log --oneline`; reset to any of them.
83
83
 
84
+ ## Upgrading yourself (npm deployments)
85
+
86
+ When the deployment consumes `shraga` as an npm dependency, you can move your own pinned version.
87
+ **Only when the user asks** — there is no timer on this.
88
+
89
+ ```bash
90
+ curl -s $ORIGIN/api/self-upgrade # current, latest, blockers
91
+ curl -s -XPOST $ORIGIN/api/self-upgrade -d '{"version":"latest"}' # or a specific "0.1.35"
92
+ ```
93
+
94
+ - **Owner only.** Both routes 403 for anyone else.
95
+ - **Check `blockers` first and relay them.** A source checkout (upgrade with git instead) and a
96
+ local dev symlink at `node_modules/shraga` are both refused on purpose — do not work around either.
97
+ - **202 means started, not succeeded.** The upgrade restarts the server, so the POST cannot report
98
+ the outcome. A detached supervisor installs, restarts, waits for `/api/version` to report the new
99
+ version, soaks it, and **reverts to the previous version automatically** if it doesn't hold.
100
+ - **The result reaches you after the restart**, as a `self-upgrade.finished` event carrying
101
+ `status` (`ok` / `reverted` / `failed` / `revert-failed`) and a `log` path. Tell the owner what
102
+ happened — especially `revert-failed`, which means the box needs a human.
103
+
84
104
  ## Key paths (in source)
85
105
 
86
106
  - `src/server/` — Express + WebSocket server, Claude agent SDK integration
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.34",
3
+ "version": "0.1.35",
4
4
  "description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -54,6 +54,7 @@ import { addUnread, markRead as markUnread, getUnreads } from './unread.ts';
54
54
  import { loadShragaConfig, getPublicOrigin } from './shraga-config.ts';
55
55
  import { startSidecars, stopSidecars } from './mcp-sidecar.ts';
56
56
  import { syncVendorRepos } from './vendor-sync.ts';
57
+ import { SelfUpgrade } from './self-upgrade/index.ts';
57
58
  import { initEngines, getAvailableEngines, getEngine } from './engine/index.ts';
58
59
  import { statsSampler } from './stats.ts';
59
60
  import { getAll as getAllContacts } from './contacts.ts';
@@ -116,6 +117,7 @@ for (const e of __reg.engines ?? []) registerEngine(e);
116
117
  for (const s of __reg.eventSubs ?? []) subscribeEvent(s.source, s.handler);
117
118
  await initEngines();
118
119
  if (!PASSIVE) syncVendorRepos().catch(err => console.warn('[vendor-sync] error:', (err as Error).message));
120
+ const selfUpgrade = new SelfUpgrade();
119
121
  seedDefaults();
120
122
  const purged = purgeExpiredSkills();
121
123
  if (purged.length) console.log(`[skills] Purged ${purged.length} expired skill(s): ${purged.join(', ')}`);
@@ -182,6 +184,41 @@ app.get('/api/version', (_req, res) => {
182
184
  } catch { res.json({ version: 'unknown' }); }
183
185
  });
184
186
 
187
+ // ── Self-upgrade (owner only) ────────────────────────────────────────────────
188
+ // Upgrading replaces the code serving this request and restarts the process, so it is strictly an
189
+ // owner action, and the response can only ever be "started" — the OUTCOME arrives after the restart,
190
+ // via the self-upgrade.finished event (see deliverPendingReport) and GET /api/self-upgrade.
191
+
192
+ app.get('/api/self-upgrade', requireAuth, async (req, res) => {
193
+ if (!(req as any).user?.isOwner) return void res.status(403).json({ error: 'Only the owner can manage upgrades' });
194
+ const current = selfUpgrade.currentVersion();
195
+ let latest: string | null = null;
196
+ let latestError: string | undefined;
197
+ try { latest = await selfUpgrade.latestVersion(); }
198
+ catch (err) { latestError = (err as Error).message; }
199
+ res.json({
200
+ current,
201
+ latest,
202
+ latestError,
203
+ upToDate: !!latest && latest === current,
204
+ blockers: selfUpgrade.blockers(),
205
+ inFlight: selfUpgrade.inFlight(),
206
+ lastReport: selfUpgrade.lastReport(),
207
+ });
208
+ });
209
+
210
+ app.post('/api/self-upgrade', requireAuth, async (req, res) => {
211
+ if (!(req as any).user?.isOwner) return void res.status(403).json({ error: 'Only the owner can manage upgrades' });
212
+ try {
213
+ const plan = await selfUpgrade.start({ version: req.body?.version });
214
+ // 409, not 500: a refusal is a well-formed answer about the deployment's state, and the caller
215
+ // (often the agent, relaying to a human) needs the reason, not a stack trace.
216
+ res.status(plan.started ? 202 : 409).json(plan);
217
+ } catch (err) {
218
+ res.status(500).json({ error: (err as Error).message });
219
+ }
220
+ });
221
+
185
222
  // Cached host stats — returns the in-memory ring buffer (does NOT sample on request).
186
223
  app.get('/api/stats', requireAuth, (_req, res) => {
187
224
  res.json({ samples: statsSampler.getStats() });
@@ -2034,6 +2071,11 @@ await new Promise<void>((resolve) => {
2034
2071
  console.log(`[server] Running on http://0.0.0.0:${PORT}`);
2035
2072
  resolve();
2036
2073
  if (PASSIVE) return; // no sidecars, recovery, or MCP warmers in passive mode
2074
+ // Deliver here, not earlier in boot: an upgrade's outcome is only knowable once we are the
2075
+ // process that came back, and the event has to land after the dispatcher is subscribed or the
2076
+ // owner never hears how it went.
2077
+ try { selfUpgrade.deliverPendingReport(); }
2078
+ catch (err) { console.warn('[self-upgrade] could not deliver report:', (err as Error).message); }
2037
2079
  startSidecars().catch(err => console.error('[sidecar] startup error:', err));
2038
2080
  recoverInterruptedSessions().catch(err => console.error('[recovery] failed:', err));
2039
2081
  // The disk MCP catalog is warmed off the turn path by whichever engine consumes it — the CE default
@@ -0,0 +1,203 @@
1
+ // Self-upgrade — re-pin Shraga's own package version in the deployment, restart, verify, and revert
2
+ // automatically if the new version doesn't come back healthy.
3
+ //
4
+ // SHAPE: this module only PREFLIGHTS and HANDS OFF. The upgrade itself runs in a detached
5
+ // supervisor.sh, because the restart kills this process — code that dies mid-operation cannot verify
6
+ // or roll back its own work. Everything risky lives in the script; everything that decides whether
7
+ // the attempt is even allowed lives here, where it can be tested and can answer the caller.
8
+ //
9
+ // USER-REQUESTED ONLY by design. There is no timer in this module: `POST /api/self-upgrade` is the
10
+ // entry point. Deployments that want it scheduled can point a schedule at that route, which keeps
11
+ // the "should I upgrade tonight?" policy out of the mechanism.
12
+ import { existsSync, lstatSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
13
+ import { spawn } from 'node:child_process';
14
+ import path from 'node:path';
15
+ import { APP_ROOT, PACKAGE_ROOT, dataPath } from '../paths.ts';
16
+ import { emitEvent } from '../events/bus.ts';
17
+
18
+ const TAG = '[self-upgrade]';
19
+ const PKG = 'shraga';
20
+
21
+ export class SelfUpgrade {
22
+ public constructor(public options?: Partial<SelfUpgradeOptions>) {
23
+ this.options = { ...new SelfUpgradeOptions(), ...options };
24
+ }
25
+
26
+ private get o(): SelfUpgradeOptions { return this.options as SelfUpgradeOptions; }
27
+
28
+ /** Version of `pkg` this deployment currently has pinned in its app-root package.json. */
29
+ public currentVersion(): string | null {
30
+ try {
31
+ const pkg = JSON.parse(readFileSync(path.join(this.o.appRoot, 'package.json'), 'utf8'));
32
+ const dep = pkg.dependencies?.[this.o.pkg] ?? pkg.devDependencies?.[this.o.pkg];
33
+ return typeof dep === 'string' ? dep.replace(/^[\^~]/, '') : null;
34
+ } catch { return null; }
35
+ }
36
+
37
+ /** Latest version on the registry. Plain HTTP against the registry — no npm CLI, no auth needed
38
+ * for a public package, and it works the same on a box with no npm installed. */
39
+ public async latestVersion(): Promise<string> {
40
+ const res = await fetch(`${this.o.registry}/${this.o.pkg}/latest`, { signal: AbortSignal.timeout(15_000) });
41
+ if (!res.ok) throw new Error(`registry lookup failed: ${res.status} ${res.statusText}`);
42
+ const body = await res.json() as { version?: string };
43
+ if (!body.version) throw new Error('registry returned no version');
44
+ return body.version;
45
+ }
46
+
47
+ public async versionExists(version: string): Promise<boolean> {
48
+ const res = await fetch(`${this.o.registry}/${this.o.pkg}/${version}`, { signal: AbortSignal.timeout(15_000) });
49
+ return res.ok;
50
+ }
51
+
52
+ /** Everything that must be true before we are willing to touch the deployment. Returns the reasons
53
+ * it is NOT safe; empty array = go. Each check exists because of a way this can go wrong:
54
+ * - not an npm consumer → there is no dep pin to move (source checkout); upgrading means `git pull`.
55
+ * - linked dependency → node_modules/<pkg> is a dev symlink to a working checkout. Installing
56
+ * would silently replace live local source with a registry build.
57
+ * - no restart command → we could install a new version and never run it, leaving the pin and
58
+ * the running process disagreeing with no way to reconcile.
59
+ * - upgrade in flight → two supervisors racing on package.json is how you get a tree that
60
+ * matches neither version. */
61
+ public blockers(): string[] {
62
+ const reasons: string[] = [];
63
+ const pkgPath = path.join(this.o.appRoot, 'package.json');
64
+
65
+ if (!existsSync(pkgPath)) {
66
+ reasons.push(`no package.json at ${this.o.appRoot} — nothing to re-pin`);
67
+ } else if (!this.currentVersion()) {
68
+ reasons.push(`${this.o.pkg} is not a declared dependency of ${pkgPath} — this looks like a source checkout, upgrade it with git`);
69
+ }
70
+
71
+ const linkPath = path.join(this.o.appRoot, 'node_modules', this.o.pkg);
72
+ try {
73
+ if (lstatSync(linkPath).isSymbolicLink()) {
74
+ reasons.push(`node_modules/${this.o.pkg} is a symlink (local dev link) — installing would replace live local source with a registry build`);
75
+ }
76
+ } catch { /* absent is fine: install will create it */ }
77
+
78
+ if (!this.o.restartCmd) {
79
+ reasons.push('no restart command configured (RESTART_CMD / SHRAGA_RESTART_CMD) — an installed version could never be started');
80
+ }
81
+
82
+ const active = this.inFlight();
83
+ if (active) reasons.push(`an upgrade to ${active.target} is already in flight (started ${active.startedAt})`);
84
+
85
+ return reasons;
86
+ }
87
+
88
+ /** The in-flight marker, or null. Stale markers (older than maxRunMs) are ignored rather than
89
+ * blocking forever — a supervisor killed by a reboot must not wedge the feature permanently. */
90
+ public inFlight(): { target: string; startedAt: string } | null {
91
+ try {
92
+ const lock = JSON.parse(readFileSync(this.o.lockFile, 'utf8')) as { target: string; startedAt: string; at: number };
93
+ if (Date.now() - lock.at > this.o.maxRunMs) return null;
94
+ return { target: lock.target, startedAt: lock.startedAt };
95
+ } catch { return null; }
96
+ }
97
+
98
+ /**
99
+ * Start an upgrade. Resolves as soon as the supervisor is detached — the result arrives later, in
100
+ * the report file, because this process is about to be restarted by that supervisor.
101
+ */
102
+ public async start(opts: { version?: string } = {}): Promise<UpgradePlan> {
103
+ const from = this.currentVersion();
104
+ const target = !opts.version || opts.version === 'latest' ? await this.latestVersion() : opts.version.replace(/^v/, '');
105
+
106
+ const blockers = this.blockers();
107
+ if (blockers.length) return { started: false, from, target, reason: blockers.join('; ') };
108
+ if (from === target) return { started: false, from, target, reason: `already on ${target}` };
109
+ if (!await this.versionExists(target)) return { started: false, from, target, reason: `${this.o.pkg}@${target} does not exist on the registry` };
110
+
111
+ mkdirSync(path.dirname(this.o.reportFile), { recursive: true });
112
+ writeFileSync(this.o.lockFile, JSON.stringify({ target, startedAt: new Date().toISOString(), at: Date.now() }));
113
+
114
+ // detached + ignored stdio: the supervisor must outlive us, and it will — we are its first
115
+ // casualty. It logs to a file of its own (see supervisor.sh).
116
+ const child = spawn('bash', [this.o.supervisorScript], {
117
+ cwd: this.o.appRoot,
118
+ detached: true,
119
+ stdio: 'ignore',
120
+ env: {
121
+ ...process.env,
122
+ APP_ROOT: this.o.appRoot,
123
+ PKG: this.o.pkg,
124
+ TARGET: target,
125
+ FROM: from ?? '',
126
+ RESTART_CMD: this.o.restartCmd,
127
+ HEALTH_URL: this.o.healthUrl,
128
+ REPORT: this.o.reportFile,
129
+ BUN: this.o.bun,
130
+ BOOT_TIMEOUT: String(this.o.bootTimeoutSec),
131
+ SOAK: String(this.o.soakSec),
132
+ },
133
+ });
134
+ child.unref();
135
+
136
+ console.log(`${TAG} handed off ${this.o.pkg} ${from} -> ${target} to supervisor pid ${child.pid}`);
137
+ return { started: true, from, target, reason: `upgrading to ${target}; the result will be reported when the server comes back` };
138
+ }
139
+
140
+ /** Read the report left by the last supervisor run, if any. */
141
+ public lastReport(): UpgradeReport | null {
142
+ try { return JSON.parse(readFileSync(this.o.reportFile, 'utf8')) as UpgradeReport; }
143
+ catch { return null; }
144
+ }
145
+
146
+ /**
147
+ * Call once at boot. A finished upgrade is only observable AFTER the restart, so this is where the
148
+ * outcome finally reaches a human: the report is turned into an event, and the deployment's own
149
+ * notifier trigger (the same path scheduled-job failures use) DMs the owner. Consuming the report
150
+ * — deleting it — is what makes the delivery exactly-once across restarts.
151
+ */
152
+ public deliverPendingReport(): UpgradeReport | null {
153
+ const report = this.lastReport();
154
+ if (!report) return null;
155
+ try { unlinkSync(this.o.reportFile); } catch { /* report already gone; emit anyway */ }
156
+ try { unlinkSync(this.o.lockFile); } catch { /* no lock to clear */ }
157
+
158
+ console.log(`${TAG} ${report.status}: ${report.detail}`);
159
+ emitEvent('self-upgrade.finished', report);
160
+ return report;
161
+ }
162
+ }
163
+
164
+ export class SelfUpgradeOptions {
165
+ /** The DEPLOYMENT root (holds package.json + node_modules) — never PACKAGE_ROOT, which for an npm
166
+ * consumer is node_modules/shraga and has no dep pin to move. */
167
+ public appRoot: string = APP_ROOT;
168
+ public pkg: string = PKG;
169
+ public registry: string = process.env.SHRAGA_UPGRADE_REGISTRY?.trim() || 'https://registry.npmjs.org';
170
+ public restartCmd: string = (process.env.SHRAGA_RESTART_CMD || process.env.RESTART_CMD || '').trim();
171
+ public healthUrl: string = `http://127.0.0.1:${process.env.PORT || 3032}/api/version`;
172
+ public bun: string = process.env.SHRAGA_BUN?.trim() || process.execPath || 'bun';
173
+ /** Shipped inside src/, so it reaches npm consumers (package.json `files` includes src/). */
174
+ public supervisorScript: string = path.join(PACKAGE_ROOT, 'src', 'server', 'self-upgrade', 'supervisor.sh');
175
+ public reportFile: string = dataPath('.self-upgrade', 'report.json');
176
+ public lockFile: string = dataPath('.self-upgrade', 'in-flight.json');
177
+ public bootTimeoutSec: number = 180;
178
+ public soakSec: number = 60;
179
+ /** After this, an in-flight marker is treated as abandoned (supervisor killed by a reboot). */
180
+ public maxRunMs: number = 30 * 60 * 1000;
181
+ }
182
+
183
+ export interface UpgradePlan {
184
+ started: boolean;
185
+ from: string | null;
186
+ target: string;
187
+ reason: string;
188
+ }
189
+
190
+ export interface UpgradeReport {
191
+ status: 'ok' | 'reverted' | 'failed' | 'revert-failed';
192
+ detail: string;
193
+ from: string;
194
+ target: string;
195
+ installed: string;
196
+ package: string;
197
+ log: string;
198
+ finishedAt: string;
199
+ }
200
+
201
+ declare module '../events/types.ts' {
202
+ interface ShragaEventMap { 'self-upgrade.finished': UpgradeReport }
203
+ }
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env bash
2
+ # Upgrade supervisor — runs DETACHED, outside the server it is upgrading.
3
+ #
4
+ # WHY A SCRIPT AND NOT SERVER CODE: an upgrade restarts the server, so the process that starts the
5
+ # upgrade cannot observe whether it worked. Anything that must survive the restart — the verify, and
6
+ # above all the REVERT — has to live outside it. This is the same shape as power-guard.sh and
7
+ # health-watchdog.sh, and it is the whole reason a self-upgrade can be trusted: the rollback path
8
+ # does not depend on the code being rolled back.
9
+ #
10
+ # Contract (all via env, no positional args):
11
+ # APP_ROOT consumer root holding package.json + the lockfile (required)
12
+ # PKG dependency name to re-pin, normally "shraga" (required)
13
+ # TARGET version to install, e.g. 0.1.35 (required)
14
+ # FROM version currently pinned, restored on failure (required)
15
+ # RESTART_CMD how to restart the service (required)
16
+ # HEALTH_URL must report TARGET after the restart (required)
17
+ # REPORT JSON report path; the server reads it on next boot (required)
18
+ # BUN bun binary (default: bun on PATH)
19
+ # BOOT_TIMEOUT seconds to wait for the version to flip (default: 180)
20
+ # SOAK seconds it must KEEP answering after that (default: 60)
21
+ #
22
+ # Exit code is advisory only — nobody is listening. The REPORT file is the real output.
23
+ set -uo pipefail
24
+
25
+ : "${APP_ROOT:?}" "${PKG:?}" "${TARGET:?}" "${FROM:?}" "${RESTART_CMD:?}" "${HEALTH_URL:?}" "${REPORT:?}"
26
+ BUN="${BUN:-bun}"
27
+ BOOT_TIMEOUT="${BOOT_TIMEOUT:-180}"
28
+ SOAK="${SOAK:-60}"
29
+
30
+ cd "$APP_ROOT" || exit 1
31
+ BACKUP="$(mktemp -d)"
32
+ LOG="${REPORT%.json}.log"
33
+ exec >>"$LOG" 2>&1
34
+
35
+ ts() { date '+%F %T'; }
36
+ say() { echo "[upgrade] $(ts) $*"; }
37
+
38
+ # The report is the only channel back to the server, so write it defensively: a partial or
39
+ # unparseable file must not look like a success.
40
+ write_report() { # status detail installed
41
+ local tmp="$REPORT.tmp"
42
+ cat > "$tmp" <<JSON
43
+ {
44
+ "status": "$1",
45
+ "detail": $(printf '%s' "$2" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'),
46
+ "from": "$FROM",
47
+ "target": "$TARGET",
48
+ "installed": "$3",
49
+ "package": "$PKG",
50
+ "log": "$LOG",
51
+ "finishedAt": "$(date -u '+%FT%TZ')"
52
+ }
53
+ JSON
54
+ mv "$tmp" "$REPORT"
55
+ }
56
+
57
+ # Snapshot EVERYTHING the install can rewrite. Restoring package.json without its lockfile would
58
+ # resolve a different tree than the one that was known-good.
59
+ snapshot() {
60
+ cp package.json "$BACKUP/" 2>/dev/null || return 1
61
+ for f in bun.lock bun.lockb package-lock.json; do [ -f "$f" ] && cp "$f" "$BACKUP/"; done
62
+ return 0
63
+ }
64
+ restore() {
65
+ cp "$BACKUP/package.json" package.json || return 1
66
+ for f in bun.lock bun.lockb package-lock.json; do [ -f "$BACKUP/$f" ] && cp "$BACKUP/$f" "$f"; done
67
+ return 0
68
+ }
69
+
70
+ pin() { # version — rewrite only this dep's pin, leaving the rest of package.json byte-identical
71
+ PKG="$PKG" V="$1" python3 - <<'PY'
72
+ import json, os, re
73
+ pkg, ver = os.environ['PKG'], os.environ['V']
74
+ src = open('package.json').read()
75
+ pat = re.compile(r'("%s"\s*:\s*")[^"]*(")' % re.escape(pkg))
76
+ if not pat.search(src):
77
+ raise SystemExit(f'{pkg} not found in package.json')
78
+ open('package.json', 'w').write(pat.sub(lambda m: m.group(1) + ver + m.group(2), src, count=1))
79
+ PY
80
+ }
81
+
82
+ # Healthy = the endpoint answers AND reports the version we expect. "It responds" is not enough:
83
+ # a server that came back on the OLD version is a failed upgrade, not a healthy one.
84
+ reports_version() { # version
85
+ local body
86
+ body="$(curl -sf -m 10 "$HEALTH_URL" 2>/dev/null)" || return 1
87
+ printf '%s' "$body" | grep -q "\"$1\"" || return 1
88
+ return 0
89
+ }
90
+
91
+ wait_for_version() { # version timeout
92
+ local deadline=$(( SECONDS + $2 ))
93
+ while [ "$SECONDS" -lt "$deadline" ]; do
94
+ reports_version "$1" && return 0
95
+ sleep 5
96
+ done
97
+ return 1
98
+ }
99
+
100
+ # A server that boots, flips the version, then dies 20s later has NOT upgraded successfully — that is
101
+ # exactly the crash-loop an unattended upgrade must catch. Keep probing for the whole soak window.
102
+ soak() { # version seconds
103
+ local deadline=$(( SECONDS + $2 ))
104
+ while [ "$SECONDS" -lt "$deadline" ]; do
105
+ reports_version "$1" || return 1
106
+ sleep 5
107
+ done
108
+ return 0
109
+ }
110
+
111
+ install_and_restart() { # version label
112
+ say "installing $PKG@$1 ($2)"
113
+ if ! pin "$1"; then say "pin failed"; return 1; fi
114
+ if ! "$BUN" install; then say "bun install failed"; return 1; fi
115
+ say "restarting via: $RESTART_CMD"
116
+ eval "$RESTART_CMD" || say "restart command returned non-zero (continuing — some managers do)"
117
+ return 0
118
+ }
119
+
120
+ # ── Upgrade ───────────────────────────────────────────────────────────────────
121
+ say "=== $PKG $FROM -> $TARGET ==="
122
+ if ! snapshot; then
123
+ write_report failed "could not snapshot package.json in $APP_ROOT — refused to touch anything" "$FROM"
124
+ exit 1
125
+ fi
126
+
127
+ if ! install_and_restart "$TARGET" upgrade; then
128
+ restore && "$BUN" install >/dev/null 2>&1
129
+ write_report failed "install of $PKG@$TARGET failed; package.json restored, service untouched" "$FROM"
130
+ exit 1
131
+ fi
132
+
133
+ if wait_for_version "$TARGET" "$BOOT_TIMEOUT" && soak "$TARGET" "$SOAK"; then
134
+ say "verified $TARGET (booted + soaked ${SOAK}s)"
135
+ write_report ok "upgraded to $TARGET and verified for ${SOAK}s" "$TARGET"
136
+ exit 0
137
+ fi
138
+
139
+ # ── Revert ────────────────────────────────────────────────────────────────────
140
+ say "verification FAILED — reverting to $FROM"
141
+ if ! restore; then
142
+ write_report revert-failed "upgrade to $TARGET failed AND the backup could not be restored — MANUAL FIX NEEDED. Backup: $BACKUP" unknown
143
+ exit 1
144
+ fi
145
+ if ! install_and_restart "$FROM" revert; then
146
+ write_report revert-failed "upgrade to $TARGET failed and reinstalling $FROM ALSO failed — MANUAL FIX NEEDED. Backup: $BACKUP" unknown
147
+ exit 1
148
+ fi
149
+
150
+ if wait_for_version "$FROM" "$BOOT_TIMEOUT"; then
151
+ say "reverted to $FROM"
152
+ write_report reverted "upgrade to $TARGET failed verification; reverted to $FROM and confirmed healthy" "$FROM"
153
+ rm -rf "$BACKUP"
154
+ exit 0
155
+ fi
156
+
157
+ write_report revert-failed "upgrade to $TARGET failed and the revert to $FROM did not come back healthy — MANUAL FIX NEEDED. Backup: $BACKUP" unknown
158
+ exit 1