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
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,30 @@ jobs:
- name: Verify corpus entries against their released blobs
run: pnpm check:replay-compat

released-surface-compat:
# The daemon RPC wire ledger (#1432) is compared against the ledger as it
# stood at the last RELEASED tag, which only a full-history checkout can
# read. Same split as the replay-compat corpus above: the shallow unit lane
# holds the ledger to its source, this job holds it to the last release.
name: Released-Surface Compatibility
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
fetch-tags: true

- name: Setup toolchain
uses: ./.github/actions/setup-node-pnpm

- name: Verify the wire-compat rules
run: pnpm check:daemon-wire-compat:test

- name: Compare the daemon RPC wire surface against the last released tag
run: pnpm check:daemon-wire-compat

coverage:
# Runs the full unit + provider-integration suites under coverage with
# thresholds, so a separate unit-tests job would rerun the same tests.
Expand Down
12 changes: 12 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@ when a running daemon needs to pick up a new TypeScript build.
`pnpm package:npm` is a release guard, not a routine development command. Use the specific commands
above while iterating.

### Released-surface baselines roll forward on publish

Compatibility gates baseline against the last **released tag**, not against `main`, so publishing is
what advances them — there is no separate baseline-refresh step and no regenerate command. Tagging a
release makes that commit's `test/wire-compat/ledger.json` the new baseline for
`pnpm check:daemon-wire-compat`, and its `.ad` corpus tags the new ceiling for
`pnpm check:replay-compat`. The practical consequence for a normal PR: wire churn *within* an
unreleased branch is free, and only the net change since the last publish has to carry a
`DAEMON_RPC_PROTOCOL_VERSION` bump or a `compatibleChanges` acknowledgment. After a release that
bumped the protocol version, the acknowledgments accumulated against the previous one no longer
match any current digest and are dropped — git history keeps the audit trail.

## Validate a change

Use the smallest trustworthy loop while editing:
Expand Down
9 changes: 9 additions & 0 deletions docs/adr/0006-daemon-rpc-protocol-version.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ Protocol-breaking changes must update `DAEMON_RPC_PROTOCOL_VERSION`, tests that
metadata, and at least one remote-client regression test that proves mismatched protocols fail before
command RPC.

The bump rules above are enforced rather than remembered (#1432). `test/wire-compat/surface.ts`
declares the wire surface grouped by the bullets in this decision, and `test/wire-compat/ledger.json`
records what each declaration hashes to and at which protocol version. The unit lane fails when a
declaration's shape moves without the ledger following, and `pnpm check:daemon-wire-compat` compares
the ledger against the last released tag: drift since that release must carry either a bump or a
`compatibleChanges` acknowledgment invoking the additive list above. Where a bullet is only partly
digestible — the `/health` and `/rpc` path literals inside `http-server.ts` — the manifest records
the gap and its reason instead of implying coverage. See `test/wire-compat/README.md`.

Legacy remote daemons without `rpcProtocolVersion` remain reachable. This keeps the first release of
the proxy compatible with older HTTP daemons, but it means absence of the marker is not proof of
compatibility.
1 change: 1 addition & 0 deletions docs/agents/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ The mapping it encodes, for when you need to run a gate directly or reason about
| CLI help/guidance (`src/cli/parser/cli-help.ts`, `src/cli-schema/`) | `pnpm exec vitest run src/cli/parser/__tests__ src/cli-schema/command-schema-guards.test.ts scripts/__tests__` — the `scripts/__tests__` gates enforce help-topic benchmark coverage and pin the bench's quoted CLI samples to the real renderers |
| Help benchmark cases (`scripts/help-conformance-*.mjs`) | `pnpm exec vitest run scripts/__tests__` (deterministic gates); model-backed: `pnpm bench:help-conformance` (paid LLM calls, local only) |
| `.ad` grammar (`src/replay/script.ts`, gesture arity, replay vars) | `pnpm exec vitest run --project unit-core test/replay-compat` — the frozen replay-compat corpus asserts which released script surfaces still parse; a flipped verdict is edited in `test/replay-compat/manifest.ts`, never in the script. Adding or re-pinning a corpus entry also runs `pnpm check:replay-compat`, which re-derives each entry from its release tag in git history |
| Daemon RPC wire surface (the declarations listed in `test/wire-compat/surface.ts` — JSON-RPC envelope, request/response/error/artifact/progress framing, `/health` payload, HTTP auth headers) | `pnpm exec vitest run --project unit-core test/wire-compat` holds the ledger to its source and prints the digest to paste; `pnpm check:daemon-wire-compat` compares it against the last released tag and requires a `DAEMON_RPC_PROTOCOL_VERSION` bump or a `compatibleChanges` ack for the drift. Read ADR 0006 to decide which; `test/wire-compat/README.md` walks both |
| Anything in `src/`, `test/` | `pnpm format` (`skills/` is Markdown-only guidance: oxfmt ignores `**/*.md`, and the affected-check selector classifies it docs-only) |
| Workspace package source (`packages/*/src/**`) | Root format/lint/typecheck plus layering (R11 package-boundaries); Vitest resolves affected tests through the module graph; package manifests/tsconfigs fail open to the full set |
| A decision kernel or its tests (`packages/kernel/src/errors.ts`, `src/daemon/ref-frame.ts`, `src/commands/interaction/runtime/settle.ts`, `src/utils/scroll-edge-state.ts`, `packages/selectors/src/`) | `pnpm mutation:affected --base origin/main` (minutes; GitHub runs it per PR — see the mutation ratchet section) |
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@
"check:package": "node --experimental-strip-types scripts/check-package.ts",
"check:command-docs": "vitest run --project unit-core src/__tests__/command-doc-coverage.test.ts",
"check:replay-compat": "node --experimental-strip-types scripts/check-replay-compat-provenance.ts",
"check:daemon-wire-compat": "node --experimental-strip-types scripts/wire-compat/run.ts",
"check:daemon-wire-compat:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/wire-compat/model.test.ts",
"check:tmpdir-leaks": "node --experimental-strip-types scripts/check-tmpdir-leaks.ts",
"check:tmpdir-leaks:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/check-tmpdir-leaks-model.test.ts scripts/vitest-tmpdir-global-setup.test.ts scripts/node-test-tmpdir.test.ts scripts/swift-toolchain-tmpdir.test.ts",
"check:freerange": "fr",
Expand Down
9 changes: 9 additions & 0 deletions scripts/check-affected/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,15 @@ export const CHECK_CATALOG: readonly CheckSpec[] = [
ciJobs: ['Replay-Compat Provenance'],
localRunnable: true,
},
{
id: 'daemon-wire-compat',
label: 'Daemon RPC wire surface vs. last released tag',
kind: { type: 'script', script: 'check:daemon-wire-compat' },
// Same shape as replay-compat: the released ledger is only readable from a
// full-history checkout, so this cannot live in the shallow unit lane.
ciJobs: ['Released-Surface Compatibility'],
localRunnable: true,
},
];

export function getCheckSpec(id: CheckId): CheckSpec {
Expand Down
1 change: 1 addition & 0 deletions scripts/check-affected/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ test('every catalog command resolves against package scripts', () => {
'build:macos-helper': 'x',
'test:smoke:web': 'x',
'check:replay-compat': 'x',
'check:daemon-wire-compat': 'x',
};
for (const spec of CHECK_CATALOG) {
const command = resolveCommand(spec, scripts, 'origin/main');
Expand Down
35 changes: 34 additions & 1 deletion scripts/check-affected/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
// non-.ts fixture whose owning suite cannot be derived). Existing GitHub CI
// remains authoritative; this only optimizes local/agent feedback.

import { WIRE_SURFACE_FILES } from '../../test/wire-compat/surface.ts';

export type CheckId =
| 'format'
| 'lint'
Expand All @@ -39,7 +41,8 @@ export type CheckId =
| 'android-helpers'
| 'macos-helper'
| 'web-smoke'
| 'replay-compat';
| 'replay-compat'
| 'daemon-wire-compat';

// The complete local check universe. A fail-open plan selects all of these;
// keep it in sync with the catalog in checks.ts (asserted by the self-test).
Expand All @@ -66,6 +69,7 @@ export const ALL_CHECKS: readonly CheckId[] = [
'macos-helper',
'web-smoke',
'replay-compat',
'daemon-wire-compat',
];

export type SelectionReason = {
Expand Down Expand Up @@ -331,6 +335,34 @@ const replayCompatOwnership: OwnershipRule = ({ file }) => {
return selections;
};

// The daemon RPC wire ledger (#1432). The wire SOURCE files are the ones that
// would otherwise slip: editing `packages/kernel/src/contracts.ts` selects
// vitest-related, but the wire gate reads that file as TEXT rather than
// importing it, so it is invisible to the module graph Vitest walks. The file
// list is read from the manifest instead of restated here, so a declaration
// added under a new file selects the gate the day it is listed.
//
// `ledger.json` needs the rule for the second reason `.ad` corpus data does:
// a non-.ts file under test/ resolves to `format` alone and would fail open.
// (`scripts/wire-compat/` needs no branch — all of scripts/ already fails open.)
const daemonWireCompatOwnership: OwnershipRule = ({ file }) => {
if (!file.startsWith('test/wire-compat/') && !WIRE_SURFACE_FILES.includes(file)) return [];
return [
reason(
'daemon-wire-compat',
file,
'own:daemon-wire-compat',
'the wire ledger is compared against the last released tag',
),
reason(
'unit',
file,
'own:daemon-wire-compat',
'the wire ledger is held to its source by the unit-lane gate',
),
];
};

const BUILD_OWNERSHIP: ReadonlyArray<{
check: CheckId;
rule: string;
Expand Down Expand Up @@ -392,6 +424,7 @@ const OWNERSHIP_RULES: readonly OwnershipRule[] = [
nodeIntegrationOwnership,
testAppOwnership,
replayCompatOwnership,
daemonWireCompatOwnership,
buildOwnership,
];

Expand Down
1 change: 1 addition & 0 deletions scripts/check-affected/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ const ALL_SCRIPTS: Record<string, string> = {
'test:integration:node': 'x',
'test:integration:progress:check': 'x',
'check:replay-compat': 'x',
'check:daemon-wire-compat': 'x',
};

const ARGS = { base: 'origin/main', head: 'HEAD', json: false, run: true };
Expand Down
127 changes: 127 additions & 0 deletions scripts/wire-compat/model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* 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, []);
});

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, []);
});
Loading
Loading