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
31 changes: 30 additions & 1 deletion packages/host-kit/src/internal/version.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { afterEach, test, vi } from 'vitest';
import { mkdtempForTestSync } from './tmp-dir.fixtures.ts';
import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo';
import { resolveAgentDeviceProjectRoot } from './project-root.ts';
import { findProjectRoot, readVersion } from './version.ts';
import { compareVersions, findProjectRoot, isNewerVersion, readVersion } from './version.ts';

afterEach(() => {
vi.restoreAllMocks();
Expand Down Expand Up @@ -128,3 +128,32 @@ test('from this source tree, the project root is the agent-device manifest, not
assert.equal(manifest.name, 'agent-device');
assert.equal(readVersion(), manifest.version);
});

test('isNewerVersion orders releases numerically per segment', () => {
assert.equal(isNewerVersion('0.21.6', '0.20.8'), true);
assert.equal(isNewerVersion('0.21.12', '0.21.6'), true);
assert.equal(isNewerVersion('1.0.0', '0.21.12'), true);
assert.equal(isNewerVersion('0.20.8', '0.21.6'), false);
assert.equal(isNewerVersion('0.21.6', '0.21.6'), false);
});

test('isNewerVersion ranks a release above the prerelease of the same base', () => {
// main carries `-dev` between releases (scripts/release-mark-dev.mjs): a released client meeting
// a `-dev` daemon of the same base is the upgrade, and the reverse is the downgrade.
assert.equal(isNewerVersion('0.21.13', '0.21.13-dev'), true);
assert.equal(isNewerVersion('0.21.13-dev', '0.21.13'), false);
assert.equal(isNewerVersion('0.21.13-dev', '0.21.12'), true);
assert.equal(isNewerVersion('0.21.12', '0.21.13-dev'), false);
assert.equal(isNewerVersion('0.21.13-dev', '0.21.13-dev'), false);
assert.equal(isNewerVersion('0.21.13-rc.2', '0.21.13-rc.1'), true);
assert.equal(isNewerVersion('0.21.13-rc.10', '0.21.13-rc.9'), true);
assert.equal(isNewerVersion('0.21.13-beta', '0.21.13-alpha.1'), true);
assert.equal(isNewerVersion('0.21.13+build.2', '0.21.13+build.1'), false);
});

test('compareVersions reads malformed or prefixed versions conservatively', () => {
assert.equal(compareVersions('v0.21.13', '0.21.13'), 0);
assert.equal(compareVersions('garbage', '0.0.1'), -1);
assert.equal(compareVersions('garbage', '0.0.0'), 0);
assert.equal(compareVersions('1.2.3.4', '1.2.3'), -1, 'four segments is not a version');
});
66 changes: 66 additions & 0 deletions packages/host-kit/src/internal/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,69 @@ export function findProjectRoot(): string {
projectRootMemo.set('self', resolved);
return resolved;
}

/**
* Whether `candidate` is a later release than `baseline` (see {@link compareVersions}).
*/
export function isNewerVersion(candidate: string, baseline: string): boolean {
return compareVersions(candidate, baseline) > 0;
}

/**
* SemVer order for the versions this package publishes: numeric `major.minor.patch` first, then a
* release sorts after any prerelease of the same base (`0.21.13` > `0.21.13-dev`, the shape main
* carries between releases), and prerelease fields compare per dot-separated field, numerically
* when both are numbers and lexically otherwise. Build metadata is ignored. A string that is not a
* version at all reads as `0.0.0`, so a malformed version always compares as the oldest.
*/
export function compareVersions(left: string, right: string): number {
const a = parseVersion(left);
const b = parseVersion(right);
return compareRelease(a.release, b.release) || comparePrerelease(a.prerelease, b.prerelease);
}

const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;

type ParsedVersion = { release: [number, number, number]; prerelease: string[] };

function parseVersion(version: string): ParsedVersion {
const match = SEMVER.exec(version.trim());
if (!match) return { release: [0, 0, 0], prerelease: [] };
Comment on lines +59 to +61
return {
release: [Number(match[1]), Number(match[2]), Number(match[3])],
prerelease: match[4]?.split('.') ?? [],
};
}

function compareRelease(a: ParsedVersion['release'], b: ParsedVersion['release']): number {
for (let i = 0; i < 3; i += 1) {
if (a[i] !== b[i]) return a[i]! > b[i]! ? 1 : -1;
}
return 0;
}

/** A release (no prerelease) sorts after every prerelease of the same base. */
function comparePrerelease(a: string[], b: string[]): number {
if (a.length === 0) return b.length === 0 ? 0 : 1;
if (b.length === 0) return -1;
const fields = Math.max(a.length, b.length);
for (let i = 0; i < fields; i += 1) {
const x = a[i];
const y = b[i];
if (x === undefined) return -1;
if (y === undefined) return 1;
const order = comparePrereleaseField(x, y);
if (order !== 0) return order;
}
return 0;
}

/** Numeric fields compare as numbers and sort below alphanumeric ones; the rest compare lexically. */
function comparePrereleaseField(x: string, y: string): number {
if (x === y) return 0;
const xNumeric = /^\d+$/.test(x);
const yNumeric = /^\d+$/.test(y);
if (xNumeric && yNumeric) return Number(x) > Number(y) ? 1 : -1;
if (xNumeric !== yNumeric) return xNumeric ? -1 : 1;
return x > y ? 1 : -1;
}
7 changes: 6 additions & 1 deletion packages/host-kit/src/version.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
export { findProjectRoot, readVersion } from './internal/version.ts';
export {
compareVersions,
findProjectRoot,
isNewerVersion,
readVersion,
} from './internal/version.ts';
export { DAEMON_SOURCE_ENTRY, isSourceCheckoutProjectRoot } from './internal/project-root.ts';
74 changes: 74 additions & 0 deletions src/__tests__/test-utils/daemon-http-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import http from 'node:http';
import { listenOnLoopback } from './loopback.ts';

// A loopback stand-in for a running daemon: answers `GET /health`, echoes `responseData` as the
// result of every `POST /rpc`, and records what it was asked, for the daemon-client tests that
// decide which daemon a command keeps.

export type HttpDaemonFixture = {
server: http.Server;
port: number;
seenPaths: string[];
rpcRequests: Record<string, any>[];
};

export async function startHttpDaemonFixture(
responseData: Record<string, unknown>,
): Promise<HttpDaemonFixture> {
const seenPaths: string[] = [];
const rpcRequests: Record<string, any>[] = [];
const server = http.createServer((req, res) => {
const url = new URL(req.url || '/', 'http://127.0.0.1');
seenPaths.push(`${req.method ?? 'GET'} ${url.pathname}`);

if (req.method === 'GET' && url.pathname === '/health') {
res.writeHead(200);
res.end('ok');
return;
}

if (req.method === 'POST' && url.pathname === '/rpc') {
const chunks: Buffer[] = [];
req.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
req.on('end', () => {
const rpcRequest = JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record<
string,
any
>;
rpcRequests.push(rpcRequest);
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
jsonrpc: '2.0',
id: rpcRequest.id,
result: { ok: true, data: responseData },
}),
);
});
return;
}

res.writeHead(404);
res.end('not found');
});
const port = await listenOnLoopback(server);
return { server, port, seenPaths, rpcRequests };
}

/** Swaps `process.stderr.write` for a buffer until `restore`, so a test can read what was printed. */
export function captureStderr(): { read: () => string; restore: () => void } {
const originalWrite = process.stderr.write.bind(process.stderr);
let captured = '';
(process.stderr as { write: typeof process.stderr.write }).write = ((chunk: unknown) => {
captured += String(chunk);
return true;
}) as typeof process.stderr.write;
return {
read: () => captured,
restore: () => {
process.stderr.write = originalWrite;
},
};
}
26 changes: 26 additions & 0 deletions src/__tests__/update-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,32 @@ test('notifier prints cached upgrade notice once for a newly discovered version'
assert.equal(cache.prompted, true);
});

test('notifier treats the release as newer than the -dev build of the same base', () => {
// main carries `-dev` between releases; the shared SemVer comparator ranks the release above it,
// where numeric string collation ranked it below and never prompted.
const stateDir = makeTempStateDir();
cleanupPaths.push(stateDir);
writeCache(stateDir, {
latestVersion: '0.12.0',
checkedAt: '2026-03-25T10:00:00.000Z',
});

let stderr = '';
vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => {
stderr += String(chunk);
return true;
}) as typeof process.stderr.write);

maybeRunUpgradeNotifier({
command: 'devices',
currentVersion: '0.12.0-dev',
stateDir,
flags: {},
});

assert.match(stderr, /Update available: agent-device 0\.12\.0-dev -> 0\.12\.0/);
});

test('notifier skips repeat prompts after the cached version was already shown', () => {
const stateDir = makeTempStateDir();
cleanupPaths.push(stateDir);
Expand Down
5 changes: 1 addition & 4 deletions src/cli/update-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { runCmdDetached } from '@agent-device/host-kit/command';
import { compareVersions } from '@agent-device/host-kit/version';

const PACKAGE_NAME = 'agent-device';
const UPDATE_CHECK_INTERVAL_MS = 14 * 24 * 60 * 60 * 1000;
Expand Down Expand Up @@ -168,10 +169,6 @@ function parseTimestamp(value: string | undefined): number | undefined {
return Number.isNaN(parsed) ? undefined : parsed;
}

function compareVersions(left: string, right: string): number {
return left.localeCompare(right, undefined, { numeric: true });
}

export function readUpdateCheckWorkerArgs(
argv: string[],
): { cachePath: string; currentVersion: string } | null {
Expand Down
72 changes: 5 additions & 67 deletions src/daemon-client/__tests__/daemon-client-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ import {
listenOnLoopback,
supportsLoopbackBind,
} from '../../__tests__/test-utils/loopback.ts';
import {
captureStderr,
startHttpDaemonFixture,
type HttpDaemonFixture,
} from '../../__tests__/test-utils/daemon-http-fixture.ts';
import { AppError } from '@agent-device/kernel/errors';
import { runCmdDetachedMonitored, runCmdSync } from '@agent-device/host-kit/command';
import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell';
Expand All @@ -46,13 +51,6 @@ type DaemonInfoFixture = {
processStartTime?: string;
};

type HttpDaemonFixture = {
server: http.Server;
port: number;
seenPaths: string[];
rpcRequests: Record<string, any>[];
};

const mockRunCmdDetached = vi.mocked(runCmdDetachedMonitored);
const mockRunCmdSync = vi.mocked(runCmdSync);
const mockSleep = vi.mocked(sleep);
Expand Down Expand Up @@ -109,51 +107,6 @@ function writeDaemonLock(
);
}

async function startHttpDaemonFixture(
responseData: Record<string, unknown>,
): Promise<HttpDaemonFixture> {
const seenPaths: string[] = [];
const rpcRequests: Record<string, any>[] = [];
const server = http.createServer((req, res) => {
const url = new URL(req.url || '/', 'http://127.0.0.1');
seenPaths.push(`${req.method ?? 'GET'} ${url.pathname}`);

if (req.method === 'GET' && url.pathname === '/health') {
res.writeHead(200);
res.end('ok');
return;
}

if (req.method === 'POST' && url.pathname === '/rpc') {
const chunks: Buffer[] = [];
req.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
req.on('end', () => {
const rpcRequest = JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record<
string,
any
>;
rpcRequests.push(rpcRequest);
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
jsonrpc: '2.0',
id: rpcRequest.id,
result: { ok: true, data: responseData },
}),
);
});
return;
}

res.writeHead(404);
res.end('not found');
});
const port = await listenOnLoopback(server);
return { server, port, seenPaths, rpcRequests };
}

/** Like `startHttpDaemonFixture`, but every RPC call returns `errorResult` as an `{ok:false}` result. */
async function startHttpDaemonErrorFixture(
errorResult: Record<string, unknown>,
Expand Down Expand Up @@ -649,21 +602,6 @@ test('sendToDaemon replaces socket-only daemon metadata when HTTP transport is r
}
});

function captureStderr(): { read: () => string; restore: () => void } {
const originalWrite = process.stderr.write.bind(process.stderr);
let captured = '';
(process.stderr as { write: typeof process.stderr.write }).write = ((chunk: unknown) => {
captured += String(chunk);
return true;
}) as typeof process.stderr.write;
return {
read: () => captured,
restore: () => {
process.stderr.write = originalWrite;
},
};
}

test('sendRequest timeout cleanup uses resolved daemon paths instead of request flags', async (t) => {
if (!(await supportsLoopbackBind())) {
t.skip('loopback listeners are not permitted in this environment');
Expand Down
Loading
Loading