fix(control-pane): fail a load whose snapshot cannot be shown, and run the shown query

render() ran after load()'s ordered failure path and after shownLoad
had moved on. A snapshot the board could not show (a session whose
detectedHarnesses is stored as an object) threw past the ordering, and
an older valid snapshot could no longer take the board. render() now
runs inside that path, and shownLoad moves only once it returns.

Run posted state.query, the query typed last. When an older load
succeeds after a newer one failed, the board shows the older query's
results and recall command while Run acted on the newer query. Each
load now keeps its query, render() records it for the board, and Run
posts that.

The test harness assigns new arrays instead of pushing onto
page.pending, and records requests the same way.
This commit is contained in:
L4XB
2026-09-25 06:45:11 +02:00
parent a82d587e3f
commit 217f303713
2 changed files with 79 additions and 16 deletions
+15 -10
View File
@@ -410,7 +410,7 @@ function renderControlPaneHtml() {
</div>
<div id="app" hidden></div>
<script>
const state = { query: '' };
const state = { query: '', shownQuery: '' };
const $ = selector => document.querySelector(selector);
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, char => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
@@ -633,10 +633,12 @@ function renderControlPaneHtml() {
output.textContent = 'Running ' + actionId + '...';
try {
// An action runs for the query its card on the board was built for.
// After a failed load that is not always the query typed last.
const response = await fetch('/api/actions/' + encodeURIComponent(actionId), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query: state.query })
body: JSON.stringify({ query: state.shownQuery })
});
const payload = await readJsonResponse(response);
output.textContent = JSON.stringify(payload, null, 2);
@@ -648,12 +650,18 @@ function renderControlPaneHtml() {
async function load(live = false) {
const id = ++loadsStarted;
let snapshot;
const query = state.query;
try {
const url = new URL('/api/snapshot', window.location.href);
if (state.query) url.searchParams.set('query', state.query);
if (query) url.searchParams.set('query', query);
const response = await fetch(url);
snapshot = await readJsonResponse(response);
const snapshot = await readJsonResponse(response);
// A snapshot that cannot be shown fails its load like one that could
// not be fetched, and older data may still take the board.
if (id > shownLoad) {
render(snapshot, query);
shownLoad = id;
}
} catch (error) {
if (id < newestFinished) return;
newestFinished = id;
@@ -661,10 +669,6 @@ function renderControlPaneHtml() {
showFailure();
return;
}
if (id > shownLoad) {
shownLoad = id;
render(snapshot);
}
if (id < newestFinished) {
// A newer load failed first. This data is still the newest on the
// board, so the failure stays, dated by it.
@@ -676,7 +680,7 @@ function renderControlPaneHtml() {
clearError('#app');
}
function render(snapshot) {
function render(snapshot, query) {
$('#query').value = snapshot.knowledge.query || state.query;
$('#db-path').textContent = snapshot.database.exists ? snapshot.dbPath : 'database missing';
state.allowActions = Boolean(snapshot.execution.allowActions);
@@ -690,6 +694,7 @@ function renderControlPaneHtml() {
...action,
executable: snapshot.execution.allowActions && action.executable
})));
state.shownQuery = query;
loadedAt = new Date();
}
+64 -6
View File
@@ -42,7 +42,8 @@ class PageDate extends Date {
// whose callback the test fires itself. While `hold` is set, a fetch waits in
// `pending` until the test settles it, with the page's snapshot or another one.
// With `hold`, the first load is held too. Listeners are kept per element, so a
// test can press a button.
// test can press a button. Every request is recorded, and `page.script` holds
// the page's own functions, such as the runAction a Run button calls.
function openPage(snapshot, { hold = false } = {}) {
const elements = new Map();
const element = selector => {
@@ -61,28 +62,30 @@ function openPage(snapshot, { hold = false } = {}) {
}
return elements.get(selector);
};
const page = { online: true, hold, pending: [], refresh: null, element };
vm.runInNewContext(inlineScript(renderControlPaneHtml()), {
const page = { online: true, hold, pending: [], requests: [], refresh: null, element };
page.script = {
document: { hidden: false, querySelector: element, querySelectorAll: () => [] },
window: { location: { href: 'http://127.0.0.1:8765/' } },
URL,
Intl,
Date: PageDate,
console,
fetch: () =>
fetch: (url, options = {}) =>
new Promise((resolve, reject) => {
page.requests = [...page.requests, { url: String(url), options }];
const reply = {
succeed: (data = snapshot) => resolve({ ok: true, status: 200, statusText: 'OK', json: async () => data }),
fail: () => reject(new TypeError('Failed to fetch'))
};
if (page.hold) page.pending.push(reply);
if (page.hold) page.pending = [...page.pending, reply];
else if (page.online) reply.succeed();
else reply.fail();
}),
setInterval: callback => {
page.refresh = callback;
}
});
};
vm.runInNewContext(inlineScript(renderControlPaneHtml()), page.script);
return page;
}
@@ -281,6 +284,61 @@ async function runTests() {
passed++;
else failed++;
if (
await test('a snapshot that cannot be shown fails its load, and older data can still take the board', async () => {
const page = openPage(snapshot);
await settle();
const answer = query => ({ ...snapshot, knowledge: { ...snapshot.knowledge, query } });
// The session table cannot list harnesses stored as an object.
const unshowable = {
...answer('newer'),
sessions: [{ id: 'session-1', state: 'running', detectedHarnesses: { claude: true } }]
};
page.hold = true;
page.refresh();
page.refresh();
page.pending[1].succeed(unshowable);
await settle();
const box = page.element('#app');
assert.strictEqual(box.hidden, false, 'the newer load failed');
assert.match(box.textContent, /Live refresh failed\. The data below is from /);
page.pending[0].succeed(answer('older'));
await settle();
assert.strictEqual(page.element('#query').value, 'older', 'the older snapshot is shown');
assert.strictEqual(box.hidden, false, 'the newer failure stays up');
})
)
passed++;
else failed++;
if (
await test('Run acts on the query whose results are on the board', async () => {
const page = openPage(snapshot);
await settle();
const answer = query => ({ ...snapshot, knowledge: { ...snapshot.knowledge, query } });
const search = query => {
page.element('#query').value = query;
page.element('#query-form').listeners.submit({ preventDefault() {} });
};
page.hold = true;
search('older');
search('newer');
page.pending[1].fail();
await settle();
page.pending[0].succeed(answer('older'));
await settle();
page.hold = false;
await page.script.runAction('recall-knowledge');
const run = page.requests.find(request => request.options.method === 'POST');
assert.strictEqual(run.url, '/api/actions/recall-knowledge');
assert.deepStrictEqual(JSON.parse(run.options.body), { query: 'older' }, 'the recall shown on the board');
})
)
passed++;
else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}