ompchamber 3.5.0 → 3.6.0
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.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +357 -353
- package/dist/client/static/js/index-7n838fz5.css +1 -0
- package/dist/client/static/js/{index-w0cem7n5.js → index-qp2sfmn2.js} +591 -592
- package/package.json +1 -1
- package/src/cli/lib/commands/status.js +7 -0
- package/src/client/components/layout/session-sidebar/about-modal/UpdateLog.tsx +50 -0
- package/src/client/components/layout/session-sidebar/about-modal/UpdateSection.tsx +12 -17
- package/src/client/components/settings/ProjectSelectorDropdown.tsx +8 -2
- package/src/client/components/settings/SettingsSidebarList.tsx +5 -1
- package/src/client/components/settings/categories/CommandSettings.tsx +47 -9
- package/src/client/components/settings/categories/SkillCatalogSettings.tsx +23 -43
- package/src/client/components/settings/categories/SkillSettings.tsx +51 -7
- package/src/client/components/settings/categories/command-settings/DetailPane.tsx +109 -103
- package/src/client/components/settings/categories/command-settings/SidebarList.tsx +6 -1
- package/src/client/components/settings/categories/skill-settings/DetailPane.tsx +130 -31
- package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +9 -3
- package/src/client/data/settings/command.ts +0 -40
- package/src/client/hooks/settings/workspace-roots.ts +99 -0
- package/src/client/hooks/ui/updates/index.ts +178 -0
- package/src/server/db.server.ts +41 -11
- package/src/server/index.ts +17 -1
- package/src/server/lib/db/client.ts +17 -0
- package/src/server/lib/db/compact-chat-overlay.test.ts +121 -0
- package/src/server/lib/db/compact-chat-overlay.ts +90 -0
- package/src/server/lib/lifecycle/fd-pressure.test.ts +66 -0
- package/src/server/lib/lifecycle/fd-pressure.ts +159 -0
- package/src/server/lib/omp/config/commands.test.ts +167 -0
- package/src/server/lib/omp/config/commands.ts +195 -0
- package/src/server/lib/omp/config/scope.test.ts +49 -0
- package/src/server/lib/omp/config/scope.ts +65 -0
- package/src/server/lib/omp/config/skills-catalog.ts +35 -3
- package/src/server/lib/omp/config/skills.test.ts +105 -0
- package/src/server/lib/omp/config/skills.ts +260 -47
- package/src/server/lib/omp/rpc/process.ts +22 -14
- package/src/server/lib/omp/rpc/session-registry.ts +6 -0
- package/src/server/lib/omp/rpc/utility.ts +45 -23
- package/src/server/lib/omp/session/merge-stored-anchor.test.ts +85 -0
- package/src/server/lib/omp/session/merge-stored-overlay-rows.test.ts +106 -0
- package/src/server/lib/omp/session/merge-stored.ts +78 -11
- package/src/server/lib/omp/session/reload.server.test.ts +84 -0
- package/src/server/lib/omp/session/reload.server.ts +47 -0
- package/src/server/lib/omp/session/subagent-presence.ts +40 -9
- package/src/server/lib/terminal/liveness.server.ts +65 -0
- package/src/server/lib/terminal/runtime.server.ts +70 -117
- package/src/server/lib/terminal/session.server.ts +5 -2
- package/src/server/lib/terminal/store.ts +72 -0
- package/src/server/lib/updates/apply.ts +62 -33
- package/src/server/lib/updates/install.ts +9 -2
- package/src/server/lib/updates/omp.ts +37 -9
- package/src/server/lib/updates/single-flight.test.ts +61 -0
- package/src/server/lib/updates/single-flight.ts +68 -0
- package/src/server/routes/chat/session.ts +13 -3
- package/src/server/routes/health/probe.ts +8 -0
- package/src/server/routes/omp/index.ts +0 -2
- package/src/server/routes/settings/commands.ts +102 -15
- package/src/server/routes/settings/skills.ts +165 -113
- package/src/server/routes/terminal/sessions.ts +1 -1
- package/src/server/routes/updates/apply.ts +101 -14
- package/src/shared/lib/chat/composer/client.ts +27 -18
- package/src/shared/lib/chat/composer/scope.test.ts +87 -0
- package/src/shared/lib/markdown/katex.ts +11 -1
- package/src/shared/lib/markdown/marked.ts +68 -13
- package/src/shared/lib/markdown/math.test.ts +53 -1
- package/src/shared/lib/updates/progress.test.ts +54 -0
- package/src/shared/lib/updates/progress.ts +50 -0
- package/src/shared/types/settings/command.ts +15 -2
- package/src/shared/types/settings/skill.ts +13 -0
- package/dist/client/static/js/index-vx0ernrv.css +0 -1
- package/src/client/hooks/ui/updates.ts +0 -108
- package/src/server/routes/omp/commands.ts +0 -29
|
@@ -4,18 +4,16 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* The terminal registry:
|
|
8
|
-
* they die.
|
|
7
|
+
* The terminal registry: creation, attachment, input, resize and reaping.
|
|
9
8
|
*
|
|
10
9
|
* One real shell per terminal id, created on the first `attach` (there is no
|
|
11
10
|
* separate create endpoint to race against), kept alive while clients come and
|
|
12
11
|
* go, and killed through the process group so the commands it started die with
|
|
13
|
-
* it. Session mechanics live in `session.server.ts
|
|
14
|
-
*
|
|
12
|
+
* it. Session mechanics live in `session.server.ts`, the process-wide state in
|
|
13
|
+
* `store.ts`, and the liveness questions in `liveness.server.ts`.
|
|
15
14
|
*/
|
|
16
15
|
|
|
17
|
-
import
|
|
18
|
-
import { detectBusyShells, type ShellProcess } from '@/server/lib/terminal/foreground';
|
|
16
|
+
import { countLiveTerminals } from '@/server/lib/terminal/liveness.server';
|
|
19
17
|
import {
|
|
20
18
|
TERMINATION_GRACE_MS,
|
|
21
19
|
TerminalRuntimeError,
|
|
@@ -27,6 +25,7 @@ import {
|
|
|
27
25
|
type TerminalSession,
|
|
28
26
|
type TerminalViewer,
|
|
29
27
|
} from '@/server/lib/terminal/session.server';
|
|
28
|
+
import { terminalStore } from '@/server/lib/terminal/store';
|
|
30
29
|
import { TERMINAL_MAX_INPUT_BYTES, clampTerminalSize, type TerminalSnapshot } from '@/shared/lib/workspace/terminal/protocol';
|
|
31
30
|
|
|
32
31
|
const MAX_TERMINALS = 8;
|
|
@@ -41,41 +40,10 @@ export interface TerminalAttachment {
|
|
|
41
40
|
replay: Uint8Array;
|
|
42
41
|
}
|
|
43
42
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
* PTY children are detached sessions that keep running. A module-local `Map`
|
|
49
|
-
* would be replaced by a fresh empty one on each reload, leaving the previous
|
|
50
|
-
* shells alive with nothing tracking them — unreachable from the client, never
|
|
51
|
-
* counted, never reaped, and still holding their slots. Anchoring the map to
|
|
52
|
-
* the process keeps a reload from orphaning anything.
|
|
53
|
-
*/
|
|
54
|
-
declare global {
|
|
55
|
-
// eslint-disable-next-line no-var
|
|
56
|
-
var __ompChamberTerminals: Map<string, TerminalSession> | undefined;
|
|
57
|
-
// eslint-disable-next-line no-var
|
|
58
|
-
var __ompChamberTerminalSweep: ReturnType<typeof setInterval> | undefined;
|
|
59
|
-
// eslint-disable-next-line no-var
|
|
60
|
-
var __ompChamberTerminalResizes: Map<string, ReturnType<typeof setTimeout>> | undefined;
|
|
61
|
-
// eslint-disable-next-line no-var
|
|
62
|
-
var __ompChamberTerminalCleanupInstalled: boolean | undefined;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const sessions: Map<string, TerminalSession> = (globalThis.__ompChamberTerminals ??= new Map());
|
|
66
|
-
|
|
67
|
-
function getSweepTimer() {
|
|
68
|
-
return globalThis.__ompChamberTerminalSweep;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function setSweepTimer(timer: ReturnType<typeof setInterval> | undefined) {
|
|
72
|
-
globalThis.__ompChamberTerminalSweep = timer;
|
|
73
|
-
return timer;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function getResizeTimers(): Map<string, ReturnType<typeof setTimeout>> {
|
|
77
|
-
return (globalThis.__ompChamberTerminalResizes ??= new Map());
|
|
78
|
-
}
|
|
43
|
+
/** The process-wide state; see `store.ts` for why it is not module-local. */
|
|
44
|
+
const registry = terminalStore();
|
|
45
|
+
const sessions = registry.sessions;
|
|
46
|
+
const resizeTimers = registry.resizeTimers;
|
|
79
47
|
|
|
80
48
|
/**
|
|
81
49
|
* Attach `viewer` to `request.id`, creating the shell when the id is unknown.
|
|
@@ -83,30 +51,9 @@ function getResizeTimers(): Map<string, ReturnType<typeof setTimeout>> {
|
|
|
83
51
|
* Registration and history capture happen in one synchronous block: Bun cannot
|
|
84
52
|
* interleave a PTY `data` callback inside it, so the viewer can neither miss
|
|
85
53
|
* output nor receive it twice.
|
|
86
|
-
*
|
|
87
|
-
* The cap counts *live* shells only. An exited shell is a corpse holding a
|
|
88
|
-
* scrollback nobody is reading, and letting corpses consume the budget is how
|
|
89
|
-
* a session that restarted a shell a few times hits the limit with nothing
|
|
90
|
-
* running: every restart leaves its predecessor behind.
|
|
91
54
|
*/
|
|
92
55
|
export async function attachTerminal(viewer: TerminalViewer, request: AttachRequest): Promise<TerminalAttachment> {
|
|
93
|
-
|
|
94
|
-
if (!session) {
|
|
95
|
-
reclaimExitedTerminals();
|
|
96
|
-
if ((await countLiveTerminals()) >= MAX_TERMINALS) {
|
|
97
|
-
throw new TerminalRuntimeError('capacity', `Terminal limit reached (${MAX_TERMINALS} busy shells)`);
|
|
98
|
-
}
|
|
99
|
-
const created = await createSession(request);
|
|
100
|
-
sessions.set(request.id, created);
|
|
101
|
-
// An exit with nobody watching is the common case for a replaced shell
|
|
102
|
-
// (restart, repo switch): reap it as soon as it lands instead of leaving
|
|
103
|
-
// the record for the sweep, which is up to a minute of holding a slot.
|
|
104
|
-
void created.proc?.exited.then(() => {
|
|
105
|
-
if (created.viewers.size === 0) forgetTerminal(created.id);
|
|
106
|
-
});
|
|
107
|
-
ensureSweeper();
|
|
108
|
-
session = created;
|
|
109
|
-
}
|
|
56
|
+
const session = await openTerminal(request);
|
|
110
57
|
|
|
111
58
|
const replay = session.history.replay();
|
|
112
59
|
const snapshot = snapshotOf(session);
|
|
@@ -116,22 +63,59 @@ export async function attachTerminal(viewer: TerminalViewer, request: AttachRequ
|
|
|
116
63
|
}
|
|
117
64
|
|
|
118
65
|
/**
|
|
119
|
-
*
|
|
66
|
+
* The shell for `request.id`, creating it at most once.
|
|
67
|
+
*
|
|
68
|
+
* Creation awaits — cwd resolution, the shell probe, the cap's `ps` — so two
|
|
69
|
+
* `attach` frames for the same new id both used to pass the `sessions.get`
|
|
70
|
+
* check and both spawned a shell. The map kept whichever landed last and the
|
|
71
|
+
* other became unreachable: a PTY plus a detached shell with no record, which
|
|
72
|
+
* the reaper could never reclaim and the cap never counted. Measured on this
|
|
73
|
+
* runtime: two concurrent attaches for one id left two shells for one record.
|
|
74
|
+
* A page reload racing its own reconnect is enough to produce that.
|
|
120
75
|
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* is how a panel that was restarted or switched a few times pins the budget
|
|
125
|
-
* with nothing running, and the next attach is refused.
|
|
76
|
+
* Sharing the in-flight promise makes the loser await the winner's session
|
|
77
|
+
* instead of creating a rival. The map lives in `store.ts` beside the records,
|
|
78
|
+
* because a reload that dropped it would reopen the same race.
|
|
126
79
|
*/
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
80
|
+
async function openTerminal(request: AttachRequest): Promise<TerminalSession> {
|
|
81
|
+
const existing = sessions.get(request.id);
|
|
82
|
+
if (existing) return existing;
|
|
83
|
+
|
|
84
|
+
const inFlight = registry.creating.get(request.id);
|
|
85
|
+
if (inFlight) return inFlight;
|
|
86
|
+
|
|
87
|
+
const entry = createTerminal(request);
|
|
88
|
+
registry.creating.set(request.id, entry);
|
|
89
|
+
const release = () => {
|
|
90
|
+
if (registry.creating.get(request.id) === entry) registry.creating.delete(request.id);
|
|
91
|
+
};
|
|
92
|
+
void entry.then(release, release);
|
|
93
|
+
return entry;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Spawn one shell, register it, and arm the reaper.
|
|
98
|
+
*
|
|
99
|
+
* The cap counts *live* shells only. An exited shell is a corpse holding a
|
|
100
|
+
* scrollback nobody is reading, and letting corpses consume the budget is how
|
|
101
|
+
* a session that restarted a shell a few times hits the limit with nothing
|
|
102
|
+
* running: every restart leaves its predecessor behind.
|
|
103
|
+
*/
|
|
104
|
+
async function createTerminal(request: AttachRequest): Promise<TerminalSession> {
|
|
105
|
+
reclaimExitedTerminals();
|
|
106
|
+
if ((await countLiveTerminals()) >= MAX_TERMINALS) {
|
|
107
|
+
throw new TerminalRuntimeError('capacity', `Terminal limit reached (${MAX_TERMINALS} busy shells)`);
|
|
132
108
|
}
|
|
133
|
-
const
|
|
134
|
-
|
|
109
|
+
const created = await createSession(request);
|
|
110
|
+
sessions.set(request.id, created);
|
|
111
|
+
// An exit with nobody watching is the common case for a replaced shell
|
|
112
|
+
// (restart, repo switch): reap it as soon as it lands instead of leaving
|
|
113
|
+
// the record for the sweep, which is up to a minute of holding a slot.
|
|
114
|
+
void created.proc?.exited.then(() => {
|
|
115
|
+
if (created.viewers.size === 0) forgetTerminal(created.id);
|
|
116
|
+
});
|
|
117
|
+
ensureSweeper();
|
|
118
|
+
return created;
|
|
135
119
|
}
|
|
136
120
|
|
|
137
121
|
/**
|
|
@@ -162,32 +146,6 @@ export function detachTerminal(viewer: TerminalViewer, id: string): void {
|
|
|
162
146
|
if (session.status === 'exited' && session.viewers.size === 0) forgetTerminal(id);
|
|
163
147
|
}
|
|
164
148
|
|
|
165
|
-
/**
|
|
166
|
-
* Live terminal summaries, optionally narrowed to one scope, so a client can
|
|
167
|
-
* adopt a shell it does not have an id for (a reloaded page, a second tab).
|
|
168
|
-
*
|
|
169
|
-
* `busy` marks a shell with a foreground command — the panel shows it as
|
|
170
|
-
* running, and the cap budgets it. One `ps` for the whole list.
|
|
171
|
-
*/
|
|
172
|
-
export async function listTerminals(root?: string | null, repo?: string | null): Promise<TerminalSnapshot[]> {
|
|
173
|
-
const scope = root ? (repo && repo !== '.' ? path.join(root, repo) : root) : null;
|
|
174
|
-
const running: ShellProcess[] = [];
|
|
175
|
-
const out: TerminalSnapshot[] = [];
|
|
176
|
-
for (const session of sessions.values()) {
|
|
177
|
-
if (scope && !isWithin(session.cwd, scope)) continue;
|
|
178
|
-
if (session.status === 'running' && session.proc) {
|
|
179
|
-
running.push({ id: session.id, pid: session.proc.pid });
|
|
180
|
-
}
|
|
181
|
-
out.push(snapshotOf(session));
|
|
182
|
-
}
|
|
183
|
-
const busy = await detectBusyShells(running);
|
|
184
|
-
return out.map((snapshot) => ({ ...snapshot, busy: busy.has(snapshot.id) }));
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function isWithin(target: string, root: string): boolean {
|
|
188
|
-
return target === root || target.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
149
|
export function writeTerminalInput(id: string, data: Uint8Array): void {
|
|
192
150
|
const session = sessions.get(id);
|
|
193
151
|
if (!session || session.status !== 'running') return;
|
|
@@ -231,8 +189,6 @@ export function resizeTerminal(id: string, cols: number, rows: number): void {
|
|
|
231
189
|
* and a repaint of an intermediate size is invisible work. A single timeout is
|
|
232
190
|
* reused so the burst coalesces into one signal.
|
|
233
191
|
*/
|
|
234
|
-
const resizeTimers = getResizeTimers();
|
|
235
|
-
|
|
236
192
|
export function resizeTerminalDebounced(id: string, cols: number, rows: number): void {
|
|
237
193
|
const pending = resizeTimers.get(id);
|
|
238
194
|
if (pending) clearTimeout(pending);
|
|
@@ -295,7 +251,8 @@ function clearPendingResize(id: string): void {
|
|
|
295
251
|
* own session and would otherwise outlive the server.
|
|
296
252
|
*/
|
|
297
253
|
export function disposeAllTerminals(): void {
|
|
298
|
-
clearInterval(
|
|
254
|
+
clearInterval(registry.sweep);
|
|
255
|
+
registry.sweep = undefined;
|
|
299
256
|
for (const [id, session] of sessions) {
|
|
300
257
|
clearPendingResize(id);
|
|
301
258
|
session.viewers.clear();
|
|
@@ -306,8 +263,8 @@ export function disposeAllTerminals(): void {
|
|
|
306
263
|
}
|
|
307
264
|
|
|
308
265
|
function ensureSweeper(): void {
|
|
309
|
-
if (
|
|
310
|
-
|
|
266
|
+
if (registry.sweep !== undefined) return;
|
|
267
|
+
registry.sweep = setInterval(() => {
|
|
311
268
|
// Exited shells are reaped on exit and on detach; this catches the one
|
|
312
269
|
// that finished while a viewer was still attached to it.
|
|
313
270
|
reclaimExitedTerminals();
|
|
@@ -319,22 +276,18 @@ function ensureSweeper(): void {
|
|
|
319
276
|
if (now - session.lastActivity < IDLE_TIMEOUT_MS) continue;
|
|
320
277
|
forgetTerminal(id);
|
|
321
278
|
}
|
|
322
|
-
}, IDLE_SWEEP_MS)
|
|
323
|
-
|
|
279
|
+
}, IDLE_SWEEP_MS);
|
|
280
|
+
registry.sweep.unref?.();
|
|
324
281
|
}
|
|
325
282
|
|
|
326
283
|
/**
|
|
327
284
|
* Register process-wide cleanup exactly once. A `bun --hot` reload re-evaluates
|
|
328
285
|
* this module while the shells live on, so the guard keeps a reload from
|
|
329
|
-
* stacking duplicate signal handlers.
|
|
286
|
+
* stacking duplicate signal handlers. It rides the same store as the registry,
|
|
287
|
+
* which is what makes it survive the reload that would otherwise reset it.
|
|
330
288
|
*/
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
var __ompChamberTerminalCleanupInstalled: boolean | undefined;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
if (!globalThis.__ompChamberTerminalCleanupInstalled) {
|
|
337
|
-
globalThis.__ompChamberTerminalCleanupInstalled = true;
|
|
289
|
+
if (!registry.cleanupInstalled) {
|
|
290
|
+
registry.cleanupInstalled = true;
|
|
338
291
|
process.once('exit', disposeAllTerminals);
|
|
339
292
|
process.once('SIGINT', disposeAllTerminals);
|
|
340
293
|
process.once('SIGTERM', disposeAllTerminals);
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
|
|
30
30
|
import { resolveRoot } from '@/server/lib/fs/root';
|
|
31
31
|
import { scopeToRepo } from '@/server/lib/fs/repo-scope';
|
|
32
|
+
import { describeSpawnFailure } from '@/server/lib/lifecycle/fd-pressure';
|
|
32
33
|
import { createTerminalHistory, type TerminalHistory } from '@/server/lib/terminal/history';
|
|
33
34
|
import { resolveShellExecutable, shellCandidates, shellLaunch, terminalEnv } from '@/server/lib/terminal/shell';
|
|
34
35
|
import {
|
|
@@ -160,8 +161,10 @@ export async function createSession(request: AttachRequest): Promise<TerminalSes
|
|
|
160
161
|
});
|
|
161
162
|
} catch (error) {
|
|
162
163
|
closeTerminalStream(session);
|
|
163
|
-
|
|
164
|
-
|
|
164
|
+
// `describeSpawnFailure` keeps Bun's own message and adds the real cause
|
|
165
|
+
// when the process is out of descriptors — the raw error argues about
|
|
166
|
+
// `/bin/sh`, which is the shim, not the thing that ran out.
|
|
167
|
+
throw new TerminalRuntimeError('spawn', `Failed to start ${executable}: ${describeSpawnFailure(error)}`);
|
|
165
168
|
}
|
|
166
169
|
session.proc = proc;
|
|
167
170
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The terminal registry's process-wide state, anchored to `globalThis`.
|
|
8
|
+
*
|
|
9
|
+
* `bun run --hot` re-evaluates the terminal modules on every server-file edit,
|
|
10
|
+
* while the PTY children are detached sessions that keep running. A
|
|
11
|
+
* module-local `Map` would be replaced by a fresh empty one on each reload,
|
|
12
|
+
* leaving the previous shells alive with nothing tracking them: unreachable from
|
|
13
|
+
* the client, never counted, never reaped, still holding their slots. Anchoring
|
|
14
|
+
* the state to the process keeps a reload from orphaning anything.
|
|
15
|
+
*
|
|
16
|
+
* The in-flight map has to live here for the same reason, one step further: a
|
|
17
|
+
* reload that dropped it would let the next `attach` start a second creation
|
|
18
|
+
* for an id whose first creation is still awaiting — exactly the race the map
|
|
19
|
+
* exists to prevent. `sweep` survives a reload too, or every generation would
|
|
20
|
+
* start its own reaper.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { TerminalSession } from '@/server/lib/terminal/session.server';
|
|
24
|
+
|
|
25
|
+
/** Handle for a pending resize timeout. */
|
|
26
|
+
export type TerminalTimeout = ReturnType<typeof setTimeout>;
|
|
27
|
+
/** Handle for the idle reaper's interval. */
|
|
28
|
+
export type TerminalSweep = ReturnType<typeof setInterval>;
|
|
29
|
+
|
|
30
|
+
export interface TerminalStore {
|
|
31
|
+
/** One record per terminal id, live or exited-but-still-watched. */
|
|
32
|
+
sessions: Map<string, TerminalSession>;
|
|
33
|
+
/** One in-flight `createSession` per terminal id. See the module doc. */
|
|
34
|
+
creating: Map<string, Promise<TerminalSession>>;
|
|
35
|
+
/** Debounced resize timeouts, keyed by terminal id. */
|
|
36
|
+
resizeTimers: Map<string, TerminalTimeout>;
|
|
37
|
+
/** The reaper, or undefined when it was never started. */
|
|
38
|
+
sweep: TerminalSweep | undefined;
|
|
39
|
+
/** Process-exit cleanup is registered once per process, not per reload. */
|
|
40
|
+
cleanupInstalled: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
declare global {
|
|
44
|
+
// eslint-disable-next-line no-var
|
|
45
|
+
var __ompChamberTerminalState: TerminalStore | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* The pre-object shape: a bare sessions Map under its own key. Read once, so a
|
|
48
|
+
* server that reloads into this module keeps the shells it is already running
|
|
49
|
+
* instead of dropping their records on the floor.
|
|
50
|
+
*/
|
|
51
|
+
// eslint-disable-next-line no-var
|
|
52
|
+
var __ompChamberTerminals: Map<string, TerminalSession> | undefined;
|
|
53
|
+
// eslint-disable-next-line no-var
|
|
54
|
+
var __ompChamberTerminalSweep: TerminalSweep | undefined;
|
|
55
|
+
// eslint-disable-next-line no-var
|
|
56
|
+
var __ompChamberTerminalResizes: Map<string, TerminalTimeout> | undefined;
|
|
57
|
+
// eslint-disable-next-line no-var
|
|
58
|
+
var __ompChamberTerminalCleanupInstalled: boolean | undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function terminalStore(): TerminalStore {
|
|
62
|
+
if (globalThis.__ompChamberTerminalState) return globalThis.__ompChamberTerminalState;
|
|
63
|
+
const legacySessions = globalThis.__ompChamberTerminals;
|
|
64
|
+
const legacyResizes = globalThis.__ompChamberTerminalResizes;
|
|
65
|
+
return (globalThis.__ompChamberTerminalState = {
|
|
66
|
+
sessions: legacySessions instanceof Map ? legacySessions : new Map(),
|
|
67
|
+
creating: new Map(),
|
|
68
|
+
resizeTimers: legacyResizes instanceof Map ? legacyResizes : new Map(),
|
|
69
|
+
sweep: globalThis.__ompChamberTerminalSweep,
|
|
70
|
+
cleanupInstalled: globalThis.__ompChamberTerminalCleanupInstalled === true,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -1,49 +1,78 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Applies a requested update. oh-my-pi is updated through the omp binary;
|
|
3
3
|
* OMPChamber through the shared self-update engine, which replaces the running
|
|
4
|
-
* install in place (bun global install, or git fast-forward + rebuild)
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* install in place (bun global install, or git fast-forward + rebuild).
|
|
5
|
+
*
|
|
6
|
+
* Neither branch restarts anything: this module only reports what happened.
|
|
7
|
+
* The caller decides, because the console has to flush the result to the
|
|
8
|
+
* browser BEFORE the process that is serving it goes away.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
|
-
import { scheduleSelfRestart } from '@/server/lib/lifecycle/restart';
|
|
10
11
|
import { updateOmpChamber } from '@/server/lib/updates/install';
|
|
11
12
|
import { applyOmpUpdate } from '@/server/lib/updates/omp';
|
|
13
|
+
import { withUpdateSlot } from '@/server/lib/updates/single-flight';
|
|
12
14
|
import type { UpdateApplyResult, UpdateTarget } from '@/shared/types/updates';
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
/** A stage label or a line of command output, as the update produces it. */
|
|
17
|
+
export interface UpdateProgressHooks {
|
|
18
|
+
onLine?: (chunk: string) => void;
|
|
19
|
+
onStage?: (label: string) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `updated` is what the caller needs to decide about a restart: an "already up
|
|
24
|
+
* to date" answer is a success that must not bounce a healthy server.
|
|
25
|
+
*/
|
|
26
|
+
export interface AppliedUpdate {
|
|
27
|
+
result: UpdateApplyResult;
|
|
28
|
+
updated: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Runs one update, and refuses to start while another holds the slot — see
|
|
33
|
+
* `single-flight.ts` for why the console cannot be the gate. The refusal is
|
|
34
|
+
* thrown, not returned as a result: it is the caller's decision whether that is
|
|
35
|
+
* a 409 before the work starts or a failure frame inside it.
|
|
36
|
+
*/
|
|
37
|
+
export async function applyUpdate(target: UpdateTarget, hooks: UpdateProgressHooks = {}): Promise<AppliedUpdate> {
|
|
38
|
+
return withUpdateSlot(target, async () => {
|
|
39
|
+
if (target === 'omp') {
|
|
40
|
+
try {
|
|
41
|
+
const { output } = await applyOmpUpdate({ onLine: hooks.onLine });
|
|
42
|
+
return {
|
|
43
|
+
result: { success: true, target: 'omp', manual: false, message: 'oh-my-pi updated', output },
|
|
44
|
+
updated: true,
|
|
45
|
+
};
|
|
46
|
+
} catch (err) {
|
|
47
|
+
return {
|
|
48
|
+
result: {
|
|
49
|
+
success: false,
|
|
50
|
+
target: 'omp',
|
|
51
|
+
manual: false,
|
|
52
|
+
message: err instanceof Error ? err.message : 'Failed to update oh-my-pi',
|
|
53
|
+
},
|
|
54
|
+
updated: false,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (target === 'ompchamber') {
|
|
60
|
+
const result = await updateOmpChamber({ onLine: hooks.onLine, onStage: hooks.onStage });
|
|
20
61
|
return {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
62
|
+
result: {
|
|
63
|
+
success: result.success,
|
|
64
|
+
target: 'ompchamber',
|
|
65
|
+
manual: result.manual,
|
|
66
|
+
message: result.message,
|
|
67
|
+
output: result.output || undefined,
|
|
68
|
+
},
|
|
69
|
+
updated: result.updated,
|
|
25
70
|
};
|
|
26
71
|
}
|
|
27
|
-
}
|
|
28
72
|
|
|
29
|
-
if (target === 'ompchamber') {
|
|
30
|
-
const result = await updateOmpChamber();
|
|
31
|
-
// Only a call that actually replaced files has something to restart for:
|
|
32
|
-
// an "already up to date" answer must not bounce a healthy server.
|
|
33
|
-
const restart = result.success && result.updated ? scheduleSelfRestart() : null;
|
|
34
73
|
return {
|
|
35
|
-
success:
|
|
36
|
-
|
|
37
|
-
manual: result.manual,
|
|
38
|
-
message: restart ? `${result.message} ${restart.message}` : result.message,
|
|
39
|
-
output: result.output || undefined,
|
|
74
|
+
result: { success: false, target, manual: true, message: 'Unknown update target' },
|
|
75
|
+
updated: false,
|
|
40
76
|
};
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
return {
|
|
44
|
-
success: false,
|
|
45
|
-
target,
|
|
46
|
-
manual: true,
|
|
47
|
-
message: 'Unknown update target',
|
|
48
|
-
};
|
|
77
|
+
});
|
|
49
78
|
}
|
|
@@ -166,7 +166,14 @@ export function planUpdateSteps({ method, version, pkgRoot, bunBin }: { method:
|
|
|
166
166
|
];
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
|
|
169
|
+
/**
|
|
170
|
+
* Drain a child's stdout/stderr, forwarding each chunk to `sink` as it arrives
|
|
171
|
+
* and returning the whole decoded text. Exported because the omp update path
|
|
172
|
+
* needs the same streaming read: buffering with `new Response(stream).text()`
|
|
173
|
+
* emits nothing until the process exits, which is exactly what makes an update
|
|
174
|
+
* look hung.
|
|
175
|
+
*/
|
|
176
|
+
export async function pumpStream(stream: ReadableStream<Uint8Array>, sink?: (chunk: string) => void): Promise<string> {
|
|
170
177
|
const reader = stream.getReader();
|
|
171
178
|
const decoder = new TextDecoder();
|
|
172
179
|
let text = '';
|
|
@@ -191,7 +198,7 @@ async function runStep(step: UpdateStep, onLine?: (chunk: string) => void): Prom
|
|
|
191
198
|
timeout: STEP_TIMEOUT_MS,
|
|
192
199
|
env: { ...Bun.env, PAGER: 'cat', FORCE_COLOR: '0' },
|
|
193
200
|
});
|
|
194
|
-
const [stdout, stderr] = await Promise.all([
|
|
201
|
+
const [stdout, stderr] = await Promise.all([pumpStream(proc.stdout, onLine), pumpStream(proc.stderr, onLine)]);
|
|
195
202
|
const exitCode = await proc.exited;
|
|
196
203
|
return { ok: exitCode === 0, exitCode, output: `${stdout}${stderr}`.trim() };
|
|
197
204
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { invalidateOmpCliCache, resolveOmpBin } from '@/server/lib/omp/core/cli';
|
|
8
|
+
import { pumpStream } from '@/server/lib/updates/install';
|
|
8
9
|
import type { UpdateTargetInfo } from '@/shared/types/updates';
|
|
9
10
|
|
|
10
11
|
/** Runs a command, capturing stdout/stderr separately. Non-zero exits keep the captured output. */
|
|
@@ -68,19 +69,46 @@ export async function checkOmpUpdate(): Promise<UpdateTargetInfo> {
|
|
|
68
69
|
return { current, latest, updateAvailable, installed: true, error };
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
export
|
|
72
|
+
export interface OmpUpdateHooks {
|
|
73
|
+
/** Receives command output as it arrives (the console streams it). */
|
|
74
|
+
onLine?: (chunk: string) => void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function applyOmpUpdate(hooks: OmpUpdateHooks = {}): Promise<{ output: string }> {
|
|
72
78
|
const bin = resolveOmpBin();
|
|
73
79
|
if (!bin) throw new Error('omp binary not found');
|
|
74
80
|
|
|
81
|
+
const proc = Bun.spawn({
|
|
82
|
+
cmd: [bin, 'update'],
|
|
83
|
+
stdout: 'pipe',
|
|
84
|
+
stderr: 'pipe',
|
|
85
|
+
timeout: 300_000,
|
|
86
|
+
maxBuffer: 1024 * 1024 * 4,
|
|
87
|
+
env: { ...Bun.env, PAGER: 'cat', FORCE_COLOR: '0' },
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Streamed, not buffered: `omp update` prints its stages as it goes, and the
|
|
91
|
+
// console draws them. A `new Response(proc.stdout).text()` here would hold
|
|
92
|
+
// every byte until the process exits — the spinner-blind behavior this path
|
|
93
|
+
// exists to remove. `maxBuffer` still bounds each stream, so a runaway child
|
|
94
|
+
// is capped exactly as it was under the buffered read.
|
|
95
|
+
let exitCode: number | null = null;
|
|
96
|
+
let stdout = '';
|
|
97
|
+
let stderr = '';
|
|
75
98
|
try {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
99
|
+
[stdout, stderr] = await Promise.all([
|
|
100
|
+
pumpStream(proc.stdout, hooks.onLine),
|
|
101
|
+
pumpStream(proc.stderr, hooks.onLine),
|
|
102
|
+
]);
|
|
103
|
+
exitCode = await proc.exited;
|
|
104
|
+
} finally {
|
|
105
|
+
// The next request must re-probe the binary: `omp update` can replace its
|
|
106
|
+
// own launcher, and a cached path may no longer exist.
|
|
83
107
|
invalidateOmpCliCache();
|
|
84
|
-
throw err instanceof Error ? err : new Error(String(err));
|
|
85
108
|
}
|
|
109
|
+
|
|
110
|
+
if (exitCode !== 0) {
|
|
111
|
+
throw new Error(combined(stdout, stderr).trim() || `omp update exited with code ${exitCode}`);
|
|
112
|
+
}
|
|
113
|
+
return { output: combined(stdout, stderr).trim() };
|
|
86
114
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single-flight contract for updates. Two properties matter and neither is
|
|
3
|
+
* visible from the call site: a run refusing to start while another holds the
|
|
4
|
+
* slot, and the slot always being free again afterwards — a stuck claim would
|
|
5
|
+
* answer "already running" until the server restarted.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
|
|
10
|
+
import { runningUpdate, UpdateInProgressError, withUpdateSlot } from '@/server/lib/updates/single-flight';
|
|
11
|
+
|
|
12
|
+
/** A run that stays open until `release()` is called. */
|
|
13
|
+
function deferred() {
|
|
14
|
+
let release!: (value: string) => void;
|
|
15
|
+
const promise = new Promise<string>((resolve) => {
|
|
16
|
+
release = resolve;
|
|
17
|
+
});
|
|
18
|
+
return { promise, release };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('withUpdateSlot', () => {
|
|
22
|
+
test('a run is refused while another holds the slot', async () => {
|
|
23
|
+
const gate = deferred();
|
|
24
|
+
const running = withUpdateSlot('omp', () => gate.promise);
|
|
25
|
+
expect(runningUpdate()).toBe('omp');
|
|
26
|
+
|
|
27
|
+
await expect(withUpdateSlot('ompchamber', async () => 'never')).rejects.toThrow(UpdateInProgressError);
|
|
28
|
+
|
|
29
|
+
gate.release('done');
|
|
30
|
+
await expect(running).resolves.toBe('done');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('the refusal names the update that is already running', async () => {
|
|
34
|
+
const gate = deferred();
|
|
35
|
+
const running = withUpdateSlot('ompchamber', () => gate.promise);
|
|
36
|
+
|
|
37
|
+
const error = await withUpdateSlot('omp', async () => 'never').catch((cause: unknown) => cause);
|
|
38
|
+
expect(error).toBeInstanceOf(UpdateInProgressError);
|
|
39
|
+
expect((error as Error).message).toContain('OMPChamber');
|
|
40
|
+
|
|
41
|
+
gate.release('done');
|
|
42
|
+
await expect(running).resolves.toBe('done');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('the slot is free again once the run settles', async () => {
|
|
46
|
+
await expect(withUpdateSlot('omp', async () => 'first')).resolves.toBe('first');
|
|
47
|
+
|
|
48
|
+
expect(runningUpdate()).toBeNull();
|
|
49
|
+
await expect(withUpdateSlot('omp', async () => 'ok')).resolves.toBe('ok');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a run that throws does not leave the slot held', async () => {
|
|
53
|
+
await expect(
|
|
54
|
+
withUpdateSlot('omp', async () => {
|
|
55
|
+
throw new Error('boom');
|
|
56
|
+
}),
|
|
57
|
+
).rejects.toThrow('boom');
|
|
58
|
+
|
|
59
|
+
expect(runningUpdate()).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|