ompchamber 0.5.0 → 0.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/README.md +46 -1
- 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.8515cbc6f7.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.0ac93ee878.js → index.47d3b04d5f.js} +7 -7
- package/dist/client/static/js/{uy.5f791144ce.js → uy.2e8929ed0c.js} +2 -2
- package/package.json +13 -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 +30 -9
- 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 +2 -5
- package/src/cli/lib/paths.js +8 -28
- package/src/cli/lib/runtime.js +118 -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/editor/use-file-editor.test.ts +158 -0
- package/src/client/hooks/editor/use-file-editor.ts +35 -19
- package/src/server/index.ts +51 -1
- package/src/server/lib/lifecycle/identity.ts +108 -0
- package/src/server/lib/lifecycle/instance.ts +102 -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/plugins/static.test.ts +63 -0
- package/src/server/plugins/static.ts +28 -4
- package/src/server/routes/health/probe.ts +8 -0
- package/src/shared/lib/markdown/mermaid.ts +8 -0
- 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/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.8515cbc6f7.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.47d3b04d5f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{uy.5f791144ce.js.LICENSE.txt → uy.2e8929ed0c.js.LICENSE.txt} +0 -0
|
@@ -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,12 @@
|
|
|
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 { acquirePortLock, claimPort, PortInUseError } from '@/server/lib/lifecycle/port-guard';
|
|
7
10
|
import { isMockMode } from '@/server/mock.server';
|
|
8
11
|
|
|
9
12
|
// Refuse to run without a resolvable omp binary — before the listener opens and
|
|
@@ -24,10 +27,57 @@ console.log(`[ompchamber] db: ${await getDatabasePath()}`);
|
|
|
24
27
|
|
|
25
28
|
const port = Number(Bun.env.PORT) || 3000;
|
|
26
29
|
const host = Bun.env.HOST || 'localhost';
|
|
30
|
+
const mode = Bun.env.NODE_ENV === 'production' ? 'prod' : 'dev';
|
|
31
|
+
const launchMode = Bun.env.OMPCHAMBER_LAUNCH_MODE === 'daemon' || Bun.env.OMPCHAMBER_LAUNCH_MODE === 'foreground'
|
|
32
|
+
? Bun.env.OMPCHAMBER_LAUNCH_MODE
|
|
33
|
+
: 'direct';
|
|
34
|
+
|
|
35
|
+
const lock = await acquirePortLock(port);
|
|
36
|
+
if (!lock) {
|
|
37
|
+
console.error(`[ompchamber] another OMPChamber instance is starting on port ${port} right now — retry in a moment.`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
27
40
|
|
|
28
41
|
const app = new Elysia().use(apiRoutes).use(ssrRoutes);
|
|
29
42
|
|
|
30
|
-
|
|
43
|
+
try {
|
|
44
|
+
await claimPort({
|
|
45
|
+
port,
|
|
46
|
+
host,
|
|
47
|
+
listen: async () => {
|
|
48
|
+
// `reusePort: false` is load-bearing: Elysia's Bun adapter hardcodes
|
|
49
|
+
// `reusePort: true`, which lets a second server bind a port already in
|
|
50
|
+
// use and then sit invisible while the first-bound socket takes every
|
|
51
|
+
// connection.
|
|
52
|
+
await app.listen({ port, hostname: host, reusePort: false });
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
} catch (error) {
|
|
56
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
57
|
+
console.error(error instanceof PortInUseError ? `[ompchamber] ${message}` : `[ompchamber] could not listen on ${host}:${port} — ${message}`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
} finally {
|
|
60
|
+
lock.release();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The record is written by the process that owns the port, so `status`, `stop`
|
|
64
|
+
// and the next `serve` see dev, foreground and daemon servers alike. A hot
|
|
65
|
+
// reload re-runs this entry in the same process: keeping the first `startedAt`
|
|
66
|
+
// for a PID that already recorded this port is what makes `status` report real
|
|
67
|
+
// uptime instead of resetting it on every save.
|
|
68
|
+
const previousRecord = readInstanceRecord(port);
|
|
69
|
+
if (!writeInstanceRecord({
|
|
70
|
+
pid: process.pid,
|
|
71
|
+
port,
|
|
72
|
+
host,
|
|
73
|
+
mode,
|
|
74
|
+
launchMode,
|
|
75
|
+
startedAt: previousRecord?.pid === process.pid ? previousRecord.startedAt : new Date().toISOString(),
|
|
76
|
+
version: pkg.version,
|
|
77
|
+
})) {
|
|
78
|
+
console.log('[ompchamber] instance record not written (data directory not writable) — `ompchamber status` will fall back to probing this port');
|
|
79
|
+
}
|
|
80
|
+
process.on('exit', () => removeInstanceRecord(port));
|
|
31
81
|
|
|
32
82
|
// The listener is already accepting requests here, so the probe only postpones
|
|
33
83
|
// this last banner line, never startup. Printing it after the probe keeps the
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The per-port instance record: who is serving this port, in what mode, since
|
|
8
|
+
* when.
|
|
9
|
+
*
|
|
10
|
+
* Written by the server that owns the port (not by the CLI that happened to
|
|
11
|
+
* spawn it), so a server started by `bun run dev`, by `--foreground`, or by the
|
|
12
|
+
* daemon path is discoverable the same way. Writes are best-effort: a read-only
|
|
13
|
+
* data directory must degrade `status` to a probe, never take the server down.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
|
|
18
|
+
import { getInstancePath, getRunDir } from '@/server/lib/lifecycle/paths';
|
|
19
|
+
|
|
20
|
+
export type InstanceMode = 'dev' | 'prod';
|
|
21
|
+
export type InstanceLaunchMode = 'daemon' | 'foreground' | 'direct';
|
|
22
|
+
|
|
23
|
+
export interface InstanceRecord {
|
|
24
|
+
pid: number;
|
|
25
|
+
port: number;
|
|
26
|
+
host: string;
|
|
27
|
+
mode: InstanceMode;
|
|
28
|
+
launchMode: InstanceLaunchMode;
|
|
29
|
+
startedAt: string;
|
|
30
|
+
version: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isLaunchMode(value: unknown): value is InstanceLaunchMode {
|
|
34
|
+
return value === 'daemon' || value === 'foreground' || value === 'direct';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Record for `port`, or null when absent or unreadable. */
|
|
38
|
+
export function readInstanceRecord(port: number | string): InstanceRecord | null {
|
|
39
|
+
try {
|
|
40
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(getInstancePath(port), 'utf8'));
|
|
41
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
42
|
+
const record = parsed as Record<string, unknown>;
|
|
43
|
+
if (typeof record.pid !== 'number' || typeof record.port !== 'number') return null;
|
|
44
|
+
return {
|
|
45
|
+
pid: record.pid,
|
|
46
|
+
port: record.port,
|
|
47
|
+
host: typeof record.host === 'string' ? record.host : 'localhost',
|
|
48
|
+
mode: record.mode === 'prod' ? 'prod' : 'dev',
|
|
49
|
+
launchMode: isLaunchMode(record.launchMode) ? record.launchMode : 'direct',
|
|
50
|
+
startedAt: typeof record.startedAt === 'string' ? record.startedAt : '',
|
|
51
|
+
version: typeof record.version === 'string' ? record.version : '',
|
|
52
|
+
};
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Every readable record in the run directory. */
|
|
59
|
+
export function listInstanceRecords(): InstanceRecord[] {
|
|
60
|
+
const records: InstanceRecord[] = [];
|
|
61
|
+
try {
|
|
62
|
+
for (const file of fs.readdirSync(getRunDir())) {
|
|
63
|
+
const match = /^(\d+)\.json$/.exec(file);
|
|
64
|
+
if (!match) continue;
|
|
65
|
+
const record = readInstanceRecord(match[1]);
|
|
66
|
+
if (record) records.push(record);
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// No run directory yet: nothing has ever been served.
|
|
70
|
+
}
|
|
71
|
+
return records;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Persist the record for its port (sibling temp file + rename, mode 0600).
|
|
76
|
+
* Returns false instead of throwing when the data directory is not writable.
|
|
77
|
+
*/
|
|
78
|
+
export function writeInstanceRecord(record: InstanceRecord): boolean {
|
|
79
|
+
const target = getInstancePath(record.port);
|
|
80
|
+
const temp = `${target}.${process.pid}.tmp`;
|
|
81
|
+
try {
|
|
82
|
+
fs.writeFileSync(temp, JSON.stringify(record, null, 2), { mode: 0o600 });
|
|
83
|
+
fs.renameSync(temp, target);
|
|
84
|
+
return true;
|
|
85
|
+
} catch {
|
|
86
|
+
try {
|
|
87
|
+
fs.rmSync(temp, { force: true });
|
|
88
|
+
} catch {
|
|
89
|
+
// Best-effort cleanup only.
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Drop the record for `port`. Never throws. */
|
|
96
|
+
export function removeInstanceRecord(port: number | string): void {
|
|
97
|
+
try {
|
|
98
|
+
fs.rmSync(getInstancePath(port), { force: true });
|
|
99
|
+
} catch {
|
|
100
|
+
// Best-effort: a stale record is detected by PID identity on the next read.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Who is serving a port right now.
|
|
8
|
+
*
|
|
9
|
+
* Two independent sources answer that question, and they are deliberately
|
|
10
|
+
* combined: the instance record (written by the server itself) and the live
|
|
11
|
+
* `/api/health` probe. The record is the only source for a port whose server is
|
|
12
|
+
* deaf (hung, suspended, still starting), while the probe is the only source
|
|
13
|
+
* for a server started outside the CLI — a `bun run dev` server must be
|
|
14
|
+
* discoverable, and before this module it was invisible to every CLI command.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { getProcessState, isProcessAlive } from '@/server/lib/lifecycle/identity';
|
|
18
|
+
import { readInstanceRecord } from '@/server/lib/lifecycle/instance';
|
|
19
|
+
import { fetchHealth } from '@/server/lib/lifecycle/probe';
|
|
20
|
+
|
|
21
|
+
export interface Occupant {
|
|
22
|
+
pid: number;
|
|
23
|
+
port: number;
|
|
24
|
+
host?: string;
|
|
25
|
+
version?: string;
|
|
26
|
+
/** `dev` | `prod` as reported by the live server, else from the record. */
|
|
27
|
+
mode?: string;
|
|
28
|
+
launchMode?: string;
|
|
29
|
+
startedAt?: string;
|
|
30
|
+
uptime?: number;
|
|
31
|
+
source: 'registry+probe' | 'probe' | 'registry';
|
|
32
|
+
/** True when the occupant answered `/api/health` as OMPChamber itself. */
|
|
33
|
+
confirmedByHealth: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The OMPChamber instance on `port`, or null when the port is free/foreign. */
|
|
37
|
+
export async function findOccupant(port: number, host?: string | null): Promise<Occupant | null> {
|
|
38
|
+
const record = readInstanceRecord(port);
|
|
39
|
+
const recordIsLive = record !== null
|
|
40
|
+
&& isProcessAlive(record.pid)
|
|
41
|
+
&& getProcessState(record.pid) !== 'mismatched';
|
|
42
|
+
|
|
43
|
+
const health = await fetchHealth(port, record?.host ?? host);
|
|
44
|
+
const healthPid = health?.pid;
|
|
45
|
+
if (health && typeof healthPid === 'number' && isProcessAlive(healthPid)) {
|
|
46
|
+
return {
|
|
47
|
+
pid: healthPid,
|
|
48
|
+
port,
|
|
49
|
+
host: record?.host ?? host ?? undefined,
|
|
50
|
+
version: health.version ?? record?.version,
|
|
51
|
+
mode: health.mode ?? record?.mode,
|
|
52
|
+
launchMode: record?.launchMode,
|
|
53
|
+
startedAt: health.startedAt ?? record?.startedAt,
|
|
54
|
+
uptime: health.uptime,
|
|
55
|
+
source: recordIsLive && record.pid === healthPid ? 'registry+probe' : 'probe',
|
|
56
|
+
confirmedByHealth: true,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (record && recordIsLive) {
|
|
61
|
+
return {
|
|
62
|
+
pid: record.pid,
|
|
63
|
+
port,
|
|
64
|
+
host: record.host,
|
|
65
|
+
version: record.version,
|
|
66
|
+
mode: record.mode,
|
|
67
|
+
launchMode: record.launchMode,
|
|
68
|
+
startedAt: record.startedAt,
|
|
69
|
+
source: 'registry',
|
|
70
|
+
confirmedByHealth: false,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One-line identity for logs and errors: `pid 1234 (dev, direct, v0.5.0, up 2m)`. */
|
|
78
|
+
export function describeOccupant(occupant: Occupant): string {
|
|
79
|
+
const details: string[] = [];
|
|
80
|
+
if (occupant.mode) details.push(occupant.mode);
|
|
81
|
+
if (occupant.launchMode) details.push(occupant.launchMode);
|
|
82
|
+
if (occupant.version) details.push(`v${occupant.version}`);
|
|
83
|
+
const age = occupantAge(occupant);
|
|
84
|
+
if (age) details.push(`up ${age}`);
|
|
85
|
+
if (!occupant.confirmedByHealth) details.push('unconfirmed');
|
|
86
|
+
const suffix = details.length > 0 ? ` (${details.join(', ')})` : '';
|
|
87
|
+
return `pid ${occupant.pid}${suffix}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function occupantAge(occupant: Occupant): string | null {
|
|
91
|
+
const seconds = typeof occupant.uptime === 'number'
|
|
92
|
+
? Math.round(occupant.uptime)
|
|
93
|
+
: occupant.startedAt
|
|
94
|
+
? Math.round((Date.now() - Date.parse(occupant.startedAt)) / 1000)
|
|
95
|
+
: Number.NaN;
|
|
96
|
+
if (!Number.isFinite(seconds) || seconds < 0) return null;
|
|
97
|
+
if (seconds < 60) return `${seconds}s`;
|
|
98
|
+
const minutes = Math.floor(seconds / 60);
|
|
99
|
+
if (minutes < 60) return `${minutes}m`;
|
|
100
|
+
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
101
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where OMPChamber keeps per-port runtime state.
|
|
8
|
+
*
|
|
9
|
+
* Both the server and the CLI need these paths, and they must agree: the server
|
|
10
|
+
* owns the record for the port it listens on, and the CLI reads that same record
|
|
11
|
+
* to answer `status` / `stop` / `logs`. One module is what makes the agreement
|
|
12
|
+
* structural — the previous split (CLI wrote, server never did) is why a server
|
|
13
|
+
* started outside the CLI was invisible to every CLI command.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import os from 'os';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Root of OMPChamber's own state (~/.ompchamber, or OMPCHAMBER_DATA_DIR).
|
|
22
|
+
*
|
|
23
|
+
* `os.homedir()` rather than HOME: a process started without a shell (cron,
|
|
24
|
+
* launchd, a service) has no HOME, and an empty value would silently move the
|
|
25
|
+
* data directory to a cwd-relative path.
|
|
26
|
+
*/
|
|
27
|
+
export function getDataDir(): string {
|
|
28
|
+
const override = (Bun.env.OMPCHAMBER_DATA_DIR ?? '').trim();
|
|
29
|
+
if (override) {
|
|
30
|
+
return path.resolve(override.startsWith('~') ? path.join(os.homedir(), override.slice(1)) : override);
|
|
31
|
+
}
|
|
32
|
+
return path.join(os.homedir(), '.ompchamber');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Directory holding per-instance records and handover locks (mode 0700). */
|
|
36
|
+
export function getRunDir(): string {
|
|
37
|
+
const dir = path.join(getDataDir(), 'run');
|
|
38
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
39
|
+
return dir;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `<data>/run/<port>.json` — the record written by the server serving `port`. */
|
|
43
|
+
export function getInstancePath(port: number | string): string {
|
|
44
|
+
return path.join(getRunDir(), `${port}.json`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `<data>/run/<port>.lock` — held only while a port handover is in progress. */
|
|
48
|
+
export function getLockPath(port: number | string): string {
|
|
49
|
+
return path.join(getRunDir(), `${port}.lock`);
|
|
50
|
+
}
|