Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions packages/host-service/src/discovery.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createHash } from "node:crypto";
import { lstatSync, readdirSync, realpathSync } from "node:fs";
import { chmod, mkdir, open, readdir, readFile, stat, unlink } from "node:fs/promises";
import { join, resolve } from "node:path";
import { chmod, lstat, mkdir, open, readdir, readFile, realpath, stat, unlink } from "node:fs/promises";
import { isAbsolute, join, relative, resolve } from "node:path";
import type {
ArtifactDescriptor,
DurableEntry,
Expand Down Expand Up @@ -49,6 +49,8 @@ type DiscoveryFileSystem = FileSystem & {
maxBytes: number,
expectedIdentity?: string,
) => Promise<string | Uint8Array>;
realpath?: (path: string) => Promise<string>;
isSymbolicLink?: (path: string) => Promise<boolean>;
};
const OBSERVER_MAX_BUFFER_BYTES = 2 * 1024 * 1024;
const OBSERVER_TAIL_ANCHOR_BYTES = 4 * 1024;
Expand All @@ -66,6 +68,8 @@ const realFs: DiscoveryFileSystem = {
},
stat: async path => await stat(path),
readdir: async path => (await readdir(path, { withFileTypes: true })).map(entry => join(path, entry.name)),
realpath: async path => await realpath(path),
isSymbolicLink: async path => (await lstat(path)).isSymbolicLink(),
readFile: async path => await readFile(path),
readFileSlice: async (path, maxBytes) => {
const handle = await open(path, "r");
Expand Down Expand Up @@ -1449,6 +1453,7 @@ function isEncodedProjectDirectory(path: string): boolean {
export class FileSessionDiscovery implements SessionDiscovery {
private readonly index = new Map<string, FileIndexEntry>();
private rootMisses = 0;
private canonicalRoot?: string;
readonly page?: SessionDiscovery["page"];

constructor(
Expand All @@ -1467,6 +1472,18 @@ export class FileSessionDiscovery implements SessionDiscovery {
this.page = (session, args) => pageReader.page(session, args);
}
}
private async rootPath(): Promise<string> {
if (this.canonicalRoot !== undefined) return this.canonicalRoot;
this.canonicalRoot = this.fs.realpath ? await this.fs.realpath(this.root) : resolve(this.root);
return this.canonicalRoot;
}
private async containedPath(path: string): Promise<string | null> {
const canonical = this.fs.realpath ? await this.fs.realpath(path) : resolve(path);
const fromRoot = relative(await this.rootPath(), canonical);
return fromRoot === "" || (!fromRoot.startsWith("..") && !isAbsolute(fromRoot))
? canonical
: null;
}
private async parse(path: string, size: number, metadataOnly: boolean): Promise<SessionRecord> {
if (size > MAX_TRANSCRIPT_BYTES) {
const readSlice = this.fs.readFileSlice;
Expand Down Expand Up @@ -1508,6 +1525,7 @@ export class FileSessionDiscovery implements SessionDiscovery {
const children = await this.fs.readdir(path);
const output: string[] = [];
for (const child of children.sort()) {
if (this.fs.isSymbolicLink && (await this.fs.isSymbolicLink(child).catch(() => true))) continue;
const info = await this.fs.stat(child).catch(() => null);
if (!info) continue;
if (info.isFile() && child.endsWith(".jsonl")) {
Expand Down Expand Up @@ -1556,9 +1574,11 @@ export class FileSessionDiscovery implements SessionDiscovery {
for (const path of files) {
let identity: string;
try {
identity = realpathSync.native(path);
const contained = await this.containedPath(path);
if (contained === null) continue;
identity = contained;
} catch {
identity = resolve(path);
continue;
}
if (seen.has(identity)) continue;
seen.add(identity);
Expand Down
28 changes: 13 additions & 15 deletions packages/host-service/test/discovery.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { mkdtemp, rm, stat, symlink, writeFile } from "node:fs/promises";
import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { decodeServerFrame, entryId, hostId, parseBounded, projectId, sessionId } from "@t4-code/host-wire";
Expand Down Expand Up @@ -1167,11 +1167,9 @@ test("incrementally indexes a large corpus and evicts only deleted or changed fi
expect(reads).toBe(0);
});

test("rebinds a canonical cached transcript when its live symlink alias changes", async () => {
const root = await mkdtemp(join(tmpdir(), "omp-discovery-alias-"));
const target = join(root, "session-target.jsonl");
const firstAlias = join(root, "alias-a.jsonl");
const secondAlias = join(root, "alias-b.jsonl");
test("rejects transcript symlinks even when their targets are readable", async () => {
const root = await mkdtemp(join(tmpdir(), "omp-discovery-root-"));
const outside = await mkdtemp(join(tmpdir(), "omp-discovery-outside-"));
const contents = JSON.stringify({
type: "session",
version: 3,
Expand All @@ -1180,18 +1178,18 @@ test("rebinds a canonical cached transcript when its live symlink alias changes"
timestamp: stamp,
});
try {
await writeFile(target, contents);
await symlink(target, firstAlias);
const internalTarget = join(root, "session-target.data");
const externalTarget = join(outside, "session-target.jsonl");
await writeFile(internalTarget, contents);
await writeFile(externalTarget, contents);
await symlink(internalTarget, join(root, "internal-alias.jsonl"));
await symlink(externalTarget, join(root, "external-alias.jsonl"));

const discovery = new FileSessionDiscovery(root, undefined, host);
const first = await discovery.list();
expect(first[0]?.path).toBe(firstAlias);
await rm(firstAlias);
await symlink(target, secondAlias);
const warmAlias = await discovery.list();
expect(warmAlias[0]?.path).toBe(secondAlias);
await expect(stat(warmAlias[0]!.path)).resolves.toBeDefined();
await expect(discovery.list()).resolves.toEqual([]);
} finally {
await rm(root, { recursive: true, force: true });
await rm(outside, { recursive: true, force: true });
}
});

Expand Down
Loading