mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
Merge pull request #442 from irisitymichaelgrundberg/feat/restore-sessions-after-reboot
feat(sessions): offer to rebuild the sessions a host reboot destroyed
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { Session } from '../../session.js';
|
||||
import type { SessionState } from '../../types.js';
|
||||
|
||||
export interface SessionPort {
|
||||
readonly sessions: ReadonlyMap<string, Session>;
|
||||
@@ -12,5 +13,28 @@ export interface SessionPort {
|
||||
setupSessionListeners(session: Session): Promise<void>;
|
||||
persistSessionState(session: Session): void;
|
||||
persistSessionStateNow(session: Session): void;
|
||||
/**
|
||||
* Re-apply the persisted state a freshly CONSTRUCTED session does not carry.
|
||||
*
|
||||
* A `Session` built from a record holds only what its constructor takes, so
|
||||
* persisting it would otherwise REPLACE the fuller record with the reduced one.
|
||||
* Two phases: `before-spawn` shapes the pane (the custom-model environment and
|
||||
* the nice priority) and must precede `startInteractive()`; `after-spawn` is
|
||||
* the session's own history (the pin, token and cost totals, auto-compact,
|
||||
* auto-clear, auto-resume, colour, image watcher, flicker filter) and must NOT
|
||||
* land on a session whose pane failed to start.
|
||||
*/
|
||||
reapplyPersistedSessionState(
|
||||
session: Session,
|
||||
saved: SessionState,
|
||||
phase: 'before-spawn' | 'after-spawn'
|
||||
): Promise<void>;
|
||||
/**
|
||||
* Undo a session that was registered but never got a working pane: the map
|
||||
* entry, its tab-layout slot, and any pane the launch created before throwing.
|
||||
* Unlike {@link cleanupSession} it leaves the persisted record, the lifetime
|
||||
* token totals, the Ralph state and the workspace's own files untouched.
|
||||
*/
|
||||
discardPartiallyBuiltSession(sessionId: string): Promise<void>;
|
||||
getSessionStateWithRespawn(session: Session): unknown;
|
||||
}
|
||||
|
||||
@@ -957,6 +957,10 @@ class CodemanApp {
|
||||
this.registerServiceWorker();
|
||||
// Fetch tunnel status for header indicator (desktop only)
|
||||
this.loadTunnelStatus();
|
||||
// Ask whether a host reboot left sessions worth rebuilding (banner, never
|
||||
// automatic). handleInit() re-reads it on every SSE init; this covers the
|
||||
// path where that event never arrives.
|
||||
this.initRebootRestoreBanner?.();
|
||||
// Share a single settings fetch between both consumers
|
||||
const settingsPromise = fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.data ?? null).catch(() => null);
|
||||
this.loadQuickStartCases(null, settingsPromise);
|
||||
@@ -3759,6 +3763,12 @@ class CodemanApp {
|
||||
// a fresh load / reconnect (authoritative; wins over the localStorage restore).
|
||||
if (data.planUsage) this.updatePlanUsageChip(data.planUsage);
|
||||
|
||||
// A board left open across a host reboot reconnects HERE, to a server that came
|
||||
// back with an empty session list. The reboot-restore offer is built at boot,
|
||||
// before any client could be listening, so re-read it on every init rather than
|
||||
// only on the page-load path.
|
||||
this.refreshRebootRestoreBanner?.();
|
||||
|
||||
// Update version displays (header and toolbar)
|
||||
if (data.version) {
|
||||
const versionEl = this.$('versionDisplay');
|
||||
|
||||
@@ -213,6 +213,24 @@
|
||||
<button class="offline-banner-retry" id="offlineBannerRetry" onclick="app.retryConnection()">Retry now</button>
|
||||
</div>
|
||||
|
||||
<!-- Reboot-restore offer: shown when the server found sessions a host reboot
|
||||
killed and is asking whether to rebuild them. Populated by
|
||||
reboot-restore-ui.js; nothing is created until the user clicks. -->
|
||||
<div class="reboot-restore-banner" id="rebootRestoreBanner" role="status" hidden>
|
||||
<span class="reboot-restore-banner-icon" aria-hidden="true">↺</span>
|
||||
<span class="reboot-restore-banner-text" id="rebootRestoreBannerText"></span>
|
||||
<span class="reboot-restore-banner-detail" id="rebootRestoreBannerDetail"></span>
|
||||
<span class="reboot-restore-banner-note">Conversations return; terminal history does not.</span>
|
||||
<button
|
||||
class="reboot-restore-banner-accept"
|
||||
id="rebootRestoreBannerAccept"
|
||||
onclick="app.restoreRebootSessions()"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
<button class="reboot-restore-banner-dismiss" onclick="app.dismissRebootRestore()">Dismiss</button>
|
||||
</div>
|
||||
|
||||
<!-- Timer Banner (shown when timed run is active) -->
|
||||
<div class="timer-banner" id="timerBanner" style="display: none;">
|
||||
<div class="timer-content">
|
||||
@@ -3535,6 +3553,7 @@
|
||||
<script defer src="readmymind-ui.js"></script>
|
||||
<script defer src="ultracode-panel.js"></script>
|
||||
<script defer src="approvals-ui.js"></script>
|
||||
<script defer src="reboot-restore-ui.js"></script>
|
||||
<script defer src="admin-ui.js"></script>
|
||||
<script defer src="session-ui.js"></script>
|
||||
<script defer src="webview-tabs.js"></script>
|
||||
|
||||
@@ -3241,6 +3241,43 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
other banners. The overlay is fixed and handles its own insets.
|
||||
============================================================================ */
|
||||
@media (max-width: 599px) {
|
||||
/* Reboot-restore banner: the same treatment as the offline banner below. Its
|
||||
text and note are nowrap and the two buttons cannot shrink, so without this
|
||||
the actions are pushed off a phone-width viewport and become unreachable. */
|
||||
.reboot-restore-banner {
|
||||
padding: 0.4rem 0.5rem;
|
||||
padding-left: calc(0.5rem + var(--safe-area-left));
|
||||
padding-right: calc(0.5rem + var(--safe-area-right));
|
||||
font-size: 0.7rem;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* The session names and the scrollback note are the first things to go. The
|
||||
count plus the two buttons carry the message on their own, and the note
|
||||
survives as the accept button's title. */
|
||||
.reboot-restore-banner-detail,
|
||||
.reboot-restore-banner-note {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* A flex item will not shrink below its content width at the default
|
||||
`min-width: auto`, so without this the nowrap text pushes the buttons off a
|
||||
360px viewport and the ellipsis never engages. */
|
||||
.reboot-restore-banner-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.reboot-restore-banner-accept,
|
||||
.reboot-restore-banner-dismiss {
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.reboot-restore-banner-accept {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.offline-banner {
|
||||
padding: 0.4rem 0.5rem;
|
||||
padding-left: calc(0.5rem + var(--safe-area-left));
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @fileoverview Reboot-restore banner: offer back the sessions a host reboot destroyed.
|
||||
*
|
||||
* A host reboot takes the tmux server down with it, so every session's pane dies
|
||||
* and the board comes up empty. The server works out what was running from the
|
||||
* records it still holds at boot, and this banner asks the user whether to
|
||||
* rebuild them. Nothing is created until they click, because the server's
|
||||
* reboot guess is a heuristic and a wrong automatic restore would spawn CLI
|
||||
* processes nobody asked for.
|
||||
*
|
||||
* Seeded from `GET /api/reboot-restore` on init and again on every SSE reconnect,
|
||||
* because the tab most likely to want this is one that was open across the reboot
|
||||
* and reconnects to a server that came back up with an empty board. Restore posts to
|
||||
* `POST /api/reboot-restore/restore` and Dismiss posts to
|
||||
* `POST /api/reboot-restore/dismiss`. Dismiss always clears the banner; Restore
|
||||
* re-reads the plan afterwards, because the server puts back anything it could
|
||||
* not build for a reason that may pass, such as a session limit or an agent that
|
||||
* would not start. The restored sessions arrive as ordinary `session:created`
|
||||
* events, so no extra rendering is needed here.
|
||||
*
|
||||
* The banner says that terminal history did not survive, because a restored
|
||||
* session is a new pane: the conversation continues and the scrollback does not.
|
||||
* Saying so is what keeps an empty pane from reading as a broken restore.
|
||||
* Backend: src/web/reboot-restore-registry.ts, src/web/routes/reboot-restore-routes.ts.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (CodemanApp class, showToast)
|
||||
* @dependency api-client.js at runtime (this._api / this._apiJson)
|
||||
* @loadorder 11.7 of 17, after approvals-ui.js
|
||||
*/
|
||||
|
||||
/** Plain-language wording for one skip reason, for the toast after a restore. */
|
||||
function rebootSkipReason(reason) {
|
||||
switch (reason) {
|
||||
case 'workspace-missing':
|
||||
return 'workspace is gone';
|
||||
case 'workspace-forbidden':
|
||||
return 'workspace is outside your space';
|
||||
case 'already-live':
|
||||
return 'already open';
|
||||
case 'capacity-reached':
|
||||
return 'session limit reached';
|
||||
case 'rebuild-failed':
|
||||
return 'the agent would not start';
|
||||
default:
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/** Ask the server whether a reboot left anything on offer, and show the banner if so. */
|
||||
async initRebootRestoreBanner() {
|
||||
const data = await this._apiJson('/api/reboot-restore');
|
||||
const sessions = data?.sessions ?? [];
|
||||
if (sessions.length === 0) return;
|
||||
this._rebootRestoreSessions = sessions;
|
||||
this.renderRebootRestoreBanner();
|
||||
},
|
||||
|
||||
renderRebootRestoreBanner() {
|
||||
const banner = this.$('rebootRestoreBanner');
|
||||
if (!banner) return;
|
||||
const sessions = this._rebootRestoreSessions ?? [];
|
||||
if (sessions.length === 0) {
|
||||
banner.hidden = true;
|
||||
return;
|
||||
}
|
||||
const count = sessions.length;
|
||||
const text = this.$('rebootRestoreBannerText');
|
||||
if (text) {
|
||||
const noun = count === 1 ? 'session' : 'sessions';
|
||||
text.textContent = `Restore ${count} ${noun} from before the reboot`;
|
||||
}
|
||||
const detail = this.$('rebootRestoreBannerDetail');
|
||||
if (detail) {
|
||||
// Names, so the user can tell what they are about to relaunch.
|
||||
const names = sessions
|
||||
.map((s) => s.name || s.workingDir?.split('/').pop() || s.id.slice(0, 8))
|
||||
.slice(0, 4)
|
||||
.join(', ');
|
||||
detail.textContent = count > 4 ? `${names}, …` : names;
|
||||
detail.title = sessions.map((s) => `${s.name || s.id}\n${s.workingDir}`).join('\n\n');
|
||||
}
|
||||
const accept = this.$('rebootRestoreBannerAccept');
|
||||
// The note is hidden at phone width, so the warning travels on the button too.
|
||||
if (accept) accept.title = 'Conversations return; terminal history does not.';
|
||||
banner.hidden = false;
|
||||
},
|
||||
|
||||
/** Rebuild everything on offer. The panes are new, so scrollback does not come back. */
|
||||
async restoreRebootSessions() {
|
||||
const button = this.$('rebootRestoreBannerAccept');
|
||||
if (button) button.disabled = true;
|
||||
const res = await this._api('/api/reboot-restore/restore', { method: 'POST', body: {} });
|
||||
if (res && res.status === 409) {
|
||||
if (button) button.disabled = false;
|
||||
this.showToast?.('A restore is already running', 'info');
|
||||
return;
|
||||
}
|
||||
// The uniform envelope wraps every /api payload; reading the outer object
|
||||
// would report every count as zero.
|
||||
const body = res && res.ok ? (await res.json().catch(() => null))?.data : null;
|
||||
if (!body) {
|
||||
if (button) button.disabled = false;
|
||||
this.showToast?.('Could not restore the sessions', 'error');
|
||||
return;
|
||||
}
|
||||
const restored = body.restored?.length ?? 0;
|
||||
const skipped = body.skipped?.length ?? 0;
|
||||
// Re-read rather than clearing: the server puts back anything it could not
|
||||
// build for a reason that may pass, such as a session limit or an agent that
|
||||
// would not start, and blanking the banner here would put those entries out
|
||||
// of reach until a reload.
|
||||
await this.refreshRebootRestoreBanner();
|
||||
if (button) button.disabled = false;
|
||||
if (restored > 0) {
|
||||
const noun = restored === 1 ? 'conversation' : 'conversations';
|
||||
this.showToast?.(`Restored ${restored} ${noun}. Terminal history did not survive the reboot.`, 'success');
|
||||
}
|
||||
if (skipped > 0) {
|
||||
// Each reason means a different next step for the user, so they are not
|
||||
// collapsed into one message: capacity clears by closing something, a
|
||||
// failed start usually means the CLI is not on the server's PATH.
|
||||
const reasons = new Set((body.skipped ?? []).map((s) => s.reason));
|
||||
this.showToast?.(`${skipped} not restored: ${[...reasons].map(rebootSkipReason).join('; ')}`, 'warning');
|
||||
}
|
||||
},
|
||||
|
||||
/** Re-read the offer after a reconnect, for a tab that was open across the reboot. */
|
||||
async refreshRebootRestoreBanner() {
|
||||
const data = await this._apiJson('/api/reboot-restore');
|
||||
this._rebootRestoreSessions = data?.sessions ?? [];
|
||||
this.renderRebootRestoreBanner();
|
||||
},
|
||||
|
||||
/** Drop the offer. The Resume list still reaches every one of these conversations. */
|
||||
async dismissRebootRestore() {
|
||||
this._rebootRestoreSessions = [];
|
||||
this.renderRebootRestoreBanner();
|
||||
await this._apiPost('/api/reboot-restore/dismiss', {});
|
||||
},
|
||||
});
|
||||
@@ -15243,6 +15243,85 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
||||
skin, including the light ones. Visibility is driven by the `hidden`
|
||||
attribute, so the display rules need !important to lose to it. */
|
||||
|
||||
/* Reboot-restore offer. Amber rather than red: nothing is wrong, the board is
|
||||
asking a question, and the user can ignore it. See reboot-restore-ui.js. */
|
||||
.reboot-restore-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.45rem 1rem;
|
||||
background: linear-gradient(90deg, #b45309, #92400e);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.35);
|
||||
color: #fff;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
flex-shrink: 0;
|
||||
z-index: 1250;
|
||||
}
|
||||
|
||||
.reboot-restore-banner[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.reboot-restore-banner-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.reboot-restore-banner-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reboot-restore-banner-detail {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-weight: 500;
|
||||
/* A flex item will not shrink below its content width at the default
|
||||
`min-width: auto`, so without this the session names push the buttons out of
|
||||
the line between the phone breakpoint and full width. */
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reboot-restore-banner-note {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.reboot-restore-banner-accept,
|
||||
.reboot-restore-banner-dismiss {
|
||||
flex-shrink: 0;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 5px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reboot-restore-banner-accept:hover,
|
||||
.reboot-restore-banner-dismiss:hover {
|
||||
background: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
|
||||
.reboot-restore-banner-accept:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.reboot-restore-banner-dismiss {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
background: transparent;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.offline-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* @fileoverview The pending restore plan: what a host reboot destroyed, waiting on a click.
|
||||
*
|
||||
* The boot pass builds this plan inside `restoreMuxSessions()`, in the window
|
||||
* where reconciliation has reported the dead sessions and `cleanupStaleSessions()`
|
||||
* has not pruned their records yet. The board then offers "restore N sessions
|
||||
* from before the reboot", and `web/routes/reboot-restore-routes` spends the plan
|
||||
* when the user clicks.
|
||||
*
|
||||
* Invariants:
|
||||
* - Entries are in-memory only. A server restart drops the plan, and nothing
|
||||
* re-builds it, because the records it was built from are pruned by then.
|
||||
* That costs the convenience this feature adds and never the conversation:
|
||||
* the conversation IS the transcript under `~/.claude/projects`, which
|
||||
* `services/unified-session-service.ts` reads for the Welcome screen's Resume
|
||||
* list and the Session Manager, and `resumeHistorySession()` in
|
||||
* `web/public/terminal-ui.js` resumes from a row there with no persisted
|
||||
* session record involved. A dropped plan therefore returns the user to
|
||||
* resuming by hand, one at a time, which is where they are without this
|
||||
* feature. What the plan held that a transcript does not is the owner, the
|
||||
* name, the env overrides, the effort and the lineage.
|
||||
* - Module-level singleton in the style of `web/approval-inbox.ts`: no `Session`
|
||||
* import and no IO, which keeps it unit-testable and cycle-free.
|
||||
* - Spending is take-then-build: `take()` removes entries synchronously, before
|
||||
* the route's first `await`, so a double-click or two devices cannot both
|
||||
* reach the same entry and put two panes on one conversation.
|
||||
* - One restore runs at a time per owner. `beginSpending()` single-flights the
|
||||
* route, so two concurrent clicks cannot interleave pane creation for the same
|
||||
* user, while two different users never block each other.
|
||||
*
|
||||
* @dependencies reboot-restore (RebootRestoreEntry)
|
||||
* @consumedby web/server (plan build at boot), web/routes/reboot-restore-routes
|
||||
*
|
||||
* @module web/reboot-restore-registry
|
||||
*/
|
||||
|
||||
import type { RebootRestoreEntry } from '../reboot-restore.js';
|
||||
|
||||
/**
|
||||
* A plan older than this is dropped on read. A machine that rebooted yesterday
|
||||
* has moved on, and an offer nobody took by then is noise rather than a rescue.
|
||||
*/
|
||||
const PLAN_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export class RebootRestoreRegistry {
|
||||
/** Keyed by session id, in the order the boot pass found them. */
|
||||
private entries = new Map<string, RebootRestoreEntry>();
|
||||
/** When the boot pass built the plan, in ms since the epoch. */
|
||||
private builtAt = 0;
|
||||
/**
|
||||
* Entries handed to a restore that has not finished, by session id, each
|
||||
* remembering which caller is spending it.
|
||||
*
|
||||
* A taken entry is still part of the offer until its restore resolves it, so
|
||||
* it has to stay reachable by everything that can invalidate an offer. Holding
|
||||
* the entries themselves — rather than a counter to compare against later —
|
||||
* means `clear()` filters them by the SAME `canAccess(entry.owner)` predicate
|
||||
* it already applies to the plan. A counter cannot do that, because the caller
|
||||
* spending an entry need not be its owner: an admin may restore another user's
|
||||
* sessions, and then the spender and the owner are different keys.
|
||||
*/
|
||||
private parked = new Map<string, { entry: RebootRestoreEntry; spender: string | undefined }>();
|
||||
/**
|
||||
* Owners with a restore in flight, between its take and its last pane.
|
||||
* Keyed by owner so one user's restore does not turn another user's click into
|
||||
* a conflict; `take()` already guarantees no two callers get the same entry.
|
||||
* Single-user mode has one key, `undefined`, so it behaves as one global flight.
|
||||
*/
|
||||
private spending = new Set<string | undefined>();
|
||||
|
||||
/** Replace the plan with what the boot pass found. An empty list clears it. */
|
||||
set(entries: readonly RebootRestoreEntry[]): void {
|
||||
this.entries = new Map(entries.map((entry) => [entry.sessionId, entry]));
|
||||
this.builtAt = entries.length > 0 ? Date.now() : 0;
|
||||
// A fresh boot plan supersedes anything an in-flight restore still holds.
|
||||
this.parked.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* The entries a viewer may see, newest plan first-come order preserved.
|
||||
*
|
||||
* @param canAccess Ownership predicate, so a user sees their own entries and
|
||||
* an admin sees all. Applied here rather than in the route so the count the
|
||||
* banner shows and the entries a click spends come from one filter.
|
||||
*/
|
||||
list(canAccess: (owner: string | undefined) => boolean): RebootRestoreEntry[] {
|
||||
this.dropIfExpired();
|
||||
return [...this.entries.values()].filter((entry) => canAccess(entry.owner));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove and return the entries a click is about to spend.
|
||||
*
|
||||
* Synchronous and total: an entry leaves the plan here, before any pane is
|
||||
* created, so a second click finds nothing to spend. Entries a caller may not
|
||||
* access are left in place, and unknown ids are ignored.
|
||||
*
|
||||
* @param sessionIds The ids to spend, or undefined for every visible entry.
|
||||
*/
|
||||
take(
|
||||
canAccess: (owner: string | undefined) => boolean,
|
||||
sessionIds: readonly string[] | undefined,
|
||||
spender: string | undefined
|
||||
): RebootRestoreEntry[] {
|
||||
this.dropIfExpired();
|
||||
const wanted = sessionIds ? new Set(sessionIds) : undefined;
|
||||
const taken: RebootRestoreEntry[] = [];
|
||||
for (const entry of [...this.entries.values()]) {
|
||||
if (wanted && !wanted.has(entry.sessionId)) continue;
|
||||
if (!canAccess(entry.owner)) continue;
|
||||
this.entries.delete(entry.sessionId);
|
||||
// Parked rather than forgotten: until this restore resolves the entry, a
|
||||
// dismiss still has to be able to reach and cancel it.
|
||||
this.parked.set(entry.sessionId, { entry, spender });
|
||||
taken.push(entry);
|
||||
}
|
||||
return taken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put entries back after a rebuild never got as far as creating a pane.
|
||||
*
|
||||
* Used for the click-time rejections that may resolve themselves: a workspace
|
||||
* that comes back, a capacity limit the user makes room under, a CLI that
|
||||
* starts once its binary is on the PATH. A conversation the user resumed by
|
||||
* hand is NOT put back, because that one cannot stop being true, and an entry
|
||||
* the banner keeps re-offering forever is noise only Dismiss can clear.
|
||||
*/
|
||||
releaseFlight(spender: string | undefined, keep: readonly RebootRestoreEntry[]): void {
|
||||
const wanted = new Set(keep.map((entry) => entry.sessionId));
|
||||
let added = 0;
|
||||
for (const [sessionId, held] of [...this.parked]) {
|
||||
if (held.spender !== spender) continue;
|
||||
this.parked.delete(sessionId);
|
||||
// Still parked means nothing cancelled it while the restore ran. A dismiss,
|
||||
// an expiry or a fresh boot plan removes it from `parked`, and then it does
|
||||
// not come back however the restore ended.
|
||||
if (wanted.has(sessionId)) {
|
||||
this.entries.set(sessionId, held.entry);
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
if (added > 0 && this.builtAt === 0) this.builtAt = Date.now();
|
||||
}
|
||||
|
||||
/** Drop the entries a viewer can see. Returns how many went. */
|
||||
clear(canAccess: (owner: string | undefined) => boolean): number {
|
||||
const removable = [...this.entries.values()].filter((entry) => canAccess(entry.owner));
|
||||
for (const entry of removable) this.entries.delete(entry.sessionId);
|
||||
// Entries a restore is holding are dismissed by the same rule, so a dismiss
|
||||
// that lands mid-restore wins. Judged on the ENTRY's owner, exactly as above,
|
||||
// rather than on who happens to be restoring it.
|
||||
let parkedRemoved = 0;
|
||||
for (const [sessionId, held] of [...this.parked]) {
|
||||
if (!canAccess(held.entry.owner)) continue;
|
||||
this.parked.delete(sessionId);
|
||||
parkedRemoved += 1;
|
||||
}
|
||||
if (this.entries.size === 0) this.builtAt = 0;
|
||||
return removable.length + parkedRemoved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim the right to run a restore for one owner, or report that owner already
|
||||
* has one running. Callers that get `true` must call `endSpending()` in a
|
||||
* `finally` with the same owner.
|
||||
*/
|
||||
beginSpending(owner?: string): boolean {
|
||||
if (this.spending.has(owner)) return false;
|
||||
this.spending.add(owner);
|
||||
return true;
|
||||
}
|
||||
|
||||
endSpending(owner?: string): void {
|
||||
this.spending.delete(owner);
|
||||
}
|
||||
|
||||
/** Test hook: forget everything, including the single-flight claim. */
|
||||
reset(): void {
|
||||
this.entries.clear();
|
||||
this.parked.clear();
|
||||
this.builtAt = 0;
|
||||
this.spending.clear();
|
||||
}
|
||||
|
||||
private dropIfExpired(): void {
|
||||
if (this.builtAt > 0 && Date.now() - this.builtAt > PLAN_TTL_MS) {
|
||||
// A restore that took entries just before the expiry must not hand them
|
||||
// back afterwards and give an expired plan another full day of life.
|
||||
this.parked.clear();
|
||||
this.entries.clear();
|
||||
this.builtAt = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Process-wide singleton, mirroring `approvalInbox`. */
|
||||
export const rebootRestoreRegistry = new RebootRestoreRegistry();
|
||||
@@ -11,6 +11,7 @@ export { registerCronRoutes } from './cron-routes.js';
|
||||
export { registerSystemRoutes } from './system-routes.js';
|
||||
export { registerHookEventRoutes } from './hook-event-routes.js';
|
||||
export { registerApprovalRoutes } from './approval-routes.js';
|
||||
export { registerRebootRestoreRoutes } from './reboot-restore-routes.js';
|
||||
export { registerReadMyMindRoutes } from './readmymind-routes.js';
|
||||
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
|
||||
export { registerCaseRoutes } from './case-routes.js';
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* @fileoverview Reboot-restore routes: offer back the sessions a host reboot destroyed.
|
||||
*
|
||||
* The boot pass leaves a plan in `web/reboot-restore-registry` when the machine
|
||||
* plausibly rebooted. The board reads it, shows a banner, and the user decides:
|
||||
* - `GET /api/reboot-restore`: what is on offer, ownership-scoped
|
||||
* - `POST /api/reboot-restore/restore`: rebuild some or all of it
|
||||
* - `POST /api/reboot-restore/dismiss`: drop the offer
|
||||
*
|
||||
* A click, not the heuristic, is what creates panes. The heuristic only decides
|
||||
* whether the banner appears, so a wrong yes costs a line of text the user
|
||||
* dismisses rather than N CLI processes nobody asked for.
|
||||
*
|
||||
* Rebuilding is take-then-build: entries leave the plan synchronously at the top
|
||||
* of the route, before the first `await`, and the whole route is single-flighted,
|
||||
* so a double-click or two devices cannot put two panes on one conversation.
|
||||
* Three things are re-checked at click time rather than trusted from boot: the
|
||||
* owner's privilege grant, the workspace still being on disk, and the
|
||||
* conversation not already being live because the user resumed it by hand.
|
||||
*
|
||||
* A rebuilt session comes back attached, idle and disarmed. Respawn controllers
|
||||
* and Ralph loops are deliberately not re-armed, and its terminal scrollback is
|
||||
* gone, because the pane is new. The banner says so.
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import { RebootRestoreRequestSchema } from '../schemas.js';
|
||||
import {
|
||||
parseBody,
|
||||
getAuthUser,
|
||||
canAccessOwned,
|
||||
ownerFor,
|
||||
isWorkingDirAllowedForUsername,
|
||||
sessionCapacityMessage,
|
||||
} from '../route-helpers.js';
|
||||
import { rebootRestoreRegistry } from '../reboot-restore-registry.js';
|
||||
import { rejectAlreadyLive, type RebootRestoreEntry, type RebootRestoreRejection } from '../../reboot-restore.js';
|
||||
import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
|
||||
import { Session } from '../../session.js';
|
||||
import { resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
import { getCli } from '../../config/cli-registry/registry.js';
|
||||
import { applyWorkspaceHooks } from '../../hooks-config.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import { STATS_COLLECTION_INTERVAL_MS } from '../../config/server-timing.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { SessionAttachmentHistoryItem } from '../../types.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
|
||||
type RebootRestoreCtx = SessionPort & EventPort & ConfigPort & InfraPort;
|
||||
|
||||
/** The banner's view of one restorable session. The record itself never leaves the server. */
|
||||
function toBannerItem(entry: RebootRestoreEntry) {
|
||||
return {
|
||||
id: entry.sessionId,
|
||||
name: entry.name,
|
||||
workingDir: entry.workingDir,
|
||||
mode: entry.mode,
|
||||
owner: entry.owner,
|
||||
};
|
||||
}
|
||||
|
||||
export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRestoreCtx): void {
|
||||
const accessorFor = (req: Parameters<typeof getAuthUser>[0]) => {
|
||||
const user = getAuthUser(req);
|
||||
return (owner: string | undefined) => canAccessOwned(user, owner);
|
||||
};
|
||||
|
||||
// ========== What is on offer ==========
|
||||
|
||||
app.get('/api/reboot-restore', async (req) => {
|
||||
const entries = rebootRestoreRegistry.list(accessorFor(req));
|
||||
return {
|
||||
sessions: entries.map(toBannerItem),
|
||||
// Said plainly here so the banner never implies a full restore: the pane is
|
||||
// new, so the conversation continues and the terminal history does not.
|
||||
scrollbackRestored: false,
|
||||
};
|
||||
});
|
||||
|
||||
// ========== Spend it ==========
|
||||
|
||||
app.post('/api/reboot-restore/restore', async (req, reply) => {
|
||||
const body = parseBody(RebootRestoreRequestSchema, req.body, 'Invalid reboot restore request');
|
||||
const canAccess = accessorFor(req);
|
||||
const owner = ownerFor(req);
|
||||
|
||||
// Take BEFORE the first await: a second click must find nothing to spend.
|
||||
// The flight is per owner, because `take()` already guarantees two callers
|
||||
// never receive the same entry, so one user's restore need not block another's.
|
||||
if (!rebootRestoreRegistry.beginSpending(owner)) {
|
||||
return reply.code(409).send(createErrorResponse(ApiErrorCode.CONFLICT, 'A reboot restore is already running'));
|
||||
}
|
||||
const taken = rebootRestoreRegistry.take(canAccess, body.sessionIds, owner);
|
||||
// Entries nothing built a pane for, returned to the plan on every exit path
|
||||
// including a throw. Without this a failure between here and the loop would
|
||||
// spend the offer and rebuild nothing, and the plan cannot be rebuilt.
|
||||
const unspent = new Set(taken);
|
||||
|
||||
try {
|
||||
if (taken.length === 0) return { restored: [], skipped: [] };
|
||||
|
||||
// The plan was built at boot and the board has moved on since. A conversation
|
||||
// the user resumed by hand from the Resume list is already on screen, and a
|
||||
// second pane on it would fight the first for the same transcript. This one
|
||||
// is never re-offered: unlike a missing workspace, it cannot stop being true.
|
||||
const liveSessionIds = new Set(ctx.sessions.keys());
|
||||
const liveConversationIds = new Set(
|
||||
[...ctx.sessions.values()].map((session) => session.claudeSessionId).filter((id): id is string => !!id)
|
||||
);
|
||||
const { restore, skipped } = rejectAlreadyLive(taken, liveSessionIds, liveConversationIds);
|
||||
for (const entry of taken) {
|
||||
if (skipped.some((s) => s.sessionId === entry.sessionId)) unspent.delete(entry);
|
||||
}
|
||||
|
||||
const restored: ReturnType<typeof toBannerItem>[] = [];
|
||||
const failures: RebootRestoreRejection[] = [...skipped];
|
||||
const workspaceHooksEnabled = await ctx.getWorkspaceHooksEnabled();
|
||||
|
||||
for (const entry of restore) {
|
||||
// Capacity is re-checked per iteration, because this loop is itself
|
||||
// creating the sessions it counts. The offer can be a day old, so the
|
||||
// board may be fuller now than the plan assumed.
|
||||
const capMsg = sessionCapacityMessage(ctx.sessions, entry.owner);
|
||||
if (capMsg) {
|
||||
failures.push({ sessionId: entry.sessionId, reason: 'capacity-reached' });
|
||||
continue;
|
||||
}
|
||||
// A repo can be deleted between the boot that planned this and the click.
|
||||
if (!existsSync(entry.workingDir)) {
|
||||
failures.push({ sessionId: entry.sessionId, reason: 'workspace-missing' });
|
||||
continue;
|
||||
}
|
||||
// Multi-user workspace separation: the create route confines a non-admin's
|
||||
// workingDir to their own case space, and a grant can be withdrawn between
|
||||
// the session's creation and this restore, so the confinement is re-run
|
||||
// rather than inherited from the record. Keyed on the OWNER, not on the
|
||||
// caller: an admin spending another user's entry must be held to that
|
||||
// user's confinement, and `isWorkingDirAllowed` would wave an admin
|
||||
// through. The same reason the two grant re-checks below read
|
||||
// `saved.owner`.
|
||||
if (!(await isWorkingDirAllowedForUsername(entry.owner, entry.workingDir))) {
|
||||
// Left on offer: a withdrawn grant can be restored, unlike an already-open
|
||||
// conversation, so this is not the permanent kind of refusal.
|
||||
failures.push({ sessionId: entry.sessionId, reason: 'workspace-forbidden' });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const saved = entry.state;
|
||||
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const session = new Session({
|
||||
// The old id is reused on purpose: a pinned record, subagent parents,
|
||||
// window states and the lifecycle log all key off it, and the unpinned
|
||||
// record is gone, so there is nothing to collide with.
|
||||
id: saved.id,
|
||||
workingDir: saved.workingDir,
|
||||
mode: saved.mode,
|
||||
name: saved.name,
|
||||
createdAt: saved.createdAt,
|
||||
mux: ctx.mux,
|
||||
useMux: true,
|
||||
// No `muxSession`: the reboot took the pane with it, so `startInteractive()`
|
||||
// takes its create branch and makes a fresh one.
|
||||
claudeMode: await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, saved.owner),
|
||||
allowedTools: claudeModeConfig.allowedTools,
|
||||
resumeSessionId: entry.resumeConversationId,
|
||||
// Re-resolved against the owner's CURRENT grant, never replayed from the
|
||||
// record: a grant held when the record was written may be gone now.
|
||||
envOverrides: await clampEnvOverridesForOwner(
|
||||
saved.owner,
|
||||
(saved as { __envOverrides?: Record<string, string> }).__envOverrides
|
||||
),
|
||||
effort: saved.effort,
|
||||
attachmentHistory:
|
||||
(saved as { __attachmentHistory?: SessionAttachmentHistoryItem[] }).__attachmentHistory ??
|
||||
saved.attachmentHistory,
|
||||
lastSubmitAt: saved.lastSubmitAt,
|
||||
claudeSessionChain: saved.claudeSessionChain,
|
||||
lastActivityAt: saved.lastActivityAt,
|
||||
owner: saved.owner,
|
||||
parentSessionId: saved.parentSessionId,
|
||||
});
|
||||
|
||||
await ctx.addSession(session);
|
||||
// Before the listeners, because setupSessionListeners() reads the
|
||||
// image-watcher flag this phase restores; before the spawn, because the
|
||||
// custom-model environment and the nice priority shape the process.
|
||||
await ctx.reapplyPersistedSessionState(session, saved, 'before-spawn');
|
||||
await ctx.setupSessionListeners(session);
|
||||
await session.startInteractive();
|
||||
// The session's own history, applied only once the pane exists: on a
|
||||
// failed start these totals would belong to a session that never ran.
|
||||
// Both halves precede the route's OWN persist, which matters because a
|
||||
// constructed session carries none of this and `toState()` is written
|
||||
// wholesale, so persisting first would replace the fuller record with
|
||||
// the reduced one and drop the pin that keeps it from being pruned. A
|
||||
// listener-driven persist can still land inside the debounce window
|
||||
// while the pane starts; the write below repairs the record.
|
||||
await ctx.reapplyPersistedSessionState(session, saved, 'after-spawn');
|
||||
ctx.persistSessionState(session);
|
||||
|
||||
// A session without its workspace hooks goes silently blind: no stop or
|
||||
// idle events for respawn, no Approvals Inbox item, no red tab on a
|
||||
// blocking dialog. The boot-time sweep finished hours ago, so the click
|
||||
// path installs them itself. `hooks: 'always'` is the capability that says
|
||||
// this CLI installs Codeman's hooks into the workspace.
|
||||
if (workspaceHooksEnabled && getCli(session.mode)?.capabilities.hooks === 'always') {
|
||||
await applyWorkspaceHooks(session.workingDir, true).catch((err: unknown) =>
|
||||
console.warn(`[reboot-restore] hook install failed for ${session.workingDir}: ${getErrorMessage(err)}`)
|
||||
);
|
||||
}
|
||||
|
||||
getLifecycleLog().log({ event: 'recovered', sessionId: session.id, name: session.name });
|
||||
// Every other open tab and phone needs this; the clicking tab already has
|
||||
// the response, and the client's handler is an idempotent upsert.
|
||||
ctx.broadcast(SseEvent.SessionCreated, ctx.getSessionStateWithRespawn(session));
|
||||
restored.push(toBannerItem(entry));
|
||||
} catch (err) {
|
||||
// One entry that will not start must not stop the rest of the pass, and
|
||||
// must not leave a registered session with no pane behind it: by this
|
||||
// point the session is in `ctx.sessions`, holds a tab-layout slot and has
|
||||
// listeners.
|
||||
//
|
||||
// Reaching this is rarer than it looks, measured against a real server:
|
||||
// the CLI resolver finds its binary by absolute path rather than through
|
||||
// PATH, and tmux falls back to another directory rather than failing when
|
||||
// it cannot enter the workspace, so neither of the two obvious "freshly
|
||||
// booted machine" failures throws. What is left is the mux layer itself
|
||||
// failing, which is why this path is defended rather than expected.
|
||||
console.error(`[reboot-restore] failed to rebuild ${entry.sessionId}:`, err);
|
||||
// Not cleanupSession(): that is the user-initiated delete, and it would
|
||||
// count this session's historical tokens into the lifetime totals, demote
|
||||
// a pinned record to `stopped` (which this pass reads as an intentional
|
||||
// kill, making the session permanently unrestorable) and delete the
|
||||
// workspace's `.claude-images`. This undoes only the construction.
|
||||
await ctx
|
||||
.discardPartiallyBuiltSession(entry.sessionId)
|
||||
.catch((discardErr: unknown) =>
|
||||
console.error(`[reboot-restore] discarding a failed rebuild failed: ${getErrorMessage(discardErr)}`)
|
||||
);
|
||||
failures.push({ sessionId: entry.sessionId, reason: 'rebuild-failed' });
|
||||
// Left on offer: the user can put the binary back and click again.
|
||||
continue;
|
||||
}
|
||||
unspent.delete(entry);
|
||||
}
|
||||
|
||||
if (restored.length > 0) {
|
||||
// A reboot leaves recovery with nothing alive to find, so its own block never
|
||||
// started the stats collector. This clears and re-arms its interval, so it is
|
||||
// safe to call whether or not the collector is already running.
|
||||
ctx.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS);
|
||||
}
|
||||
|
||||
return { restored, skipped: failures };
|
||||
} finally {
|
||||
// Anything that never became a pane goes back on offer, including after a
|
||||
// throw, so a transient failure costs a retry rather than the whole plan.
|
||||
// Ends the flight: entries still parked for it come back if they are in
|
||||
// `unspent`, and a Dismiss that unparked them meanwhile wins.
|
||||
rebootRestoreRegistry.releaseFlight(owner, [...unspent]);
|
||||
rebootRestoreRegistry.endSpending(owner);
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Drop it ==========
|
||||
|
||||
app.post('/api/reboot-restore/dismiss', async (req) => {
|
||||
const dismissed = rebootRestoreRegistry.clear(accessorFor(req));
|
||||
return { dismissed };
|
||||
});
|
||||
}
|
||||
@@ -89,6 +89,7 @@ import {
|
||||
} from '../route-helpers.js';
|
||||
import { buildAgentCaseMarker, writeAgentCaseMarker } from '../../agent-case-marker.js';
|
||||
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
|
||||
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
|
||||
import { resolveCliLaunchError } from '../../utils/cli-launcher.js';
|
||||
import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
|
||||
@@ -442,72 +443,6 @@ export async function _clampExternalCliBypassForOwner(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Env-var keys a non-granted owner must not be able to set, because each one
|
||||
* hands back privilege the config clamp above just removed, or redirects a
|
||||
* credential-resolution endpoint.
|
||||
*
|
||||
* The DeepSeek three are reachable because `DSH_*` and `DEEPSEEK_*` are
|
||||
* allowlisted `envOverrides` prefixes (schemas.ts) — which they have to be, since
|
||||
* that is also how a user configures the harness's non-privileged knobs.
|
||||
*
|
||||
* - `DSH_PERMISSION_MODE` IS the harness's permission switch. Every other CLI's
|
||||
* bypass is a command-line FLAG, reachable only through the per-CLI config the
|
||||
* clamp already owns; this one is an env var, so the config clamp alone is
|
||||
* half a gate.
|
||||
* - `DSH_HOME` points the launcher at a profile tree, and a profile's plugin code
|
||||
* executes at BOOT, before any approval row can apply. A user who can write a
|
||||
* workspace can put a profile in it, so this is the wider of the two.
|
||||
* - `DEEPSEEK_BASE_URL` aims the provider endpoint, and `_configureCliEnv()`
|
||||
* forwards the SERVER's own `DEEPSEEK_API_KEY` into every dsh pane before
|
||||
* `applyEnvOverrides()` runs — so a non-granted owner who could set the base
|
||||
* URL would have the operator's API key sent as a bearer credential to a host
|
||||
* of their choosing. (`DEEPSEEK_API_KEY` itself stays overridable: supplying
|
||||
* your OWN key removes privilege rather than granting it.)
|
||||
* - `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN` are where omp resolves
|
||||
* credentials from — the same shape as `DEEPSEEK_BASE_URL` above, reachable
|
||||
* because `OMP_*` is an allowlisted prefix. Unlike DeepSeek, Codeman does not
|
||||
* forward any operator-held key into an omp pane today (omp's provider
|
||||
* credentials live in `~/.omp` config files, not env vars), so there is no
|
||||
* known concrete exfiltration path yet — clamped defensively anyway, since a
|
||||
* non-granted owner redirecting where a shared multi-tenant deployment
|
||||
* resolves auth from is not something to allow silently (found in
|
||||
* Ark0N/Codeman#353 review; omp's own knobs are otherwise mostly `PI_*`,
|
||||
* already allowlisted for pi and not addressed here — see resolveOmpHome()).
|
||||
*/
|
||||
function ownerClampedEnvKeys(): string[] {
|
||||
return enabledClis().flatMap((entry) => entry.capabilities.privilegedEnvKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Env-var half of the multi-user bypass clamp.
|
||||
*
|
||||
* `clampExternalCliBypassForOwner()` clamps the per-CLI CONFIG, and for every CLI
|
||||
* but DeepSeek that is the whole story. Here it is not: `applyEnvOverrides()` runs
|
||||
* AFTER `_configureCliEnv()` in tmux-manager, so an override sent on the SAME
|
||||
* request lands last and wins, and a non-granted owner could restore
|
||||
* `danger-full-access` on the very request the config clamp downgraded.
|
||||
*
|
||||
* Keys are DROPPED rather than rewritten: dropping falls through to what
|
||||
* `_configureCliEnv()` exports, which is the clamped config and the server's own
|
||||
* `DSH_HOME`, i.e. exactly the intended state. No-op in single-user mode and for a
|
||||
* granted owner, like every other clamp here
|
||||
* (`canUsernameRunPrivilegedCommands()` returns true when `!isMultiUserMode()`),
|
||||
* and it returns the caller's own object untouched when there is nothing to strip.
|
||||
*/
|
||||
async function clampEnvOverridesForOwner(
|
||||
owner: string | undefined,
|
||||
envOverrides: Record<string, string> | undefined
|
||||
): Promise<Record<string, string> | undefined> {
|
||||
if (!envOverrides) return envOverrides;
|
||||
const keys = ownerClampedEnvKeys();
|
||||
if (!keys.some((key) => key in envOverrides)) return envOverrides;
|
||||
if (await canUsernameRunPrivilegedCommands(owner)) return envOverrides;
|
||||
const clamped = { ...envOverrides };
|
||||
for (const key of keys) delete clamped[key];
|
||||
return clamped;
|
||||
}
|
||||
|
||||
/** Test hook: the env-var half of the same multi-user safety gate. */
|
||||
export const _clampEnvOverridesForOwner = clampEnvOverridesForOwner;
|
||||
|
||||
|
||||
@@ -1161,6 +1161,20 @@ const NotificationEventSchema = z
|
||||
})
|
||||
.optional();
|
||||
|
||||
/**
|
||||
* Body of `POST /api/reboot-restore/restore`.
|
||||
*
|
||||
* `sessionIds` restores a subset, and omitting it restores everything the caller
|
||||
* can see. The ids are session ids from `GET /api/reboot-restore`, and an id the
|
||||
* caller does not own is ignored rather than refused, matching how the session
|
||||
* list scopes rather than 403s.
|
||||
*/
|
||||
export const RebootRestoreRequestSchema = z
|
||||
.object({
|
||||
sessionIds: z.array(z.string().max(128)).max(200).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const SettingsUpdateSchema = z
|
||||
.object({
|
||||
// User-facing product branding. This changes browser/UI copy only; package,
|
||||
|
||||
+229
-1
@@ -39,7 +39,9 @@ import { fileURLToPath } from 'node:url';
|
||||
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync, statSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { hostname as getHostname } from 'node:os';
|
||||
import { hostname as getHostname, uptime as osUptime } from 'node:os';
|
||||
import { looksLikeHostReboot, newestPersistedActivity, planRebootRestore } from '../reboot-restore.js';
|
||||
import { rebootRestoreRegistry } from './reboot-restore-registry.js';
|
||||
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
|
||||
import { normalizeBasePath, stripBasePath, joinBasePath } from '../config/base-path.js';
|
||||
import { GLYPH, palette } from '../cli-style.js';
|
||||
@@ -171,6 +173,7 @@ import {
|
||||
registerScheduledRoutes,
|
||||
registerHookEventRoutes,
|
||||
registerApprovalRoutes,
|
||||
registerRebootRestoreRoutes,
|
||||
registerReadMyMindRoutes,
|
||||
registerStatusTelemetryRoutes,
|
||||
registerSystemRoutes,
|
||||
@@ -668,6 +671,8 @@ export class WebServer extends EventEmitter {
|
||||
setupSessionListeners: this.setupSessionListeners.bind(this),
|
||||
persistSessionState: this.persistSessionState.bind(this),
|
||||
persistSessionStateNow: this._persistSessionStateNow.bind(this),
|
||||
reapplyPersistedSessionState: this.reapplyPersistedSessionState.bind(this),
|
||||
discardPartiallyBuiltSession: this.discardPartiallyBuiltSession.bind(this),
|
||||
getSessionStateWithRespawn: this.getSessionStateWithRespawn.bind(this),
|
||||
// EventPort
|
||||
broadcast: this.broadcast.bind(this),
|
||||
@@ -1060,6 +1065,7 @@ export class WebServer extends EventEmitter {
|
||||
registerScheduledRoutes(this.app, ctx);
|
||||
registerHookEventRoutes(this.app, ctx);
|
||||
registerApprovalRoutes(this.app, ctx);
|
||||
registerRebootRestoreRoutes(this.app, ctx);
|
||||
registerReadMyMindRoutes(this.app, ctx);
|
||||
registerStatusTelemetryRoutes(this.app, ctx);
|
||||
registerSystemRoutes(this.app, ctx);
|
||||
@@ -2857,6 +2863,212 @@ export class WebServer extends EventEmitter {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Work out what a host reboot destroyed, and leave it on offer for the board.
|
||||
*
|
||||
* Runs inside `restoreMuxSessions()`, in the window after `reconcileSessions()`
|
||||
* has reported the dead sessions and before `finalizeRestoredState()` prunes
|
||||
* their records, so `state.json` is still the full picture here. That window is
|
||||
* the only place the plan can be built, which is why the boot pass builds it
|
||||
* even though nothing is rebuilt until a user clicks.
|
||||
*
|
||||
* Nothing is created here. The plan goes to `rebootRestoreRegistry`, the board
|
||||
* offers it as a banner, and `web/routes/reboot-restore-routes` rebuilds what
|
||||
* the user asks for. A wrong reboot guess therefore costs a line of text the
|
||||
* user dismisses, not N CLI processes nobody asked for.
|
||||
*
|
||||
* @returns how many sessions are on offer.
|
||||
*/
|
||||
private planRebootRestoreOffer(dead: string[], livePaneCount: number): number {
|
||||
if (dead.length === 0) return 0;
|
||||
|
||||
const persisted = this.store.getSessions();
|
||||
if (
|
||||
!looksLikeHostReboot({
|
||||
livePaneCount,
|
||||
deadSessionCount: dead.length,
|
||||
uptimeSeconds: osUptime(),
|
||||
newestPersistedActivityAt: newestPersistedActivity(persisted),
|
||||
now: Date.now(),
|
||||
})
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const { restore, skipped } = planRebootRestore(dead, persisted, (workingDir) => existsSync(workingDir));
|
||||
if (skipped.length > 0) {
|
||||
console.log(`[Server] Reboot restore is passing over ${skipped.length} dead session(s):`);
|
||||
for (const rejection of skipped) {
|
||||
console.log(`[Server] ${rejection.sessionId}: ${rejection.reason}`);
|
||||
}
|
||||
}
|
||||
rebootRestoreRegistry.set(restore);
|
||||
if (restore.length > 0) {
|
||||
console.log(`[Server] Host reboot detected; offering ${restore.length} session(s) for restore`);
|
||||
}
|
||||
return restore.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-apply the persisted state that a `Session` constructor does not take.
|
||||
*
|
||||
* The reboot-restore route builds a session from a record rather than
|
||||
* attaching to a surviving pane, so everything the constructor has no
|
||||
* parameter for starts at its default. Persisting such a session writes
|
||||
* `toState()` wholesale, which would REPLACE the record with the reduced
|
||||
* version — and for a pinned session that is worse than losing a setting,
|
||||
* because `cleanupSessionsByIds()` keeps a record only while it is pinned, so
|
||||
* dropping the pin hands the record to the next stale sweep.
|
||||
*
|
||||
* Split in two phases because the two halves have opposite timing needs:
|
||||
*
|
||||
* - `before-spawn` shapes the pane itself, so it has to land before the CLI
|
||||
* process starts, and before `setupSessionListeners()`, which reads the
|
||||
* image-watcher flag. The custom-model selection is an environment injection
|
||||
* and the nice priority is applied to the spawn.
|
||||
* - `after-spawn` is the session's own accumulated history. It must NOT land
|
||||
* on a session whose pane failed to start: the totals would then belong to a
|
||||
* session that never ran, and any later cleanup would add them to the
|
||||
* lifetime figures a second time.
|
||||
*
|
||||
* Respawn and Ralph are deliberately NOT re-armed: a machine that just came up
|
||||
* is the worst moment to turn an autonomous run loose, and the user re-arms
|
||||
* what they want. Ralph's loop CONFIGURATION does not survive either, because
|
||||
* `toState()` reads `ralphEnabled` and the completion phrase off a live
|
||||
* tracker, and there is no way to hold them without arming the loop.
|
||||
*/
|
||||
async reapplyPersistedSessionState(
|
||||
session: Session,
|
||||
saved: SessionState,
|
||||
phase: 'before-spawn' | 'after-spawn'
|
||||
): Promise<void> {
|
||||
if (phase === 'before-spawn') {
|
||||
// The custom-model env has to be rebuilt from the endpoint store: the persist
|
||||
// deliberately keeps the injected VALUES out of state.json, so only the
|
||||
// bookkeeping survives a restart and the values are re-derived here.
|
||||
const savedCustomModel = (saved as { __customModel?: CustomModelBookkeeping }).__customModel;
|
||||
if (savedCustomModel) {
|
||||
session.setCustomModel(savedCustomModel, await this._rebuildCustomModelEnv(session, savedCustomModel));
|
||||
}
|
||||
if (saved.niceEnabled !== undefined || saved.niceValue !== undefined) {
|
||||
session.setNice({ enabled: saved.niceEnabled, niceValue: saved.niceValue });
|
||||
}
|
||||
// `setupSessionListeners()` READS this flag to decide whether to start the
|
||||
// watcher, so setting it later would leave the session reporting the feature
|
||||
// as on with nothing watching.
|
||||
if (saved.imageWatcherEnabled !== undefined) session.imageWatcherEnabled = saved.imageWatcherEnabled;
|
||||
return;
|
||||
}
|
||||
|
||||
if (saved.pinned) session.restorePin(true, saved.pinnedAt);
|
||||
if (saved.autoCompactEnabled !== undefined || saved.autoCompactThreshold !== undefined) {
|
||||
session.setAutoCompact(saved.autoCompactEnabled ?? false, saved.autoCompactThreshold, saved.autoCompactPrompt);
|
||||
}
|
||||
if (saved.autoClearEnabled !== undefined || saved.autoClearThreshold !== undefined) {
|
||||
session.setAutoClear(saved.autoClearEnabled ?? false, saved.autoClearThreshold);
|
||||
}
|
||||
if (saved.autoResumeEnabled) {
|
||||
session.restoreAutoResume(true, saved.autoResumeAt);
|
||||
}
|
||||
if (saved.inputTokens !== undefined || saved.outputTokens !== undefined || saved.totalCost !== undefined) {
|
||||
session.restoreTokens(saved.inputTokens ?? 0, saved.outputTokens ?? 0, saved.totalCost ?? 0);
|
||||
// Seed the daily-usage baseline, or the restored totals are counted again as new usage.
|
||||
this.lastRecordedTokens.set(session.id, {
|
||||
input: saved.inputTokens ?? 0,
|
||||
output: saved.outputTokens ?? 0,
|
||||
});
|
||||
}
|
||||
if (saved.color) session.setColor(saved.color);
|
||||
if (saved.flickerFilterEnabled !== undefined) session.flickerFilterEnabled = saved.flickerFilterEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo a session that was registered but never got a working pane.
|
||||
*
|
||||
* Deliberately NOT `cleanupSession()`, which is the user-initiated delete: that
|
||||
* path adds the session's token totals to the lifetime figures, demotes a
|
||||
* pinned record to `stopped` (the durable marker of an intentional kill, which
|
||||
* would make the session permanently ineligible for a reboot restore), drops
|
||||
* the persisted Ralph state, and recursively removes `.claude-images` from the
|
||||
* WORKING DIRECTORY, which belongs to the workspace rather than to this session
|
||||
* and may hold another live session's pasted images.
|
||||
*
|
||||
* Everything else `_doCleanupSession()` does, this has to do as well. It is the
|
||||
* inverse of `registerSessionWithLayout()` plus `setupSessionListeners()`, and
|
||||
* every registration those two make has to come back out — above all
|
||||
* `sessionListenerRefs`, whose presence makes `setupSessionListeners()` return
|
||||
* early. Leaving that entry behind is worse than the leak this function exists
|
||||
* to prevent: the retry reuses the same session id, wires no listeners at all,
|
||||
* and the user gets a tab that never shows output.
|
||||
*
|
||||
* The persisted record, the lifetime totals, the stored Ralph state and the
|
||||
* workspace's own files are left exactly as they were, so the session stays
|
||||
* restorable on the next attempt.
|
||||
*/
|
||||
async discardPartiallyBuiltSession(sessionId: string): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
this.sessions.delete(sessionId);
|
||||
|
||||
// --- the inverse of setupSessionListeners(), in reverse order ---
|
||||
// Listeners first: while they are attached, one of them can still reach a
|
||||
// tracker this is about to stop.
|
||||
const listeners = this.sessionListenerRefs.get(sessionId);
|
||||
if (listeners) {
|
||||
detachSessionListeners(session, listeners);
|
||||
this.sessionListenerRefs.delete(sessionId);
|
||||
}
|
||||
// An FSWatcher on the workspace that nothing else closes.
|
||||
imageWatcher.unwatchSession(sessionId);
|
||||
// An fs.watch on the workspace (or on @fix_plan.md), likewise.
|
||||
session.ralphTracker.stopWatchingFixPlan();
|
||||
const summaryTracker = this.runSummaryTrackers.get(sessionId);
|
||||
if (summaryTracker) {
|
||||
summaryTracker.stop();
|
||||
this.runSummaryTrackers.delete(sessionId);
|
||||
}
|
||||
|
||||
// --- what anything else may have attached to this id in the meantime ---
|
||||
// A rebuild can fail AFTER startInteractive() resolved, and a restored
|
||||
// workspace still carries Codeman's hooks, so the CLI can post a hook event
|
||||
// within milliseconds. Each of these outlives the listeners and would
|
||||
// otherwise meet the retry, which reuses the same session id by design.
|
||||
this.stopTranscriptWatcher(sessionId);
|
||||
attachmentRegistry.clearSession(sessionId);
|
||||
sessionWaits.notifySignal(sessionId, 'exit');
|
||||
sessionWaits.cancelAll(sessionId);
|
||||
approvalInbox.resolveForSession(sessionId, 'session_ended');
|
||||
|
||||
// --- the inverse of the construction itself ---
|
||||
this.sse.cleanupSessionBatches(sessionId);
|
||||
this.persistDeb.cancelKey(sessionId);
|
||||
fileStreamManager.closeSessionStreams(sessionId);
|
||||
// `lastRecordedTokens` is deliberately NOT deleted: the `after-spawn` phase
|
||||
// seeds it as the daily-usage baseline for these restored totals, and the
|
||||
// retry reuses the id, so dropping it would count them as new usage.
|
||||
// The per-session custom-model config dir carries the endpoint's API key, and
|
||||
// `before-spawn` may already have written it. Nothing else would ever remove
|
||||
// it: the stale sweep only touches state.json. A retry rewrites it.
|
||||
removeConfigDir(customModelConfigDir(sessionId));
|
||||
try {
|
||||
session.removeAllListeners();
|
||||
await session.stop(true);
|
||||
} catch (err) {
|
||||
console.warn(`[Server] stopping a partially built session failed: ${getErrorMessage(err)}`);
|
||||
// `stop()` kills the mux session in its last block, after destroying its
|
||||
// trackers, so a throw on the way there leaves the pane running.
|
||||
await this.mux.killSession(sessionId).catch(() => {});
|
||||
}
|
||||
try {
|
||||
await this.tabLayouts.sessionsRemoved([{ id: sessionId, owner: session.owner }]);
|
||||
} catch (err) {
|
||||
console.warn(`[Server] releasing the tab layout slot failed: ${getErrorMessage(err)}`);
|
||||
}
|
||||
// Any `session:updated` the half-built session emitted before it failed left a
|
||||
// tab on every other open board, and the client's handler is an upsert.
|
||||
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
|
||||
}
|
||||
|
||||
private async restoreMuxSessions(): Promise<boolean> {
|
||||
try {
|
||||
// Reconcile mux sessions to find which ones are still alive (also discovers unknown ones)
|
||||
@@ -2866,6 +3078,22 @@ export class WebServer extends EventEmitter {
|
||||
console.log(`[Server] Discovered ${discovered.length} unknown mux session(s)`);
|
||||
}
|
||||
|
||||
// Build the reboot-restore offer HERE: `dead` is only known after
|
||||
// reconciliation, and the records it reads are pruned by
|
||||
// `cleanupStaleSessions()` as soon as `finalizeRestoredState()` runs.
|
||||
//
|
||||
// Guarded on its own, because this runs inside the try that decides whether
|
||||
// RECOVERY succeeded. A throw here would otherwise be caught below, report
|
||||
// restoration as failed, and block the stale cleanup and layout
|
||||
// reconciliation that follow — turning an optional convenience into a
|
||||
// failure of the thing it is supposed to help. An offer nobody gets is the
|
||||
// correct way for this to fail.
|
||||
try {
|
||||
this.planRebootRestoreOffer(dead, alive.length);
|
||||
} catch (err) {
|
||||
console.error('[Server] Building the reboot-restore offer failed; continuing recovery:', err);
|
||||
}
|
||||
|
||||
if (alive.length > 0 || discovered.length > 0) {
|
||||
console.log(`[Server] Found ${alive.length + discovered.length} alive mux session(s) from previous run`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user