ompchamber 3.1.1 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +279 -280
- package/dist/client/static/js/index-01dv383b.css +1 -0
- package/dist/client/static/js/{index-qhzsybkn.js → index-1q6165r3.js} +641 -641
- package/package.json +1 -1
- package/src/cli/lib/output.js +15 -8
- package/src/client/components/common/Modal.tsx +14 -4
- package/src/client/components/{layout/session-sidebar/SubagentList.tsx → common/subagent-list/index.tsx} +8 -10
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +6 -30
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +11 -5
- package/src/client/components/layout/session-sidebar/index.tsx +4 -2
- package/src/client/components/mobile/LayoutWrapper.tsx +6 -0
- package/src/client/components/mobile/SessionSidebar.tsx +4 -0
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +66 -29
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +50 -12
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
- package/src/client/components/settings/Modal.tsx +35 -6
- package/src/client/components/settings/SettingsSidebarList.tsx +1 -1
- package/src/client/components/settings/categories/AgentSettings.tsx +45 -28
- package/src/client/components/settings/categories/CommandSettings.tsx +45 -28
- package/src/client/components/settings/categories/McpSettings.tsx +48 -31
- package/src/client/components/settings/categories/ProjectSettings.tsx +34 -19
- package/src/client/components/settings/categories/ProviderSettings.tsx +77 -53
- package/src/client/components/settings/categories/SkillSettings.tsx +34 -17
- package/src/client/components/settings/categories/UsageSettings.tsx +53 -39
- package/src/client/components/settings/categories/behavior-settings/Editor.tsx +7 -7
- package/src/client/components/settings/categories/mcp-settings/ImportModal.tsx +1 -1
- package/src/client/components/settings/categories/mcp-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/project-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +8 -3
- package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +9 -4
- package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/skill-catalog/AddSourceModal.tsx +1 -1
- package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/master-detail/index.tsx +68 -0
- package/src/client/components/workspace/SearchPanel.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +7 -1
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/index.tsx +1 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +2 -1
- package/src/client/components/workspace/diff-panel/SplitView.tsx +31 -5
- package/src/client/components/workspace/diff-panel/Toolbar.tsx +77 -6
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +18 -5
- package/src/client/components/workspace/diff-panel/index.tsx +36 -7
- package/src/client/components/workspace/editor/Toolbar.tsx +11 -4
- package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
- package/src/client/hooks/chat/omp/session-list.tsx +31 -1
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +7 -1
- package/src/client/hooks/chat/subagent/index.ts +37 -11
- package/src/client/hooks/editor/use-file-editor.ts +1 -1
- package/src/client/hooks/settings/master-detail.ts +33 -0
- package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
- package/src/server/lib/db/client.ts +5 -1
- package/src/server/lib/fs/git-diff.ts +34 -10
- package/src/server/lib/fs/git-repos.ts +81 -0
- package/src/server/lib/lifecycle/flock.ts +199 -0
- package/src/server/lib/lifecycle/identity.ts +5 -1
- package/src/server/lib/lifecycle/port-guard.ts +18 -3
- package/src/server/lib/lifecycle/proc/darwin.ts +52 -17
- package/src/server/lib/lifecycle/proc/linux.ts +29 -7
- package/src/server/lib/lifecycle/proc/ps.ts +7 -0
- package/src/server/lib/lifecycle/proc/types.ts +17 -0
- package/src/server/lib/lifecycle/proc/win32.ts +5 -0
- package/src/server/lib/observer-ws.ts +150 -0
- package/src/server/lib/omp/rpc/constants.ts +9 -0
- package/src/server/lib/omp/rpc/subagent-liveness.ts +2 -12
- package/src/server/lib/omp/session/file-scan.ts +7 -1
- package/src/server/lib/omp/session/files.ts +95 -96
- package/src/server/lib/omp/session/locator.ts +18 -8
- package/src/server/lib/omp/session/messages.ts +7 -7
- package/src/server/lib/omp/session/scan.ts +108 -0
- package/src/server/lib/omp/session/stats.ts +50 -50
- package/src/server/lib/omp/session/telemetry/scan.ts +89 -7
- package/src/server/lib/omp/session/telemetry-raw.ts +24 -8
- package/src/server/lib/omp/session/usage/aggregate.ts +170 -55
- package/src/server/lib/terminal/runtime.server.ts +3 -0
- package/src/server/lib/terminal/session.server.ts +30 -3
- package/src/server/routes/agent/command.test.ts +69 -0
- package/src/server/routes/agent/command.ts +14 -0
- package/src/server/routes/agent/ws.test.ts +159 -0
- package/src/server/routes/agent/ws.ts +22 -97
- package/src/server/routes/btw/ws.ts +22 -95
- package/src/server/routes/fs/git.ts +61 -70
- package/src/server/routes/omp/session-stats.ts +1 -1
- package/src/server/routes/telemetry/tokens.ts +6 -4
- package/src/server/routes/terminal/run.ts +4 -8
- package/src/server/routes/terminal/ws.ts +24 -2
- package/src/shared/lib/chat/omp/agent-events.test.ts +33 -1
- package/src/shared/lib/chat/omp/agent-events.ts +10 -0
- package/src/shared/lib/code/language.ts +146 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +2 -1
- package/src/shared/lib/code/syntax-highlight.ts +0 -122
- package/src/shared/lib/fs/diff-parser.test.ts +5 -5
- package/src/shared/lib/fs/diff-parser.ts +67 -42
- package/src/shared/lib/omp/subagent/parse.test.ts +88 -0
- package/src/shared/lib/omp/subagent/parse.ts +17 -0
- package/src/shared/lib/omp/subagent/transcript-client.test.ts +100 -0
- package/src/shared/lib/omp/subagent/transcript-client.ts +17 -1
- package/src/shared/types/omp/subagent.ts +6 -0
- package/dist/client/static/js/index-5yfre1kw.css +0 -1
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Expanded-session rows, shared by the desktop and mobile session sidebars.
|
|
8
|
+
*
|
|
9
|
+
* A session row's roster (its `SubagentList`) is shown while that row's id is
|
|
10
|
+
* in the expanded set, so both sidebars must read and write ONE set — persisted
|
|
11
|
+
* in `app_settings.omp_sidebar_expanded_sessions` — or a row expanded on the
|
|
12
|
+
* phone would come back collapsed on the desktop.
|
|
13
|
+
*
|
|
14
|
+
* The `?subagent=` deep link auto-expands its session row: a reload on a
|
|
15
|
+
* subagent transcript must leave the roster entry that opened it visible.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useCallback, useEffect, useState } from 'preact/hooks';
|
|
19
|
+
import { useSearchParams } from '@/client/lib/router/search-params';
|
|
20
|
+
import { loadExpandedSessionIds, saveExpandedSessionIds } from '@/shared/lib/workspace/sidebar-expanded';
|
|
21
|
+
|
|
22
|
+
export interface ExpandedSessionsHandle {
|
|
23
|
+
expandedSessionIds: Set<string>;
|
|
24
|
+
/** Collapse an expanded row, or expand a collapsed one. Persists the result. */
|
|
25
|
+
toggleSession: (sessionId: string) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function useExpandedSessions(): ExpandedSessionsHandle {
|
|
29
|
+
const [searchParams] = useSearchParams();
|
|
30
|
+
const urlSubagentId = searchParams.get('subagent');
|
|
31
|
+
const urlSessionId = searchParams.get('sessionId');
|
|
32
|
+
const [expandedSessionIds, setExpandedSessionIds] = useState<Set<string>>(() => new Set());
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const saved = loadExpandedSessionIds();
|
|
36
|
+
if (saved && saved.size > 0) {
|
|
37
|
+
setExpandedSessionIds(saved);
|
|
38
|
+
}
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!urlSubagentId || !urlSessionId) return;
|
|
43
|
+
setExpandedSessionIds(prev => {
|
|
44
|
+
if (prev.has(urlSessionId)) return prev;
|
|
45
|
+
const next = new Set(prev);
|
|
46
|
+
next.add(urlSessionId);
|
|
47
|
+
return next;
|
|
48
|
+
});
|
|
49
|
+
}, [urlSubagentId, urlSessionId]);
|
|
50
|
+
|
|
51
|
+
const toggleSession = useCallback((sessionId: string) => {
|
|
52
|
+
setExpandedSessionIds((prev) => {
|
|
53
|
+
const next = new Set(prev);
|
|
54
|
+
if (next.has(sessionId)) next.delete(sessionId);
|
|
55
|
+
else next.add(sessionId);
|
|
56
|
+
saveExpandedSessionIds(next);
|
|
57
|
+
return next;
|
|
58
|
+
});
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
return { expandedSessionIds, toggleSession };
|
|
62
|
+
}
|
|
@@ -44,7 +44,11 @@ export function createDb(path: string): DbClient {
|
|
|
44
44
|
return prep(sql).all(...(params as never[])) as T[];
|
|
45
45
|
},
|
|
46
46
|
async run(sql: string, params: any[] = []) {
|
|
47
|
-
|
|
47
|
+
// Through `prep`, like `get`/`all`: `Database.run` does not cache its
|
|
48
|
+
// query (it prepares and finalizes per call), which cost every INSERT /
|
|
49
|
+
// UPDATE / DELETE a compile — measured 0.881 µs/op against 0.627 µs/op
|
|
50
|
+
// for the cached statement.
|
|
51
|
+
const result = prep(sql).run(...(params as never[]));
|
|
48
52
|
return { changes: result.changes, lastID: Number(result.lastInsertRowid) };
|
|
49
53
|
},
|
|
50
54
|
async exec(sql: string) {
|
|
@@ -2,20 +2,32 @@ import path from 'path';
|
|
|
2
2
|
import type { FileDiffData } from '@/shared/types/git';
|
|
3
3
|
import { runShell, shellOk } from '@/server/lib/fs/shell';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Context lines requested when the reader wants the whole file rather than the
|
|
7
|
+
* hunks. `--unified` takes any count, so this is a cap on the FILE, not on the
|
|
8
|
+
* change: git emits every line it can reach around the edits.
|
|
9
|
+
*/
|
|
10
|
+
const FULL_CONTEXT = 1000000;
|
|
11
|
+
|
|
5
12
|
/** Runs one git diff probe and returns its stdout ('' on failure). */
|
|
6
|
-
async function gitOut(command: string, cwd: string, timeout: number): Promise<string> {
|
|
7
|
-
const result = await runShell(command, { cwd, timeout, maxBuffer
|
|
13
|
+
async function gitOut(command: string, cwd: string, timeout: number, maxBuffer: number = 1024 * 1024 * 2): Promise<string> {
|
|
14
|
+
const result = await runShell(command, { cwd, timeout, maxBuffer });
|
|
8
15
|
return shellOk(result) ? result.stdout : '';
|
|
9
16
|
}
|
|
10
17
|
|
|
11
18
|
/**
|
|
12
19
|
* Fetch git diff for a specific file in the working directory or index.
|
|
20
|
+
*
|
|
21
|
+
* `fullContext` asks git for the entire file rather than the surrounding hunks,
|
|
22
|
+
* which is what the panel's "Full Code" mode renders — the diff comes back in
|
|
23
|
+
* the same unified format, so no second rendering path exists.
|
|
13
24
|
*/
|
|
14
25
|
export async function fetchWorkingFileDiff(
|
|
15
26
|
targetDir: string,
|
|
16
27
|
file: string,
|
|
17
28
|
staged: boolean = false,
|
|
18
|
-
requestedStatus?: string
|
|
29
|
+
requestedStatus?: string,
|
|
30
|
+
fullContext: boolean = false
|
|
19
31
|
): Promise<FileDiffData> {
|
|
20
32
|
const cleanFile = file.replace(/^[./\\]+/, '').replace(/\\/g, '/');
|
|
21
33
|
const fullPath = path.join(targetDir, cleanFile);
|
|
@@ -45,21 +57,33 @@ export async function fetchWorkingFileDiff(
|
|
|
45
57
|
oldContent = await gitOut(`git show HEAD:"${cleanFile}"`, targetDir, 5000)
|
|
46
58
|
|| await gitOut(`git show :0:"${cleanFile}"`, targetDir, 5000);
|
|
47
59
|
|
|
60
|
+
// `-U<n>` on every probe: the full-code mode asks for the whole file, and
|
|
61
|
+
// the diff-only mode keeps git's own default by passing no flag at all.
|
|
62
|
+
const ctx = fullContext ? ` -U${FULL_CONTEXT}` : '';
|
|
63
|
+
// Full-context output is the file, not the change, so a fixed buffer would
|
|
64
|
+
// truncate a large file's diff — and a killed process is not a success, so
|
|
65
|
+
// `gitOut` reports it as no diff at all and the synthesized whole-file
|
|
66
|
+
// rewrite below takes over silently. Sized off the content already read, so
|
|
67
|
+
// it cannot exceed roughly twice the file.
|
|
68
|
+
const buf = fullContext
|
|
69
|
+
? Math.max(1024 * 1024 * 16, (oldContent.length + newContent.length) * 2 + 1024 * 1024)
|
|
70
|
+
: 1024 * 1024 * 2;
|
|
71
|
+
|
|
48
72
|
if (staged) {
|
|
49
73
|
// 1. Try staged diff (index vs HEAD), 2. HEAD diff, 3. unstaged diff
|
|
50
|
-
diff = await gitOut(`git diff --cached -- "${cleanFile}"`, targetDir, 10000)
|
|
51
|
-
|| await gitOut(`git diff HEAD -- "${cleanFile}"`, targetDir, 10000)
|
|
52
|
-
|| await gitOut(`git diff -- "${cleanFile}"`, targetDir, 10000);
|
|
74
|
+
diff = await gitOut(`git diff${ctx} --cached -- "${cleanFile}"`, targetDir, 10000, buf)
|
|
75
|
+
|| await gitOut(`git diff${ctx} HEAD -- "${cleanFile}"`, targetDir, 10000, buf)
|
|
76
|
+
|| await gitOut(`git diff${ctx} -- "${cleanFile}"`, targetDir, 10000, buf);
|
|
53
77
|
} else if (status === '??' || status === 'U' || status === '?') {
|
|
54
78
|
// Untracked file: --no-index exits 1 when a diff exists, so read stdout
|
|
55
79
|
// directly instead of relying on the exit code.
|
|
56
|
-
const untracked = await runShell(`git diff --no-index /dev/null "${cleanFile}"`, { cwd: targetDir, timeout: 10000, maxBuffer:
|
|
80
|
+
const untracked = await runShell(`git diff${ctx} --no-index /dev/null "${cleanFile}"`, { cwd: targetDir, timeout: 10000, maxBuffer: buf });
|
|
57
81
|
diff = untracked.stdout.trim() ? untracked.stdout : '';
|
|
58
82
|
} else {
|
|
59
83
|
// 1. Working tree diff, 2. cached diff, 3. HEAD diff
|
|
60
|
-
diff = await gitOut(`git diff -- "${cleanFile}"`, targetDir, 10000)
|
|
61
|
-
|| await gitOut(`git diff --cached -- "${cleanFile}"`, targetDir, 10000)
|
|
62
|
-
|| await gitOut(`git diff HEAD -- "${cleanFile}"`, targetDir, 10000);
|
|
84
|
+
diff = await gitOut(`git diff${ctx} -- "${cleanFile}"`, targetDir, 10000, buf)
|
|
85
|
+
|| await gitOut(`git diff${ctx} --cached -- "${cleanFile}"`, targetDir, 10000, buf)
|
|
86
|
+
|| await gitOut(`git diff${ctx} HEAD -- "${cleanFile}"`, targetDir, 10000, buf);
|
|
63
87
|
}
|
|
64
88
|
|
|
65
89
|
// 4. If git diff is still empty but new content exists:
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Nested-repository discovery for the right-panel repo picker.
|
|
8
|
+
*
|
|
9
|
+
* A workspace is often a folder OF repositories (`projects/<name>/<sub>/.git`),
|
|
10
|
+
* so the picker lists every repo under the scoped root. The walk is expensive
|
|
11
|
+
* on a deep tree, so it runs in the BACKGROUND after the loader has already
|
|
12
|
+
* answered with the root's own status; the client polls until `pending` clears.
|
|
13
|
+
* Results are cached per scoped root for the life of the process.
|
|
14
|
+
*
|
|
15
|
+
* The `find` prunes `node_modules` (the one directory guaranteed to hold
|
|
16
|
+
* unrelated `.git` directories, and the one that makes the walk unbounded).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { runShell } from '@/server/lib/fs/shell';
|
|
20
|
+
|
|
21
|
+
const MAX_GIT_DEPTH = 8;
|
|
22
|
+
|
|
23
|
+
async function getRepos(rootDir: string): Promise<string[]> {
|
|
24
|
+
try {
|
|
25
|
+
// Search deep enough to discover nested/child git repos inside a monorepo
|
|
26
|
+
// workspace (e.g. projects/<name>/<sub>/.git) while pruning node_modules.
|
|
27
|
+
const result = await runShell(
|
|
28
|
+
`find . -maxdepth ${MAX_GIT_DEPTH} -name node_modules -prune -o -name .git -type d -print`,
|
|
29
|
+
{ cwd: rootDir, timeout: 12000, maxBuffer: 1024 * 1024 }
|
|
30
|
+
);
|
|
31
|
+
const discovered = result.stdout
|
|
32
|
+
.trim()
|
|
33
|
+
.split('\n')
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.map(line => {
|
|
36
|
+
const cleaned = line.replace(/^\.\//, '').replace(/\/\.git$/, '');
|
|
37
|
+
return cleaned === '.git' || !cleaned ? '.' : cleaned;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const uniqueRepos = Array.from(new Set(discovered));
|
|
41
|
+
if (uniqueRepos.includes('.')) {
|
|
42
|
+
return ['.', ...uniqueRepos.filter(r => r !== '.')];
|
|
43
|
+
}
|
|
44
|
+
return uniqueRepos.length ? uniqueRepos : ['.'];
|
|
45
|
+
} catch {
|
|
46
|
+
return ['.'];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface RepoDiscovery {
|
|
51
|
+
repos: string[];
|
|
52
|
+
done: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const repoDiscovery = new Map<string, RepoDiscovery>();
|
|
56
|
+
|
|
57
|
+
/** Begin the background walk for `rootDir`; a no-op once one has started. */
|
|
58
|
+
export function startRepoScan(rootDir: string): void {
|
|
59
|
+
if (repoDiscovery.has(rootDir)) return;
|
|
60
|
+
const d: RepoDiscovery = { repos: ['.'], done: false };
|
|
61
|
+
repoDiscovery.set(rootDir, d);
|
|
62
|
+
void getRepos(rootDir)
|
|
63
|
+
.then(repos => { d.repos = repos; d.done = true; })
|
|
64
|
+
.catch(() => { d.repos = ['.']; d.done = true; });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Drop the cached result for `rootDir` and start a fresh walk. Used by the
|
|
69
|
+
* repo-switcher's refresh button, which must not serve the previous answer.
|
|
70
|
+
*/
|
|
71
|
+
export function rescanRepos(rootDir: string): void {
|
|
72
|
+
repoDiscovery.delete(rootDir);
|
|
73
|
+
startRepoScan(rootDir);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The discovered repos for `rootDir`, and whether the walk is still running. */
|
|
77
|
+
export function discoveredRepos(rootDir: string): { repos: string[]; pending: boolean } {
|
|
78
|
+
const d = repoDiscovery.get(rootDir);
|
|
79
|
+
if (!d) return { repos: ['.'], pending: false };
|
|
80
|
+
return { repos: d.done ? d.repos : ['.'], pending: !d.done };
|
|
81
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `flock(2)` through `bun:ffi`, for the port-handover lock.
|
|
8
|
+
*
|
|
9
|
+
* The kernel releases a flock when the holding process dies, which is exactly
|
|
10
|
+
* the property a startup lock wants — no PID readback, no stale-holder
|
|
11
|
+
* classification, and no polling loop to detect a dead holder. The previous
|
|
12
|
+
* PID-file scheme needed all three: it wrote a PID, re-read it on every retry,
|
|
13
|
+
* asked the OS whether that PID was still OMPChamber (four `sysctl` calls per
|
|
14
|
+
* question on macOS), and deleted the file to release.
|
|
15
|
+
*
|
|
16
|
+
* `flock` is exported by `libc.dylib` / `libSystem.B.dylib` / `libc.so.6`, so it
|
|
17
|
+
* is reachable the same way `lib/lifecycle/proc/darwin.ts` reaches `sysctl`.
|
|
18
|
+
* Windows has no `flock`, and a host can refuse `dlopen` or answer `ENOLCK`
|
|
19
|
+
* (some NFS mounts) — `flockAvailable()` is a real capability probe, and every
|
|
20
|
+
* caller keeps its own fallback for the false case.
|
|
21
|
+
*
|
|
22
|
+
* **The lock file is never unlinked while it may be held.** Removing it is the
|
|
23
|
+
* classic flock race: another process that already opened the path keeps a
|
|
24
|
+
* handle on the unlinked inode and acquires it successfully while a third
|
|
25
|
+
* process creates a fresh file at the same path and also succeeds. Leaving the
|
|
26
|
+
* file in place makes the inode the lock, which is what flock is for; the file
|
|
27
|
+
* is an empty marker, one per port.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import fs from 'fs';
|
|
31
|
+
import { dlopen, FFIType } from 'bun:ffi';
|
|
32
|
+
import type { FFIFunction, Library } from 'bun:ffi';
|
|
33
|
+
|
|
34
|
+
const LOCK_EX = 2;
|
|
35
|
+
/** Non-blocking: fail immediately rather than waiting inside the syscall. */
|
|
36
|
+
const LOCK_NB = 4;
|
|
37
|
+
|
|
38
|
+
const FLOCK_SYMBOLS = {
|
|
39
|
+
flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
|
|
40
|
+
} satisfies Record<string, FFIFunction>;
|
|
41
|
+
|
|
42
|
+
/** Library names to try, in the order each platform resolves them. */
|
|
43
|
+
const LIBC_NAMES = ['libc.dylib', 'libSystem.B.dylib', 'libc.so.6'] as const;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Held leases and the probe result, on `globalThis` rather than in module
|
|
47
|
+
* bindings.
|
|
48
|
+
*
|
|
49
|
+
* A `bun --hot` reload re-evaluates this module while the process (and its open
|
|
50
|
+
* descriptors) lives on. A fresh module-level Map would forget that this PID
|
|
51
|
+
* already holds the port's lock, so the reloaded server would open a second fd,
|
|
52
|
+
* find the lock taken by its own previous fd, and refuse to start.
|
|
53
|
+
*/
|
|
54
|
+
interface FlockHost {
|
|
55
|
+
/** `lockPath` → the fd holding it and how many acquires it backs. */
|
|
56
|
+
held: Map<string, { fd: number; refs: number }>;
|
|
57
|
+
/** Tri-state capability probe: undefined until asked, then lib-or-null. */
|
|
58
|
+
available: boolean | undefined;
|
|
59
|
+
/** The opened library, shared across reloads. */
|
|
60
|
+
lib: Library<typeof FLOCK_SYMBOLS> | null | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
declare global {
|
|
64
|
+
// eslint-disable-next-line no-var
|
|
65
|
+
var __ompChamberFlock: FlockHost | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function host(): FlockHost {
|
|
69
|
+
return (globalThis.__ompChamberFlock ??= { held: new Map(), available: undefined, lib: undefined });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** libc with `flock`, or null when this host has none. Cached for the process. */
|
|
73
|
+
function libc(): Library<typeof FLOCK_SYMBOLS> | null {
|
|
74
|
+
const state = host();
|
|
75
|
+
if (state.lib !== undefined) return state.lib;
|
|
76
|
+
state.lib = null;
|
|
77
|
+
if (process.platform === 'win32') return state.lib;
|
|
78
|
+
for (const name of LIBC_NAMES) {
|
|
79
|
+
try {
|
|
80
|
+
state.lib = dlopen(name, FLOCK_SYMBOLS);
|
|
81
|
+
return state.lib;
|
|
82
|
+
} catch {
|
|
83
|
+
// Try the next name; a host without any of them keeps the null answer.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return state.lib;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* True when `flock(2)` actually works here — the symbol resolving is not
|
|
91
|
+
* enough, because a filesystem can answer `ENOLCK`. Probed once, on a
|
|
92
|
+
* throwaway file, so a caller can pick its mechanism up front instead of
|
|
93
|
+
* discovering mid-wait that the syscall never works.
|
|
94
|
+
*/
|
|
95
|
+
export function flockAvailable(): boolean {
|
|
96
|
+
const state = host();
|
|
97
|
+
if (state.available !== undefined) return state.available;
|
|
98
|
+
state.available = false;
|
|
99
|
+
const symbols = libc();
|
|
100
|
+
if (!symbols) return false;
|
|
101
|
+
const probePath = `${process.env.TMPDIR ?? '/tmp'}/ompchamber-flock-${process.pid}`;
|
|
102
|
+
let fd: number | null = null;
|
|
103
|
+
try {
|
|
104
|
+
fd = fs.openSync(probePath, 'a');
|
|
105
|
+
const ok = symbols.symbols.flock(fd, LOCK_EX | LOCK_NB) === 0;
|
|
106
|
+
fs.closeSync(fd);
|
|
107
|
+
fd = null;
|
|
108
|
+
fs.rmSync(probePath, { force: true });
|
|
109
|
+
if (!ok) {
|
|
110
|
+
// The probe file was somehow already locked — treat the capability as
|
|
111
|
+
// unproven rather than reporting a lock we do not hold.
|
|
112
|
+
state.lib = null;
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
state.available = true;
|
|
116
|
+
return true;
|
|
117
|
+
} catch {
|
|
118
|
+
state.lib = null;
|
|
119
|
+
return false;
|
|
120
|
+
} finally {
|
|
121
|
+
if (fd !== null) {
|
|
122
|
+
try {
|
|
123
|
+
fs.closeSync(fd);
|
|
124
|
+
} catch {
|
|
125
|
+
// Already closed.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface FlockHandle {
|
|
132
|
+
/** Release the lease. Idempotent. */
|
|
133
|
+
release: () => void;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Take `LOCK_EX | LOCK_NB` on `lockPath`, or null when another process holds
|
|
138
|
+
* it. Returns null too when `flock` is unavailable — callers must then use
|
|
139
|
+
* their own mechanism.
|
|
140
|
+
*
|
|
141
|
+
* The fd is kept open for as long as the lease lives: closing it is what
|
|
142
|
+
* releases the lock, and the kernel does the same on process death.
|
|
143
|
+
*/
|
|
144
|
+
export function tryFlock(lockPath: string): FlockHandle | null {
|
|
145
|
+
const state = host();
|
|
146
|
+
const existing = state.held.get(lockPath);
|
|
147
|
+
if (existing) {
|
|
148
|
+
// Re-entrant within one process: flock is per open-file-description, so a
|
|
149
|
+
// second fd here would block against our own lock.
|
|
150
|
+
existing.refs += 1;
|
|
151
|
+
return { release: () => releaseFlock(lockPath) };
|
|
152
|
+
}
|
|
153
|
+
const symbols = libc();
|
|
154
|
+
if (!symbols) return null;
|
|
155
|
+
let fd: number;
|
|
156
|
+
try {
|
|
157
|
+
// `a` creates it when absent and never truncates; the file's content is
|
|
158
|
+
// irrelevant (the inode is the lock), but the PID is useful when a human
|
|
159
|
+
// looks at the directory.
|
|
160
|
+
fd = fs.openSync(lockPath, 'a');
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
let acquired = false;
|
|
165
|
+
try {
|
|
166
|
+
acquired = symbols.symbols.flock(fd, LOCK_EX | LOCK_NB) === 0;
|
|
167
|
+
} catch {
|
|
168
|
+
acquired = false;
|
|
169
|
+
}
|
|
170
|
+
if (!acquired) {
|
|
171
|
+
try {
|
|
172
|
+
fs.closeSync(fd);
|
|
173
|
+
} catch {
|
|
174
|
+
// Already closed.
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
fs.writeFileSync(fd, String(process.pid));
|
|
180
|
+
} catch {
|
|
181
|
+
// Informational only.
|
|
182
|
+
}
|
|
183
|
+
state.held.set(lockPath, { fd, refs: 1 });
|
|
184
|
+
return { release: () => releaseFlock(lockPath) };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function releaseFlock(lockPath: string): void {
|
|
188
|
+
const state = host();
|
|
189
|
+
const entry = state.held.get(lockPath);
|
|
190
|
+
if (!entry) return;
|
|
191
|
+
entry.refs -= 1;
|
|
192
|
+
if (entry.refs > 0) return;
|
|
193
|
+
state.held.delete(lockPath);
|
|
194
|
+
try {
|
|
195
|
+
fs.closeSync(entry.fd);
|
|
196
|
+
} catch {
|
|
197
|
+
// Already closed; the kernel released the lock either way.
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -37,7 +37,11 @@ export type ProcessState = 'dead' | 'matched' | 'mismatched' | 'unknown';
|
|
|
37
37
|
*/
|
|
38
38
|
export function isProcessAlive(pid: number): boolean {
|
|
39
39
|
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
40
|
-
|
|
40
|
+
const liveness = processProbe.liveness(pid);
|
|
41
|
+
// A zombie is NOT alive: it holds no port and will never run again. An
|
|
42
|
+
// `unknown` (a probe that could not answer) keeps the previous bias and
|
|
43
|
+
// counts as alive, so a sandboxed host cannot make a live server look dead.
|
|
44
|
+
return liveness === 'alive' || liveness === 'unknown';
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
/**
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import fs from 'fs';
|
|
24
24
|
|
|
25
|
+
import { flockAvailable, tryFlock } from '@/server/lib/lifecycle/flock';
|
|
25
26
|
import { getProcessState } from '@/server/lib/lifecycle/identity';
|
|
26
27
|
import { describeOccupant, findOccupant } from '@/server/lib/lifecycle/occupant';
|
|
27
28
|
import { getLockPath } from '@/server/lib/lifecycle/paths';
|
|
@@ -48,13 +49,27 @@ export interface PortLock {
|
|
|
48
49
|
* Two starters racing on one free port would each probe it as free and then
|
|
49
50
|
* fight over the bind, with the loser reporting a bare `EADDRINUSE` against a
|
|
50
51
|
* 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.
|
|
52
|
-
* holder is dead (or is not OMPChamber, i.e. a recycled PID) is reclaimed.
|
|
52
|
+
* the loser fail with a message that names the actual situation.
|
|
53
53
|
*
|
|
54
|
-
*
|
|
54
|
+
* The mechanism is `flock(2)` when the host has it (see `flock.ts`): the kernel
|
|
55
|
+
* releases it on process death, so there is no stale-holder case to detect and
|
|
56
|
+
* no polling loop. The PID-file path below stays as the fallback for a host
|
|
57
|
+
* without `flock` (Windows, a filesystem answering `ENOLCK`); it needs the
|
|
58
|
+
* extra machinery — PID readback, `getProcessState` classification, and
|
|
59
|
+
* reclaiming a lock whose holder died or whose PID was recycled.
|
|
55
60
|
*/
|
|
56
61
|
export async function acquirePortLock(port: number, waitMs = LOCK_WAIT_MS): Promise<PortLock | null> {
|
|
57
62
|
const lockPath = getLockPath(port);
|
|
63
|
+
if (flockAvailable()) {
|
|
64
|
+
const deadline = Date.now() + waitMs;
|
|
65
|
+
for (;;) {
|
|
66
|
+
const handle = tryFlock(lockPath);
|
|
67
|
+
if (handle) return handle;
|
|
68
|
+
if (Date.now() >= deadline) return null;
|
|
69
|
+
await Bun.sleep(150);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
58
73
|
const deadline = Date.now() + waitMs;
|
|
59
74
|
for (;;) {
|
|
60
75
|
try {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
import { dlopen, FFIType, ptr } from 'bun:ffi';
|
|
29
29
|
import type { FFIFunction, Library } from 'bun:ffi';
|
|
30
30
|
|
|
31
|
-
import type { ProcessProbe } from '@/server/lib/lifecycle/proc/types';
|
|
31
|
+
import type { ProcessLiveness, ProcessProbe } from '@/server/lib/lifecycle/proc/types';
|
|
32
32
|
|
|
33
33
|
/** `sysctl` MIB pieces. */
|
|
34
34
|
const CTL_KERN = 1;
|
|
@@ -45,8 +45,21 @@ const KINFO_PROC_BYTES = 648;
|
|
|
45
45
|
const P_STAT_OFFSET = 36;
|
|
46
46
|
/** Darwin `p_stat` values. */
|
|
47
47
|
const SZOMB = 5;
|
|
48
|
+
/** `p_stat` values that mean "exited but not yet reaped". */
|
|
48
49
|
const SZOMB_LIKE = new Set([SZOMB, 6 /* SDEAD */]);
|
|
49
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Scratch for the `sysctl(KERN_PROC_PID)` call below, at module scope.
|
|
53
|
+
*
|
|
54
|
+
* One `isProcessAlive` asks this question twice (liveness then zombie), and
|
|
55
|
+
* `getProcessState` asks it twice more — so the buffers are reused rather than
|
|
56
|
+
* reallocated per call, the same reasoning as `bsdInfo` further down. Safe
|
|
57
|
+
* because the whole probe is synchronous: no `await` can interleave a caller.
|
|
58
|
+
*/
|
|
59
|
+
const procMib = new Int32Array([CTL_KERN, KERN_PROC, KERN_PROC_PID, 0]);
|
|
60
|
+
const procSize = new BigUint64Array([BigInt(KINFO_PROC_BYTES)]);
|
|
61
|
+
const procBuffer = new Uint8Array(KINFO_PROC_BYTES);
|
|
62
|
+
|
|
50
63
|
/** Upper bound on a command line block; the kernel truncates instead. */
|
|
51
64
|
const ARGV_BUFFER_BYTES = 262_144;
|
|
52
65
|
const PATH_BUFFER_BYTES = 4096;
|
|
@@ -106,24 +119,27 @@ const BSDINFO_PID_OFFSET = 12;
|
|
|
106
119
|
const BSDINFO_TPGID_OFFSET = 112;
|
|
107
120
|
|
|
108
121
|
/**
|
|
109
|
-
* `p_stat` for a
|
|
122
|
+
* `p_stat` for a PID, `'absent'` when the kernel says no such process, or null
|
|
123
|
+
* when the call itself failed.
|
|
110
124
|
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
125
|
+
* The two failure modes are NOT the same question and callers must not merge
|
|
126
|
+
* them: `sysctl(KERN_PROC_PID)` answers rc=0 with a zero-length reply for a PID
|
|
127
|
+
* that does not exist (verified: 999999 → rc 0, size 0), while a sandbox denies
|
|
128
|
+
* the call with a non-zero rc. Reading the second as "dead" would let a sandbox
|
|
129
|
+
* report a live server as gone.
|
|
113
130
|
*/
|
|
114
|
-
function procStatus(pid: number): number | null {
|
|
131
|
+
function procStatus(pid: number): number | 'absent' | null {
|
|
115
132
|
const bound = bindLibs();
|
|
116
133
|
if (bound === null) return null;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const buffer = new Uint8Array(KINFO_PROC_BYTES);
|
|
134
|
+
procMib[3] = pid;
|
|
135
|
+
procSize[0] = BigInt(KINFO_PROC_BYTES);
|
|
120
136
|
try {
|
|
121
|
-
if (bound.libc.symbols.sysctl(ptr(
|
|
137
|
+
if (bound.libc.symbols.sysctl(ptr(procMib), 4, ptr(procBuffer), ptr(procSize), null, 0) !== 0) return null;
|
|
122
138
|
} catch {
|
|
123
139
|
return null;
|
|
124
140
|
}
|
|
125
|
-
if (Number(
|
|
126
|
-
return
|
|
141
|
+
if (Number(procSize[0]) === 0) return 'absent';
|
|
142
|
+
return procBuffer[P_STAT_OFFSET];
|
|
127
143
|
}
|
|
128
144
|
|
|
129
145
|
/**
|
|
@@ -197,21 +213,40 @@ function signalAlive(pid: number): boolean {
|
|
|
197
213
|
}
|
|
198
214
|
}
|
|
199
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Liveness in one lookup, for both the probe and its own `isAlive`/`isZombie`
|
|
218
|
+
* shims. A method calling `this` would break the moment a caller destructured
|
|
219
|
+
* the probe, and the session registry does pass probes around by reference.
|
|
220
|
+
*/
|
|
221
|
+
function darwinLiveness(pid: number): ProcessLiveness {
|
|
222
|
+
const status = procStatus(pid);
|
|
223
|
+
if (status === 'absent') return 'dead';
|
|
224
|
+
if (status !== null) return SZOMB_LIKE.has(status) ? 'zombie' : 'alive';
|
|
225
|
+
// The syscall itself failed (sandbox). A path or a signal still separates
|
|
226
|
+
// "exists" from "gone" — a path read answers for root-owned PIDs, and signal
|
|
227
|
+
// 0 answers for the rest — so only a PID that fails both is reported dead.
|
|
228
|
+
// Anything else is `unknown`, which callers treat as alive-but-unverified.
|
|
229
|
+
if (executablePath(pid) !== null || signalAlive(pid)) return 'alive';
|
|
230
|
+
return 'unknown';
|
|
231
|
+
}
|
|
232
|
+
|
|
200
233
|
export const darwinProbe: ProcessProbe = {
|
|
201
234
|
commandLine(pid) {
|
|
202
235
|
const args = argvOf(pid);
|
|
203
236
|
if (args && args.length > 0) return args.join(' ');
|
|
204
237
|
return executablePath(pid);
|
|
205
238
|
},
|
|
239
|
+
liveness: darwinLiveness,
|
|
206
240
|
isAlive(pid) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
241
|
+
// `procStatus` already reports a zombie as not-alive, so the old
|
|
242
|
+
// `isAlive && !isZombie` pair asked it twice for one answer.
|
|
243
|
+
const liveness = darwinLiveness(pid);
|
|
244
|
+
// `unknown` keeps the previous fallback's bias: a sandboxed host must not
|
|
245
|
+
// make a live server look dead.
|
|
246
|
+
return liveness === 'alive' || liveness === 'unknown';
|
|
211
247
|
},
|
|
212
248
|
isZombie(pid) {
|
|
213
|
-
|
|
214
|
-
return status !== null && SZOMB_LIKE.has(status);
|
|
249
|
+
return darwinLiveness(pid) === 'zombie';
|
|
215
250
|
},
|
|
216
251
|
/**
|
|
217
252
|
* `e_tpgid` — the field `ps -o tpgid=` prints. `proc_pidinfo` is the cheap
|
|
@@ -49,6 +49,20 @@ function statTail(pid: number): string[] | null {
|
|
|
49
49
|
*/
|
|
50
50
|
const TPGID_FIELD = 5;
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* `{ state, tpgid }` from ONE `/proc/<pid>/stat` read.
|
|
54
|
+
*
|
|
55
|
+
* `isZombie` and `foregroundGroup` both need this file, and the terminal cap
|
|
56
|
+
* asks both about the same shell; reading it once per question instead of twice
|
|
57
|
+
* is why the two are resolved together.
|
|
58
|
+
*/
|
|
59
|
+
function procStat(pid: number): { state: string; tpgid: number } | null {
|
|
60
|
+
const fields = statTail(pid);
|
|
61
|
+
if (fields === null) return null;
|
|
62
|
+
const tpgid = Number(fields[TPGID_FIELD]);
|
|
63
|
+
return { state: fields[STATE_FIELD], tpgid: Number.isFinite(tpgid) ? tpgid : 0 };
|
|
64
|
+
}
|
|
65
|
+
|
|
52
66
|
export const linuxProbe: ProcessProbe = {
|
|
53
67
|
commandLine(pid) {
|
|
54
68
|
const raw = readFile(`/proc/${pid}/cmdline`);
|
|
@@ -59,22 +73,30 @@ export const linuxProbe: ProcessProbe = {
|
|
|
59
73
|
// Kernel threads have no argv — `comm` is all the identity there is.
|
|
60
74
|
return readFile(`/proc/${pid}/comm`)?.trim() || null;
|
|
61
75
|
},
|
|
76
|
+
liveness(pid) {
|
|
77
|
+
// A `/proc` entry exists for a zombie too, so the state field is what
|
|
78
|
+
// separates a corpse from a running process.
|
|
79
|
+
const stat = procStat(pid);
|
|
80
|
+
if (stat === null) {
|
|
81
|
+
// No `stat` file: either the PID is gone or the read failed. `existsSync`
|
|
82
|
+
// keeps the previous answer for a process with no readable stat.
|
|
83
|
+
return fs.existsSync(`/proc/${pid}`) ? 'unknown' : 'dead';
|
|
84
|
+
}
|
|
85
|
+
return ZOMBIE_STATES.has(stat.state) ? 'zombie' : 'alive';
|
|
86
|
+
},
|
|
62
87
|
isAlive(pid) {
|
|
63
88
|
// A `/proc` entry exists for a zombie too, so liveness is "the directory is
|
|
64
89
|
// there"; `isZombie` is what separates a corpse from a running process.
|
|
65
90
|
return fs.existsSync(`/proc/${pid}`);
|
|
66
91
|
},
|
|
67
92
|
isZombie(pid) {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
return ZOMBIE_STATES.has(fields[STATE_FIELD]);
|
|
93
|
+
const stat = procStat(pid);
|
|
94
|
+
return stat !== null && ZOMBIE_STATES.has(stat.state);
|
|
71
95
|
},
|
|
72
96
|
foregroundGroup(pid) {
|
|
73
97
|
// Already read for `isZombie` on the same question, so this costs no extra
|
|
74
98
|
// syscall beyond the file read itself.
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
const tpgid = Number(fields[TPGID_FIELD]);
|
|
78
|
-
return Number.isFinite(tpgid) ? tpgid : null;
|
|
99
|
+
const stat = procStat(pid);
|
|
100
|
+
return stat === null ? null : stat.tpgid;
|
|
79
101
|
},
|
|
80
102
|
};
|