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
4 changes: 4 additions & 0 deletions packages/contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@
"types": "./src/command-platform-execution.ts",
"default": "./src/command-platform-execution.ts"
},
"./daemon-http": {
"types": "./src/daemon-http.ts",
"default": "./src/daemon-http.ts"
},
"./daemon-owner-cleanup": {
"types": "./src/daemon-owner-cleanup.ts",
"default": "./src/daemon-owner-cleanup.ts"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import {
buildDaemonHealthPayload,
buildDaemonHttpAuthHeaders,
buildDaemonHttpBaseUrl,
buildDaemonHttpTenantHeaders,
buildDaemonHttpUrl,
} from '../http-contract.ts';
DAEMON_RPC_PROTOCOL_VERSION,
} from './daemon-http.ts';

test('buildDaemonHttpBaseUrl appends the public agent-device base path', () => {
assert.equal(
Expand Down Expand Up @@ -43,3 +45,22 @@ test('buildDaemonHttpTenantHeaders omits blank tenant identities', () => {
});
assert.deepEqual(buildDaemonHttpTenantHeaders(''), {});
});

test('buildDaemonHealthPayload takes the version from its caller and keeps the payload shape', () => {
assert.deepEqual(buildDaemonHealthPayload('agent-device-daemon', '0.20.9'), {
ok: true,
service: 'agent-device-daemon',
version: '0.20.9',
rpcProtocolVersion: DAEMON_RPC_PROTOCOL_VERSION,
});
assert.deepEqual(
buildDaemonHealthPayload('agent-device-proxy', '0.20.9', { upstream: { ok: true } }),
{
ok: true,
service: 'agent-device-proxy',
version: '0.20.9',
rpcProtocolVersion: DAEMON_RPC_PROTOCOL_VERSION,
upstream: { ok: true },
},
);
});
61 changes: 61 additions & 0 deletions packages/contracts/src/daemon-http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// The daemon HTTP wire vocabulary shared by the daemon server, the remote
// proxy, and every client that talks to them: the base path, the tenant and
// network-access header names, the URL/auth/tenant header builders, and the
// /health payload. Client and server must agree on all of it, so neither side
// owns it (ADR 0006).
export const DAEMON_HTTP_BASE_PATH = '/agent-device';
export const DAEMON_HTTP_TENANT_HEADER = 'x-agent-device-tenant';
export const DAEMON_HTTP_NETWORK_ACCESS_HEADER = 'x-agent-device-network-access';
export const DAEMON_HTTP_PUBLIC_NETWORK_ACCESS = 'public-only';

export function buildDaemonHttpBaseUrl(baseUrl: string): string {
return buildDaemonHttpUrl(baseUrl, DAEMON_HTTP_BASE_PATH);
}

export function buildDaemonHttpUrl(baseUrl: string, route: string): string {
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
return new URL(route.replace(/^\/+/, ''), normalizedBase).toString();
}

export function buildDaemonHttpAuthHeaders(token: string | undefined): Record<string, string> {
const normalizedToken = token?.trim();
if (!normalizedToken) return {};
return {
authorization: `Bearer ${normalizedToken}`,
'x-agent-device-token': normalizedToken,
};
}

export function buildDaemonHttpTenantHeaders(tenantId: string | undefined): Record<string, string> {
const normalizedTenantId = tenantId?.trim();
if (!normalizedTenantId) return {};
return { [DAEMON_HTTP_TENANT_HEADER]: normalizedTenantId };
}

// See docs/adr/0006-daemon-rpc-protocol-version.md before changing this value.
// Enforced, not just documented: `test/wire-compat/` digests the declarations
// that cross this boundary and fails when one changes shape without a bump or
// an acknowledged-compatible entry (#1432).
export const DAEMON_RPC_PROTOCOL_VERSION = 2;

export type DaemonHealthPayload = {
ok: true;
service: 'agent-device-daemon' | 'agent-device-proxy';
version: string;
rpcProtocolVersion: number;
upstream?: unknown;
};

export function buildDaemonHealthPayload(
service: DaemonHealthPayload['service'],
version: string,
options: { upstream?: unknown } = {},
): DaemonHealthPayload {
return {
ok: true,
service,
version,
rpcProtocolVersion: DAEMON_RPC_PROTOCOL_VERSION,
...(options.upstream !== undefined ? { upstream: options.upstream } : {}),
};
}
2 changes: 1 addition & 1 deletion scripts/ios-snapshot-benchmark/proxy-client-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import fs from 'node:fs';
import { performance } from 'node:perf_hooks';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { buildDaemonHttpBaseUrl } from '../../src/daemon/http-contract.ts';
import { buildDaemonHttpBaseUrl } from '@agent-device/contracts/daemon-http';
import {
BenchmarkCellAdmissionError,
BenchmarkContentionError,
Expand Down
1 change: 1 addition & 0 deletions scripts/layering/contracts-exports.snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@agent-device/contracts/clipboard-runtime",
"@agent-device/contracts/command",
"@agent-device/contracts/command-platform-execution",
"@agent-device/contracts/daemon-http",
"@agent-device/contracts/daemon-owner-cleanup",
"@agent-device/contracts/device",
"@agent-device/contracts/device-readiness-runtime",
Expand Down
125 changes: 125 additions & 0 deletions scripts/wire-compat/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,131 @@ test('a removed wire declaration passes with a bump', () => {
assert.deepEqual(result.failures, []);
});

// #2318 moved the daemon HTTP wire contract from src/daemon to
// packages/contracts. A declaration that left its path but was re-declared
// unchanged is a file move, not a removal: a released peer still parses it,
// so it must not force a protocol bump.
test('a declaration moved to a new file unchanged is a move, not a removal', () => {
const movedFrom = 'src/daemon/http-contract.ts#buildDaemonHttpUrl';
const movedTo = 'packages/contracts/src/daemon-http.ts#buildDaemonHttpUrl';
const released = ledger({
declarations: { [movedFrom]: 'sha256:ddd', [META]: 'sha256:bbb' },
});
const current = ledger({ declarations: { [movedTo]: 'sha256:ddd', [META]: 'sha256:bbb' } });
const result = compareWireLedgers({
baselineTag: 'v0.20.6',
released,
current,
digests: new Map(Object.entries({ [movedTo]: 'sha256:ddd', [META]: 'sha256:bbb' })),
});
assert.deepEqual(result.moved, [movedFrom]);
assert.deepEqual(result.removed, []);
assert.deepEqual(result.added, []);
assert.deepEqual(result.failures, []);
});

// A baseline key that leaves and a same-named key that arrives with a moved
// digest are textually indistinguishable, so the gate reads the pair as a
// CHANGE at the destination (ackable at the new digest) rather than the
// bump-only removal the key-pair alone would suggest.
test('a move that changes the shape is a change, acked at the path it moved to', () => {
const movedFrom = 'src/daemon/http-health.ts#buildDaemonHealthPayload';
const movedTo = 'packages/contracts/src/daemon-http.ts#buildDaemonHealthPayload';
const released = ledger({ declarations: { [movedFrom]: 'sha256:old' } });
const current = ledger({
declarations: { [movedTo]: 'sha256:new' },
compatibleChanges: [
{
declaration: movedTo,
digest: 'sha256:new',
rationale: 'The added parameter is caller-local; the wire payload is unchanged.',
},
],
});
const digests = new Map(Object.entries({ [movedTo]: 'sha256:new' }));
const withoutAck = compareWireLedgers({
baselineTag: 'v0.20.6',
released,
current: ledger({ declarations: { [movedTo]: 'sha256:new' } }),
digests,
});
assert.deepEqual(withoutAck.changed, [movedTo]);
assert.deepEqual(withoutAck.removed, []);
assert.equal(withoutAck.failures.length, 1);
const withAck = compareWireLedgers({ baselineTag: 'v0.20.6', released, current, digests });
assert.deepEqual(withAck.failures, []);
});

// Names are not unique across files: while a baseline declaration still owns
// the name at its own path, a same-named declaration elsewhere cannot be
// identified as a move of this one, so the baseline key stays a removal.
test('a name still owned by the baseline is not a move, so it remains a removal', () => {
const serverSendJson = 'src/daemon/server/http-server.ts#sendJson';
const uploadSendJson = 'src/daemon/upload-http.ts#sendJson';
const released = ledger({
declarations: { [serverSendJson]: 'sha256:old', [uploadSendJson]: 'sha256:eee' },
});
const current = ledger({ declarations: { [uploadSendJson]: 'sha256:eee' } });
const result = compareWireLedgers({
baselineTag: 'v0.20.6',
released,
current,
digests: new Map(Object.entries({ [uploadSendJson]: 'sha256:eee' })),
});
assert.deepEqual(result.removed, [serverSendJson]);
assert.deepEqual(result.moved, []);
assert.equal(result.failures.length, 1);
});

// One destination cannot be two declarations' move. When two same-name
// baseline declarations leave their paths and one same-name path arrives, one
// of them is a real removal, and a removal is bump-only.
test('two same-name removals cannot share one move destination, so both stay removals', () => {
const first = 'src/a.ts#sendJson';
const second = 'src/b.ts#sendJson';
const destination = 'src/c.ts#sendJson';
const released = ledger({ declarations: { [first]: 'sha256:ddd', [second]: 'sha256:ddd' } });
const current = ledger({ declarations: { [destination]: 'sha256:ddd' } });
const result = compareWireLedgers({
baselineTag: 'v0.20.6',
released,
current,
digests: new Map(Object.entries({ [destination]: 'sha256:ddd' })),
});
assert.deepEqual(result.removed, [first, second]);
assert.deepEqual(result.moved, []);
// The destination is a key the baseline never had, so it is still reported
// as added alongside the removal that fails the gate.
assert.deepEqual(result.added, [destination]);
assert.equal(result.failures.length, 1);
});

// The contested-destination rule holds even when the arriving digest matches
// one of the sources and an ack sits at the destination: the other source's
// removal still cannot be covered.
test('a contested destination fails even when acked at the destination', () => {
const first = 'src/a.ts#sendJson';
const second = 'src/b.ts#sendJson';
const destination = 'src/c.ts#sendJson';
const released = ledger({ declarations: { [first]: 'sha256:aaa', [second]: 'sha256:old' } });
const current = ledger({
declarations: { [destination]: 'sha256:new' },
compatibleChanges: [
{ declaration: destination, digest: 'sha256:new', rationale: 'One of them moved here.' },
],
});
const result = compareWireLedgers({
baselineTag: 'v0.20.6',
released,
current,
digests: new Map(Object.entries({ [destination]: 'sha256:new' })),
});
assert.deepEqual(result.removed, [first, second]);
assert.deepEqual(result.moved, []);
assert.equal(result.failures.length, 1);
assert.match(result.failures[0]!, /an ack cannot cover it/);
});

test('a newly added wire declaration is additive and needs nothing', () => {
const added = 'packages/kernel/src/contracts.ts#NewEnvelope';
const current = ledger({
Expand Down
108 changes: 102 additions & 6 deletions scripts/wire-compat/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,16 @@ export type WireComparison = {
changed: readonly string[];
/** Declarations the baseline had and the current wire surface does not. */
removed: readonly string[];
/** Declarations added since the baseline; additive, so never a failure. */
/**
* Baseline declarations that left their path but were re-declared unchanged
* (same name, same digest) at a single new path that no other left
* declaration also claims: a file move, never a failure.
*/
moved: readonly string[];
/**
* Current keys the baseline never had (pure-move destinations excepted —
* their source is reported in `moved` instead). Additive, so never a failure.
*/
added: readonly string[];
/** Whether the protocol version advanced since the baseline. */
bumped: boolean;
Expand All @@ -43,12 +52,20 @@ export function compareWireLedgers(input: WireComparisonInput): WireComparison {

const changed: string[] = [];
const removed: string[] = [];
const moved: string[] = [];
const movedDestinations = new Set<string>();
const moveDestination = soleMoveDestinations(released.declarations, digests);
for (const [key, releasedDigest] of Object.entries(released.declarations)) {
const digest = digests.get(key);
if (digest === undefined) removed.push(key);
else if (digest !== releasedDigest) changed.push(key);
const fate = baselineKeyFate(key, releasedDigest, digests, moveDestination);
if (fate.kind === 'changed') changed.push(fate.reportKey);
else if (fate.kind === 'moved') {
moved.push(key);
movedDestinations.add(fate.destination);
} else if (fate.kind === 'removed') removed.push(key);
}
const added = Object.keys(current.declarations).filter((key) => !(key in released.declarations));
const added = Object.keys(current.declarations).filter(
(key) => !(key in released.declarations) && !movedDestinations.has(key),
);

const failures: string[] = [];
const stillAt = `still ${current.protocolVersion}`;
Expand Down Expand Up @@ -85,5 +102,84 @@ export function compareWireLedgers(input: WireComparisonInput): WireComparison {
}
}

return { changed, removed, added, bumped, failures };
return { changed, removed, moved, added, bumped, failures };
}

type BaselineFate =
| { kind: 'unchanged' }
| { kind: 'changed'; reportKey: string }
| { kind: 'moved'; destination: string }
| { kind: 'removed' };

/** What a baseline declaration became in the current surface. */
function baselineKeyFate(
key: string,
releasedDigest: string,
digests: ReadonlyMap<string, string>,
moveDestination: ReadonlyMap<string, string>,
): BaselineFate {
const digest = digests.get(key);
if (digest !== undefined) {
return digest === releasedDigest ? { kind: 'unchanged' } : { kind: 'changed', reportKey: key };
}
const destination = moveDestination.get(key);
if (destination === undefined) return { kind: 'removed' };
return digests.get(destination) === releasedDigest
? { kind: 'moved', destination }
: { kind: 'changed', reportKey: destination };
}

/**
* Displaced baseline declarations mapped to the single new path they may have
* moved to — or nothing when the move cannot be identified.
*
* A same-name re-declaration at exactly one new path is a file move, which a
* released peer still parses. One destination cannot be two declarations'
* moves, though: when two same-name baseline declarations left their paths and
* only one same-name new path exists, the other declaration's loss is real and
* only a bump covers it, so the contested destination resolves to removals. A
* same-name re-declaration whose digest MOVED is a change at the destination,
* ackable (digest-pinned, rationale required) rather than bump-forcing:
* textually it is indistinguishable from a removal plus a new same-named
* declaration, and that reading gets the ack escape hatch. Candidate paths are
* limited to ones absent from the baseline: names are not unique across files
* (two files both declare `sendJson`), and a name a baseline declaration still
* owns at its own path cannot identify a move.
*/
function soleMoveDestinations(
releasedDeclarations: Record<string, string>,
digests: ReadonlyMap<string, string>,
): ReadonlyMap<string, string> {
const releasedKeys = new Set(Object.keys(releasedDeclarations));
const candidates = new Map<string, readonly string[]>();
for (const key of releasedKeys) {
if (digests.has(key)) continue;
const name = declarationName(key);
candidates.set(
key,
[...digests.keys()].filter(
(candidate) => declarationName(candidate) === name && !releasedKeys.has(candidate),
),
);
}
const claimCount = new Map<string, number>();
for (const matches of candidates.values()) {
if (matches.length === 1) {
const destination = matches[0]!;
claimCount.set(destination, (claimCount.get(destination) ?? 0) + 1);
}
}
const sole = new Map<string, string>();
for (const [key, matches] of candidates) {
if (matches.length === 1 && claimCount.get(matches[0]!) === 1) {
sole.set(key, matches[0]!);
}
}
return sole;
}

/** The declaration name in a `<file>#<name>` key. */
function declarationName(key: string): string {
const separator = key.lastIndexOf('#');
return separator >= 0 ? key.slice(separator + 1) : key;
}
2 changes: 1 addition & 1 deletion scripts/wire-compat/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,5 +88,5 @@ process.stdout.write(
`Daemon RPC wire surface checked against ${baseline.tag} ` +
`(protocol ${result.bumped ? 'bumped' : 'unchanged'}): ${WIRE_DECLARATIONS.length} ` +
`declarations, ${result.changed.length} changed, ${result.removed.length} removed, ` +
`${result.added.length} added.\n`,
`${result.added.length} added, ${result.moved.length} moved.\n`,
);
Loading
Loading