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
5 changes: 5 additions & 0 deletions .changeset/include-dev-owner-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Include the existing development workbench URL when another process already owns the project.
5 changes: 5 additions & 0 deletions packages/agent-bundle/src/dev/coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {

export interface DevLockHandle {
close(): Promise<void>;
publishServerUrl?(url: string): Promise<void>;
}

export interface ProjectPreparer {
Expand Down Expand Up @@ -265,6 +266,10 @@ export class DevCoordinator {
return this.#rebuild(invalidation);
}

async publishServerUrl(url: string): Promise<void> {
await this.#lock?.publishServerUrl?.(url);
}

async #rebuild(
invalidation: Invalidation,
token?: symbol,
Expand Down
138 changes: 131 additions & 7 deletions packages/agent-bundle/src/dev/dev-lock.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { randomUUID } from 'node:crypto';
import { createHash, randomUUID } from 'node:crypto';
import { link, lstat, mkdir, open, readFile, rm } from 'node:fs/promises';
import { basename, dirname, join, resolve } from 'node:path';

Expand All @@ -12,6 +12,7 @@ export interface DevLockOwner {
readonly nonce: string;
readonly pid: number;
readonly projectRoot: string;
readonly url?: string;
}

export interface DevLockOptions {
Expand Down Expand Up @@ -49,13 +50,27 @@ interface RecoveryRecord {
readonly owner: DevLockOwner;
}

const isLoopbackServerUrl = (value: unknown): value is string => {
if (typeof value !== 'string') return false;
try {
const parsed = new URL(value);
return parsed.protocol === 'http:' &&
(parsed.hostname === '127.0.0.1' || parsed.hostname === '[::1]') &&
parsed.origin === value;
} catch {
return false;
}
};

const parseOwnerValue = (value: unknown, projectRoot: string): DevLockOwner | undefined => {
try {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
const parsed = value as Partial<DevLockOwner>;
const pid = parsed.pid;
const hasUrl = Object.hasOwn(parsed, 'url');
const url = hasUrl && isLoopbackServerUrl(parsed.url) ? parsed.url : undefined;
if (
Object.keys(parsed).length !== 4 ||
Object.keys(parsed).length !== (hasUrl ? 5 : 4) ||
!Object.hasOwn(parsed, 'createdAt') ||
!Object.hasOwn(parsed, 'nonce') ||
!Object.hasOwn(parsed, 'pid') ||
Expand All @@ -66,7 +81,8 @@ const parseOwnerValue = (value: unknown, projectRoot: string): DevLockOwner | un
typeof pid !== 'number' ||
!Number.isSafeInteger(pid) ||
pid <= 0 ||
parsed.projectRoot !== projectRoot
parsed.projectRoot !== projectRoot ||
(hasUrl && url === undefined)
) {
return undefined;
}
Expand All @@ -76,6 +92,7 @@ const parseOwnerValue = (value: unknown, projectRoot: string): DevLockOwner | un
nonce: parsed.nonce,
pid,
projectRoot: parsed.projectRoot,
...(url === undefined ? {} : { url }),
});
} catch {
return undefined;
Expand All @@ -91,6 +108,47 @@ const parseOwner = (value: string, projectRoot: string): DevLockOwner | undefine
}
};

interface DevServerUrlRecord {
readonly nonce: string;
readonly url: string;
}

const serverUrlPathFor = (path: string, nonce: string): string =>
join(dirname(path), `.${basename(path)}.server-${createHash('sha256').update(nonce).digest('hex')}`);

const serverUrlContentsFor = (owner: DevLockOwner, url: string): string =>
`${stableJson({ nonce: owner.nonce, url } satisfies DevServerUrlRecord)}\n`;

const parseServerUrl = (contents: string, owner: DevLockOwner): string | undefined => {
try {
const value: unknown = JSON.parse(contents);
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
const record = value as Partial<DevServerUrlRecord>;
if (
Object.keys(record).length !== 2 ||
record.nonce !== owner.nonce ||
!isLoopbackServerUrl(record.url)
) return undefined;
const canonical = `${stableJson({ nonce: record.nonce, url: record.url })}\n`;
return contents === canonical ? record.url : undefined;
} catch {
return undefined;
}
};

const readServerUrl = async (
storage: DevLockStorage,
path: string,
owner: DevLockOwner,
): Promise<string | undefined> => {
try {
return parseServerUrl(await storage.readFile(serverUrlPathFor(path, owner.nonce), 'utf8'), owner);
} catch (error) {
if (isErrno(error, 'ENOENT')) return undefined;
throw error;
}
};

const parseRecoveryRecord = (value: string, projectRoot: string): RecoveryRecord | undefined => {
try {
const parsedValue: unknown = JSON.parse(value);
Expand Down Expand Up @@ -225,12 +283,15 @@ const acquireRecoveryGate = async (

export class DevLock {
readonly #contents: string;
#owner: DevLockOwner;
readonly #path: string;
readonly #probeProcess: (pid: number) => boolean;
readonly #recoveryPath: string;
readonly #storage: DevLockStorage;
#closed = false;
#closePromise: Promise<void> | undefined;
#publishPromise: Promise<void> | undefined;
#publishingUrl: string | undefined;

constructor(
path: string,
Expand All @@ -245,16 +306,70 @@ export class DevLock {
this.#recoveryPath = recoveryPath;
this.#storage = storage;
this.#contents = contents;
this.owner = owner;
this.#owner = owner;
}

readonly owner: DevLockOwner;
get owner(): DevLockOwner {
return this.#owner;
}

publishServerUrl(url: string): Promise<void> {
if (!isLoopbackServerUrl(url)) return Promise.reject(new TypeError('Development server URL must be a loopback HTTP origin.'));
if (this.#closed || this.#closePromise !== undefined) return Promise.reject(new Error('Development lock is closing.'));
if (this.#owner.url === url) return Promise.resolve();
if (this.#owner.url !== undefined) return Promise.reject(new Error('Development lock already published a different server URL.'));
if (this.#publishPromise !== undefined) {
return this.#publishingUrl === url
? this.#publishPromise
: Promise.reject(new Error('Development lock is publishing a different server URL.'));
}

const owner = Object.freeze({ ...this.#owner, url });
const serverUrlPath = serverUrlPathFor(this.#path, this.#owner.nonce);
const serverUrlContents = serverUrlContentsFor(this.#owner, url);
const publishPromise = (async () => {
const currentContents = await this.#storage.readFile(this.#path, 'utf8');
if (currentContents !== this.#contents) {
throw new DevLockError('DEV_LOCK_INVALID', 'Development lock ownership changed before its server URL could be published.');
}
if (!(await writeCompleteExclusive(this.#storage, serverUrlPath, serverUrlContents, this.#owner.nonce))) {
const existing = await this.#storage.readFile(serverUrlPath, 'utf8');
if (existing !== serverUrlContents) {
throw new DevLockError('DEV_LOCK_INVALID', 'The development server URL record belongs to a different owner.');
}
}
if (await this.#storage.readFile(this.#path, 'utf8') !== this.#contents) {
await removeIfOwned(this.#storage, serverUrlPath, serverUrlContents);
throw new DevLockError('DEV_LOCK_INVALID', 'Development lock ownership changed while its server URL was published.');
}
this.#owner = owner;
})();
this.#publishingUrl = url;
this.#publishPromise = publishPromise;
void publishPromise.then(
() => {
if (this.#publishPromise === publishPromise) {
this.#publishPromise = undefined;
this.#publishingUrl = undefined;
}
},
() => {
if (this.#publishPromise === publishPromise) {
this.#publishPromise = undefined;
this.#publishingUrl = undefined;
}
},
);
return publishPromise;
}

close(): Promise<void> {
if (this.#closed) return Promise.resolve();
if (this.#closePromise !== undefined) return this.#closePromise;

const pendingPublication = this.#publishPromise;
const closePromise = (async () => {
await pendingPublication?.catch(() => undefined);
const recoveryContents = await acquireRecoveryGate(
this.#storage,
this.#recoveryPath,
Expand All @@ -264,6 +379,7 @@ export class DevLock {
try {
await removeIfOwned(this.#storage, this.#path, this.#contents);
await removeIfOwned(this.#storage, candidatePathFor(this.#path, this.owner.nonce), this.#contents);
await this.#storage.remove(serverUrlPathFor(this.#path, this.owner.nonce), { force: true });
} finally {
await removeIfOwned(this.#storage, this.#recoveryPath, recoveryContents);
}
Expand Down Expand Up @@ -332,10 +448,17 @@ export const acquireDevLock = async (options: DevLockOptions): Promise<DevLock>
);
}
if (probeProcess(currentOwner.pid)) {
const serverUrl = await readServerUrl(storage, path, currentOwner);
const reportedOwner = serverUrl === undefined
? currentOwner
: Object.freeze({ ...currentOwner, url: serverUrl });
const ownerDetails = serverUrl === undefined
? `pid ${currentOwner.pid}`
: `pid ${currentOwner.pid}, ${serverUrl}`;
throw new DevLockError(
'DEV_LOCK_HELD',
`Another agent-bundle dev process owns this project (pid ${currentOwner.pid}).`,
currentOwner,
`Another agent-bundle dev process owns this project (${ownerDetails}).`,
reportedOwner,
);
}

Expand All @@ -358,6 +481,7 @@ export const acquireDevLock = async (options: DevLockOptions): Promise<DevLock>
}
if (probeProcess(ownerDuringRecovery.pid)) return undefined;
await removeIfOwned(storage, path, currentContents);
await storage.remove(serverUrlPathFor(path, ownerDuringRecovery.nonce), { force: true });
await removeIfOwned(
storage,
candidatePathFor(path, ownerDuringRecovery.nonce),
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-bundle/src/dev/foreground-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export class ForegroundServerStartError extends Error {
/** The small coordinator surface required by foreground HTTP routes. */
export interface ForegroundCoordinator {
close(): Promise<void>;
publishServerUrl?(url: string): Promise<void>;
rebuild(invalidation: Invalidation): Promise<unknown>;
start(): Promise<unknown>;
status(): ProjectStatus;
Expand Down Expand Up @@ -599,6 +600,7 @@ export class ForegroundServer {
throw new Error('Foreground server did not report a TCP address.');
}
this.#url = `http://${addressToHost(address)}:${address.port}`;
await this.#coordinator.publishServerUrl?.(this.#url);
} catch (error) {
if (this.#closePromise !== undefined) throw error;
this.#closing = true;
Expand Down
44 changes: 43 additions & 1 deletion packages/agent-bundle/tests/dev-lock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,24 @@ import { acquireDevLock } from '../src/dev/dev-lock.ts';
const lockPathFor = (root: string): string => join(root, '.agent-bundle', 'dev.lock');
const recoveryPathFor = (root: string): string => `${lockPathFor(root)}.recovery`;

it('rejects a second writer with stable metadata for the live owning process', async () => {
it('rejects a second writer with the live owning process URL', async () => {
const root = await mkdtemp(join(tmpdir(), 'agent bundle dev lock with spaces '));

try {
const first = await acquireDevLock({
now: () => new Date('2026-08-14T12:00:00.000Z'),
projectRoot: root,
});
await first.publishServerUrl('http://127.0.0.1:48721');

await expect(acquireDevLock({ projectRoot: root })).rejects.toMatchObject({
code: 'DEV_LOCK_HELD',
message: `Another agent-bundle dev process owns this project (pid ${process.pid}, http://127.0.0.1:48721).`,
owner: {
createdAt: '2026-08-14T12:00:00.000Z',
pid: process.pid,
projectRoot: root,
url: 'http://127.0.0.1:48721',
},
});
expect(first.owner).not.toHaveProperty('version');
Expand All @@ -38,7 +41,10 @@ it('rejects a second writer with stable metadata for the live owning process', a
pid: process.pid,
projectRoot: root,
});
expect(published).not.toHaveProperty('url');
expect(published).not.toHaveProperty('version');
expect(first.owner).toMatchObject({ url: 'http://127.0.0.1:48721' });
expect((await lstat(lockPathFor(root))).mode & 0o777).toBe(0o600);
expect(typeof published.nonce).toBe('string');
expect(published.nonce).not.toBe('');

Expand All @@ -50,6 +56,42 @@ it('rejects a second writer with stable metadata for the live owning process', a
}
});

it('does not overwrite a replacement lock while publishing the server URL', async () => {
const root = await mkdtemp(join(tmpdir(), 'agent bundle dev lock publication race '));
let replaceOnRead = false;
let replacement: Awaited<ReturnType<typeof acquireDevLock>> | undefined;
const storage = {
link,
lstat,
mkdir,
open,
readFile: async (...args: Parameters<typeof readFile>) => {
const contents = await readFile(...args);
if (replaceOnRead && args[0] === lockPathFor(root)) {
replaceOnRead = false;
await rm(lockPathFor(root), { force: true });
replacement = await acquireDevLock({ projectRoot: root });
}
return contents;
},
remove: rm,
};

const first = await acquireDevLock({ projectRoot: root, storage });
try {
replaceOnRead = true;
await expect(first.publishServerUrl('http://127.0.0.1:48721')).rejects.toMatchObject({
code: 'DEV_LOCK_INVALID',
});

const published = JSON.parse(await readFile(lockPathFor(root), 'utf8')) as { readonly nonce: string };
expect(published.nonce).toBe(replacement?.owner.nonce);
} finally {
await Promise.allSettled([first.close(), replacement?.close()]);
await rm(root, { force: true, recursive: true });
}
});

it('recovers a dead lock only after probing its recorded pid', async () => {
const root = await mkdtemp(join(tmpdir(), 'agent bundle dev lock recovery '));
const stalePid = 2_147_483_647;
Expand Down
6 changes: 6 additions & 0 deletions packages/agent-bundle/tests/dev-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const status = (): ProjectStatus => ({

class RecordingCoordinator {
readonly invalidations: Invalidation[] = [];
readonly publishedUrls: string[] = [];
closeCalls = 0;
failClose = false;
startCalls = 0;
Expand All @@ -36,6 +37,10 @@ class RecordingCoordinator {
if (this.failClose) throw new Error('coordinator close failure');
}

async publishServerUrl(url: string): Promise<void> {
this.publishedUrls.push(url);
}

async rebuild(invalidation: Invalidation): Promise<{ readonly diagnostics: readonly []; readonly outcome: 'failed' }> {
this.invalidations.push(invalidation);
return { diagnostics: [], outcome: 'failed' };
Expand Down Expand Up @@ -242,6 +247,7 @@ it('serves typed project status and supplied prebuilt assets after starting the

try {
expect(coordinator.startCalls).toBe(1);
expect(coordinator.publishedUrls).toEqual([server.url]);

const [statusResponse, assetResponse] = await Promise.all([
fetch(`${server.url}/api/project/status`),
Expand Down
Loading