mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
6e22e266d7
* refactor(contracts): own the daemon HTTP wire contract so clients stop importing src/daemon Move the pure wire vocabulary (base path, header names, URL/auth/tenant builders, /health payload) from src/daemon into @agent-device/contracts as the daemon-http subpath, so src/remote and src/cli stop importing daemon server internals. buildDaemonHealthPayload takes the version its caller advertises (R18 keeps host mechanics out of contracts); both callers pass readVersion(). Wire-compat surface, mutation, and ledger references follow the package path. * chore(gates): pin the moved daemon HTTP wire surface and teach the released-baseline check file moves Exports map + snapshot gain the daemon-http subpath. The wire ledger re-keys the eight moved declarations (buildDaemonHealthPayload moves with its new caller-supplied version parameter, acked additive). The released-baseline comparison now classifies a baseline declaration that re-appears unchanged at exactly one new path as a move instead of a removal: a file move is not wire surface a released peer stopped sending. A move that changes shape is a change acked at the destination path, and a name still owned by the baseline stays a removal.
253 lines
10 KiB
TypeScript
253 lines
10 KiB
TypeScript
/**
|
|
* The acceptance criterion #1432 states in one sentence — "a wire-type change
|
|
* without an RPC version bump fails" — proved from fixtures.
|
|
*
|
|
* It cannot be proved end-to-end yet: no released tag carries a wire ledger
|
|
* until this lands and ships, so `run.ts` has nothing to diff against. These
|
|
* cases pin the rule now, and `run.ts` becomes a thin git reader over them.
|
|
*/
|
|
|
|
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import type { WireLedger } from '../../test/wire-compat/ledger.ts';
|
|
import { compareWireLedgers } from './model.ts';
|
|
|
|
const RESPONSE = 'packages/kernel/src/contracts.ts#DaemonResponse';
|
|
const META = 'packages/kernel/src/contracts.ts#DaemonRequestMeta';
|
|
|
|
function ledger(overrides: Partial<WireLedger> = {}): WireLedger {
|
|
return {
|
|
protocolVersion: 2,
|
|
declarations: { [RESPONSE]: 'sha256:aaa', [META]: 'sha256:bbb' },
|
|
compatibleChanges: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function compare(current: WireLedger, digests: Record<string, string>) {
|
|
return compareWireLedgers({
|
|
baselineTag: 'v0.20.6',
|
|
released: ledger(),
|
|
current,
|
|
digests: new Map(Object.entries(digests)),
|
|
});
|
|
}
|
|
|
|
test('an unchanged wire surface passes', () => {
|
|
const result = compare(ledger(), { [RESPONSE]: 'sha256:aaa', [META]: 'sha256:bbb' });
|
|
assert.deepEqual(result.failures, []);
|
|
assert.deepEqual(result.changed, []);
|
|
});
|
|
|
|
test('a changed wire declaration without a bump or ack fails, naming the symbol', () => {
|
|
const current = ledger({ declarations: { [RESPONSE]: 'sha256:zzz', [META]: 'sha256:bbb' } });
|
|
const result = compare(current, { [RESPONSE]: 'sha256:zzz', [META]: 'sha256:bbb' });
|
|
assert.deepEqual(result.changed, [RESPONSE]);
|
|
assert.equal(result.failures.length, 1);
|
|
assert.match(result.failures[0]!, /DaemonResponse/);
|
|
assert.match(result.failures[0]!, /without bumping DAEMON_RPC_PROTOCOL_VERSION \(still 2\)/);
|
|
});
|
|
|
|
test('the same change passes once the protocol version is bumped', () => {
|
|
const current = ledger({
|
|
protocolVersion: 3,
|
|
declarations: { [RESPONSE]: 'sha256:zzz', [META]: 'sha256:bbb' },
|
|
});
|
|
const result = compare(current, { [RESPONSE]: 'sha256:zzz', [META]: 'sha256:bbb' });
|
|
assert.equal(result.bumped, true);
|
|
assert.deepEqual(result.failures, []);
|
|
});
|
|
|
|
test('the same change passes with a compatible-change ack at the new digest', () => {
|
|
const current = ledger({
|
|
declarations: { [RESPONSE]: 'sha256:zzz', [META]: 'sha256:bbb' },
|
|
compatibleChanges: [
|
|
{ declaration: RESPONSE, digest: 'sha256:zzz', rationale: 'Added an optional field.' },
|
|
],
|
|
});
|
|
const result = compare(current, { [RESPONSE]: 'sha256:zzz', [META]: 'sha256:bbb' });
|
|
assert.deepEqual(result.failures, []);
|
|
});
|
|
|
|
// The ack is keyed by the digest it covers precisely so it expires. Without
|
|
// this, one "added an optional field" ack would launder every later change to
|
|
// the same declaration.
|
|
test('an ack pinned to a superseded digest does not cover the next change', () => {
|
|
const current = ledger({
|
|
declarations: { [RESPONSE]: 'sha256:yyy', [META]: 'sha256:bbb' },
|
|
compatibleChanges: [
|
|
{ declaration: RESPONSE, digest: 'sha256:zzz', rationale: 'Covered the previous change.' },
|
|
],
|
|
});
|
|
const result = compare(current, { [RESPONSE]: 'sha256:yyy', [META]: 'sha256:bbb' });
|
|
assert.equal(result.failures.length, 1);
|
|
assert.match(result.failures[0]!, /DaemonResponse/);
|
|
});
|
|
|
|
test('an ack with an empty rationale does not count as an ack', () => {
|
|
const current = ledger({
|
|
declarations: { [RESPONSE]: 'sha256:zzz', [META]: 'sha256:bbb' },
|
|
compatibleChanges: [{ declaration: RESPONSE, digest: 'sha256:zzz', rationale: ' ' }],
|
|
});
|
|
const result = compare(current, { [RESPONSE]: 'sha256:zzz', [META]: 'sha256:bbb' });
|
|
assert.equal(result.failures.length, 1);
|
|
});
|
|
|
|
test('a removed wire declaration fails even when acked, because only a bump covers it', () => {
|
|
const current = ledger({
|
|
declarations: { [META]: 'sha256:bbb' },
|
|
compatibleChanges: [
|
|
{ declaration: RESPONSE, digest: 'sha256:aaa', rationale: 'Nobody used it.' },
|
|
],
|
|
});
|
|
const result = compare(current, { [META]: 'sha256:bbb' });
|
|
assert.deepEqual(result.removed, [RESPONSE]);
|
|
assert.equal(result.failures.length, 1);
|
|
assert.match(result.failures[0]!, /an ack cannot cover it/);
|
|
});
|
|
|
|
test('a removed wire declaration passes with a bump', () => {
|
|
const current = ledger({ protocolVersion: 3, declarations: { [META]: 'sha256:bbb' } });
|
|
const result = compare(current, { [META]: 'sha256:bbb' });
|
|
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({
|
|
declarations: { [RESPONSE]: 'sha256:aaa', [META]: 'sha256:bbb', [added]: 'sha256:ccc' },
|
|
});
|
|
const result = compare(current, {
|
|
[RESPONSE]: 'sha256:aaa',
|
|
[META]: 'sha256:bbb',
|
|
[added]: 'sha256:ccc',
|
|
});
|
|
assert.deepEqual(result.added, [added]);
|
|
assert.deepEqual(result.failures, []);
|
|
});
|