ompchamber 0.4.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 +56 -7
- 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/dc.6dc4b0a04b.js +2 -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.bf1b1fe988.js → index.47d3b04d5f.js} +7 -7
- package/dist/client/static/js/{uy.5f791144ce.js → uy.2e8929ed0c.js} +2 -2
- package/package.json +13 -6
- package/src/cli/lib/args.js +7 -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 +19 -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/components/workspace/file-explorer/TreeItem.tsx +8 -2
- package/src/client/components/workspace/file-explorer/index.tsx +6 -0
- package/src/client/data/models/catalog.ts +8 -0
- package/src/client/data/samples/dialogue-session.ts +1 -1
- package/src/client/data/samples/sample-tools/other.ts +1 -1
- package/src/client/hooks/chat/timeline/send.ts +1 -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 +104 -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/lib/models/provider-registry.server.ts +44 -20
- package/src/server/lib/omp/core/cli.ts +3 -2
- package/src/server/lib/omp/core/startup.ts +64 -0
- package/src/server/lib/omp/rpc/process.ts +1 -1
- package/src/server/mock.server.ts +6 -12
- package/src/server/plugins/static.test.ts +63 -0
- package/src/server/plugins/static.ts +28 -4
- package/src/server/routes/chat/stream.ts +13 -30
- package/src/server/routes/health/probe.ts +8 -0
- package/src/server/routes/models/root.ts +5 -1
- package/src/shared/lib/chat/stream-service.ts +0 -76
- package/src/shared/lib/fs/paths.ts +19 -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/art.62139ed479.js +0 -2
- 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/{a.e05da304c8.js.LICENSE.txt → dc.6dc4b0a04b.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/{art.62139ed479.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.bf1b1fe988.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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Port guarding for a starting OMPChamber server.
|
|
8
|
+
*
|
|
9
|
+
* One port is served by exactly one process, and **a starting instance never
|
|
10
|
+
* stops another one** — not a CLI server, not a dev run. An occupant that
|
|
11
|
+
* identifies itself as OMPChamber makes the newcomer exit with the ways to pick
|
|
12
|
+
* a different port; freeing a port is what `ompchamber stop --port <port>` is
|
|
13
|
+
* for, done deliberately and per port.
|
|
14
|
+
*
|
|
15
|
+
* The refusal is load-bearing rather than defensive: a second bind is *not*
|
|
16
|
+
* refused by the runtime (Elysia's Bun adapter hardcodes `reusePort: true`), so
|
|
17
|
+
* two servers would coexist on one port and only the first-bound socket would
|
|
18
|
+
* receive connections. The newcomer would look healthy in its own log while the
|
|
19
|
+
* browser kept talking to the old build; the guard stops that state from being
|
|
20
|
+
* created in the first place.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import fs from 'fs';
|
|
24
|
+
|
|
25
|
+
import { getProcessState } from '@/server/lib/lifecycle/identity';
|
|
26
|
+
import { describeOccupant, findOccupant } from '@/server/lib/lifecycle/occupant';
|
|
27
|
+
import { getLockPath } from '@/server/lib/lifecycle/paths';
|
|
28
|
+
import { isPortAvailable } from '@/server/lib/lifecycle/probe';
|
|
29
|
+
|
|
30
|
+
const LOCK_WAIT_MS = 3_000;
|
|
31
|
+
/** Grace period for a port that is settling (a server that just exited, or one that is starting). */
|
|
32
|
+
const SETTLE_WAIT_MS = 3_000;
|
|
33
|
+
|
|
34
|
+
export class PortInUseError extends Error {
|
|
35
|
+
constructor(message: string) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = 'PortInUseError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PortLock {
|
|
42
|
+
release: () => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Exclusive per-port startup lock.
|
|
47
|
+
*
|
|
48
|
+
* Two starters racing on one free port would each probe it as free and then
|
|
49
|
+
* fight over the bind, with the loser reporting a bare `EADDRINUSE` against a
|
|
50
|
+
* process it cannot identify yet. Holding the lock across probe-and-bind makes
|
|
51
|
+
* the loser fail with a message that names the actual situation. A lock whose
|
|
52
|
+
* holder is dead (or is not OMPChamber, i.e. a recycled PID) is reclaimed.
|
|
53
|
+
*
|
|
54
|
+
* Returns null when another starter holds the lock through the wait window.
|
|
55
|
+
*/
|
|
56
|
+
export async function acquirePortLock(port: number, waitMs = LOCK_WAIT_MS): Promise<PortLock | null> {
|
|
57
|
+
const lockPath = getLockPath(port);
|
|
58
|
+
const deadline = Date.now() + waitMs;
|
|
59
|
+
for (;;) {
|
|
60
|
+
try {
|
|
61
|
+
fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx', mode: 0o600 });
|
|
62
|
+
return { release: () => releasePortLock(lockPath) };
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let holder = Number.NaN;
|
|
68
|
+
try {
|
|
69
|
+
holder = Number(fs.readFileSync(lockPath, 'utf8').trim());
|
|
70
|
+
} catch {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const holderState = Number.isFinite(holder) && holder > 0 ? getProcessState(holder) : 'dead';
|
|
75
|
+
if (holderState === 'dead' || holderState === 'mismatched') {
|
|
76
|
+
fs.rmSync(lockPath, { force: true });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (holder === process.pid) return { release: () => releasePortLock(lockPath) };
|
|
80
|
+
if (Date.now() >= deadline) return null;
|
|
81
|
+
await Bun.sleep(150);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function releasePortLock(lockPath: string): void {
|
|
86
|
+
try {
|
|
87
|
+
if (fs.readFileSync(lockPath, 'utf8').trim() === String(process.pid)) fs.rmSync(lockPath, { force: true });
|
|
88
|
+
} catch {
|
|
89
|
+
// Already gone, or never ours to remove.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ClaimPortOptions {
|
|
94
|
+
port: number;
|
|
95
|
+
host: string;
|
|
96
|
+
/**
|
|
97
|
+
* Binds the port; must throw when the OS refuses. Retried while the port
|
|
98
|
+
* looks occupied by something that is not an identified OMPChamber instance.
|
|
99
|
+
*/
|
|
100
|
+
listen: () => Promise<void>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Bind `port`, or fail with an actionable error.
|
|
105
|
+
*
|
|
106
|
+
* The bind is the authoritative question ("can this process listen here") and
|
|
107
|
+
* `listen` is what answers it; the probe only short-circuits the common case.
|
|
108
|
+
* When either says the port is taken, the occupant is identified so the error
|
|
109
|
+
* can name it — and then the process exits, leaving every other instance
|
|
110
|
+
* running.
|
|
111
|
+
*/
|
|
112
|
+
export async function claimPort({ port, host, listen }: ClaimPortOptions): Promise<void> {
|
|
113
|
+
const deadline = Date.now() + SETTLE_WAIT_MS;
|
|
114
|
+
let bindError: unknown = null;
|
|
115
|
+
|
|
116
|
+
for (;;) {
|
|
117
|
+
if (await isPortAvailable(port, host)) {
|
|
118
|
+
try {
|
|
119
|
+
await listen();
|
|
120
|
+
return;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
bindError = error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const occupant = await findOccupant(port, host);
|
|
127
|
+
|
|
128
|
+
// `bun run --hot` re-evaluates this entry inside the running process, so the
|
|
129
|
+
// port is held by *us* and Bun swaps the fetch handler on the next `listen`.
|
|
130
|
+
// Treating that as a conflict would kill the dev loop on every save.
|
|
131
|
+
if (occupant && occupant.pid === process.pid) {
|
|
132
|
+
await listen();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (occupant) throw await buildPortInUseError(port, host, bindError);
|
|
137
|
+
|
|
138
|
+
if (Date.now() >= deadline) throw await buildPortInUseError(port, host, bindError);
|
|
139
|
+
await Bun.sleep(200);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Every way to point this server at another port. */
|
|
144
|
+
function portGuidance(): string {
|
|
145
|
+
return [
|
|
146
|
+
' ompchamber serve --port 3001',
|
|
147
|
+
' OMPCHAMBER_PORT=3001 ompchamber serve',
|
|
148
|
+
' PORT=3001 bun run dev # or: PORT=3001 bun run start',
|
|
149
|
+
].join('\n');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function buildPortInUseError(port: number, host: string, bindError: unknown): Promise<PortInUseError> {
|
|
153
|
+
const occupant = await findOccupant(port, host);
|
|
154
|
+
const bindDetail = bindError instanceof Error ? `\n Bind error: ${bindError.message}` : '';
|
|
155
|
+
|
|
156
|
+
if (occupant) {
|
|
157
|
+
return new PortInUseError(
|
|
158
|
+
`Port ${port} is already served by OMPChamber ${describeOccupant(occupant)}.\n`
|
|
159
|
+
+ ` Nothing was stopped — a starting instance never stops another one.\n`
|
|
160
|
+
+ ` Stop it first: ompchamber stop --port ${port}\n`
|
|
161
|
+
+ ` Or start on another port:\n${portGuidance()}`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return new PortInUseError(
|
|
166
|
+
`Port ${port} is in use by another process (no OMPChamber instance answered on it).${bindDetail}\n`
|
|
167
|
+
+ ` Find it with: lsof -nP -iTCP:${port} -sTCP:LISTEN\n`
|
|
168
|
+
+ ` Start on another port:\n${portGuidance()}`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Port occupancy probing.
|
|
8
|
+
*
|
|
9
|
+
* Availability is decided by *attempting a bind*, not by inspecting a PID file.
|
|
10
|
+
* That matters on Bun: `Bun.serve` — and therefore Elysia's `listen`, which
|
|
11
|
+
* hardcodes `reusePort: true` — lets a second process bind a port already in
|
|
12
|
+
* use, so the only reliable question a starter can ask the OS is "can I bind
|
|
13
|
+
* this?" (`net.createServer` never sets SO_REUSEPORT, so a second bind fails as
|
|
14
|
+
* it should, regardless of who is already listening).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import net from 'net';
|
|
18
|
+
|
|
19
|
+
const HEALTH_TIMEOUT_MS = 1500;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Address a local probe should use for a bind host.
|
|
23
|
+
*
|
|
24
|
+
* `0.0.0.0`/`::` are bind-any addresses and cannot be connected to; loopback is
|
|
25
|
+
* the address that reaches the same listener.
|
|
26
|
+
*/
|
|
27
|
+
export function probeHost(host?: string | null): string {
|
|
28
|
+
const value = (host ?? '').trim();
|
|
29
|
+
if (value.length === 0 || value === '0.0.0.0' || value === '::' || value === '[::]') return '127.0.0.1';
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* True when `port` can still be bound on `host`.
|
|
35
|
+
*
|
|
36
|
+
* The probe binds *the same address the server will bind* — a wildcard server
|
|
37
|
+
* is probed with a wildcard bind. Probing loopback instead would ask a
|
|
38
|
+
* different question: `net` sets SO_REUSEADDR, so a loopback bind can succeed
|
|
39
|
+
* while `0.0.0.0:<port>` is already taken, and the port would look free right
|
|
40
|
+
* up until the real `listen` failed.
|
|
41
|
+
*/
|
|
42
|
+
export async function isPortAvailable(port: number, host?: string | null): Promise<boolean> {
|
|
43
|
+
if (!Number.isFinite(port) || port <= 0) return false;
|
|
44
|
+
const bindHost = (host ?? '').trim() || 'localhost';
|
|
45
|
+
const { promise, resolve } = Promise.withResolvers<boolean>();
|
|
46
|
+
const probe = net.createServer();
|
|
47
|
+
probe.unref();
|
|
48
|
+
probe.once('error', () => resolve(false));
|
|
49
|
+
probe.listen({ port, host: bindHost }, () => {
|
|
50
|
+
probe.close(() => resolve(true));
|
|
51
|
+
});
|
|
52
|
+
return await promise;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Poll until the port can be bound, or the deadline passes. */
|
|
56
|
+
export async function waitForPortFree(
|
|
57
|
+
port: number,
|
|
58
|
+
host: string | null | undefined,
|
|
59
|
+
timeoutMs: number,
|
|
60
|
+
intervalMs = 150,
|
|
61
|
+
): Promise<boolean> {
|
|
62
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
63
|
+
for (;;) {
|
|
64
|
+
if (await isPortAvailable(port, host)) return true;
|
|
65
|
+
if (Date.now() >= deadline) return false;
|
|
66
|
+
await Bun.sleep(intervalMs);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface HealthPayload {
|
|
71
|
+
service: string;
|
|
72
|
+
pid?: number;
|
|
73
|
+
version?: string;
|
|
74
|
+
startedAt?: string;
|
|
75
|
+
uptime?: number;
|
|
76
|
+
mock?: boolean;
|
|
77
|
+
runtime?: string;
|
|
78
|
+
mode?: string;
|
|
79
|
+
port?: number;
|
|
80
|
+
host?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* `GET /api/health` for an OMPChamber listener, or null.
|
|
85
|
+
*
|
|
86
|
+
* Only payloads that identify themselves as OMPChamber are returned — every
|
|
87
|
+
* caller here is asking "is an OMPChamber server on this port", so a foreign
|
|
88
|
+
* HTTP service answers null rather than a half-trusted object.
|
|
89
|
+
*/
|
|
90
|
+
export async function fetchHealth(
|
|
91
|
+
port: number,
|
|
92
|
+
host?: string | null,
|
|
93
|
+
timeoutMs = HEALTH_TIMEOUT_MS,
|
|
94
|
+
): Promise<HealthPayload | null> {
|
|
95
|
+
if (!Number.isFinite(port) || port <= 0) return null;
|
|
96
|
+
try {
|
|
97
|
+
const response = await fetch(`http://${probeHost(host)}:${port}/api/health`, {
|
|
98
|
+
headers: { accept: 'application/json' },
|
|
99
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
100
|
+
});
|
|
101
|
+
if (!response.ok) return null;
|
|
102
|
+
const body: unknown = await response.json();
|
|
103
|
+
if (!body || typeof body !== 'object') return null;
|
|
104
|
+
const payload = body as Record<string, unknown>;
|
|
105
|
+
if (payload.service !== 'ompchamber') return null;
|
|
106
|
+
return {
|
|
107
|
+
service: 'ompchamber',
|
|
108
|
+
pid: typeof payload.pid === 'number' ? payload.pid : undefined,
|
|
109
|
+
version: typeof payload.version === 'string' ? payload.version : undefined,
|
|
110
|
+
startedAt: typeof payload.startedAt === 'string' ? payload.startedAt : undefined,
|
|
111
|
+
uptime: typeof payload.uptime === 'number' ? payload.uptime : undefined,
|
|
112
|
+
mock: typeof payload.mock === 'boolean' ? payload.mock : undefined,
|
|
113
|
+
runtime: typeof payload.runtime === 'string' ? payload.runtime : undefined,
|
|
114
|
+
mode: typeof payload.mode === 'string' ? payload.mode : undefined,
|
|
115
|
+
port: typeof payload.port === 'number' ? payload.port : undefined,
|
|
116
|
+
host: typeof payload.host === 'string' ? payload.host : undefined,
|
|
117
|
+
};
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -23,12 +23,54 @@ export const PROVIDERS_SETTINGS_KEY = 'omp_providers_config';
|
|
|
23
23
|
|
|
24
24
|
const RPC_CACHE_TTL_MS = 60_000;
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
/** Minimal shape of a get_login_providers entry, as the chamber reads it. */
|
|
27
|
+
export interface DetectedLoginProvider {
|
|
27
28
|
id: string;
|
|
28
29
|
name: string;
|
|
29
30
|
authenticated: boolean;
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
function isDetectedLoginProvider(provider: unknown): provider is DetectedLoginProvider {
|
|
34
|
+
return (
|
|
35
|
+
typeof provider === 'object' && provider !== null
|
|
36
|
+
&& typeof (provider as { id?: unknown }).id === 'string'
|
|
37
|
+
&& typeof (provider as { name?: unknown }).name === 'string'
|
|
38
|
+
&& typeof (provider as { authenticated?: unknown }).authenticated === 'boolean'
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isOmpModel(model: unknown): model is OmpModel {
|
|
43
|
+
return (
|
|
44
|
+
typeof model === 'object' && model !== null
|
|
45
|
+
&& typeof (model as OmpModel).id === 'string'
|
|
46
|
+
&& typeof (model as OmpModel).provider === 'string'
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface OmpRegistrySnapshot {
|
|
51
|
+
/** Login providers omp reports (authenticated or not). */
|
|
52
|
+
providers: DetectedLoginProvider[];
|
|
53
|
+
/** Every model omp currently serves. */
|
|
54
|
+
models: OmpModel[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Raw omp registry probe — the two commands that define "detected" for the
|
|
59
|
+
* chamber: the login providers omp knows and the models they serve. Parsed here
|
|
60
|
+
* once so the provider settings page and the startup banner count the same set.
|
|
61
|
+
* Throws when the utility RPC is unreachable; each caller degrades its own way.
|
|
62
|
+
*/
|
|
63
|
+
export async function fetchOmpRegistrySnapshot(): Promise<OmpRegistrySnapshot> {
|
|
64
|
+
const [loginResponse, modelsResponse] = await Promise.all([
|
|
65
|
+
runUtilityCommand<{ providers?: unknown }>({ type: 'get_login_providers' }, 30_000),
|
|
66
|
+
runUtilityCommand<{ models?: unknown }>({ type: 'get_available_models' }, 60_000),
|
|
67
|
+
]);
|
|
68
|
+
return {
|
|
69
|
+
providers: Array.isArray(loginResponse.providers) ? loginResponse.providers.filter(isDetectedLoginProvider) : [],
|
|
70
|
+
models: Array.isArray(modelsResponse.models) ? modelsResponse.models.filter(isOmpModel) : [],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
32
74
|
/**
|
|
33
75
|
* Provider registry straight from the omp agent: authenticated login providers
|
|
34
76
|
* (omp auth) plus their registered models (get_available_models). This is the
|
|
@@ -38,25 +80,7 @@ async function loadRpcProviderItems(): Promise<ProviderItem[]> {
|
|
|
38
80
|
const cached = globalThis.__ompChamberProvidersRpcCache;
|
|
39
81
|
if (cached && cached.expiresAt > Date.now()) return cached.data;
|
|
40
82
|
try {
|
|
41
|
-
const
|
|
42
|
-
runUtilityCommand<{ providers?: unknown }>({ type: 'get_login_providers' }, 30_000),
|
|
43
|
-
runUtilityCommand<{ models?: unknown }>({ type: 'get_available_models' }, 60_000),
|
|
44
|
-
]);
|
|
45
|
-
const loginProviders = Array.isArray(loginResponse.providers)
|
|
46
|
-
? loginResponse.providers.filter((provider): provider is LoginProvider => (
|
|
47
|
-
typeof provider === 'object' && provider !== null
|
|
48
|
-
&& typeof (provider as { id?: unknown }).id === 'string'
|
|
49
|
-
&& typeof (provider as { name?: unknown }).name === 'string'
|
|
50
|
-
&& typeof (provider as { authenticated?: unknown }).authenticated === 'boolean'
|
|
51
|
-
))
|
|
52
|
-
: [];
|
|
53
|
-
const available = Array.isArray(modelsResponse.models)
|
|
54
|
-
? modelsResponse.models.filter((model): model is OmpModel => (
|
|
55
|
-
typeof model === 'object' && model !== null
|
|
56
|
-
&& typeof (model as OmpModel).id === 'string'
|
|
57
|
-
&& typeof (model as OmpModel).provider === 'string'
|
|
58
|
-
))
|
|
59
|
-
: [];
|
|
83
|
+
const { providers: loginProviders, models: available } = await fetchOmpRegistrySnapshot();
|
|
60
84
|
const disabled = await readDisabledProviders().catch(() => new Set<string>());
|
|
61
85
|
|
|
62
86
|
const items = loginProviders.map((provider) => {
|
|
@@ -20,6 +20,7 @@ let cachedBin: string | null = null;
|
|
|
20
20
|
let binMissAt = 0;
|
|
21
21
|
|
|
22
22
|
const BIN_NAME = process.platform === 'win32' ? 'omp.exe' : 'omp';
|
|
23
|
+
|
|
23
24
|
// Only successes are cached for the process lifetime. omp may be installed (or
|
|
24
25
|
// PATH repaired) while the server runs; a permanently cached "not found" would
|
|
25
26
|
// keep the UI reporting a missing binary until restart.
|
|
@@ -32,7 +33,7 @@ export function invalidateOmpCliCache(): void {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
function probeOmpBin(): string | null {
|
|
35
|
-
const override = Bun.env.
|
|
36
|
+
const override = Bun.env.OMPCHAMBER_OMP_BIN;
|
|
36
37
|
if (override) return existsSync(override) ? override : null;
|
|
37
38
|
|
|
38
39
|
const onPath = Bun.which(BIN_NAME);
|
|
@@ -49,7 +50,7 @@ function probeOmpBin(): string | null {
|
|
|
49
50
|
return Bun.which(BIN_NAME, { PATH: fallbackPath });
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
/** Resolve the omp binary:
|
|
53
|
+
/** Resolve the omp binary: OMPCHAMBER_OMP_BIN override, then PATH lookup. Returns
|
|
53
54
|
* null when omp is not installed. A hit is cached for the process lifetime; a
|
|
54
55
|
* miss is re-probed after MISS_TTL_MS. */
|
|
55
56
|
export function resolveOmpBin(): string | null {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Startup gate: the chamber must not run without a resolvable `omp` binary.
|
|
8
|
+
*
|
|
9
|
+
* Every live capability — RPC agent sessions, config get/set, session state,
|
|
10
|
+
* model lists, updates — shells out to that binary (see ./cli.ts), so a missing
|
|
11
|
+
* install is not a degraded mode to browse into: the UI would open onto an
|
|
12
|
+
* empty shell whose every action fails. Both entry points call this before
|
|
13
|
+
* binding a port (src/server/index.ts) or spawning a detached server
|
|
14
|
+
* (src/cli/lib/commands/serve.js), turning the failure into one actionable line
|
|
15
|
+
* instead of a health check that never passes.
|
|
16
|
+
*
|
|
17
|
+
* `MOCK=true` is exempt by definition — demo mode exists to run on presets
|
|
18
|
+
* without a real omp install.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { resolveOmpBin } from '@/server/lib/omp/core/cli';
|
|
22
|
+
import { getAgentDir, getConfigRoot } from '@/server/lib/omp/core/paths';
|
|
23
|
+
import { isMockMode } from '@/server/mock.server';
|
|
24
|
+
|
|
25
|
+
/** Null when the app may start; otherwise the message to print before exiting. */
|
|
26
|
+
export function ompStartupError(): string | null {
|
|
27
|
+
if (isMockMode()) return null;
|
|
28
|
+
if (resolveOmpBin()) return null;
|
|
29
|
+
return [
|
|
30
|
+
'omp binary not found — OMPChamber cannot start.',
|
|
31
|
+
'Agent sessions, omp config, session state and updates all run through the `omp` CLI,',
|
|
32
|
+
'so the binary must be resolvable before the server starts.',
|
|
33
|
+
'Install AI Oh-My-Pi and put `omp` on your PATH, or set OMPCHAMBER_OMP_BIN=/path/to/omp.',
|
|
34
|
+
'To run on demo data without an omp install, start with MOCK=true.',
|
|
35
|
+
].join('\n');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface OmpStartupPaths {
|
|
39
|
+
/** Resolved `omp` executable, or null when unresolved (mock mode). */
|
|
40
|
+
bin: string | null;
|
|
41
|
+
/** `~/.omp` — PI_CONFIG_DIR rename applied. */
|
|
42
|
+
configDir: string;
|
|
43
|
+
/** `~/.omp/agent` — PI_CODING_AGENT_DIR override applied. */
|
|
44
|
+
agentDir: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Startup banner lines. The caller owns the prefix.
|
|
48
|
+
*
|
|
49
|
+
* These are the locations the running process actually uses: the executable it
|
|
50
|
+
* spawns and the agent tree it reads. Printing them makes a misdirected install
|
|
51
|
+
* (two omps on PATH, a stray PI_CODING_AGENT_DIR) visible in the log instead of
|
|
52
|
+
* inferred from empty session lists. */
|
|
53
|
+
export function ompStartupLogLines(): string[] {
|
|
54
|
+
const paths: OmpStartupPaths = {
|
|
55
|
+
bin: resolveOmpBin(),
|
|
56
|
+
configDir: getConfigRoot(),
|
|
57
|
+
agentDir: getAgentDir(),
|
|
58
|
+
};
|
|
59
|
+
return [
|
|
60
|
+
`omp binary: ${paths.bin ?? 'not found (MOCK mode — presets only)'}`,
|
|
61
|
+
`omp config dir: ${paths.configDir}`,
|
|
62
|
+
`omp agent dir: ${paths.agentDir}`,
|
|
63
|
+
];
|
|
64
|
+
}
|
|
@@ -46,7 +46,7 @@ export class RpcProcess {
|
|
|
46
46
|
const resolveBin = options.dependencies?.resolveOmpBin ?? resolveOmpBin;
|
|
47
47
|
const bin = resolveBin();
|
|
48
48
|
if (!bin) {
|
|
49
|
-
throw new Error('omp binary not found. Install oh-my-pi or set
|
|
49
|
+
throw new Error('omp binary not found. Install oh-my-pi or set OMPCHAMBER_OMP_BIN.');
|
|
50
50
|
}
|
|
51
51
|
this.cwd = options.cwd;
|
|
52
52
|
if (options.onFrame) this.frameListeners.add(options.onFrame);
|
|
@@ -5,29 +5,23 @@
|
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Utility to determine whether the application is running in MOCK mode.
|
|
8
|
-
*
|
|
8
|
+
*
|
|
9
9
|
* - When MOCK=true (or MOCK=1): all data uses mock / simulated presets (demo chat traces, mock token analytics, mock settings presets).
|
|
10
10
|
* - When MOCK=false (or MOCK=0): all data uses real data (real SQLite persistence, real token calculations, real workspace state).
|
|
11
|
+
* - Unset (or any unrecognized value) defaults to REAL data — the chamber is a
|
|
12
|
+
* diagnostic console for a live omp install, so demo presets must be opted
|
|
13
|
+
* into explicitly rather than inherited by omission.
|
|
11
14
|
*/
|
|
12
15
|
export function isMockMode(): boolean {
|
|
13
16
|
const envVal = (Bun.env.MOCK || '').trim().toLowerCase();
|
|
14
|
-
|
|
15
|
-
if (envVal === 'false' || envVal === '0' || envVal === 'off' || envVal === 'no') {
|
|
16
|
-
return false;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
if (envVal === 'true' || envVal === '1' || envVal === 'on' || envVal === 'yes') {
|
|
20
|
-
return true;
|
|
21
|
-
}
|
|
22
17
|
|
|
23
|
-
|
|
24
|
-
return true;
|
|
18
|
+
return envVal === 'true' || envVal === '1' || envVal === 'on' || envVal === 'yes';
|
|
25
19
|
}
|
|
26
20
|
|
|
27
21
|
export function getMockModeInfo(): { isMock: boolean; rawEnv: string } {
|
|
28
22
|
const isMock = isMockMode();
|
|
29
23
|
return {
|
|
30
24
|
isMock,
|
|
31
|
-
rawEnv: Bun.env.MOCK || (isMock ? 'true
|
|
25
|
+
rawEnv: Bun.env.MOCK || (isMock ? 'true' : 'false (default)'),
|
|
32
26
|
};
|
|
33
27
|
}
|