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.
Files changed (71) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +357 -353
  3. package/dist/client/static/js/index-7n838fz5.css +1 -0
  4. package/dist/client/static/js/{index-w0cem7n5.js → index-qp2sfmn2.js} +591 -592
  5. package/package.json +1 -1
  6. package/src/cli/lib/commands/status.js +7 -0
  7. package/src/client/components/layout/session-sidebar/about-modal/UpdateLog.tsx +50 -0
  8. package/src/client/components/layout/session-sidebar/about-modal/UpdateSection.tsx +12 -17
  9. package/src/client/components/settings/ProjectSelectorDropdown.tsx +8 -2
  10. package/src/client/components/settings/SettingsSidebarList.tsx +5 -1
  11. package/src/client/components/settings/categories/CommandSettings.tsx +47 -9
  12. package/src/client/components/settings/categories/SkillCatalogSettings.tsx +23 -43
  13. package/src/client/components/settings/categories/SkillSettings.tsx +51 -7
  14. package/src/client/components/settings/categories/command-settings/DetailPane.tsx +109 -103
  15. package/src/client/components/settings/categories/command-settings/SidebarList.tsx +6 -1
  16. package/src/client/components/settings/categories/skill-settings/DetailPane.tsx +130 -31
  17. package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +9 -3
  18. package/src/client/data/settings/command.ts +0 -40
  19. package/src/client/hooks/settings/workspace-roots.ts +99 -0
  20. package/src/client/hooks/ui/updates/index.ts +178 -0
  21. package/src/server/db.server.ts +41 -11
  22. package/src/server/index.ts +17 -1
  23. package/src/server/lib/db/client.ts +17 -0
  24. package/src/server/lib/db/compact-chat-overlay.test.ts +121 -0
  25. package/src/server/lib/db/compact-chat-overlay.ts +90 -0
  26. package/src/server/lib/lifecycle/fd-pressure.test.ts +66 -0
  27. package/src/server/lib/lifecycle/fd-pressure.ts +159 -0
  28. package/src/server/lib/omp/config/commands.test.ts +167 -0
  29. package/src/server/lib/omp/config/commands.ts +195 -0
  30. package/src/server/lib/omp/config/scope.test.ts +49 -0
  31. package/src/server/lib/omp/config/scope.ts +65 -0
  32. package/src/server/lib/omp/config/skills-catalog.ts +35 -3
  33. package/src/server/lib/omp/config/skills.test.ts +105 -0
  34. package/src/server/lib/omp/config/skills.ts +260 -47
  35. package/src/server/lib/omp/rpc/process.ts +22 -14
  36. package/src/server/lib/omp/rpc/session-registry.ts +6 -0
  37. package/src/server/lib/omp/rpc/utility.ts +45 -23
  38. package/src/server/lib/omp/session/merge-stored-anchor.test.ts +85 -0
  39. package/src/server/lib/omp/session/merge-stored-overlay-rows.test.ts +106 -0
  40. package/src/server/lib/omp/session/merge-stored.ts +78 -11
  41. package/src/server/lib/omp/session/reload.server.test.ts +84 -0
  42. package/src/server/lib/omp/session/reload.server.ts +47 -0
  43. package/src/server/lib/omp/session/subagent-presence.ts +40 -9
  44. package/src/server/lib/terminal/liveness.server.ts +65 -0
  45. package/src/server/lib/terminal/runtime.server.ts +70 -117
  46. package/src/server/lib/terminal/session.server.ts +5 -2
  47. package/src/server/lib/terminal/store.ts +72 -0
  48. package/src/server/lib/updates/apply.ts +62 -33
  49. package/src/server/lib/updates/install.ts +9 -2
  50. package/src/server/lib/updates/omp.ts +37 -9
  51. package/src/server/lib/updates/single-flight.test.ts +61 -0
  52. package/src/server/lib/updates/single-flight.ts +68 -0
  53. package/src/server/routes/chat/session.ts +13 -3
  54. package/src/server/routes/health/probe.ts +8 -0
  55. package/src/server/routes/omp/index.ts +0 -2
  56. package/src/server/routes/settings/commands.ts +102 -15
  57. package/src/server/routes/settings/skills.ts +165 -113
  58. package/src/server/routes/terminal/sessions.ts +1 -1
  59. package/src/server/routes/updates/apply.ts +101 -14
  60. package/src/shared/lib/chat/composer/client.ts +27 -18
  61. package/src/shared/lib/chat/composer/scope.test.ts +87 -0
  62. package/src/shared/lib/markdown/katex.ts +11 -1
  63. package/src/shared/lib/markdown/marked.ts +68 -13
  64. package/src/shared/lib/markdown/math.test.ts +53 -1
  65. package/src/shared/lib/updates/progress.test.ts +54 -0
  66. package/src/shared/lib/updates/progress.ts +50 -0
  67. package/src/shared/types/settings/command.ts +15 -2
  68. package/src/shared/types/settings/skill.ts +13 -0
  69. package/dist/client/static/js/index-vx0ernrv.css +0 -1
  70. package/src/client/hooks/ui/updates.ts +0 -108
  71. package/src/server/routes/omp/commands.ts +0 -29
@@ -4,18 +4,16 @@
4
4
  */
5
5
 
6
6
  /**
7
- * The terminal registry: which shells are live, who is watching them, and when
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`; this module owns the map,
14
- * the viewer sets, the idle reaper and process-exit cleanup.
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 path from 'path';
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
- * The registry lives on `globalThis`, not in a module-local binding.
46
- *
47
- * `bun run --hot` re-evaluates this module on every server-file edit, but the
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
- let session = sessions.get(request.id);
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
- * Shells that are actually doing work, which is what the cap budgets.
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
- * Two things are deliberately excluded: an exited shell (a corpse with a
122
- * scrollback) and a shell sitting at its prompt — the PTY's foreground process
123
- * group tells them apart exactly (see `foreground.ts`). Counting idle shells
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
- export async function countLiveTerminals(): Promise<number> {
128
- const running: ShellProcess[] = [];
129
- for (const session of sessions.values()) {
130
- if (session.status !== 'running' || !session.proc) continue;
131
- running.push({ id: session.id, pid: session.proc.pid });
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 busy = await detectBusyShells(running);
134
- return busy.size;
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(setSweepTimer(undefined));
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 (getSweepTimer() !== undefined) return;
310
- setSweepTimer(setInterval(() => {
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
- getSweepTimer()!.unref();
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
- declare global {
332
- // eslint-disable-next-line no-var
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
- const message = error instanceof Error ? error.message : String(error);
164
- throw new TerminalRuntimeError('spawn', `Failed to start ${executable}: ${message}`);
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) and then
5
- * restarts this instance through a detached `ompchamber restart` — unless the
6
- * instance was started from source, which its own launcher has to restart.
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
- export async function applyUpdate(target: UpdateTarget): Promise<UpdateApplyResult> {
15
- if (target === 'omp') {
16
- try {
17
- const { output } = await applyOmpUpdate();
18
- return { success: true, target: 'omp', manual: false, message: 'oh-my-pi updated', output };
19
- } catch (err) {
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
- success: false,
22
- target: 'omp',
23
- manual: false,
24
- message: err instanceof Error ? err.message : 'Failed to update oh-my-pi',
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: result.success,
36
- target: 'ompchamber',
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
- async function pump(stream: ReadableStream<Uint8Array>, sink?: (chunk: string) => void): Promise<string> {
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([pump(proc.stdout, onLine), pump(proc.stderr, onLine)]);
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 async function applyOmpUpdate(): Promise<{ output: string }> {
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
- const { stdout, stderr, exitCode } = await runCapture(bin, ['update'], 300000, 1024 * 1024 * 4);
77
- invalidateOmpCliCache();
78
- if (exitCode !== 0) {
79
- throw new Error(combined(stdout, stderr).trim() || `omp update exited with code ${exitCode}`);
80
- }
81
- return { output: combined(stdout, stderr).trim() };
82
- } catch (err) {
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
+ });