From 114b0eeaba1d3cb9cb142e4521c79b396d3e439d Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 28 Sep 2026 08:14:11 +0700 Subject: [PATCH 01/10] feat(tooling): one changelog file per pull request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CONTRIBUTING.md` asks every runtime change to add an entry at the top of `CHANGELOG.md`, so two pull requests in flight write into the same few lines and git has nothing to merge on. Against `main` at `81eb443`, `git merge-tree` over the 85 open pull requests GitHub reports as conflicting that aren't mine puts `CHANGELOG.md` in the conflict set of 41 — more than any other path — and for 6 it is the only one. `docs/CURRENT-STATE.md`, the other file rule 3 names, conflicts for 23, and merges cleanly for 23 of the 41: edits land in the section for the area you touched, so they only collide when the areas do. A pull request now adds `changelog.d/-.md` instead. Distinct filenames never conflict, and the pull request number makes them distinct for free. `npm run changelog` splices the directory into `CHANGELOG.md` and empties it; `npm run changelog:check` reports the batch and changes nothing. Fragment text is copied through unchanged apart from trailing whitespace. `src/annotations/drawTool.test.mjs` reads `CHANGELOG.md` as prose and slices a fixed window after a heading, so an assembler that reflowed an entry could move text across that window. Fragments are deliberately outside `scripts/format-scope.json` for the same reason. The assembler refuses a batch before writing anything when a filename is not `-.md` or two fragments claim one pull request number, and a unit test runs that check over the real `changelog.d/`, so a fragment the release could not assemble fails `npm test` in the pull request that adds it rather than at release time. Refs #781. --- CONTRIBUTING.md | 2 +- changelog.d/README.md | 45 ++++++ docs/MAINTAINER_WORKFLOW.md | 11 +- package.json | 2 + scripts/assemble-changelog.mjs | 120 ++++++++++++++++ scripts/format-scope.json | 4 +- src/tooling/changelogFragments.test.mjs | 178 ++++++++++++++++++++++++ 7 files changed, 359 insertions(+), 3 deletions(-) create mode 100644 changelog.d/README.md create mode 100644 scripts/assemble-changelog.mjs create mode 100644 src/tooling/changelogFragments.test.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cdfbb6547..56775e824 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -149,7 +149,7 @@ ownership and adoption process. 1. Branch off `main`. 2. Keep `npm run build`, `npm test`, and `npm run test:track` green and avoid new console errors, plus the [feature gate](#feature-regression-gates) for the area you touched. -3. If you change runtime behavior, update `docs/CURRENT-STATE.md` and `CHANGELOG.md` in the same PR. +3. If you change runtime behavior, update `docs/CURRENT-STATE.md` in the same PR, and add your changelog entry as `changelog.d/-.md` instead of editing `CHANGELOG.md` — see [changelog.d/README.md](changelog.d/README.md). Distinct filenames don't conflict; a release assembles the directory into `CHANGELOG.md`. A documentation claim that needs a regression test should assert against `docs/CURRENT-STATE.md` or `README.md`, which a PR still edits directly. 4. If you add or change a data source, update [DATA_SOURCES.md](DATA_SOURCES.md) with its license and attribution. **Don't add data you don't have the right to redistribute** — fetch it at runtime instead. 5. Describe what you changed and how you verified it (screenshots welcome for anything visual). diff --git a/changelog.d/README.md b/changelog.d/README.md new file mode 100644 index 000000000..26a7c2e07 --- /dev/null +++ b/changelog.d/README.md @@ -0,0 +1,45 @@ +# Changelog fragments + +One file per entry. A pull request adds a file here instead of editing +`CHANGELOG.md`, and a release concatenates them into it. + +``` +changelog.d/-.md +``` + +`723-atc-decision-layer.md`, for example. The slug is lowercase, words separated +by `-`. Two pull requests never pick the same filename, because the pull request +number is already unique — which is the point: new `CHANGELOG.md` entries go at +the top of the file, so two pull requests in flight write into the same few lines +and git has nothing to merge on. + +Write the file exactly as the entry should read in `CHANGELOG.md`: a `-` bullet, +wrapped the way the surrounding entries are, crediting yourself and the pull +request. + +```markdown +- Report which upstream declined a Street Traffic road load. The layer row now + reads `Overpass rate-limited` instead of a general "Road data temporarily + unavailable" (yourname, #665). +``` + +The text is spliced in unchanged apart from trailing whitespace, so what you +write here is what ships. Fragments are deliberately outside +`scripts/format-scope.json`, so `npm run format` never reflows one. + +At release time: + +``` +npm run changelog:check # list what would move, change nothing +npm run changelog # move every fragment into CHANGELOG.md +``` + +The assembler refuses a batch rather than guessing: a filename that is not +`-.md`, or two fragments claiming one pull request number, +stops the run before anything is written and names the files. + +A documentation claim that needs a regression test — the way +`src/annotations/drawTool.test.mjs` asserts against `CHANGELOG.md` — should +assert against `docs/CURRENT-STATE.md` or `README.md` instead. A pull request +still edits those directly, so the text is in the file while the change is under +review; a fragment does not reach `CHANGELOG.md` until release. diff --git a/docs/MAINTAINER_WORKFLOW.md b/docs/MAINTAINER_WORKFLOW.md index 39c87be4b..632400c9c 100644 --- a/docs/MAINTAINER_WORKFLOW.md +++ b/docs/MAINTAINER_WORKFLOW.md @@ -57,7 +57,8 @@ with a reason; usefulness can also be `decline` or `discuss`. - Inspect removed tests, skipped tests, relaxed assertions, snapshot updates, mocks, and test-runner changes. A green result from weakened tests is not acceptance. - Check applicable documentation updates: runtime changes need `CURRENT-STATE.md` - and `CHANGELOG.md`; source changes need `DATA_SOURCES.md`. + and a `changelog.d/-.md` fragment rather than an edit to + `CHANGELOG.md`; source changes need `DATA_SOURCES.md`. ### 3. Security and supply chain @@ -173,6 +174,14 @@ Before merging: a stale merge. Verify the merged result matches the validated candidate tree and record the resulting commit and PR URL. Stop and report any mismatch. +Pending release notes live in `changelog.d/` as one file per pull request, so an +integration never has to resolve the top of `CHANGELOG.md`. When cutting a release, +run `npm run changelog:check` to see the batch and the order it would take, then +`npm run changelog` to splice it into `CHANGELOG.md` and empty the directory. The +assembler refuses a malformed filename or two fragments claiming one pull request +number before it writes anything, and copies the text through unchanged, so the +contributor's own wording and credit are what ship. + Repository rules enforce only their configured requirements; this document and the skill do not configure branch protection or CI. Maintainers should require CI and review on `main` and dismiss stale approvals when changes are pushed. diff --git a/package.json b/package.json index e48881c6d..4fd753168 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,8 @@ "format:check": "node scripts/format.mjs --check", "layer-token:next": "node scripts/next-layer-state-token.mjs", "layer-token:check": "node scripts/check-layer-state-tokens.mjs", + "changelog": "node scripts/assemble-changelog.mjs --write", + "changelog:check": "node scripts/assemble-changelog.mjs --check", "dev": "vite", "dev:secure": "./scripts/dev-secure.sh", "opensky:import": "./scripts/opensky-import-client.sh", diff --git a/scripts/assemble-changelog.mjs b/scripts/assemble-changelog.mjs new file mode 100644 index 000000000..8f3e854ef --- /dev/null +++ b/scripts/assemble-changelog.mjs @@ -0,0 +1,120 @@ +import { readdir, readFile, writeFile, rm } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +/** + * Release-time assembly of changelog fragments. + * + * A pull request adds `changelog.d/-.md` instead of editing + * `CHANGELOG.md`. Distinct filenames never conflict, so two pull requests in + * flight no longer write into the same few lines at the top of one file. A + * release splices the fragments in and empties the directory. + * + * Fragment text is copied through unchanged apart from trailing whitespace: + * `src/annotations/drawTool.test.mjs` reads `CHANGELOG.md` as prose and slices a + * fixed window after a heading, so reflowing an entry here could move text + * across that window. + */ +export const FRAGMENT_DIR = 'changelog.d'; +const FRAGMENT_NAME = /^(\d+)-[a-z0-9]+(?:-[a-z0-9]+)*\.md$/; +const HEADING = '# Changelog\n'; + +/** Fragment file names a release may consume, highest pull request first. */ +export function orderFragmentNames(names) { + const claimed = new Map(); + for (const name of names) { + if (name === 'README.md') continue; + const match = FRAGMENT_NAME.exec(name); + if (!match) { + throw new Error( + `Unexpected file in ${FRAGMENT_DIR}/: ${name}` + + ' — expected -.md with a lowercase slug', + ); + } + const number = Number(match[1]); + const prior = claimed.get(number); + if (prior) { + throw new Error( + `Two fragments claim pull request ${number}: ${prior} and ${name}`, + ); + } + claimed.set(number, name); + } + return [...claimed.entries()] + .sort(([a], [b]) => b - a) + .map(([, name]) => name); +} + +/** Splice fragment bodies above the existing entries, newest batch on top. */ +export function assembleChangelog(changelog, fragments) { + if (!fragments.length) return changelog; + if (!changelog.startsWith(HEADING)) { + throw new Error('CHANGELOG.md must start with "# Changelog"'); + } + const entries = changelog.slice(HEADING.length).replace(/^\n+/, ''); + const batch = fragments.map((text) => text.replace(/\s+$/, '')).join('\n\n'); + return `${HEADING}\n${batch}\n\n${entries}`; +} + +/** + * Report or apply the pending fragments in `/changelog.d`. + * + * The batch is validated in full before anything is written, so a malformed + * filename or a duplicate pull request number leaves the directory untouched. + * + * @param {string} root repository root + * @param {'--check'|'--write'} mode `--check` reports and changes nothing + * @returns {Promise<{ consumed: string[], changelog: string }>} + */ +export async function assembleFragments(root, mode) { + if (!['--check', '--write'].includes(mode)) { + throw new Error( + 'Usage: node scripts/assemble-changelog.mjs --check|--write', + ); + } + const directory = path.join(root, FRAGMENT_DIR); + const changelogFile = path.join(root, 'CHANGELOG.md'); + const consumed = orderFragmentNames(await readdir(directory)); + const bodies = []; + for (const name of consumed) { + bodies.push(await readFile(path.join(directory, name), 'utf8')); + } + const changelog = assembleChangelog( + await readFile(changelogFile, 'utf8'), + bodies, + ); + if (mode === '--write' && consumed.length) { + await writeFile(changelogFile, changelog, 'utf8'); + for (const name of consumed) await rm(path.join(directory, name)); + } + return { consumed, changelog }; +} + +const invoked = process.argv[1] + ? pathToFileURL(path.resolve(process.argv[1])).href + : ''; +if (import.meta.url === invoked) { + try { + if (process.argv.length !== 3) { + throw new Error( + 'Usage: node scripts/assemble-changelog.mjs --check|--write', + ); + } + const mode = process.argv[2]; + const { consumed } = await assembleFragments( + fileURLToPath(new URL('../', import.meta.url)), + mode, + ); + if (!consumed.length) { + console.log(`No fragments in ${FRAGMENT_DIR}/.`); + } else { + console.log( + `${mode === '--write' ? 'Assembled' : 'Would assemble'} ${consumed.length} fragment(s), in order:` + + `\n ${consumed.join('\n ')}`, + ); + } + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/format-scope.json b/scripts/format-scope.json index ef44df03f..974275366 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -844,5 +844,7 @@ "src/ui/imageryBoxTool.test.mjs", "src/ui/recentImagery.test.mjs", "src/layers/recentImagery/testDoubles.mjs", - "scripts/qa-recent-imagery.mjs" + "scripts/qa-recent-imagery.mjs", + "scripts/assemble-changelog.mjs", + "src/tooling/changelogFragments.test.mjs" ] diff --git a/src/tooling/changelogFragments.test.mjs b/src/tooling/changelogFragments.test.mjs new file mode 100644 index 000000000..880e3318d --- /dev/null +++ b/src/tooling/changelogFragments.test.mjs @@ -0,0 +1,178 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + mkdtemp, + mkdir, + writeFile, + readFile, + readdir, + rm, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + FRAGMENT_DIR, + assembleChangelog, + assembleFragments, + orderFragmentNames, +} from '../../scripts/assemble-changelog.mjs'; + +const EXISTING = + '# Changelog\n\n- An entry that was already released (someone, #1).\n'; + +const repositoryRoot = fileURLToPath(new URL('../..', import.meta.url)); + +async function fixture(t, fragments = {}) { + const root = await mkdtemp(path.join(tmpdir(), 'gev-changelog-')); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(path.join(root, FRAGMENT_DIR)); + await writeFile( + path.join(root, FRAGMENT_DIR, 'README.md'), + '# How to add one\n', + ); + await writeFile(path.join(root, 'CHANGELOG.md'), EXISTING); + for (const [name, body] of Object.entries(fragments)) { + await writeFile(path.join(root, FRAGMENT_DIR, name), body); + } + return root; +} + +test('the batch reads newest pull request first', () => { + assert.deepEqual(orderFragmentNames(['7-b.md', '112-c.md', '19-a.md']), [ + '112-c.md', + '19-a.md', + '7-b.md', + ]); +}); + +test('the directory README is documentation, never an entry', () => { + assert.deepEqual(orderFragmentNames(['README.md', '5-a.md']), ['5-a.md']); +}); + +test('two fragments claiming one pull request fail loudly, not silently', () => { + assert.throws( + () => orderFragmentNames(['5-first.md', '5-second.md']), + /Two fragments claim pull request 5/, + ); +}); + +test('a name that is not -.md is refused', () => { + for (const name of ['notes.md', '5_underscore.md', '5-Caps.md', '5-a.txt']) { + assert.throws(() => orderFragmentNames([name]), /Unexpected file/, name); + } +}); + +test('fragment text is spliced in unchanged, above the existing entries', () => { + // Wrapped, indented and punctuated the way a real entry is: the continuation + // indent and the blank line inside the entry are the parts a reflow would eat. + const entry = [ + '- A new thing, with `code` and a — dash. The second line is indented the', + ' way the surrounding entries wrap, and it keeps its two spaces.', + '', + ' A second paragraph, because some entries have one (someone, #9).', + ].join('\n'); + const result = assembleChangelog(EXISTING, [`${entry}\n\n\n`]); + assert.equal( + result, + `# Changelog\n\n${entry}\n\n- An entry that was already released (someone, #1).\n`, + ); + // The entry survives byte for byte: drawTool.test.mjs reads this file as + // prose and slices a fixed window, so reflowing would move text across it. + assert.ok(result.includes(entry)); +}); + +test('a CHANGELOG without the heading is refused rather than prepended to', () => { + assert.throws( + () => assembleChangelog('- orphan entry\n', ['- new\n']), + /must start with "# Changelog"/, + ); +}); + +test('assembling consumes the fragments and leaves the README', async (t) => { + const root = await fixture(t, { + '20-second.md': '- Second (someone, #20).', + '100-first.md': '- First (someone, #100).', + }); + const { consumed } = await assembleFragments(root, '--write'); + assert.deepEqual(consumed, ['100-first.md', '20-second.md']); + const changelog = await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'); + assert.match( + changelog, + /# Changelog\n\n- First \(someone, #100\)\.\n\n- Second \(someone, #20\)\.\n\n- An entry that was already released/, + ); + assert.deepEqual(await readdir(path.join(root, FRAGMENT_DIR)), ['README.md']); +}); + +test('a rejected batch leaves the directory and the file untouched', async (t) => { + const root = await fixture(t, { + '8-first.md': '- First (someone, #8).', + '8-again.md': '- Again (someone, #8).', + }); + await assert.rejects( + assembleFragments(root, '--write'), + /Two fragments claim pull request 8/, + ); + assert.equal( + await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'), + EXISTING, + ); + assert.deepEqual((await readdir(path.join(root, FRAGMENT_DIR))).sort(), [ + '8-again.md', + '8-first.md', + 'README.md', + ]); +}); + +test('check mode changes nothing on disk', async (t) => { + const root = await fixture(t, { '3-a.md': '- Something (someone, #3).' }); + const { consumed } = await assembleFragments(root, '--check'); + assert.deepEqual(consumed, ['3-a.md']); + assert.equal( + await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'), + EXISTING, + ); + assert.deepEqual((await readdir(path.join(root, FRAGMENT_DIR))).sort(), [ + '3-a.md', + 'README.md', + ]); +}); + +test('an empty directory is a no-op, so a release can always run it', async (t) => { + const root = await fixture(t); + const { consumed } = await assembleFragments(root, '--write'); + assert.deepEqual(consumed, []); + assert.equal( + await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'), + EXISTING, + ); +}); + +test('a mode that is neither check nor write is refused', async (t) => { + const root = await fixture(t); + await assert.rejects(assembleFragments(root, '--force'), /--check\|--write/); +}); + +// The two below run against the repository itself, so a fragment that the +// release could not assemble fails `npm test` in the pull request that adds it +// rather than at release time, when its author has moved on. + +test('every pending fragment in the repository can be assembled', async () => { + const names = await readdir(path.join(repositoryRoot, FRAGMENT_DIR)); + assert.ok( + names.includes('README.md'), + `${FRAGMENT_DIR}/README.md documents the convention and keeps the directory tracked`, + ); + // Throws on a name that is not -.md, and on two fragments + // claiming one pull request number. Every other file is an entry. + assert.equal(orderFragmentNames(names).length, names.length - 1); +}); + +test('CHANGELOG.md still carries the heading a release splices under', async () => { + const changelog = await readFile( + path.join(repositoryRoot, 'CHANGELOG.md'), + 'utf8', + ); + assert.ok(changelog.startsWith('# Changelog\n')); +}); From d29ff40d04a1ce7de8872488159e38e39f570339 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 28 Sep 2026 08:17:18 +0700 Subject: [PATCH 02/10] docs: add this pull request's own changelog fragment (#787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dogfoods the convention rather than describing it: the entry for #787 arrives as `changelog.d/787-changelog-fragments.md` and `CHANGELOG.md` is untouched. A fragment needs the pull request number, which only exists once the pull request is open, so it lands in a second commit. `changelog.d/README.md` says to push first and add the fragment after, for the same reason. Checked against the one consumer that constrains the design: assembling this fragment moves the `DISPLAY ▸ Draw` marker in `CHANGELOG.md` by exactly the length of the spliced entry, and the 1200-character window that `src/annotations/drawTool.test.mjs` slices after it is byte-identical. --- changelog.d/787-changelog-fragments.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/787-changelog-fragments.md diff --git a/changelog.d/787-changelog-fragments.md b/changelog.d/787-changelog-fragments.md new file mode 100644 index 000000000..41509c046 --- /dev/null +++ b/changelog.d/787-changelog-fragments.md @@ -0,0 +1,7 @@ +- Changelog entries are now one file per pull request. A change adds + `changelog.d/-.md` instead of editing the top of + `CHANGELOG.md`, which two pull requests in flight could not share — it was the + most frequent merge conflict in the open queue, and for six of them the only + one. `npm run changelog` splices the directory into `CHANGELOG.md` at release + time and empties it; `npm run changelog:check` reports the batch and changes + nothing. Fragment text is copied through unchanged (daikaginza, #787). From 4d42516ffedeb4ebcc24ee30004d4f8ec914790f Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 16 Oct 2026 11:01:25 +0100 Subject: [PATCH 03/10] Make changelog assembly recoverable after a partial write `--write` wrote CHANGELOG.md and then removed fragments one by one, so a failure after the write left surviving fragments that a rerun spliced in a second time. The write is now staged. The assembled file goes into changelog.d/.assembling/, the fragments are renamed in beside it, and renaming the assembled file over CHANGELOG.md is the single commit point. A run that fails rolls itself back when it can; otherwise the next `npm run changelog` reads the staging directory and either restores the fragments (assembled file still staged) or deletes the leftovers (batch landed). `--check` reports a staged batch instead of reporting past it. A failure-injection test makes every filesystem call in a `--write` fail in turn, both as a one-off error and as a crash with no in-process rollback. It asserts the retry produces exactly the uninterrupted CHANGELOG.md, with each entry once and an empty directory. The test fails against the previous write-then-delete and against delete-first. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + changelog.d/README.md | 7 ++ docs/MAINTAINER_WORKFLOW.md | 4 +- scripts/assemble-changelog.mjs | 98 ++++++++++++++-- src/tooling/changelogFragments.test.mjs | 141 ++++++++++++++++++++++++ 5 files changed, 245 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 14e8d320b..d2f0107c0 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ qa-artifacts/ 3d-models/ pinokio/ENVIRONMENT pinokio/.installed + +# Transient: a release interrupted mid-assembly (npm run changelog recovers it) +changelog.d/.assembling/ diff --git a/changelog.d/README.md b/changelog.d/README.md index 26a7c2e07..df17e32f2 100644 --- a/changelog.d/README.md +++ b/changelog.d/README.md @@ -38,6 +38,13 @@ The assembler refuses a batch rather than guessing: a filename that is not `-.md`, or two fragments claiming one pull request number, stops the run before anything is written and names the files. +If `npm run changelog` fails part-way (a full disk, a killed process), run it +again. The batch is staged in `changelog.d/.assembling/` and only lands when the +assembled file replaces `CHANGELOG.md` in a single rename, so the rerun either +finishes that batch or puts its fragments back first. It never adds an entry +twice or drops one. `npm run changelog:check` names a staged batch, if one is +left over, instead of reporting past it. + A documentation claim that needs a regression test — the way `src/annotations/drawTool.test.mjs` asserts against `CHANGELOG.md` — should assert against `docs/CURRENT-STATE.md` or `README.md` instead. A pull request diff --git a/docs/MAINTAINER_WORKFLOW.md b/docs/MAINTAINER_WORKFLOW.md index 5c773f399..ec6cd026d 100644 --- a/docs/MAINTAINER_WORKFLOW.md +++ b/docs/MAINTAINER_WORKFLOW.md @@ -130,7 +130,9 @@ resolves the top of `CHANGELOG.md`. At release, `npm run changelog:check` previe the batch and its order; `npm run changelog` splices it into `CHANGELOG.md` and empties the directory. The assembler refuses a malformed filename or two fragments claiming one PR number before writing, and copies text through unchanged so the -contributor's wording and credit ship. +contributor's wording and credit ship. The write is staged in +`changelog.d/.assembling/`, so if it fails part-way, rerunning `npm run changelog` +finishes or rolls back the batch. It never duplicates or drops an entry. Return this concise record privately unless posting is authorized. Keep sensitive security evidence in the private report: diff --git a/scripts/assemble-changelog.mjs b/scripts/assemble-changelog.mjs index 8f3e854ef..430fca77a 100644 --- a/scripts/assemble-changelog.mjs +++ b/scripts/assemble-changelog.mjs @@ -1,4 +1,4 @@ -import { readdir, readFile, writeFile, rm } from 'node:fs/promises'; +import * as fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -14,8 +14,19 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; * `src/annotations/drawTool.test.mjs` reads `CHANGELOG.md` as prose and slices a * fixed window after a heading, so reflowing an entry here could move text * across that window. + * + * A write is staged so that a failure at any point leaves a rerun unable to + * duplicate or lose an entry. The assembled file is written into + * `changelog.d/.assembling/` first, the fragments are renamed in beside it, and + * renaming the assembled file over `CHANGELOG.md` is the single commit point. + * While the assembled file is still staged the batch has not landed, so + * recovery moves the fragments back; once it is gone the batch has landed, and + * the staged fragments are leftovers to delete. Either way they are out of the + * input directory, so they are never assembled twice. */ export const FRAGMENT_DIR = 'changelog.d'; +export const STAGING_DIR = '.assembling'; +const STAGED_CHANGELOG = 'CHANGELOG.md'; const FRAGMENT_NAME = /^(\d+)-[a-z0-9]+(?:-[a-z0-9]+)*\.md$/; const HEADING = '# Changelog\n'; @@ -56,36 +67,109 @@ export function assembleChangelog(changelog, fragments) { return `${HEADING}\n${batch}\n\n${entries}`; } +/** + * Finish or roll back a batch an earlier `--write` left in the staging + * directory. A no-op when there is none. + * + * @returns {Promise<'none'|'rolled-back'|'cleaned-up'>} + */ +export async function recoverInterruptedBatch(root, io = fs) { + const directory = path.join(root, FRAGMENT_DIR); + const staging = path.join(directory, STAGING_DIR); + let staged; + try { + staged = await io.readdir(staging); + } catch (error) { + if (error.code === 'ENOENT') return 'none'; + throw error; + } + if (!staged.includes(STAGED_CHANGELOG)) { + // The assembled file was renamed over CHANGELOG.md: the batch landed. + await io.rm(staging, { recursive: true, force: true }); + return 'cleaned-up'; + } + // The batch never reached CHANGELOG.md, so every fragment goes back. + const pending = new Set(await io.readdir(directory)); + const fragments = staged.filter((name) => name !== STAGED_CHANGELOG); + const clash = fragments.filter((name) => pending.has(name)); + if (clash.length) { + throw new Error( + `Cannot roll back ${FRAGMENT_DIR}/${STAGING_DIR}/: ${clash.join(', ')}` + + ` also exist in ${FRAGMENT_DIR}/ — resolve by hand`, + ); + } + for (const name of fragments) { + await io.rename(path.join(staging, name), path.join(directory, name)); + } + await io.rm(staging, { recursive: true, force: true }); + return 'rolled-back'; +} + /** * Report or apply the pending fragments in `/changelog.d`. * * The batch is validated in full before anything is written, so a malformed * filename or a duplicate pull request number leaves the directory untouched. + * `--write` first recovers any batch an interrupted run left staged. * * @param {string} root repository root * @param {'--check'|'--write'} mode `--check` reports and changes nothing + * @param {typeof fs} [io] filesystem calls, replaceable for failure injection * @returns {Promise<{ consumed: string[], changelog: string }>} */ -export async function assembleFragments(root, mode) { +export async function assembleFragments(root, mode, io = fs) { if (!['--check', '--write'].includes(mode)) { throw new Error( 'Usage: node scripts/assemble-changelog.mjs --check|--write', ); } const directory = path.join(root, FRAGMENT_DIR); + const staging = path.join(directory, STAGING_DIR); const changelogFile = path.join(root, 'CHANGELOG.md'); - const consumed = orderFragmentNames(await readdir(directory)); + if (mode === '--write') { + await recoverInterruptedBatch(root, io); + } + const names = await io.readdir(directory); + if (names.includes(STAGING_DIR)) { + throw new Error( + `${FRAGMENT_DIR}/${STAGING_DIR}/ holds a batch an interrupted release` + + ' left behind — `npm run changelog` finishes or rolls it back', + ); + } + const consumed = orderFragmentNames(names); const bodies = []; for (const name of consumed) { - bodies.push(await readFile(path.join(directory, name), 'utf8')); + bodies.push(await io.readFile(path.join(directory, name), 'utf8')); } const changelog = assembleChangelog( - await readFile(changelogFile, 'utf8'), + await io.readFile(changelogFile, 'utf8'), bodies, ); if (mode === '--write' && consumed.length) { - await writeFile(changelogFile, changelog, 'utf8'); - for (const name of consumed) await rm(path.join(directory, name)); + try { + await io.mkdir(staging); + await io.writeFile( + path.join(staging, STAGED_CHANGELOG), + changelog, + 'utf8', + ); + for (const name of consumed) { + await io.rename(path.join(directory, name), path.join(staging, name)); + } + await io.rename(path.join(staging, STAGED_CHANGELOG), changelogFile); + await io.rm(staging, { recursive: true, force: true }); + } catch (error) { + try { + await recoverInterruptedBatch(root, io); + } catch { + throw new Error( + `${error.message} — the batch is staged in ${FRAGMENT_DIR}/${STAGING_DIR}/,` + + ' and the next `npm run changelog` finishes or rolls it back', + { cause: error }, + ); + } + throw error; + } } return { consumed, changelog }; } diff --git a/src/tooling/changelogFragments.test.mjs b/src/tooling/changelogFragments.test.mjs index 880e3318d..792542f68 100644 --- a/src/tooling/changelogFragments.test.mjs +++ b/src/tooling/changelogFragments.test.mjs @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import * as fs from 'node:fs/promises'; import { mkdtemp, mkdir, @@ -14,9 +15,11 @@ import { fileURLToPath } from 'node:url'; import { FRAGMENT_DIR, + STAGING_DIR, assembleChangelog, assembleFragments, orderFragmentNames, + recoverInterruptedBatch, } from '../../scripts/assemble-changelog.mjs'; const EXISTING = @@ -154,6 +157,144 @@ test('a mode that is neither check nor write is refused', async (t) => { await assert.rejects(assembleFragments(root, '--force'), /--check\|--write/); }); +// Failure injection. Every filesystem call the write path makes is a point +// where a release can die: a full disk, a permissions error, a killed process. + +const IO_CALLS = ['readdir', 'readFile', 'mkdir', 'writeFile', 'rename', 'rm']; +const BATCH = { + '20-second.md': '- Second (someone, #20).', + '100-first.md': '- First (someone, #100).', + '7-third.md': '- Third (someone, #7).', +}; + +/** + * The real filesystem, except that `call` throws the `nth` time it runs. With + * `crash`, every later call throws as well, the way a killed process makes no + * further calls: the in-process rollback cannot run either, so the next + * invocation is the only thing that can recover. + */ +function failing(call, nth, { crash = false } = {}) { + let seen = 0; + let dead = false; + const io = { ...fs }; + for (const name of IO_CALLS) { + io[name] = async (...args) => { + if (dead) throw new Error('process already gone'); + if (name === call && ++seen === nth) { + dead = crash; + throw new Error(`injected ${call} failure #${nth}`); + } + return fs[name](...args); + }; + } + return io; +} + +/** How many times one uninterrupted `--write` makes each filesystem call. */ +async function countCalls(t) { + const root = await fixture(t, BATCH); + const counts = Object.fromEntries(IO_CALLS.map((name) => [name, 0])); + const io = { ...fs }; + for (const name of IO_CALLS) { + io[name] = (...args) => { + counts[name] += 1; + return fs[name](...args); + }; + } + await assembleFragments(root, '--write', io); + return { + counts, + expected: await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'), + }; +} + +test('a write that fails at any call never lets a retry duplicate or lose an entry', async (t) => { + const { counts, expected } = await countCalls(t); + // The write path itself: one mkdir, one writeFile, a rename per fragment plus + // the commit rename, and the cleanup rm. Fewer would mean a step went missing. + assert.ok(counts.rename >= Object.keys(BATCH).length + 1); + for (const crash of [false, true]) { + for (const call of IO_CALLS) { + for (let nth = 1; nth <= counts[call]; nth += 1) { + const label = `${crash ? 'crash' : 'error'} at ${call} #${nth}`; + const root = await fixture(t, BATCH); + await assert.rejects( + assembleFragments(root, '--write', failing(call, nth, { crash })), + /injected|already gone/, + label, + ); + await assembleFragments(root, '--write'); + const changelog = await readFile( + path.join(root, 'CHANGELOG.md'), + 'utf8', + ); + assert.equal(changelog, expected, label); + for (const pr of [100, 20, 7, 1]) { + assert.equal( + changelog.split(`(someone, #${pr}).`).length - 1, + 1, + `${label}: #${pr} appears exactly once`, + ); + } + assert.deepEqual( + await readdir(path.join(root, FRAGMENT_DIR)), + ['README.md'], + label, + ); + } + } + } +}); + +test('an error before the commit rolls the batch back in the same run', async (t) => { + const root = await fixture(t, BATCH); + // The second rename has already moved one fragment into the staging directory. + await assert.rejects( + assembleFragments(root, '--write', failing('rename', 2)), + /injected rename failure #2/, + ); + assert.equal( + await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'), + EXISTING, + ); + assert.deepEqual( + (await readdir(path.join(root, FRAGMENT_DIR))).sort(), + [...Object.keys(BATCH), 'README.md'].sort(), + ); +}); + +test('check mode reports a batch an interrupted release left staged', async (t) => { + const root = await fixture(t, BATCH); + await assert.rejects( + assembleFragments(root, '--write', failing('rename', 2, { crash: true })), + ); + await assert.rejects( + assembleFragments(root, '--check'), + new RegExp(`${STAGING_DIR}/ holds a batch an interrupted release`), + ); + assert.equal(await recoverInterruptedBatch(root), 'rolled-back'); + assert.equal(await recoverInterruptedBatch(root), 'none'); +}); + +test('recovery refuses to roll a fragment back over a file of the same name', async (t) => { + const root = await fixture(t, BATCH); + await assert.rejects( + assembleFragments(root, '--write', failing('rename', 2, { crash: true })), + ); + await writeFile( + path.join(root, FRAGMENT_DIR, '100-first.md'), + '- Rewritten.', + ); + await assert.rejects( + assembleFragments(root, '--write'), + /Cannot roll back .*100-first\.md/, + ); + assert.equal( + await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'), + EXISTING, + ); +}); + // The two below run against the repository itself, so a fragment that the // release could not assemble fails `npm test` in the pull request that adds it // rather than at release time, when its author has moved on. From bd48533b03aba73d5eca76717d2c35e1785d4e1b Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Fri, 16 Oct 2026 05:43:24 +0530 Subject: [PATCH 04/10] fix(director): land on the requested shot A shot action seeks to the target shot's startProgress, which sceneTimingForShot computes as start / total. sceneSeekState multiplied that back into seconds and compared it with each shot's end, but (start / total) * total can round one ulp below start. The comparison then put the seek at the very end of the previous shot. With five equal 3.9 s shots, "go to" shots 2, 3 and 5 landed on shots 1, 2 and 4. Compare the requested progress against each shot's end / total instead, the same division the caller used, so a shot's own start always selects that shot. Exact boundaries still select the next shot, and anything below a start still selects the previous one. --- CHANGELOG.md | 4 ++++ docs/CURRENT-STATE.md | 2 ++ src/director/timeline.js | 16 +++++++++++---- src/director/timeline.test.mjs | 36 ++++++++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd76d7a25..da7866816 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +- Director shot actions land on the requested shot. A seek to a shot's start + could round a hair below it and select the end of the previous shot for some + shot counts and durations. + - Add Barcelona's bike share to the GBFS registry: Bicing (543 stations, the city's PBSC system) and AMBici (237 e-bike stations across the metropolitan area, run by nextbike). Both are keyless GBFS 2.3 feeds listed in diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4c06dd3cb..29f82501d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -427,6 +427,8 @@ reference. Legacy projects retain their ordinary flights, content and edits. Seeking between ordinary camera poses uses the shortest longitude arc across the dateline while preserving both authored endpoint longitudes, and turns heading and roll the short way for any authored angles. +A seek to a shot's start, as a shot action makes, selects that shot rather than +the end of the previous one. See [camera directions](DIRECTOR-CAMERA.md). Realtime voice composes separate connection, response/tool, Radio handoff, input diff --git a/src/director/timeline.js b/src/director/timeline.js index f7cffcece..5bcb42db6 100644 --- a/src/director/timeline.js +++ b/src/director/timeline.js @@ -72,19 +72,27 @@ export function sceneSeekState(scene, progress, durationForShot, holdForShot) { let shotIndex = scene.shots.length - 1; for (let index = 0; index < scene.shots.length; index += 1) { const endElapsedSec = startElapsedSec + durations[index]; - if (targetSec < endElapsedSec || index === scene.shots.length - 1) { + // Compare in progress units: a shot's startProgress (start / total) + // times total can round below its start and select the previous shot. + if ( + normalized < endElapsedSec / totalSec || + index === scene.shots.length - 1 + ) { shotIndex = index; break; } startElapsedSec = endElapsedSec; } + // Keep the elapsed time inside the selected shot, so it agrees with + // shotIndex when targetSec rounded below the shot's start. + const elapsedSec = Math.max(targetSec, startElapsedSec); const shot = scene.shots[shotIndex]; const flightDurationSec = shot.durationSec || DEFAULT_SHOT_DURATION_SEC; const holdDurationSec = holdForShot(scene, shot); const shotDurationSec = Math.max(0.001, flightDurationSec + holdDurationSec); const shotElapsedSec = Math.max( 0, - Math.min(shotDurationSec, targetSec - startElapsedSec), + Math.min(shotDurationSec, elapsedSec - startElapsedSec), ); const cameraProgress = clamp01( shotElapsedSec / Math.max(0.001, flightDurationSec), @@ -99,8 +107,8 @@ export function sceneSeekState(scene, progress, durationForShot, holdForShot) { ); const move = resolveCameraMove(scene, shot); return { - sceneProgress: totalSec > 0 ? targetSec / totalSec : 0, - sceneElapsedSec: targetSec, + sceneProgress: totalSec > 0 ? elapsedSec / totalSec : 0, + sceneElapsedSec: elapsedSec, sceneDurationSec: totalSec, shotIndex, shot, diff --git a/src/director/timeline.test.mjs b/src/director/timeline.test.mjs index 1950eeef0..69a370c98 100644 --- a/src/director/timeline.test.mjs +++ b/src/director/timeline.test.mjs @@ -49,3 +49,39 @@ test('camera seeking preserves cubic easing and shortest-angle orientation', () ); assert.deepEqual(cameraAtProgress(null, camera(20, 350), 0), camera(20, 350)); }); + +test('seeking to a shot start lands in that shot for every shot count and duration', () => { + const misses = []; + for (let count = 2; count <= 12; count += 1) { + for (let flightTenths = 1; flightTenths <= 100; flightTenths += 1) { + for (let holdTenths = 0; holdTenths <= 50; holdTenths += 1) { + const shots = Array.from({ length: count }, (_, index) => ({ + id: `s${index}`, + // Every third shot is longer, so boundaries are not all equal steps. + durationSec: flightTenths / 10 + (index % 3 === 2 ? 1.7 : 0), + holdSec: holdTenths / 10, + camera: camera(20, 0), + })); + const mixed = { shots }; + for (const [index, shot] of shots.entries()) { + const { startProgress, startElapsedSec } = sceneTimingForShot( + mixed, + shot, + duration, + ); + const seek = sceneSeekState(mixed, startProgress, duration, hold); + // One or two ulps earlier still belongs to the previous shot. + const before = startProgress * (1 - Number.EPSILON); + const previous = sceneSeekState(mixed, before, duration, hold); + if ( + seek.shotIndex !== index || + seek.sceneElapsedSec < startElapsedSec || + previous.shotIndex !== Math.max(0, index - 1) + ) + misses.push(`${count}x${flightTenths}/${holdTenths}#${index}`); + } + } + } + } + assert.deepEqual(misses.slice(0, 5), []); +}); From a316e12323d2b862a92cc00fec2c6620710badea Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:15:45 +0530 Subject: [PATCH 05/10] fix(overpass): read failure phrases from the remark, not element tags overpassLooksRuntimeError and overpassLooksRateLimited searched the whole 200 body, element tags included. A result containing a place named "Timed Out Escape Rooms" (or any tag with "runtime error", "out of memory", "too many requests" or "rate_limited") was refused as an upstream failure, and the endpoint was put on cooldown for every other query. Parse the body once. A data reply (one with an elements array) reports failure in its remark, so the phrase checks run on the remark. Any other body, an error page or JSON without elements, is matched whole as before. --- CHANGELOG.md | 5 +++++ docs/CURRENT-STATE.md | 2 +- server/providers/overpass/transport.js | 22 ++++++++++++++++++---- src/overpassProxy.test.mjs | 26 ++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd76d7a25..c2b02f0b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- Overpass data whose element tags contain a failure phrase ("timed out", + "runtime error", "too many requests" and similar, e.g. a place named "Timed + Out Escape Rooms") is returned as data instead of being refused and putting + the endpoint on cooldown; upstream failures are read from the remark. + - Add Barcelona's bike share to the GBFS registry: Bicing (543 stations, the city's PBSC system) and AMBici (237 e-bike stations across the metropolitan area, run by nextbike). Both are keyless GBFS 2.3 feeds listed in diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4c06dd3cb..3424560eb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -525,7 +525,7 @@ are protected from fallback writes. Version 6 is the export format, with zero pitch, low camera heights, scope and detection edits preserved. See [the document contract](SCENE-DOCUMENT.md); see [Director](DIRECTOR.md) for camera, pack, action and sharing support. -Search framing and annotations request semantic map features from an explicit source. The Overpass adapter owns bounded queries, member/tag decoding and request deadlines; callers retain candidate ranking, outline caching, deferred retries and scene placement. Empty, unavailable, transient and throttled outcomes remain distinct. Traffic sources return road records and installation sources return mapped records with freshness/saturation metadata, so their layers no longer decode upstream elements. ALPR already normalizes its records in the source. Traffic uses OpenFreeMap roads with optional matched TomTom congestion; ALPR uses an hourly OpenStreetMap extract. Keyless installations use military-area tiles. Detailed feature queries require operator-configured Overpass. +Search framing and annotations request semantic map features from an explicit source. The Overpass adapter owns bounded queries, member/tag decoding and request deadlines; callers retain candidate ranking, outline caching, deferred retries and scene placement. Empty, unavailable, transient and throttled outcomes remain distinct; a data reply is refused only for its `remark`, never for text in element tags. Traffic sources return road records and installation sources return mapped records with freshness/saturation metadata, so their layers no longer decode upstream elements. ALPR already normalizes its records in the source. Traffic uses OpenFreeMap roads with optional matched TomTom congestion; ALPR uses an hourly OpenStreetMap extract. Keyless installations use military-area tiles. Detailed feature queries require operator-configured Overpass. Nepal media preloads survive repeated camera-flight updates, but Stop, event disable and replacement remove abandoned frames and revoke pending Facebook sessions. Fallback evidence-card clicks respect the shared drawing-tool pointer lease. diff --git a/server/providers/overpass/transport.js b/server/providers/overpass/transport.js index 38c31bb08..e3776e291 100644 --- a/server/providers/overpass/transport.js +++ b/server/providers/overpass/transport.js @@ -14,7 +14,7 @@ import { simplifyOverpassPayloadBody } from './geometry.js'; * Checks for known rate-limit phrases in the body text regardless of * HTTP status code, since some mirrors return 200 with an error payload. * - * @param {string} bodyText - Upstream response body. + * @param {string} bodyText - The remark of a data reply, or a non-data body. * @returns {boolean} True if the body looks rate-limited. */ function overpassLooksRateLimited(bodyText) { @@ -41,6 +41,14 @@ function overpassLooksRuntimeError(bodyText) { ); } +function parseJson(text) { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + /** * True only for an upstream response that is actually Overpass data. * @@ -151,15 +159,22 @@ async function fetchOverpassPayload( signal: controller.signal, }); const responseBody = await readBody(upstream, maxResponseBytes); + const parsed = parseJson(responseBody); + // An Overpass data reply reports failure in its remark; its element + // tags are data and can contain any phrase. Any other body (an error + // page, or JSON without elements) is matched whole. + const failureText = Array.isArray(parsed?.elements) + ? String(parsed.remark || '') + : responseBody; const rateLimited = upstream.status === 429 || upstream.status === 406 || - overpassLooksRateLimited(responseBody); + overpassLooksRateLimited(failureText); if ( rateLimited || upstream.status < 200 || upstream.status >= 300 || - overpassLooksRuntimeError(responseBody) + overpassLooksRuntimeError(failureText) ) { const failures = (previous?.failures || 0) + 1; const delay = retryDelay( @@ -178,7 +193,6 @@ async function fetchOverpassPayload( failure = refusal(status, delay); continue; } - const parsed = JSON.parse(responseBody); if (!Array.isArray(parsed?.elements) || parsed.remark) throw new Error('Malformed Overpass response'); cooldowns.delete(endpoint); diff --git a/src/overpassProxy.test.mjs b/src/overpassProxy.test.mjs index ed4539fda..788846db7 100644 --- a/src/overpassProxy.test.mjs +++ b/src/overpassProxy.test.mjs @@ -126,6 +126,32 @@ test('production reader rejects oversized, malformed and runtime-failure bodies' } }); +test('element data that mentions a failure phrase is data, and sets no cooldown', async () => { + const names = ['Timed Out Escape Rooms', 'Runtime Error Bar', 'Out of Memory Lane', 'Too Many Requests Cafe', 'rate_limited']; + for (const [i, name] of names.entries()) { + const body = JSON.stringify({ elements: [{ type: 'node', id: 1, lat: 1, lon: 2, tags: { name } }] }); + let count = 0; + const options = { endpoints: [`https://phrase-${i}.example/`], fetchImpl: async () => { + count++; return new Response(body); + } }; + const payload = await fetchOverpassPayload('first', 1024, options); + assert.equal(payload.status, 200, name); + assert.ok(overpassPayloadIsData(payload), name); + assert.ok(payload.body.includes(name), name); + await fetchOverpassPayload('different', 1024, options); + assert.equal(count, 2, name); + } + const remark = JSON.stringify({ elements: [], remark: 'runtime error: rate_limited' }); + const limited = await fetchOverpassPayload('x', 1024, { endpoints: ['https://remark.example/'], + fetchImpl: async () => new Response(remark, { headers: { 'Retry-After': '90' } }) }); + assert.equal(limited.status, 429); + assert.equal(limited.retryAfterMs, 90_000); + const error = await fetchOverpassPayload('x', 1024, { endpoints: ['https://error.example/'], + fetchImpl: async () => new Response('{"error":"rate_limited"}', { headers: { 'Retry-After': '120' } }) }); + assert.equal(error.status, 429); + assert.equal(error.retryAfterMs, 120_000); +}); + function proxyHandler() { const plugin = createViteConfig({ mode: 'test' }).plugins.find(p => p.name === 'overpass-proxy'); const routes = new Map(); From 557d3bb41e7cc706d030177ac89d54f864f3ab00 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:24:09 +0530 Subject: [PATCH 06/10] fix(military): hold the last track and speed when a poll omits them MilitaryFlightRecords coerced a missing course to 0 (`courseDeg || 0`) and a missing speed to 0 (`speedMps ?? 0`) before stickyNumber, so the hold-last-known-good merge never ran. When an adsb.lol row arrived without `track` or `gs`, the aircraft's icon swung to north and the position history got a 0 kt / 0 degree fix, so dead reckoning stopped or turned it north. The civil FlightRecords holds the last value for the same input. Pass the raw values to stickyNumber, with 0 as the fallback only until a value is known, as before. A grounded row without a speed still reads 0, so a parked aircraft doesn't coast at its landing speed. --- CHANGELOG.md | 4 ++++ docs/CURRENT-STATE.md | 4 +++- src/layers/military/records.js | 13 ++++++---- src/layers/military/records.test.mjs | 36 ++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd76d7a25..df39cef89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +- Military aircraft keep their last heading and speed when an adsb.lol row omits + `track` or `gs`, instead of turning north and stopping; a grounded row without + a speed still reads 0. + - Add Barcelona's bike share to the GBFS registry: Bicing (543 stations, the city's PBSC system) and AMBici (237 e-bike stations across the metropolitan area, run by nextbike). Both are keyless GBFS 2.3 feeds listed in diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4c06dd3cb..b4a531482 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -584,7 +584,9 @@ pinning, first-position grace, source time and sea-surface placement are unchang Military flights also separate portable records and source acquisition from Cesium snapshot application. The metadata owner retains aviation feet, source -fix times, sticky fields and partial-feed retention. Rendering retains ground +fix times, sticky fields and partial-feed retention. A row without track or +speed holds the last known value, as civil records do; a grounded row without +speed reads 0. Rendering retains ground model ownership, motion history, military registration and tracking cleanup. Civil flights separate source acquisition, portable record reconciliation and diff --git a/src/layers/military/records.js b/src/layers/military/records.js index 9cd6754e6..05d91ccf0 100644 --- a/src/layers/military/records.js +++ b/src/layers/military/records.js @@ -39,8 +39,6 @@ export class MilitaryFlightRecords { ? prevMeta.altitudeFt * 0.3048 : 0 : 3048); - const track = aircraft.courseDeg || 0; - const speedMps = aircraft.speedMps ?? 0; const verticalRateMps = aircraft.verticalRateMps; const callsign = aircraft.callsign; const type = aircraft.typeCode; @@ -148,8 +146,15 @@ export class MilitaryFlightRecords { // altitudeFt/baro). geoAltitudeM, renderAltitudeM, - speedMps: stickyNumber(speedMps, prevMeta?.speedMps, null), - track: stickyNumber(track, prevMeta?.track, null), + // A poll that omits track/speed holds the last values, like the civil + // records; 0 only until one is known. A grounded row without a speed + // reads 0, so a parked plane never coasts at its landing speed. + speedMps: stickyNumber( + aircraft.speedMps, + onGround ? 0 : prevMeta?.speedMps, + 0, + ), + track: stickyNumber(aircraft.courseDeg, prevMeta?.track, 0), // Analyst seam (additive): sticky like the other kinematics. verticalRateMps: stickyNumber( verticalRateMps, diff --git a/src/layers/military/records.test.mjs b/src/layers/military/records.test.mjs index 5054fb28a..ce00c7761 100644 --- a/src/layers/military/records.test.mjs +++ b/src/layers/military/records.test.mjs @@ -114,3 +114,39 @@ test('military emergency status follows the latest poll instead of sticking', () ); assert.equal(next.meta.emergency, null); }); + +test('military records hold the last track and speed when a poll omits them', () => { + const records = store(); + records.receive(observation({ courseDeg: 270, speedMps: 200 }), context()); + const missing = records.receive( + observation({ courseDeg: null, speedMps: null }), + context(), + ); + assert.equal(missing.meta.track, 270); + assert.equal(missing.meta.speedMps, 200); + const north = records.receive( + observation({ courseDeg: 0, speedMps: 0 }), + context(), + ); + assert.equal(north.meta.track, 0); + assert.equal(north.meta.speedMps, 0); + const unseen = store().receive( + observation({ courseDeg: undefined, speedMps: undefined }), + context(), + ); + assert.equal(unseen.meta.track, 0); + assert.equal(unseen.meta.speedMps, 0); + const landing = store(); + landing.receive(observation({ courseDeg: 90, speedMps: 70 }), context()); + const parked = landing.receive( + observation({ + onGround: true, + baroAltitudeM: null, + courseDeg: null, + speedMps: null, + }), + context(), + ); + assert.equal(parked.meta.speedMps, 0); + assert.equal(parked.meta.track, 90); +}); From 1d62cdd805ea6cc9d479dbd7ef756e27a5867f44 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 16 Oct 2026 14:16:32 -0700 Subject: [PATCH 07/10] Move the changelog entry into changelog.d Main now takes release notes as one fragment per pull request (#787). --- CHANGELOG.md | 4 ---- changelog.d/1096-military-hold-track-speed.md | 3 +++ 2 files changed, 3 insertions(+), 4 deletions(-) create mode 100644 changelog.d/1096-military-hold-track-speed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e689a9d40..8ee00d839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,5 @@ # Changelog -- Military aircraft keep their last heading and speed when an adsb.lol row omits - `track` or `gs`, instead of turning north and stopping; a grounded row without - a speed still reads 0. - - Local FM seek finds stations after wrapping around 87.5–108 MHz, covers both band edges, skips the starting station and rejects spectra from an earlier tune (Aditya, @Rohilalala, #1174). diff --git a/changelog.d/1096-military-hold-track-speed.md b/changelog.d/1096-military-hold-track-speed.md new file mode 100644 index 000000000..adc1793c4 --- /dev/null +++ b/changelog.d/1096-military-hold-track-speed.md @@ -0,0 +1,3 @@ +- Military aircraft keep their last heading and speed when an adsb.lol row omits + `track` or `gs`, instead of turning north and stopping; a grounded row without + a speed still reads 0 (Aditya, @Rohilalala, #1096). From aa7943a07bfbce6fae9fd8325712e09346583f48 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 16 Oct 2026 14:16:34 -0700 Subject: [PATCH 08/10] Move the changelog entry into changelog.d Main now takes release notes as one fragment per pull request (#787). --- CHANGELOG.md | 5 ----- changelog.d/1097-overpass-remark-failure-phrases.md | 5 +++++ 2 files changed, 5 insertions(+), 5 deletions(-) create mode 100644 changelog.d/1097-overpass-remark-failure-phrases.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 873522b98..8ee00d839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,5 @@ # Changelog -- Overpass data whose element tags contain a failure phrase ("timed out", - "runtime error", "too many requests" and similar, e.g. a place named "Timed - Out Escape Rooms") is returned as data instead of being refused and putting - the endpoint on cooldown; upstream failures are read from the remark. - - Local FM seek finds stations after wrapping around 87.5–108 MHz, covers both band edges, skips the starting station and rejects spectra from an earlier tune (Aditya, @Rohilalala, #1174). diff --git a/changelog.d/1097-overpass-remark-failure-phrases.md b/changelog.d/1097-overpass-remark-failure-phrases.md new file mode 100644 index 000000000..4543710da --- /dev/null +++ b/changelog.d/1097-overpass-remark-failure-phrases.md @@ -0,0 +1,5 @@ +- Overpass data whose element tags contain a failure phrase ("timed out", + "runtime error", "too many requests" and similar, e.g. a place named "Timed + Out Escape Rooms") is returned as data instead of being refused and putting + the endpoint on cooldown; upstream failures are read from the remark (Aditya, + @Rohilalala, #1097). From 41be10f320aa7668e7b9a1dc31e66418e4ae7a08 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 16 Oct 2026 14:16:36 -0700 Subject: [PATCH 09/10] Move the changelog entry into changelog.d Main now takes release notes as one fragment per pull request (#787). --- CHANGELOG.md | 4 ---- changelog.d/1171-director-goto-shot.md | 3 +++ 2 files changed, 3 insertions(+), 4 deletions(-) create mode 100644 changelog.d/1171-director-goto-shot.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 24fb0df5c..8ee00d839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,5 @@ # Changelog -- Director shot actions land on the requested shot. A seek to a shot's start - could round a hair below it and select the end of the previous shot for some - shot counts and durations. - - Local FM seek finds stations after wrapping around 87.5–108 MHz, covers both band edges, skips the starting station and rejects spectra from an earlier tune (Aditya, @Rohilalala, #1174). diff --git a/changelog.d/1171-director-goto-shot.md b/changelog.d/1171-director-goto-shot.md new file mode 100644 index 000000000..4c08351ef --- /dev/null +++ b/changelog.d/1171-director-goto-shot.md @@ -0,0 +1,3 @@ +- Director shot actions land on the requested shot. A seek to a shot's start + could round a hair below it and select the end of the previous shot for some + shot counts and durations (Aditya, @Rohilalala, #1171). From fa8e3e989ff2c28e13c883c8570ea994e645b6ae Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:53:50 -0400 Subject: [PATCH 10/10] fix(tools): exclude unknown altitude from requested aircraft bands (#1115) aircraft_in_area compared a nullable altitude, so a contact with neither barometric nor geometric altitude matched max_altitude_m as though it were at zero. Require a reported finite altitude only when a band is requested; unfiltered queries keep the contact. --- .../1115-aircraft-altitude-band-unknown.md | 2 + docs/CURRENT-STATE.md | 3 + src/tools/queries/aviation.js | 2 + src/tools/queries/aviation.test.mjs | 59 +++++++++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 changelog.d/1115-aircraft-altitude-band-unknown.md diff --git a/changelog.d/1115-aircraft-altitude-band-unknown.md b/changelog.d/1115-aircraft-altitude-band-unknown.md new file mode 100644 index 000000000..a6b69472d --- /dev/null +++ b/changelog.d/1115-aircraft-altitude-band-unknown.md @@ -0,0 +1,2 @@ +- Exclude aircraft without a reported altitude from requested altitude bands; + unfiltered aircraft queries still retain them (rudycelekli, #1115). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index df92c0d50..1bdca7670 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -100,6 +100,9 @@ shared `area` argument (place name, bounding box, or point and radius) and cap lists at 25 rows by default. Voice offers the same queries next to its app actions: the session lists them, and the browser runs them through the same catalog, loaded on first use. See [tools and the MCP server](TOOLS.md). +Aircraft altitude-band queries require a reported barometric or geometric altitude +for each match, so a grounded contact that reports none is left out of a band too. +Queries without an altitude band retain unknown-altitude contacts. Catalog calls check their cancellation signal immediately before executing a tool. Already-cancelled queries do not start; cancellation after execution starts diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index 0b8b54b79..235f542e1 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -111,6 +111,8 @@ export const aircraftInArea = defineTool({ (record) => areaContains(area, { lat: record.latitude, lon: record.longitude }) && !(args.airborne_only && record.onGround) && + ((args.min_altitude_m == null && args.max_altitude_m == null) || + Number.isFinite(altitude(record))) && (args.min_altitude_m == null || altitude(record) >= args.min_altitude_m) && (args.max_altitude_m == null || diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 20ec833aa..d0e979ed6 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -388,3 +388,62 @@ test('the real OpenSky source marks an hour-old snapshot stale for searches', as assert.deepEqual(result.data.stale_feeds, ['civil']); assert.match(result.summary, /may be stale\.$/); }); + +test('altitude bands require a reported altitude from normalized OpenSky records', async () => { + const { normalizeOpenSkyAircraft } = + await import('../../sources/live/aircraft.js'); + const state = (id, baro, geometric) => + normalizeOpenSkyAircraft([ + id, + 'TEST', + 'Test', + 1767225600, + 1767225600, + 0, + 0, + baro, + false, + 10, + 90, + 0, + [], + geometric, + ]); + const records = [ + state('abc001', null, null), + state('abc002', 0, null), + state('abc003', null, 50), + state('abc004', 500, null), + ]; + const catalog = composeCatalog({ + tools: coreTools, + services: { + aircraft: { getSnapshot: async () => ({ records }) }, + }, + }); + const area = { bbox: [-1, -1, 1, 1] }; + assert.equal( + (await catalog.call('aircraft_in_area', { area })).data.total, + 4, + 'unfiltered answers retain unknown altitude', + ); + for (const band of [ + { max_altitude_m: 100 }, + { min_altitude_m: -500, max_altitude_m: 100 }, + ]) { + const result = await catalog.call('aircraft_in_area', { area, ...band }); + assert.deepEqual( + result.data.rows.map((row) => row.id), + ['abc002', 'abc003'], + ); + assert.deepEqual( + result.data.rows.map((row) => row.altitude_m), + [0, 50], + ); + } + const minimum = await catalog.call('aircraft_in_area', { + area, + min_altitude_m: -500, + }); + assert.equal(minimum.data.total, 3); +});