ompchamber 0.5.0 → 0.7.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/README.md +67 -6
- package/dist/client/index.html +1 -1
- package/dist/client/static/css/{index.5291f58223.css → index.51f68210a5.css} +1 -1
- package/dist/client/static/js/async/{3y.ededeebde8.js → 3y.4c2596f64c.js} +3 -3
- package/dist/client/static/js/async/73.308dff4da8.js +1 -0
- package/dist/client/static/js/async/{ty.3d93cf5f5f.js → 7g.a7814350c3.js} +1 -1
- package/dist/client/static/js/async/a.bce4542392.js +1 -0
- package/dist/client/static/js/async/g.86e2256a70.js +2 -0
- package/dist/client/static/js/async/hn.b645041e00.js +1 -0
- package/dist/client/static/js/async/{jg.69a4beade8.js → jg.d86ebd4013.js} +1 -1
- package/dist/client/static/js/async/l.12dd34e1be.js +1 -0
- package/dist/client/static/js/async/ls.9d9a01026c.js +104 -0
- package/dist/client/static/js/async/mw.ca1512563c.js +10 -0
- package/dist/client/static/js/async/nk.fc7e58325e.js +1 -0
- package/dist/client/static/js/async/{q.7fb4d2f374.js → q.542c184fec.js} +3 -3
- package/dist/client/static/js/async/t.cda4891526.js +1 -0
- package/dist/client/static/js/async/tr.e878658140.js +309 -0
- package/dist/client/static/js/async/uf.24ca3d49c3.js +2 -0
- package/dist/client/static/js/index.eccad0b70a.js +33 -0
- package/dist/client/static/js/{uy.5f791144ce.js → uy.2e8929ed0c.js} +2 -2
- package/package.json +15 -5
- package/src/cli/lib/args.js +6 -6
- package/src/cli/lib/commands/logs.js +76 -34
- package/src/cli/lib/commands/restart.js +40 -8
- package/src/cli/lib/commands/serve.js +12 -4
- package/src/cli/lib/commands/status.js +40 -18
- package/src/cli/lib/commands/stop.js +12 -7
- package/src/cli/lib/commands/update.js +23 -12
- package/src/cli/lib/paths.js +8 -28
- package/src/cli/lib/runtime.js +121 -108
- package/src/client/components/common/code-editor/index.tsx +5 -1
- package/src/client/components/common/code-surface/index.tsx +49 -1
- package/src/client/components/common/code-surface/measure.ts +99 -0
- package/src/client/components/layout/RightActivityBar.tsx +42 -69
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +5 -6
- package/src/client/components/layout/desktop-layout/index.tsx +11 -9
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +22 -29
- package/src/client/components/layout/desktop-layout/resizer/utils.test.ts +33 -0
- package/src/client/components/layout/desktop-layout/resizer/utils.ts +34 -0
- package/src/client/components/mobile/RightSidebar.tsx +42 -119
- package/src/client/components/mobile/mobile-main-view/Header.tsx +17 -1
- package/src/client/components/workspace/editor/index.tsx +4 -1
- package/src/client/hooks/chat/omp/index.ts +4 -109
- package/src/client/hooks/chat/omp/prompt-send.ts +161 -0
- package/src/client/hooks/chat/timeline/send.ts +3 -2
- package/src/client/hooks/editor/use-file-editor.test.ts +158 -0
- package/src/client/hooks/editor/use-file-editor.ts +35 -19
- package/src/server/index.ts +52 -1
- package/src/server/lib/lifecycle/bun.ts +18 -0
- package/src/server/lib/lifecycle/identity.ts +108 -0
- package/src/server/lib/lifecycle/instance.ts +102 -0
- package/src/server/lib/lifecycle/launch-mode.test.ts +32 -0
- package/src/server/lib/lifecycle/launch-mode.ts +40 -0
- package/src/server/lib/lifecycle/occupant.ts +101 -0
- package/src/server/lib/lifecycle/paths.ts +50 -0
- package/src/server/lib/lifecycle/port-guard.ts +170 -0
- package/src/server/lib/lifecycle/probe.ts +121 -0
- package/src/server/lib/lifecycle/restart.test.ts +21 -0
- package/src/server/lib/lifecycle/restart.ts +111 -0
- package/src/server/lib/omp/rpc/session-commands.ts +15 -1
- package/src/server/lib/updates/apply.ts +8 -5
- package/src/server/lib/updates/install.ts +7 -4
- package/src/server/plugins/static.test.ts +63 -0
- package/src/server/plugins/static.ts +28 -4
- package/src/server/routes/health/probe.ts +9 -0
- package/src/shared/lib/markdown/mermaid.ts +8 -0
- package/src/shared/lib/omp/session/stream-state.server.ts +16 -1
- package/src/shared/lib/workspace/panel-widths.test.ts +5 -5
- package/src/shared/lib/workspace/panel-widths.ts +23 -10
- package/src/shared/lib/workspace/right-panels.ts +10 -4
- package/dist/client/static/js/async/73.50280ce58d.js +0 -1
- package/dist/client/static/js/async/a.e05da304c8.js +0 -10
- package/dist/client/static/js/async/ar.1e14da5588.js +0 -1
- package/dist/client/static/js/async/g.7a4bbd4798.js +0 -2
- package/dist/client/static/js/async/hn.7568bdb58b.js +0 -1
- package/dist/client/static/js/async/l.102a97fce6.js +0 -104
- package/dist/client/static/js/async/l6.c56640c205.js +0 -1
- package/dist/client/static/js/async/nk.6de011832e.js +0 -1
- package/dist/client/static/js/async/t.cb3fcf8615.js +0 -1
- package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
- package/dist/client/static/js/async/vn.e98e2f3b61.js +0 -309
- package/dist/client/static/js/index.0ac93ee878.js +0 -33
- /package/dist/client/static/js/async/{3y.ededeebde8.js.LICENSE.txt → 3y.4c2596f64c.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.7a4bbd4798.js.LICENSE.txt → g.86e2256a70.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{a.e05da304c8.js.LICENSE.txt → mw.ca1512563c.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.7fb4d2f374.js.LICENSE.txt → q.542c184fec.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → uf.24ca3d49c3.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.0ac93ee878.js.LICENSE.txt → index.eccad0b70a.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{uy.5f791144ce.js.LICENSE.txt → uy.2e8929ed0c.js.LICENSE.txt} +0 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Prompt delivery for the live omp bridge: warm the session up, attach the
|
|
8
|
+
* event stream, POST the prompt, then signal the sidebars the moment it is
|
|
9
|
+
* dispatched.
|
|
10
|
+
*
|
|
11
|
+
* The signal is the point: the server writes the live `stream` row when it
|
|
12
|
+
* dispatches the prompt, so waiting for `agent_start` (spawn + ack round trip)
|
|
13
|
+
* would leave the sidebar spinner dark for seconds after the user hit send.
|
|
14
|
+
* Extracted from useOmpAgent so that hook stays under the repo's per-file size
|
|
15
|
+
* ceiling.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useCallback } from 'preact/hooks';
|
|
19
|
+
import type { Dispatch, SetStateAction } from 'preact/compat';
|
|
20
|
+
import type { OmpAgentState } from '@/shared/types';
|
|
21
|
+
import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
22
|
+
|
|
23
|
+
export interface OmpPromptSenderDeps {
|
|
24
|
+
/** Live session id the prompt targets; the caller mirrors its prop into it. */
|
|
25
|
+
sessionIdRef: { current: string | null };
|
|
26
|
+
/** Attach the event stream to a spawned session. */
|
|
27
|
+
connect: (sessionId: string) => void;
|
|
28
|
+
setState: Dispatch<SetStateAction<OmpAgentState>>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface OmpPromptSender {
|
|
32
|
+
sendPrompt: (message: string, images?: { data: string; mimeType: string }[], options?: { accessMode?: ApprovalMode }) => Promise<boolean>;
|
|
33
|
+
sendNewPrompt: (
|
|
34
|
+
message: string,
|
|
35
|
+
cwd: string,
|
|
36
|
+
images?: { data: string; mimeType: string }[],
|
|
37
|
+
composerOptions?: { model?: { provider: string; modelId: string } | null; thinkingLevel?: string | null; accessMode?: ApprovalMode },
|
|
38
|
+
) => Promise<{ sessionId: string; model: { provider: string; modelId: string } | null } | null>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function useOmpPromptSender(deps: OmpPromptSenderDeps): OmpPromptSender {
|
|
42
|
+
const { sessionIdRef, connect, setState } = deps;
|
|
43
|
+
|
|
44
|
+
/** Send a prompt to the omp session via the RPC bridge. */
|
|
45
|
+
const sendPrompt = useCallback(async (
|
|
46
|
+
message: string,
|
|
47
|
+
images?: { data: string; mimeType: string }[],
|
|
48
|
+
options?: { accessMode?: ApprovalMode },
|
|
49
|
+
) => {
|
|
50
|
+
const sid = sessionIdRef.current;
|
|
51
|
+
if (!sid) return false;
|
|
52
|
+
setState((prev) => ({ ...prev, isGenerating: true, error: null }));
|
|
53
|
+
try {
|
|
54
|
+
// Mirror omp-web handleSend: warm the session process up with get_state
|
|
55
|
+
// (spawns it on first use), then attach the event stream before sending
|
|
56
|
+
// the prompt so no agent events are missed.
|
|
57
|
+
const warmup = await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { 'Content-Type': 'application/json' },
|
|
60
|
+
// The mode rides the warmup too: this is the request that lazily spawns
|
|
61
|
+
// an idle session, and omp only accepts --approval-mode at spawn time.
|
|
62
|
+
body: JSON.stringify({
|
|
63
|
+
type: 'get_state',
|
|
64
|
+
...(options?.accessMode ? { accessMode: options.accessMode } : {}),
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
if (warmup.ok) connect(sid);
|
|
68
|
+
|
|
69
|
+
const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: { 'Content-Type': 'application/json' },
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
type: 'prompt',
|
|
74
|
+
message,
|
|
75
|
+
...(images?.length ? { images } : {}),
|
|
76
|
+
...(options?.accessMode ? { accessMode: options.accessMode } : {}),
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
const body = (await res.json().catch(() => ({}))) as { success?: boolean; error?: string };
|
|
80
|
+
if (!res.ok || body.error) {
|
|
81
|
+
// `session_busy`: the ack timed out behind a still-running turn, so the
|
|
82
|
+
// prompt may already be accepted — never resend it automatically.
|
|
83
|
+
setState((prev) => ({ ...prev, isGenerating: false, error: body.error ?? `HTTP ${res.status}` }));
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
// Tell the sidebars to re-read the session list now — the server wrote
|
|
87
|
+
// this session's live `stream` row at dispatch. Revalidation is
|
|
88
|
+
// leading-edge throttled, so this lands immediately.
|
|
89
|
+
window.dispatchEvent(new CustomEvent('omp:session-updated', { detail: { sessionId: sid } }));
|
|
90
|
+
return true;
|
|
91
|
+
} catch (e) {
|
|
92
|
+
setState((prev) => ({ ...prev, isGenerating: false, error: e instanceof Error ? e.message : String(e) }));
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}, [connect, sessionIdRef, setState]);
|
|
96
|
+
|
|
97
|
+
/** Spawn a brand-new omp session and send the first prompt: ensure_session
|
|
98
|
+
* first (returns omp's real session id), attach the event stream, then send
|
|
99
|
+
* the prompt through the existing session route so no agent events are
|
|
100
|
+
* missed. Model/thinking picks ride the ensure_session body so omp applies
|
|
101
|
+
* them BEFORE the first prompt (and their JSONL change entries are written
|
|
102
|
+
* up front, not after the run starts). Returns the new session id on
|
|
103
|
+
* success, or null on failure — the caller adopts the id as the active
|
|
104
|
+
* session. */
|
|
105
|
+
const sendNewPrompt = useCallback(async (
|
|
106
|
+
message: string,
|
|
107
|
+
cwd: string,
|
|
108
|
+
images?: { data: string; mimeType: string }[],
|
|
109
|
+
composerOptions?: { model?: { provider: string; modelId: string } | null; thinkingLevel?: string | null; accessMode?: ApprovalMode },
|
|
110
|
+
): Promise<{ sessionId: string; model: { provider: string; modelId: string } | null } | null> => {
|
|
111
|
+
setState((prev) => ({ ...prev, isGenerating: true, error: null }));
|
|
112
|
+
try {
|
|
113
|
+
const created = await fetch('/api/agent/new', {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers: { 'Content-Type': 'application/json' },
|
|
116
|
+
body: JSON.stringify({
|
|
117
|
+
type: 'ensure_session',
|
|
118
|
+
cwd,
|
|
119
|
+
...(composerOptions?.model ? composerOptions.model : {}),
|
|
120
|
+
...(composerOptions?.thinkingLevel ? { thinkingLevel: composerOptions.thinkingLevel } : {}),
|
|
121
|
+
...(composerOptions?.accessMode ? { accessMode: composerOptions.accessMode } : {}),
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
const createdBody = (await created.json().catch(() => ({}))) as {
|
|
125
|
+
success?: boolean;
|
|
126
|
+
sessionId?: string;
|
|
127
|
+
model?: { provider: string; modelId: string } | null;
|
|
128
|
+
error?: string;
|
|
129
|
+
};
|
|
130
|
+
if (!created.ok || !createdBody.sessionId) {
|
|
131
|
+
setState((prev) => ({ ...prev, isGenerating: false, error: createdBody.error ?? `HTTP ${created.status}` }));
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const sid = createdBody.sessionId;
|
|
135
|
+
connect(sid);
|
|
136
|
+
const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
|
|
137
|
+
method: 'POST',
|
|
138
|
+
headers: { 'Content-Type': 'application/json' },
|
|
139
|
+
body: JSON.stringify({
|
|
140
|
+
type: 'prompt',
|
|
141
|
+
message,
|
|
142
|
+
...(images?.length ? { images } : {}),
|
|
143
|
+
}),
|
|
144
|
+
});
|
|
145
|
+
const body = (await res.json().catch(() => ({}))) as { success?: boolean; error?: string };
|
|
146
|
+
if (!res.ok || body.error) {
|
|
147
|
+
setState((prev) => ({ ...prev, isGenerating: false, error: body.error ?? `HTTP ${res.status}` }));
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
// Same dispatch-time signal as sendPrompt: the new session's live badge
|
|
151
|
+
// must not wait for agent_start + the first JSONL write.
|
|
152
|
+
window.dispatchEvent(new CustomEvent('omp:session-updated', { detail: { sessionId: sid } }));
|
|
153
|
+
return { sessionId: sid, model: createdBody.model ?? null };
|
|
154
|
+
} catch (e) {
|
|
155
|
+
setState((prev) => ({ ...prev, isGenerating: false, error: e instanceof Error ? e.message : String(e) }));
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}, [connect, setState]);
|
|
159
|
+
|
|
160
|
+
return { sendPrompt, sendNewPrompt };
|
|
161
|
+
}
|
|
@@ -304,8 +304,9 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
|
|
|
304
304
|
next.set('sessionId', spawned.sessionId);
|
|
305
305
|
return next;
|
|
306
306
|
}, { replace: true });
|
|
307
|
-
// The
|
|
308
|
-
//
|
|
307
|
+
// The sidebar already re-read at prompt dispatch (sendNewPrompt
|
|
308
|
+
// signals it), so the new session shows its live badge without
|
|
309
|
+
// waiting for the JSONL to carry the user turn.
|
|
309
310
|
return;
|
|
310
311
|
}
|
|
311
312
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `useFileEditor` revalidation contract.
|
|
8
|
+
*
|
|
9
|
+
* A landed write bumps the workspace refreshKey, which calls `reset()`. Reset
|
|
10
|
+
* used to drop every cached buffer, so the very next render painted an empty
|
|
11
|
+
* editor — and because the read effect only re-runs on a key/identity change,
|
|
12
|
+
* the file never came back without a full page reload. The same window let a
|
|
13
|
+
* read response that was already in flight land on top of keystrokes typed
|
|
14
|
+
* after it was requested.
|
|
15
|
+
*
|
|
16
|
+
* These run against a real DOM (happy-dom) because the hook is a Preact hook
|
|
17
|
+
* driving fetches and effects: the modules below are imported inside
|
|
18
|
+
* `beforeAll`, after those globals exist, because Preact and the highlighter
|
|
19
|
+
* bind their environment at evaluation time.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
23
|
+
import { Window } from 'happy-dom';
|
|
24
|
+
import type { h as PreactH, render as PreactRender } from 'preact';
|
|
25
|
+
import type { act as PreactAct } from 'preact/test-utils';
|
|
26
|
+
import type { UseFileEditorResult, useFileEditor as UseFileEditorHook } from '@/client/hooks/editor/use-file-editor';
|
|
27
|
+
|
|
28
|
+
const DISK = 'const a = 1;\nconst b = 2;\n';
|
|
29
|
+
/** Stable identity: the read effect re-runs when its target object changes. */
|
|
30
|
+
const TARGET = { id: 1, name: 'a.js', path: 'a.js' };
|
|
31
|
+
|
|
32
|
+
let useFileEditor: typeof UseFileEditorHook;
|
|
33
|
+
let h: typeof PreactH;
|
|
34
|
+
let render: typeof PreactRender;
|
|
35
|
+
let act: typeof PreactAct;
|
|
36
|
+
|
|
37
|
+
/** Latest render's result — re-read after every settle, never held across one. */
|
|
38
|
+
let mounted: UseFileEditorResult | null = null;
|
|
39
|
+
let container: HTMLElement;
|
|
40
|
+
|
|
41
|
+
interface FsState {
|
|
42
|
+
reads: number;
|
|
43
|
+
writes: number;
|
|
44
|
+
body: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Serves `state.body` for reads and counts writes, without touching the network. */
|
|
48
|
+
function stubFetch(state: FsState) {
|
|
49
|
+
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
50
|
+
if (String(input).startsWith('/api/fs/read')) {
|
|
51
|
+
state.reads += 1;
|
|
52
|
+
return { ok: true, json: async () => ({ content: state.body }) };
|
|
53
|
+
}
|
|
54
|
+
state.writes += 1;
|
|
55
|
+
state.body = String((init?.body as FormData).get('content'));
|
|
56
|
+
return { ok: true, json: async () => ({ success: true }) };
|
|
57
|
+
}) as unknown as typeof fetch;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Lets the stub's promise chain run and the render it queues land. The chain
|
|
62
|
+
* needs no timers (nothing I/O bound), and Preact's `act` only flushes queued
|
|
63
|
+
* renders on its boundary, so both get a pass.
|
|
64
|
+
*/
|
|
65
|
+
async function settleEditor() {
|
|
66
|
+
for (let i = 0; i < 20; i++) await Promise.resolve();
|
|
67
|
+
await act(async () => {
|
|
68
|
+
for (let i = 0; i < 20; i++) await Promise.resolve();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function mountEditor() {
|
|
73
|
+
const Probe = () => {
|
|
74
|
+
mounted = useFileEditor(TARGET);
|
|
75
|
+
return null;
|
|
76
|
+
};
|
|
77
|
+
await act(async () => {
|
|
78
|
+
render(h(Probe, {}), container);
|
|
79
|
+
});
|
|
80
|
+
await settleEditor();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The DOM surface this file installs for the hook and Preact to render into. */
|
|
84
|
+
function domGlobals(win: Window): Record<string, unknown> {
|
|
85
|
+
return {
|
|
86
|
+
window: win,
|
|
87
|
+
document: win.document,
|
|
88
|
+
navigator: win.navigator,
|
|
89
|
+
Node: win.Node,
|
|
90
|
+
Element: win.Element,
|
|
91
|
+
HTMLElement: win.HTMLElement,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const installed: Record<string, unknown> = {};
|
|
96
|
+
const displaced: Record<string, unknown> = {};
|
|
97
|
+
|
|
98
|
+
beforeAll(async () => {
|
|
99
|
+
const win = new Window({ url: 'http://localhost' });
|
|
100
|
+
for (const [key, value] of Object.entries(domGlobals(win))) {
|
|
101
|
+
displaced[key] = (globalThis as Record<string, unknown>)[key];
|
|
102
|
+
installed[key] = value;
|
|
103
|
+
(globalThis as Record<string, unknown>)[key] = value;
|
|
104
|
+
}
|
|
105
|
+
({ useFileEditor } = await import('@/client/hooks/editor/use-file-editor'));
|
|
106
|
+
({ h, render } = await import('preact'));
|
|
107
|
+
({ act } = await import('preact/test-utils'));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Every test file shares one process, so the globals go back the way they were:
|
|
111
|
+
// code that branches on `typeof window` (the agent-event fold, for one) would
|
|
112
|
+
// otherwise take its browser path for the rest of the run.
|
|
113
|
+
afterAll(() => {
|
|
114
|
+
for (const key of Object.keys(installed)) {
|
|
115
|
+
if (displaced[key] === undefined) delete (globalThis as Record<string, unknown>)[key];
|
|
116
|
+
else (globalThis as Record<string, unknown>)[key] = displaced[key];
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
mounted = null;
|
|
122
|
+
container = document.createElement('div');
|
|
123
|
+
document.body.appendChild(container);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('useFileEditor revalidation', () => {
|
|
127
|
+
test('reset re-reads from disk without blanking the open file', async () => {
|
|
128
|
+
const state: FsState = { reads: 0, writes: 0, body: DISK };
|
|
129
|
+
stubFetch(state);
|
|
130
|
+
await mountEditor();
|
|
131
|
+
expect(mounted?.content).toBe(DISK);
|
|
132
|
+
|
|
133
|
+
// The refresh a landed write triggers, with newer bytes now on disk.
|
|
134
|
+
state.body = `${DISK}const c = 3;\n`;
|
|
135
|
+
await act(async () => {
|
|
136
|
+
mounted?.reset();
|
|
137
|
+
});
|
|
138
|
+
await settleEditor();
|
|
139
|
+
|
|
140
|
+
expect(state.reads).toBe(2);
|
|
141
|
+
expect(mounted?.content).toBe(state.body);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('a read that lands mid-typing does not replace the buffer', async () => {
|
|
145
|
+
const state: FsState = { reads: 0, writes: 0, body: DISK };
|
|
146
|
+
stubFetch(state);
|
|
147
|
+
await mountEditor();
|
|
148
|
+
|
|
149
|
+
await act(async () => {
|
|
150
|
+
mounted?.onChange('edited by hand');
|
|
151
|
+
mounted?.reset();
|
|
152
|
+
});
|
|
153
|
+
await settleEditor();
|
|
154
|
+
|
|
155
|
+
expect(state.reads).toBe(2);
|
|
156
|
+
expect(mounted?.content).toBe('edited by hand');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -40,9 +40,11 @@ export interface UseFileEditorResult {
|
|
|
40
40
|
saveNow: () => void;
|
|
41
41
|
copy: () => void;
|
|
42
42
|
download: () => void;
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Re-read every cached file from disk (e.g. a workspace refresh). Content
|
|
45
|
+
* already on screen stays until the fresh bytes land, and a file with
|
|
46
|
+
* unsaved edits is left alone.
|
|
47
|
+
*/
|
|
46
48
|
reset: () => void;
|
|
47
49
|
/** Drop cached content for files no longer open. */
|
|
48
50
|
retain: (ids: Array<number | string>) => void;
|
|
@@ -77,6 +79,8 @@ export function useFileEditor(
|
|
|
77
79
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
78
80
|
const [saveStatus, setSaveStatus] = useState<FileEditorSaveStatus>('idle');
|
|
79
81
|
const [copied, setCopied] = useState(false);
|
|
82
|
+
/** Bumped by `reset()` to re-run the read effect for the active file. */
|
|
83
|
+
const [revision, setRevision] = useState(0);
|
|
80
84
|
|
|
81
85
|
const key = target ? targetKey(target) : '';
|
|
82
86
|
const content = target ? (contents[key] ?? target.content ?? '') : '';
|
|
@@ -87,6 +91,14 @@ export function useFileEditor(
|
|
|
87
91
|
const loadedRef = useRef<Set<string>>(new Set());
|
|
88
92
|
const pendingRef = useRef<{ timer: number; file: FileEditorTarget; content: string } | null>(null);
|
|
89
93
|
|
|
94
|
+
// Live mirrors of the content maps: a read response resolves long after it
|
|
95
|
+
// was requested, and it has to know whether the buffer it read is still the
|
|
96
|
+
// one on screen.
|
|
97
|
+
const contentsRef = useRef(contents);
|
|
98
|
+
contentsRef.current = contents;
|
|
99
|
+
const baselinesRef = useRef(baselines);
|
|
100
|
+
baselinesRef.current = baselines;
|
|
101
|
+
|
|
90
102
|
const remember = useCallback((fileKey: string, text: string) => {
|
|
91
103
|
setContents((prev) => ({ ...prev, [fileKey]: text }));
|
|
92
104
|
setBaselines((prev) => ({ ...prev, [fileKey]: text }));
|
|
@@ -145,8 +157,11 @@ export function useFileEditor(
|
|
|
145
157
|
return;
|
|
146
158
|
}
|
|
147
159
|
|
|
160
|
+
// Only a key with nothing on screen yet shows the loading state: a
|
|
161
|
+
// re-read (refreshKey / reset) must not flash a spinner over live content.
|
|
162
|
+
const firstLoad = contentsRef.current[fileKey] === undefined;
|
|
148
163
|
let cancelled = false;
|
|
149
|
-
setIsLoading(true);
|
|
164
|
+
if (firstLoad) setIsLoading(true);
|
|
150
165
|
setLoadError(null);
|
|
151
166
|
const params = new URLSearchParams({ path: target.path });
|
|
152
167
|
if (target.root) params.set('root', target.root);
|
|
@@ -164,10 +179,20 @@ export function useFileEditor(
|
|
|
164
179
|
})
|
|
165
180
|
.then((text) => {
|
|
166
181
|
if (cancelled) return;
|
|
182
|
+
// These bytes were read before the response arrived, so anything typed
|
|
183
|
+
// since is newer. Applying them would replace those keystrokes (and,
|
|
184
|
+
// once a refresh is triggered by a save, wipe the editor back to an
|
|
185
|
+
// empty buffer) — instead let the queued write own the key.
|
|
186
|
+
const typed = contentsRef.current[fileKey];
|
|
187
|
+
if (typed !== undefined && typed !== baselinesRef.current[fileKey]) return;
|
|
188
|
+
const queued = pendingRef.current;
|
|
189
|
+
if (queued && targetKey(queued.file) === fileKey) return;
|
|
167
190
|
remember(fileKey, text ?? (fallback ? fallback(target.name) : ''));
|
|
168
191
|
})
|
|
169
192
|
.catch((err: unknown) => {
|
|
170
193
|
if (cancelled) return;
|
|
194
|
+
// A failed re-read keeps what is on screen; only a first load reports.
|
|
195
|
+
if (!firstLoad) return;
|
|
171
196
|
if (reportLoadError) {
|
|
172
197
|
remember(fileKey, '');
|
|
173
198
|
setLoadError(err instanceof Error ? err.message : 'Failed to read file');
|
|
@@ -182,7 +207,7 @@ export function useFileEditor(
|
|
|
182
207
|
return () => {
|
|
183
208
|
cancelled = true;
|
|
184
209
|
};
|
|
185
|
-
}, [key, target, fallback, reportLoadError, remember]);
|
|
210
|
+
}, [key, target, fallback, reportLoadError, remember, revision]);
|
|
186
211
|
|
|
187
212
|
useEffect(() => {
|
|
188
213
|
if (saveStatus !== 'saved') return;
|
|
@@ -239,21 +264,13 @@ export function useFileEditor(
|
|
|
239
264
|
URL.revokeObjectURL(url);
|
|
240
265
|
}, [target, contents, downloadMimeType]);
|
|
241
266
|
|
|
242
|
-
const reload = useCallback(() => {
|
|
243
|
-
if (!key) return;
|
|
244
|
-
loadedRef.current.delete(key);
|
|
245
|
-
setContents((prev) => {
|
|
246
|
-
if (prev[key] === undefined) return prev;
|
|
247
|
-
const next = { ...prev };
|
|
248
|
-
delete next[key];
|
|
249
|
-
return next;
|
|
250
|
-
});
|
|
251
|
-
}, [key]);
|
|
252
|
-
|
|
253
267
|
const reset = useCallback(() => {
|
|
268
|
+
// Invalidate every key, then let the read effect refetch the active one.
|
|
269
|
+
// Content is deliberately NOT cleared: it stays on screen until the fresh
|
|
270
|
+
// bytes land, so a refresh can never blank the editor (or drop the file
|
|
271
|
+
// that is open) while the request is in flight.
|
|
254
272
|
loadedRef.current.clear();
|
|
255
|
-
|
|
256
|
-
setBaselines({});
|
|
273
|
+
setRevision((prev) => prev + 1);
|
|
257
274
|
}, []);
|
|
258
275
|
|
|
259
276
|
const retain = useCallback((ids: Array<number | string>) => {
|
|
@@ -295,7 +312,6 @@ export function useFileEditor(
|
|
|
295
312
|
saveNow,
|
|
296
313
|
copy,
|
|
297
314
|
download,
|
|
298
|
-
reload,
|
|
299
315
|
reset,
|
|
300
316
|
retain,
|
|
301
317
|
};
|
package/src/server/index.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { Elysia } from 'elysia';
|
|
2
|
+
import pkg from '@/../package.json';
|
|
2
3
|
import { apiRoutes } from '@/server/routes';
|
|
3
4
|
import { ssrRoutes } from '@/server/plugins/ssr';
|
|
4
5
|
import { getDatabasePath } from '@/server/db.server';
|
|
5
6
|
import { fetchOmpRegistrySnapshot } from '@/server/lib/models/provider-registry.server';
|
|
6
7
|
import { ompStartupError, ompStartupLogLines } from '@/server/lib/omp/core/startup';
|
|
8
|
+
import { readInstanceRecord, removeInstanceRecord, writeInstanceRecord } from '@/server/lib/lifecycle/instance';
|
|
9
|
+
import { resolveLaunchMode } from '@/server/lib/lifecycle/launch-mode';
|
|
10
|
+
import { acquirePortLock, claimPort, PortInUseError } from '@/server/lib/lifecycle/port-guard';
|
|
7
11
|
import { isMockMode } from '@/server/mock.server';
|
|
8
12
|
|
|
9
13
|
// Refuse to run without a resolvable omp binary — before the listener opens and
|
|
@@ -24,10 +28,57 @@ console.log(`[ompchamber] db: ${await getDatabasePath()}`);
|
|
|
24
28
|
|
|
25
29
|
const port = Number(Bun.env.PORT) || 3000;
|
|
26
30
|
const host = Bun.env.HOST || 'localhost';
|
|
31
|
+
const mode = Bun.env.NODE_ENV === 'production' ? 'prod' : 'dev';
|
|
32
|
+
// argv, not env: see launch-mode.ts — an inherited `OMPCHAMBER_LAUNCH_MODE`
|
|
33
|
+
// would label a `bun run dev` started inside an OMPChamber shell as `daemon`.
|
|
34
|
+
const launchMode = resolveLaunchMode();
|
|
35
|
+
|
|
36
|
+
const lock = await acquirePortLock(port);
|
|
37
|
+
if (!lock) {
|
|
38
|
+
console.error(`[ompchamber] another OMPChamber instance is starting on port ${port} right now — retry in a moment.`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
27
41
|
|
|
28
42
|
const app = new Elysia().use(apiRoutes).use(ssrRoutes);
|
|
29
43
|
|
|
30
|
-
|
|
44
|
+
try {
|
|
45
|
+
await claimPort({
|
|
46
|
+
port,
|
|
47
|
+
host,
|
|
48
|
+
listen: async () => {
|
|
49
|
+
// `reusePort: false` is load-bearing: Elysia's Bun adapter hardcodes
|
|
50
|
+
// `reusePort: true`, which lets a second server bind a port already in
|
|
51
|
+
// use and then sit invisible while the first-bound socket takes every
|
|
52
|
+
// connection.
|
|
53
|
+
await app.listen({ port, hostname: host, reusePort: false });
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
} catch (error) {
|
|
57
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
58
|
+
console.error(error instanceof PortInUseError ? `[ompchamber] ${message}` : `[ompchamber] could not listen on ${host}:${port} — ${message}`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
} finally {
|
|
61
|
+
lock.release();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The record is written by the process that owns the port, so `status`, `stop`
|
|
65
|
+
// and the next `serve` see dev, foreground and daemon servers alike. A hot
|
|
66
|
+
// reload re-runs this entry in the same process: keeping the first `startedAt`
|
|
67
|
+
// for a PID that already recorded this port is what makes `status` report real
|
|
68
|
+
// uptime instead of resetting it on every save.
|
|
69
|
+
const previousRecord = readInstanceRecord(port);
|
|
70
|
+
if (!writeInstanceRecord({
|
|
71
|
+
pid: process.pid,
|
|
72
|
+
port,
|
|
73
|
+
host,
|
|
74
|
+
mode,
|
|
75
|
+
launchMode,
|
|
76
|
+
startedAt: previousRecord?.pid === process.pid ? previousRecord.startedAt : new Date().toISOString(),
|
|
77
|
+
version: pkg.version,
|
|
78
|
+
})) {
|
|
79
|
+
console.log('[ompchamber] instance record not written (data directory not writable) — `ompchamber status` will fall back to probing this port');
|
|
80
|
+
}
|
|
81
|
+
process.on('exit', () => removeInstanceRecord(port));
|
|
31
82
|
|
|
32
83
|
// The listener is already accepting requests here, so the probe only postpones
|
|
33
84
|
// this last banner line, never startup. Printing it after the probe keeps the
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The Bun binary to spawn child processes with.
|
|
8
|
+
*
|
|
9
|
+
* The server is usually itself Bun, so `process.execPath` is the most
|
|
10
|
+
* trustworthy answer — it is the exact runtime this install was started with.
|
|
11
|
+
* A host that is not Bun falls back to a PATH lookup, and finally to the
|
|
12
|
+
* literal `bun`, which lets the child resolve it from its own environment.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveBunBin(): string {
|
|
15
|
+
const execPath = process.execPath;
|
|
16
|
+
if (/(^|[\\/])bun$/.test(execPath)) return execPath;
|
|
17
|
+
return Bun.which('bun') ?? 'bun';
|
|
18
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Process identity for a PID recorded for a port.
|
|
8
|
+
*
|
|
9
|
+
* Liveness alone cannot answer "is this PID still an OMPChamber server": after
|
|
10
|
+
* an ungraceful exit the PID can be recycled to an unrelated process, and a
|
|
11
|
+
* liveness-only check then reports a live instance nobody is serving — which
|
|
12
|
+
* either blocks startup on a free port or, worse, lets a stale record name a
|
|
13
|
+
* stranger. Identity therefore comes from the OS command line, so a recycled
|
|
14
|
+
* PID reads back as `mismatched`.
|
|
15
|
+
*
|
|
16
|
+
* Where `ps` is unavailable (minimal containers) the on-disk `/proc` entry
|
|
17
|
+
* still answers the identity question on Linux; on Windows, where neither is
|
|
18
|
+
* cheap, identity is reported as `unknown` and callers fall back to liveness.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from 'fs';
|
|
22
|
+
|
|
23
|
+
export type ProcessState = 'dead' | 'matched' | 'mismatched' | 'unknown';
|
|
24
|
+
|
|
25
|
+
interface ProcessRow {
|
|
26
|
+
pid: number;
|
|
27
|
+
/** `ps` state column — `Z…` means defunct (exited, awaiting reaping). */
|
|
28
|
+
state: string;
|
|
29
|
+
command: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let cachedTable: { at: number; rows: ProcessRow[] } | null = null;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One snapshot of the process table, memoized for a startup decision.
|
|
36
|
+
*
|
|
37
|
+
* Every question in a port check happens within milliseconds, so a briefly
|
|
38
|
+
* cached view is strictly better than spawning `ps` per question.
|
|
39
|
+
*/
|
|
40
|
+
function readProcessTable(maxAgeMs = 250): ProcessRow[] {
|
|
41
|
+
if (cachedTable && Date.now() - cachedTable.at < maxAgeMs) return cachedTable.rows;
|
|
42
|
+
const rows: ProcessRow[] = [];
|
|
43
|
+
try {
|
|
44
|
+
const snapshot = Bun.spawnSync({
|
|
45
|
+
cmd: ['ps', '-ax', '-o', 'pid=,stat=,command='],
|
|
46
|
+
stdout: 'pipe',
|
|
47
|
+
stderr: 'ignore',
|
|
48
|
+
});
|
|
49
|
+
if (snapshot.success) {
|
|
50
|
+
for (const line of snapshot.stdout.toString().split('\n')) {
|
|
51
|
+
const match = /^\s*(\d+)\s+(\S+)\s+(.*)$/.exec(line);
|
|
52
|
+
if (!match) continue;
|
|
53
|
+
rows.push({ pid: Number(match[1]), state: match[2], command: match[3].trim() });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// No `ps` on PATH: identity falls back to /proc, liveness to signal 0.
|
|
58
|
+
}
|
|
59
|
+
cachedTable = { at: Date.now(), rows };
|
|
60
|
+
return rows;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Liveness check. Never throws.
|
|
65
|
+
*
|
|
66
|
+
* A defunct (zombie) PID still answers signal 0 — the process has exited and is
|
|
67
|
+
* only waiting for its parent to reap it — so a zombie counts as not alive: it
|
|
68
|
+
* holds no port and will never run again. Without this, a stop that worked
|
|
69
|
+
* looks like a process that "ignored SIGTERM", and stop-waits spin until their
|
|
70
|
+
* timeout.
|
|
71
|
+
*/
|
|
72
|
+
export function isProcessAlive(pid: number): boolean {
|
|
73
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
74
|
+
try {
|
|
75
|
+
process.kill(pid, 0);
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
const row = readProcessTable().find((entry) => entry.pid === pid);
|
|
80
|
+
return row ? !row.state.startsWith('Z') : true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Command line for a live PID, or null when this platform cannot report one. */
|
|
84
|
+
function readProcessCommandLine(pid: number): string | null {
|
|
85
|
+
const row = readProcessTable().find((entry) => entry.pid === pid);
|
|
86
|
+
if (row) return row.command.length > 0 ? row.command : null;
|
|
87
|
+
if (process.platform === 'linux') {
|
|
88
|
+
try {
|
|
89
|
+
return fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim() || null;
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `dead` | `matched` | `mismatched` | `unknown` for a PID.
|
|
99
|
+
*
|
|
100
|
+
* `unknown` means identity could not be read; callers treat it as "alive but
|
|
101
|
+
* unverified" — never as confirmation that the PID is ours.
|
|
102
|
+
*/
|
|
103
|
+
export function getProcessState(pid: number): ProcessState {
|
|
104
|
+
if (!isProcessAlive(pid)) return 'dead';
|
|
105
|
+
const command = readProcessCommandLine(pid);
|
|
106
|
+
if (command === null) return 'unknown';
|
|
107
|
+
return command.toLowerCase().includes('ompchamber') ? 'matched' : 'mismatched';
|
|
108
|
+
}
|