fix(webui): the organization page drops the Queue card and says running was not read when the bus sent no runs, kept Assistant drafts belong to the signed-in person, the agent drawer repaints alone (#1873) #1978
No files matched your search
@@ -302,14 +302,29 @@
|
||||
// SP11: a pane's unsent message is kept per thread for the life of the browser tab, so leaving the
|
||||
// Assistant page (a Full view link included) and coming back puts it in the composer again.
|
||||
// sessionStorage can be missing or refuse a write; the Map still keeps it while the page lives.
|
||||
// SP14: the kept messages belong to the person who typed them. They are stored with the viewer the
|
||||
// page's /api/config read names (bus.js, window.MosaicViewer), and read back only by a page that
|
||||
// read the same viewer. An entry stored under another viewer, or under none (an older entry, a
|
||||
// failed read), is removed unread, so a tab that never saw a sign-out (another tab signed out, the
|
||||
// session expired) does not hand one person's text to the next. A Console with no sign-in has one
|
||||
// fixed viewer. Until the viewer is read nothing is written; with none, messages live only in the page.
|
||||
const DRAFTS = 'mosaic-assistant-drafts';
|
||||
const kept = (() => {
|
||||
try {
|
||||
const v = JSON.parse(sessionStorage.getItem(DRAFTS) || 'null');
|
||||
return new Map(v && typeof v === 'object' && !Array.isArray(v) ? Object.entries(v).filter(([k, t]) => typeof t === 'string' && t.trim() && parse(`open=${k}`).keys[0] === k) : []);
|
||||
} catch { return new Map(); }
|
||||
})();
|
||||
const saveKept = () => { try { if (kept.size) sessionStorage.setItem(DRAFTS, JSON.stringify(Object.fromEntries(kept))); else sessionStorage.removeItem(DRAFTS); } catch { /* the Map still holds it */ } };
|
||||
const kept = new Map();
|
||||
let viewer = null, known = false, viewerRead = null;
|
||||
const saveKept = () => {
|
||||
if (!known) return;
|
||||
try { if (kept.size && viewer) sessionStorage.setItem(DRAFTS, JSON.stringify({ viewer, drafts: Object.fromEntries(kept) })); else sessionStorage.removeItem(DRAFTS); } catch { /* the Map still holds it */ }
|
||||
};
|
||||
const ready = () => (viewerRead ??= (async () => {
|
||||
let v = null, stored = null;
|
||||
try { v = await window.MosaicViewer; } catch { /* no viewer */ }
|
||||
try { stored = JSON.parse(sessionStorage.getItem(DRAFTS) || 'null'); } catch { /* none kept */ }
|
||||
viewer = typeof v === 'string' && v ? v : null;
|
||||
known = true;
|
||||
const own = !draftsOff && viewer && stored && typeof stored === 'object' && stored.viewer === viewer && stored.drafts && typeof stored.drafts === 'object' && !Array.isArray(stored.drafts);
|
||||
if (own) for (const [k, t] of Object.entries(stored.drafts)) if (!kept.has(k) && typeof t === 'string' && t.trim() && parse(`open=${k}`).keys[0] === k) kept.set(k, t);
|
||||
saveKept();
|
||||
})());
|
||||
const typedIn = p => p.chat?.draft?.() || p.draft || p.el?.querySelector('form textarea')?.value || '';
|
||||
// Signed out, by an expired session or by the Sign out form: the kept drafts go and nothing is kept
|
||||
// again, not even by the pagehide the form's own submit causes, so the next person to sign in on
|
||||
@@ -320,6 +335,7 @@
|
||||
window.addEventListener('pagehide', () => { if (mounted) for (const p of mounted.panes) keep(p); });
|
||||
function forgetDrafts() {
|
||||
draftsOff = true; kept.clear(); saveKept();
|
||||
try { sessionStorage.removeItem(DRAFTS); } catch { /* nothing to remove */ }
|
||||
if (mounted) for (const p of mounted.panes) { p.draft = ''; const t = p.el?.querySelector('form textarea'); if (t) t.value = ''; }
|
||||
}
|
||||
document.addEventListener('mosaic-signed-out', forgetDrafts);
|
||||
@@ -491,7 +507,10 @@
|
||||
// ---------- public ----------
|
||||
async function mount(container, query, { navigated = false } = {}) {
|
||||
unmount();
|
||||
++mountGen;
|
||||
const gen = ++mountGen;
|
||||
// SP14: the kept messages are known only once the viewer is; a page left meanwhile mounts nothing.
|
||||
await ready();
|
||||
if (gen !== mountGen) return;
|
||||
container.innerHTML = frame();
|
||||
const root = container.querySelector('[data-assistant]');
|
||||
mounted = { root, panes: [], show: null, turn: 0, timer: null, list: null, got: null, o: null, form: null, formTurn: 0, notice: '' };
|
||||
@@ -509,10 +528,12 @@
|
||||
else if (navigated) root.querySelector('h1').focus({ preventScroll: true });
|
||||
writeUrl(); paintTabs(); layout();
|
||||
root.querySelector('[data-assist-empty]').hidden = mounted.panes.length > 0;
|
||||
// SP14: a full set offers no fourth from the start, not only once the organization reads answer.
|
||||
root.querySelector('[data-assist-open]').disabled = mounted.panes.length >= MAX;
|
||||
await draw({ navigated: true });
|
||||
}
|
||||
function unmount() {
|
||||
if (!mounted) return;
|
||||
if (!mounted) { mountGen++; return; }
|
||||
clearTimeout(mounted.timer);
|
||||
for (const p of mounted.panes) { keep(p); p.chat?.dispose(); }
|
||||
window.removeEventListener('resize', fit);
|
||||
|
||||
@@ -2125,7 +2125,10 @@
|
||||
const path = routeOf().split('?')[0];
|
||||
if (path.startsWith('/chat')||path.startsWith('/projects')||path.startsWith('/inbox')||path.startsWith('/settings')||path.startsWith('/docs/')||wsPage(path)) render(false); else if (window.MosaicOrg ? path === '/board' : path === '/' || path === '') paintHeld(heldErr);
|
||||
};
|
||||
configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(applyConfig).catch(() => {});
|
||||
configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => { applyConfig(c); return c; }).catch(() => null);
|
||||
// SP14 (#1873): who is signed in, from this same read, for what the page keeps per person
|
||||
// (assistant.js's kept pane messages). null when the read failed or named nobody.
|
||||
window.MosaicViewer = configRead.then(c => (typeof c?.viewer === 'string' && c.viewer ? c.viewer : null));
|
||||
// E1 (P1-5): after Restart bus the organization dashboard draws its bus line now, not at the next poll.
|
||||
// E2E6 P2-2: a write on the organization page that changes other sections (a pack apply) asks for a
|
||||
// fresh read of the page; editors in progress are kept by holdLive.
|
||||
|
||||
@@ -79,6 +79,14 @@
|
||||
// never as not running.
|
||||
const STATE_UNREAD = ['s-unknown', '?', 'state not read'];
|
||||
const runsRead = item => Array.isArray(item?.running);
|
||||
// SP14 (#1873): an instances answer in which no agent carries runs has not said what runs at all, so
|
||||
// a count of running or working agents taken from it says not read, never 0. A copy that cannot
|
||||
// launch has no runs to give and is left out.
|
||||
const RUNS_WHY = 'the instance read did not say which agents run';
|
||||
const runsUnread = (inst, copies = {}) => {
|
||||
const all = [...Object.values(inst || {}), ...Object.values(copies || {}).filter(c => c && typeof c.name === 'string' && !copyBlocked(c))];
|
||||
return all.length > 0 && !all.some(runsRead);
|
||||
};
|
||||
const activity = (name, item, agents) => {
|
||||
if (!runsRead(item)) return STATE_UNREAD;
|
||||
const on = running(item) > 0;
|
||||
@@ -171,7 +179,7 @@
|
||||
const secH = (title, note = '', link = '', attrs = '') => `<div class="ws-sec-h"><h2${attrs ? ` ${attrs}` : ''}>${title}</h2>${note ? `<span class="note">${note}</span>` : ''}${link}</div>`;
|
||||
|
||||
function settingsList(org, ctx) {
|
||||
const { business, settings, instances, orgCount } = ctx;
|
||||
const { business, settings, instances, orgCount, noRuns } = ctx;
|
||||
const b = business?.business, s = settings;
|
||||
const unread = 'Could not be read';
|
||||
const names = instances ? Object.keys(instances) : null;
|
||||
@@ -184,7 +192,7 @@
|
||||
['business', 'Identity', b ? `${label(org)} · ${org.id} · kind not recorded` : unread, 'org file', 'business'],
|
||||
['flag', 'Appearance', `${titleCase(theme)} theme · ${initials(org)} avatar`, b?.appearance?.theme || org.theme ? 'org file' : 'system', 'appearance'],
|
||||
['folder', 'Projects', b ? (Array.isArray(b.projects) && b.projects.length ? plural(b.projects.length, 'project') : 'No projects') : unread, 'org file', '#/projects'],
|
||||
['people', 'Agents and roles', names ? (names.length ? `${plural(names.length, 'agent')} · ${Object.values(instances).filter(running).length} running` : 'No agents') : unread, 'org file', 'agents'],
|
||||
['people', 'Agents and roles', names ? (names.length ? `${plural(names.length, 'agent')} · ${noRuns ? 'running not read' : `${Object.values(instances).filter(running).length} running`}` : 'No agents') : unread, 'org file', 'agents'],
|
||||
['term', 'Models and routing', models ? (models.length ? models.join(' · ') : 'No models set') : unread, 'org file', 'routing'],
|
||||
['queue', 'Arbiters', b ? arbiters || 'None set' : unread, 'org file', 'business'],
|
||||
['tasks', 'Task tracker', b ? (b.vars?.['tracker.kind'] ? titleCase(b.vars['tracker.kind']) : 'Not set') : unread, 'org file', 'business'],
|
||||
@@ -276,12 +284,12 @@
|
||||
// SP13: it waits on its own instances read only until this page's reads have answered.
|
||||
let pageRead; const page = new Promise(done => { pageRead = done; });
|
||||
const drawerRead = agentDrawer(opts.q ?? queryOf(), { only: id, configIn: opts.configIn, page }).catch(() => '');
|
||||
const reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(id), data.queue(id), data.agents(id), data.claims(id), ...(stopped ? [] : [window.MosaicAutonomy?.load(id), window.MosaicPacks?.load(id), window.MosaicWorkflows?.load(id)])]);
|
||||
const reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(id), data.agents(id), data.claims(id), ...(stopped ? [] : [window.MosaicAutonomy?.load(id), window.MosaicPacks?.load(id), window.MosaicWorkflows?.load(id)])]);
|
||||
pageRead();
|
||||
const value = i => (reads[i].status === 'fulfilled' ? reads[i].value : null);
|
||||
const business = value(0), settings = value(1), instances = instancesOf(value(2));
|
||||
const threads = rowsOf(value(3)), projectRows = rowsOf(value(4)), queue = rowsOf(value(5));
|
||||
const agents = rowsOf(value(6)), claims = rowsOf(value(7));
|
||||
const threads = rowsOf(value(3)), projectRows = rowsOf(value(4));
|
||||
const agents = rowsOf(value(5)), claims = rowsOf(value(6));
|
||||
const docRead = business?.business ? await docs(id, business.business.projects).catch(() => ({ rows: [], failed: true })) : null;
|
||||
const ws = await wsRead;
|
||||
const wsRow = arr(ws?.orgs).find(r => r?.org === id) ?? null;
|
||||
@@ -312,7 +320,9 @@
|
||||
const off = !ws ? 'the read did not answer' : wsRow?.state === 'ok' ? null : wsRow?.state === 'starting' ? 'the bus is starting' : busWord(org);
|
||||
const whyNot = (names, metric = true) => off || missOf(wsRow, names) || (metric && !mine ? 'no figures were read' : null);
|
||||
const orgAgents = instances && ws ? agentRows([{ id, inst: instances, copies: copiesOf(value(2)) }], ws) : null;
|
||||
const agentsWhy = whyNot(['agents'], false) || (orgAgents ? null : 'the agent read did not answer');
|
||||
// SP14: no agent in the instances answer carries runs, so what runs, and so what works, was not read.
|
||||
const noRuns = !!instances && runsUnread(instances, copiesOf(value(2)));
|
||||
const agentsWhy = whyNot(['agents'], false) || (orgAgents ? null : 'the agent read did not answer') || (noRuns ? RUNS_WHY : null);
|
||||
const kpi = orgsOf(ws), oq = `org=${enc(id)}`;
|
||||
const orgTile = (key, title, href, why, fig) => kpiTile(kpi, { key, name: title, href, ...(why ? { none: `Not read: ${esc(why)}` } : fig) });
|
||||
const working = orgAgents ? workingOf(orgAgents) : 0, waiting = orgAgents?.filter(a => a.state === 'waiting').length ?? 0;
|
||||
@@ -347,20 +357,7 @@
|
||||
on.set('agent', pick);
|
||||
return `<tr data-ws-session-agent="${esc(pick)}"><td><a class="ws-drawer-open" href="${esc(`${orgHref(id)}/organization?${on.toString()}`)}" data-ws-drawer-open="${esc(pick)}" data-focus-key="${esc(`agent:${pick}`)}">${esc(shown)}</a></td><td>${where}</td><td class="hide-m mono">${modelCell(agent, model)}</td><td>${dot(acls, aglyph, aword, true)}${count > 1 ? ` <span class="muted">${count} sessions</span>` : ''}</td></tr>`;
|
||||
}).join('');
|
||||
const sessions = live === null ? (stopped ? '<p class="ws-panel tight note" data-bus-stopped>Sessions not read: the bus is stopped.</p>' : '<p class="ws-panel tight note">Sessions unavailable. The bus did not answer the instance read.</p>') : live.length ? `<div class="ws-tbl"><table><thead><tr><th scope="col">Agent</th><th scope="col">Project and task</th><th scope="col" class="hide-m">Model</th><th scope="col">Status</th></tr></thead><tbody>${sessionRows}</tbody></table></div>` : '<p class="ws-panel tight note">No active agent sessions</p>';
|
||||
const open = queue ? queue.filter(r => r?.state !== 'done').length : null;
|
||||
const queueNote = queue === null ? 'unavailable' : queue.length ? `${plural(queue.length, 'row')}, ${open} not done` : 'no rows';
|
||||
// E2E6 P2-6: a failed queue read says why, in the words the Queue page uses (server-redacted).
|
||||
const qerr = queue === null && reads[5].status === 'rejected' ? reads[5].reason : null;
|
||||
// SP13 (#1873): the store refuses any checkout that is a linked worktree. Said plainly: this Console
|
||||
// runs from another checkout than the one holding the queue. The Queue page reads the same queue
|
||||
// and would only refuse again, so the card drops its link. Any other refusal keeps its own words.
|
||||
const queueElsewhere = qerr?.code === 'queue-refused' && /is a linked worktree; the queue runs only in the canonical checkout/.test(qerr.detail || '');
|
||||
const queueWhy = queue !== null ? '' : `<p class="ws-panel tight note" data-org-queue-why>${queueElsewhere ? 'This Console does not run from the checkout that holds the queue, so the queue is not shown here.'
|
||||
: qerr?.code === 'queue-refused' ? `The queue refused the read.${qerr.detail ? ` ${esc(qerr.detail.replace(/<path>/g, 'This checkout'))}` : ''}`
|
||||
: qerr?.status === 0 ? 'You are signed out. Reload the page to sign in again.'
|
||||
: qerr?.code === 'not-configured' ? 'This Console has no queue to read for this organization.'
|
||||
: `The queue read did not answer${qerr?.code ? ` (${esc(qerr.code)})` : ''}.`}</p>`;
|
||||
const sessions = live !== null && noRuns ? `<p class="ws-panel tight note" data-runs-unread>Not read: ${RUNS_WHY}.</p>` : live === null ? (stopped ? '<p class="ws-panel tight note" data-bus-stopped>Sessions not read: the bus is stopped.</p>' : '<p class="ws-panel tight note">Sessions unavailable. The bus did not answer the instance read.</p>') : live.length ? `<div class="ws-tbl"><table><thead><tr><th scope="col">Agent</th><th scope="col">Project and task</th><th scope="col" class="hide-m">Model</th><th scope="col">Status</th></tr></thead><tbody>${sessionRows}</tbody></table></div>` : '<p class="ws-panel tight note">No active agent sessions</p>';
|
||||
const stoppedPanel = (attr, title) => `<section class="panel" ${attr} data-bus-stopped><h3>${title}</h3><p class="note">Not read: the bus is stopped.</p></section>`;
|
||||
|
||||
return `<article class="ws ws-dash" data-org-dashboard>
|
||||
@@ -371,15 +368,13 @@
|
||||
${docrow}
|
||||
<div class="ws-split">
|
||||
<section aria-label="Measures and sessions">
|
||||
${secH('Active agent sessions', live ? (live.length ? `${live.length} running${tileAgents ? `, ${liveWorking} working` : ', working not read'}` : 'none running') : '', `<a href="#/agents?${oq}" data-focus-key="agents-all">This organization's agents</a>`)}
|
||||
${secH('Active agent sessions', live ? (noRuns ? 'running not read' : live.length ? `${live.length} running${tileAgents ? `, ${liveWorking} working` : ', working not read'}` : 'none running') : '', `<a href="#/agents?${oq}" data-focus-key="agents-all">This organization's agents</a>`)}
|
||||
${sessions}
|
||||
${secH('Queue', queueNote, queueElsewhere ? '' : '<a href="#/queue" data-org-queue data-focus-key="queue-open">Open queue</a>')}
|
||||
${queueWhy}
|
||||
</section>
|
||||
<section aria-label="Workflows and settings">
|
||||
${window.MosaicWorkflows?.card?.(id, { stopped, label: label(org) }) ?? ''}
|
||||
${secH('Settings', 'every setting, with where its value comes from')}
|
||||
${settingsList(org, { business, settings, instances, orgCount: list ? list.length : null })}
|
||||
${settingsList(org, { business, settings, instances, noRuns, orgCount: list ? list.length : null })}
|
||||
</section>
|
||||
</div>
|
||||
<div class="ws-wide">
|
||||
@@ -699,13 +694,16 @@
|
||||
// An organization whose agent read failed is named, never counted as having none.
|
||||
const agentReads = await readInstances(o.rows.filter(r => r.state === 'ok' && !missOf(r, ['agents'])).map(r => r.org));
|
||||
const agentOk = agentReads.filter(r => r.inst), agentAll = agentRows(agentOk, body);
|
||||
const agentParts = agentOk.map(r => ({ id: r.id, n: agentAll.filter(a => a.org === r.id && a.state === 'working').length }));
|
||||
const waitingOn = agentAll.filter(a => a.state === 'waiting').length;
|
||||
// SP14 (#1873): an organization whose instances answer carries no runs adds nothing to the running
|
||||
// and working figures; the tile names it, and its row says running was not read.
|
||||
const runOk = agentOk.filter(r => !runsUnread(r.inst, r.copies)), runRows = agentAll.filter(a => runOk.some(r => r.id === a.org));
|
||||
const agentParts = runOk.map(r => ({ id: r.id, n: runRows.filter(a => a.org === r.id && a.state === 'working').length }));
|
||||
const waitingOn = runRows.filter(a => a.state === 'waiting').length;
|
||||
const ps = { needs: parts('needs', x => x.needsYou), tasks: parts('tasks', x => x.openTasks), done: parts('done', x => x.doneThisWeek), projects: parts('projects', x => x.projects?.active) };
|
||||
const needsN = fig('needs', ps.needs);
|
||||
const archived = per.filter(x => readers(o, SRC.projects).has(x.org)).reduce((a, x) => a + num(x.projects?.archived), 0);
|
||||
const tiles = !o.rows.length ? '<p class="ws-panel tight note" data-ws-tiles>No organizations yet, so there is nothing to count.</p>' : `<div class="ws-tiles six" data-ws-tiles role="group" aria-label="Key figures">
|
||||
${kpiTile(o, { key: 'agents', name: 'Agents working', href: '#/agents?state=working', big: agentOk.length ? sum(agentParts) : null, small: `of ${agentAll.length}`, parts: agentParts, delta: agentOk.length ? agentNote(runningOf(agentAll), waitingOn) : '', miss: [...notIn(['agents']), ...agentReads.filter(r => !r.inst).map(r => `${esc(o.name(r.id))} (${esc(instWhy(r))})`)] })}
|
||||
${kpiTile(o, { key: 'agents', name: 'Agents working', href: '#/agents?state=working', big: runOk.length ? sum(agentParts) : null, small: `of ${runRows.length}`, parts: agentParts, delta: runOk.length ? agentNote(runningOf(runRows), waitingOn) : '', miss: [...notIn(['agents']), ...agentReads.filter(r => !r.inst).map(r => `${esc(o.name(r.id))} (${esc(instWhy(r))})`), ...agentOk.filter(r => !runOk.includes(r)).map(r => `${esc(o.name(r.id))} (running not read)`)] })}
|
||||
${kpiTile(o, { key: 'orgs', name: 'Organizations', href: '#/dashboard', big: answered, small: `of ${o.rows.length} running`, delta: o.unread.length ? andList(o.unread.map(r => `${esc(o.name(r.org))} ${offWord(r)}`)) : 'every bus answered', attrs: ' data-ws-jump="orgs"' })}
|
||||
${kpiTile(o, { key: 'needs', name: 'Needs you', href: '#/inbox', big: needsN, small: needsN === 1 ? 'item' : 'items', parts: ps.needs, delta: needsN && t?.oldestNeedsYouAt ? `oldest ${span(t.oldestNeedsYouAt)}` : 'nothing waiting', miss: notIn(SRC.needs), ...noFig })}
|
||||
${kpiTile(o, { key: 'tasks', name: 'Open tasks', href: '#/tasks', big: fig('tasks', ps.tasks), parts: ps.tasks, miss: notIn(SRC.tasks), ...noFig })}
|
||||
@@ -725,7 +723,7 @@
|
||||
// that was not (a stopped bus) counts the agents in its file and says nothing of what runs.
|
||||
const mineRows = agentOk.some(a => a.id === id) ? agentAll.filter(a => a.org === id) : null;
|
||||
const count = mineRows ? mineRows.length : lv?.state === 'ok' && Array.isArray(lv.base?.agents) ? lv.base.agents.length : null;
|
||||
const agentsCell = count === null ? none : `${plural(count, 'agent')}${mineRows ? ` <small class="muted">(${runningOf(mineRows)} running, ${workingOf(mineRows)} working)</small>` : ''}`;
|
||||
const agentsCell = count === null ? none : `${plural(count, 'agent')}${mineRows ? ` <small class="muted">(${runOk.some(a => a.id === id) ? `${runningOf(mineRows)} running, ${workingOf(mineRows)} working` : 'running not read'})</small>` : ''}`;
|
||||
const projectsCell = x && projectsRead.has(id) ? plural(num(x.projects?.active), 'project') : none;
|
||||
const needsCell = x && inboxRead.has(id) ? (num(x.needsYou) ? `${num(x.needsYou)} needs you` : 'none waiting') : none;
|
||||
// #1873 SP2: a reason that only restates the state word ("the bus is not running" beside "bus stopped") is said once.
|
||||
@@ -1145,10 +1143,10 @@
|
||||
// page for it: once the page's own reads have answered (`opts.page`), a drawer whose instances read
|
||||
// has not says its state is being read, and repaints when the read answers. That answer is kept for
|
||||
// the one repaint it asks for; a repaint while the read is still open waits on the same read.
|
||||
let drawerInst = null; // { org, done, value, p }
|
||||
let drawerInst = null, drawerReads = 0; // { org, n, done, value, p }
|
||||
const drawerInstRead = org => {
|
||||
if (drawerInst?.org === org) return drawerInst;
|
||||
const r = { org, done: false, value: null };
|
||||
const r = { org, n: ++drawerReads, done: false, value: null };
|
||||
r.p = data.instances(org).then(v => ({ inst: instancesOf(v), copies: copiesOf(v) }), err => ({ inst: null, copies: {}, err }))
|
||||
.then(v => { r.done = true; r.value = v; return v; });
|
||||
drawerInst = r;
|
||||
@@ -1157,7 +1155,7 @@
|
||||
async function agentDrawer(q, opts = {}) {
|
||||
const pick = agentPick(q);
|
||||
if (!pick || (opts.only && pick.org !== opts.only)) return '';
|
||||
const { org, key, id } = pick;
|
||||
const { org, id } = pick;
|
||||
const ir = drawerInstRead(org);
|
||||
const [body, business, config] = await Promise.all([
|
||||
data.workspace({ org, sections: ['sessions', 'projects', 'inbox', 'tasks'] }).catch(() => null),
|
||||
@@ -1167,14 +1165,26 @@
|
||||
]);
|
||||
await Promise.race([ir.p, opts.page ?? ir.p]);
|
||||
const pending = !ir.done;
|
||||
// A drawer closed before the read answered asks for no repaint, and its answer is not kept for a
|
||||
// later open.
|
||||
// SP14 (#1873): when the read answers, only the drawer repaints, from the reads it already has; the
|
||||
// page's own reads are not made again. Each open repaints its own drawer if it is still on screen and
|
||||
// still waits on this read (data-drawer-pending names the read), so a second agent opened while the
|
||||
// read was open repaints too. A closed drawer repaints nothing, and the answer is not kept for a later open.
|
||||
if (pending) ir.p.then(() => {
|
||||
if (document.querySelector(`#bus-view [data-ws-drawer="${CSS.escape(id)}"][data-drawer-pending]`)) document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));
|
||||
else if (drawerInst === ir) drawerInst = null;
|
||||
if (drawerInst === ir) drawerInst = null;
|
||||
const node = document.querySelector(`#bus-view [data-ws-drawer="${CSS.escape(id)}"][data-drawer-pending="${ir.n}"]`);
|
||||
if (!node) return;
|
||||
const key = focusKey(node), tpl = document.createElement('template');
|
||||
tpl.innerHTML = drawerHtml(pick, { body, business, config, pending: false, instRead: ir.value });
|
||||
const fresh = tpl.content.firstElementChild;
|
||||
if (!fresh) return;
|
||||
node.replaceWith(fresh);
|
||||
refocus(fresh, key);
|
||||
});
|
||||
else if (drawerInst === ir) drawerInst = null;
|
||||
const instRead = pending ? null : ir.value;
|
||||
return drawerHtml(pick, { body, business, config, pending: pending && ir.n, instRead: pending ? null : ir.value });
|
||||
}
|
||||
function drawerHtml(pick, { body, business, config, pending, instRead }) {
|
||||
const { org, key, id } = pick;
|
||||
const o = orgsOf(body), orgName = o.name(org);
|
||||
const close = `<button type="button" class="x" data-drawer-close data-focus-key="drawer-close" aria-label="Close the agent details">×</button>`;
|
||||
const shell = (title, inner, attrs = '') => `<aside class="ws-drawer ws-o" style="${o.style(org)}" role="region" aria-labelledby="ws-drawer-h" data-ws-drawer="${esc(id)}"${attrs}><div class="hd"><h2 id="ws-drawer-h" tabindex="-1" data-focus-key="drawer-h">${esc(title)}</h2>${close}</div><div class="body">${inner}</div></aside>`;
|
||||
@@ -1243,7 +1253,7 @@
|
||||
return shell(a.name, `<dl class="ws-drawer-fields">${fields}</dl>
|
||||
<section class="ws-drawer-sec" data-drawer-tasks aria-label="Open tasks assigned to ${esc(a.name)}"><h3>Open tasks</h3>${tasksBody}${tasksAll}</section>
|
||||
<section class="ws-drawer-sec" data-drawer-decisions aria-label="Decisions ${esc(a.name)} raised"><h3>Decisions waiting on you</h3>${decBody}</section>
|
||||
${actions}`, ` data-drawer-org-id="${esc(org)}" data-drawer-name="${esc(a.name)}" data-drawer-org-name="${esc(orgName)}"${pending ? ' data-drawer-pending' : ''}`);
|
||||
${actions}`, ` data-drawer-org-id="${esc(org)}" data-drawer-name="${esc(a.name)}" data-drawer-org-name="${esc(orgName)}"${pending ? ` data-drawer-pending="${pending}"` : ''}`);
|
||||
}
|
||||
// Closing goes Back when a row link on this page pushed the current entry; otherwise it drops
|
||||
// `agent` from the address in place. Either way the page repaints without it and focus returns to the row.
|
||||
|
||||
@@ -4,7 +4,7 @@ import { proxyAdminHTTP } from './proxy-admin.mjs';
|
||||
import { createServer } from 'node:http';
|
||||
import { bindTesterSocket, TESTER_ORIGIN } from './tester-socket.mjs';
|
||||
import { readFileSync, openSync, fstatSync, closeSync, constants } from 'node:fs';
|
||||
import { timingSafeEqual, randomUUID } from 'node:crypto';
|
||||
import { timingSafeEqual, randomUUID, createHash } from 'node:crypto';
|
||||
import { resolve } from 'node:path';
|
||||
import { isIP } from 'node:net';
|
||||
import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from './bus.mjs';
|
||||
@@ -251,6 +251,10 @@ const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.header
|
||||
// Q175 phase 1 (#1690): under --sso the writer comes from a session the handler already checked,
|
||||
// so it carries `sso: true` and the public origin. The header-preview signer is read-only.
|
||||
const signedIn = (req, p) => p.sso === true || one(req, p.header) === p.user;
|
||||
// SP14 (#1873): who is signed in, as an opaque stable name the page keys what it stores per person by
|
||||
// (the Assistant's kept pane messages). A hash of the session's subject, never the subject itself. A
|
||||
// Console with no sign-in has one fixed local viewer.
|
||||
const viewerOf = session => (typeof session?.sub === 'string' && session.sub ? createHash('sha256').update(JSON.stringify([String(session.provider ?? ''), String(session.iss ?? ''), session.sub])).digest('hex').slice(0, 32) : 'local');
|
||||
const ownOrigin = p => p.origin ?? `https://${p.host}`;
|
||||
async function busSend(req, res, { send, preview, log }) {
|
||||
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required' });
|
||||
@@ -1235,7 +1239,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
|
||||
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
|
||||
// `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged.
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), ...(proxyAdmin && !tester && !granted && named && sso !== null && currentSession?.rootOwner === true ? { proxyAdmin: true, proxyAdminSend: owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy, settingsSend: !tester && !granted && Boolean(send) && named && orgBusHealthy } : {}), boot, consoleRestart: !tester && named && currentSession?.instanceRole === 'operator' && owner.send !== null && restart !== null && trail !== null, decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named });
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, viewer: viewerOf(currentSession), ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), ...(proxyAdmin && !tester && !granted && named && sso !== null && currentSession?.rootOwner === true ? { proxyAdmin: true, proxyAdminSend: owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy, settingsSend: !tester && !granted && Boolean(send) && named && orgBusHealthy } : {}), boot, consoleRestart: !tester && named && currentSession?.instanceRole === 'operator' && owner.send !== null && restart !== null && trail !== null, decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named });
|
||||
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
|
||||
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
|
||||
const file = files.get(path);
|
||||
|
||||
@@ -69,9 +69,9 @@ function orgs(state) {
|
||||
};
|
||||
}
|
||||
|
||||
async function open(t, { width = 1440, height = 900, hash, state = { down: new Set(['home']), sent: [], created: [] } } = {}) {
|
||||
async function open(t, { width = 1440, height = 900, hash, state = { down: new Set(['home']), sent: [], created: [] }, sso = createDevOwner() } = {}) {
|
||||
const organizations = orgs(state);
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} });
|
||||
const server = await startServer({ port: 0, sso, organizations, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(width, height);
|
||||
@@ -462,6 +462,52 @@ test('SP11: the Sign out form drops kept pane messages, and the page it unloads
|
||||
assert.deepEqual(state.sent, []);
|
||||
});
|
||||
|
||||
// SP14 (#1873): kept pane messages belong to the person who typed them. They are stored with who is
|
||||
// signed in, and a load under anyone else, or a store that names no one (the SP11 form), opens empty.
|
||||
// Two people sign in, one after the other, on one tab; the same person keeps theirs across a reload.
|
||||
test('SP14: a kept pane message is shown again only to the person who typed it, on the same tab', async t => {
|
||||
const state = { down: new Set(), sent: [], created: [] };
|
||||
const a1 = `alpha:${T.a1}`, hash = `#/assistant?open=${a1}`;
|
||||
const dev = createDevOwner();
|
||||
const person = sub => ({ ...dev.session(), provider: 'sso', iss: 'https://id.example.test', sub, name: sub });
|
||||
let who = person('person-a');
|
||||
const { b, base, wait } = await open(t, { hash, state, sso: { ...dev, session: () => who } });
|
||||
const stored = () => b.evaluate(`sessionStorage.getItem('mosaic-assistant-drafts') || ''`);
|
||||
const leave = async () => { await b.evaluate(`location.hash = '#/tasks'`); await wait(`!document.querySelector('[data-assist-pane]') && !!document.querySelector('[data-tasks-board]')`); };
|
||||
const load = async () => { await b.navigate(`${base}?n=${Math.random().toString(36).slice(2)}${hash}`); await wait(`${ready(a1)} && !!${field(a1)} && !${field(a1)}.disabled`); };
|
||||
await wait(`${ready(a1)} && !${field(a1)}.disabled`);
|
||||
await type(b, a1, 'note from a');
|
||||
await leave();
|
||||
assert.match(await stored(), /note from a/);
|
||||
// Stored with an opaque name for the person, never their subject.
|
||||
assert.doesNotMatch(await stored(), /person-a/);
|
||||
// Person B signs in on the same tab: the pane opens empty and A's message is gone from the store.
|
||||
who = person('person-b');
|
||||
await load();
|
||||
assert.equal(await b.evaluate(`${field(a1)}.value`), '');
|
||||
assert.doesNotMatch(await stored(), /note from a/);
|
||||
await type(b, a1, 'note from b');
|
||||
await leave();
|
||||
assert.match(await stored(), /note from b/);
|
||||
// A again: B's message is not shown to A either.
|
||||
who = person('person-a');
|
||||
await load();
|
||||
assert.equal(await b.evaluate(`${field(a1)}.value`), '');
|
||||
assert.doesNotMatch(await stored(), /note from b/);
|
||||
// The same person keeps their own message across a load.
|
||||
await type(b, a1, 'a again');
|
||||
await leave();
|
||||
await load();
|
||||
assert.equal(await b.evaluate(`${field(a1)}.value`), 'a again');
|
||||
// A store in the form kept before SP14, which names no one, is dropped.
|
||||
await leave();
|
||||
await b.evaluate(`sessionStorage.setItem('mosaic-assistant-drafts', JSON.stringify({ ${JSON.stringify(a1)}: 'unowned note' })), true`);
|
||||
await load();
|
||||
assert.equal(await b.evaluate(`${field(a1)}.value`), '');
|
||||
assert.doesNotMatch(await stored(), /unowned note/);
|
||||
assert.deepEqual(state.sent, []);
|
||||
});
|
||||
|
||||
// SP11 (#1873): a pane whose message was sent but not yet shown in the conversation is not free: a
|
||||
// fourth thread replaces the first pane that holds nothing, and that pane only once its receipt is in.
|
||||
test('SP11: a pane whose send still waits for its receipt is not replaced by a fourth thread', async t => {
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
// 2. `#/orgs/<id>?query` is the organization page with the query kept; `?agent=` opens the drawer.
|
||||
// 3. The drawer never says "not running" before its instances read has answered: while it is open the
|
||||
// state is being read, after it fails the state was not read, and the Stop note says the same.
|
||||
// 4. The Queue card says in plain words when the queue is refused because this Console runs from a
|
||||
// linked worktree, and drops Open queue; any other refusal keeps its own reason.
|
||||
// 4. (SP14 removed the Queue card; the organization page now makes no queue read.)
|
||||
// Plus the test SP12 left out: a repaint while Stop asks its question keeps the question on screen.
|
||||
// SP14 (#1873): the page has no Queue card and makes no queue read; an instances answer with no runs says
|
||||
// running was not read wherever a running or working count would show; and the drawer's instances answer
|
||||
// repaints only the drawer, with no page read made again.
|
||||
// Every bus is a fake. The real GET /api/workspace and per-organization routes serve the page; only the
|
||||
// per-organization config, the agent writes and (when a test asks) the drawer's instances read are
|
||||
// stubbed in the page.
|
||||
@@ -14,14 +16,13 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { ReadError } from '../src/reads.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
|
||||
const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString();
|
||||
|
||||
function fixture({ queueError = null } = {}) {
|
||||
function fixture({ counts = {} } = {}) {
|
||||
const LIST = [{ id: 'acme', label: 'Acme Studio', theme: 'cobalt' }];
|
||||
const d = {
|
||||
projects: [{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) }],
|
||||
@@ -52,7 +53,7 @@ function fixture({ queueError = null } = {}) {
|
||||
reads: {
|
||||
business: async () => ({ business: { id, human: 'jason', projects: d.projects.map(p => p.repo), appearance: { theme: org.theme }, vars: { 'tracker.kind': 'gitea-issues-and-milestones' }, arbiters: { decisions: 'chief-of-staff', reviews: 'reviewer', merges: 'human-owner-of-the-organization' }, instances: Object.entries(d.model).map(([instance, model]) => ({ instance, vars: { model } })) } }),
|
||||
settings: async () => ({}),
|
||||
queue: async () => { if (queueError) throw queueError; return { rows: [] }; },
|
||||
queue: async () => { counts.queue = (counts.queue ?? 0) + 1; return { rows: [] }; },
|
||||
busStatus: async () => ({}),
|
||||
},
|
||||
bus: {
|
||||
@@ -69,6 +70,11 @@ function fixture({ queueError = null } = {}) {
|
||||
// a test holds ('hold', released by window.__release()) or fails ('fail') the first bus instances read,
|
||||
// which is the drawer's: it starts before the page's own reads. With `window.__heldIdle` the held
|
||||
// answer says nothing runs.
|
||||
// Every URL the page fetches, in order, for the tests that count reads.
|
||||
const URLS = `(() => { const real = window.fetch; window.__urls = []; window.fetch = (url, init) => { window.__urls.push(String(url)); return real(url, init); }; })();`;
|
||||
// A bus answer the bus did not add runs to (its async read path): no `running` key on any instance.
|
||||
const STRIP = `(() => { const real = window.fetch; window.fetch = async (url, init) => { const r = await real(url, init); if (!/\\/bus\\/instances$/.test(String(url)) || !r.ok) return r; const v = await r.json(); for (const row of [v, ...(v.rows || [])]) for (const i of Object.values(row?.instances || {})) delete i.running; return new Response(JSON.stringify(v), { status: 200, headers: { 'content-type': 'application/json' } }); }; })();`;
|
||||
|
||||
const STUB = `(() => {
|
||||
const real = window.fetch.bind(window);
|
||||
window.__posts = []; window.__answers = []; window.__instSeen = 0;
|
||||
@@ -94,8 +100,8 @@ const STUB = `(() => {
|
||||
};
|
||||
})();`;
|
||||
|
||||
async function setup(t, { viewport = [1440, 900], hash = '#/orgs/acme/organization', inst = null, queueError = null, scripts = [] } = {}) {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ queueError }), log: () => {} });
|
||||
async function setup(t, { viewport = [1440, 900], hash = '#/orgs/acme/organization', inst = null, counts = {}, scripts = [] } = {}) {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ counts }), log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(...viewport);
|
||||
@@ -188,7 +194,7 @@ test('the bare organization address with a query is the organization page, query
|
||||
});
|
||||
|
||||
test('while the drawer\'s instances read is open it says the state is being read, never not running, then shows it', async t => {
|
||||
const { b, wait, text, has } = await setup(t, { hash: '#/orgs/acme/organization?agent=acme%3Acoder', inst: 'hold' });
|
||||
const { b, wait, text, has } = await setup(t, { hash: '#/orgs/acme/organization?agent=acme%3Acoder', inst: 'hold', scripts: [URLS] });
|
||||
const d = '[data-ws-drawer="acme:coder"]';
|
||||
await wait(`${settled} && ${open('acme:coder')}`);
|
||||
assert.equal(await has(`${d}[data-drawer-pending]`), true);
|
||||
@@ -199,11 +205,33 @@ test('while the drawer\'s instances read is open it says the state is being read
|
||||
assert.match(await text(`${d} [data-drawer-nostop]`), /^Its state is being read\./);
|
||||
// The page under it painted from its own reads: coder is a running session there.
|
||||
assert.match(await text('[data-ws-session-agent="acme:coder"]'), /working/);
|
||||
// When the read answers, the drawer repaints with the state, and Stop.
|
||||
// When the read answers, the drawer repaints with the state, and Stop. SP14: only the drawer repaints,
|
||||
// from the reads it already has: the page and its reads are left alone, and focus stays where it was.
|
||||
await b.evaluate(`document.querySelector('[data-org-dashboard]').dataset.before = '1'; document.querySelector('${d} [data-drawer-close]').focus(); window.__urls.length = 0`);
|
||||
await b.evaluate('window.__release()');
|
||||
await wait(`!document.querySelector('${d}[data-drawer-pending]') && document.querySelector('${d} [data-drawer-stop]')`);
|
||||
assert.match(await text(`${d} [data-drawer-state]`), /(^|\s)working$/);
|
||||
assert.equal(await has(`${d} [data-drawer-nostop]`), false);
|
||||
await b.evaluate('new Promise(r => setTimeout(r, 400))');
|
||||
assert.deepEqual(await b.evaluate(`window.__urls.filter(u => /^\\/api\\/(workspace|orgs\\/)/.test(u))`), [], 'no read is made again after the drawer\'s read answers');
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-org-dashboard]').dataset.before`), '1', 'the page under the drawer was not repainted');
|
||||
assert.equal(await b.evaluate('document.activeElement?.dataset?.focusKey'), 'drawer-close');
|
||||
});
|
||||
|
||||
test('SP14 review: another agent opened while the drawer\'s instances read is open repaints when that read answers', async t => {
|
||||
const { b, wait, text, has } = await setup(t, { hash: '#/orgs/acme/organization?agent=acme%3Acoder', inst: 'hold' });
|
||||
await wait(`${settled} && document.querySelector('[data-ws-drawer="acme:coder"][data-drawer-pending]')`);
|
||||
// Chief opens before the held read answers; its drawer waits on the same read.
|
||||
await b.evaluate('location.hash = "#/orgs/acme/organization?agent=acme%3Achief"');
|
||||
const d = '[data-ws-drawer="acme:chief"]';
|
||||
await wait(`document.querySelector('${d}[data-drawer-pending]')`);
|
||||
assert.equal(await text(`${d} [data-drawer-state]`), 'Reading state…');
|
||||
// The read answers. Chief's drawer repaints from it at once, not at the next poll.
|
||||
await b.evaluate('window.__release()');
|
||||
await b.evaluate('new Promise(r => setTimeout(r, 1000))');
|
||||
assert.equal(await has(`${d}[data-drawer-pending]`), false, 'chief\'s drawer still says Reading state… after its read answered');
|
||||
assert.match(await text(`${d} [data-drawer-state]`), /not running/);
|
||||
assert.equal(await has('[data-ws-drawer="acme:coder"]'), false);
|
||||
});
|
||||
|
||||
test('a drawer closed before its instances read answers keeps no answer for the next open', async t => {
|
||||
@@ -239,9 +267,7 @@ test('after the drawer\'s instances read fails it says the state was not read, a
|
||||
});
|
||||
|
||||
test('an instances answer that carries no runs says state not read, in the drawer and on the Agents table', async t => {
|
||||
// A bus answer the bus did not add runs to (its async read path): no `running` key on any instance.
|
||||
const strip = `(() => { const real = window.fetch; window.fetch = async (url, init) => { const r = await real(url, init); if (!/\\/bus\\/instances$/.test(String(url)) || !r.ok) return r; const v = await r.json(); for (const row of [v, ...(v.rows || [])]) for (const i of Object.values(row?.instances || {})) delete i.running; return new Response(JSON.stringify(v), { status: 200, headers: { 'content-type': 'application/json' } }); }; })();`;
|
||||
const { wait, text, has } = await setup(t, { hash: '#/agents?agent=acme%3Acoder', scripts: [strip] });
|
||||
const { wait, text, has } = await setup(t, { hash: '#/agents?agent=acme%3Acoder', scripts: [STRIP] });
|
||||
const d = '[data-ws-drawer="acme:coder"]';
|
||||
await wait(`${open('acme:coder')} && document.querySelector('[data-ws-agent="acme:coder"]')`);
|
||||
assert.match(await text(`${d} [data-drawer-state]`), /state not read$/);
|
||||
@@ -251,21 +277,39 @@ test('an instances answer that carries no runs says state not read, in the drawe
|
||||
assert.equal(await has('[data-ws-astate="not-read"]'), true);
|
||||
});
|
||||
|
||||
test('the Queue card: a linked-worktree refusal in plain words without Open queue; another refusal keeps its reason', async t => {
|
||||
const linked = new ReadError('queue-refused', '/srv/checkouts/feature-x is a linked worktree; the queue runs only in the canonical checkout');
|
||||
const { wait, text, has } = await setup(t, { queueError: linked });
|
||||
await wait('document.querySelector("[data-org-queue-why]")');
|
||||
assert.equal(await text('[data-org-queue-why]'), 'This Console does not run from the checkout that holds the queue, so the queue is not shown here.');
|
||||
test('SP14: the organization page has no Queue card and makes no queue read', async t => {
|
||||
const counts = {};
|
||||
const { b, wait, has } = await setup(t, { counts, scripts: [URLS] });
|
||||
await wait(`${settled} && document.querySelector('[data-ws-session-agent="acme:coder"]')`);
|
||||
await b.evaluate('new Promise(r => setTimeout(r, 400))');
|
||||
assert.equal(await has('[data-org-queue]'), false);
|
||||
assert.doesNotMatch(await text('[data-org-dashboard]'), /linked worktree|canonical checkout/);
|
||||
assert.equal(await has('[data-org-queue-why]'), false);
|
||||
const heads = await b.evaluate(`[...document.querySelectorAll('[data-org-dashboard] h2, [data-org-dashboard] h3')].map(h => h.textContent.trim())`);
|
||||
assert.ok(heads.includes('Active agent sessions'), heads.join(' | '));
|
||||
assert.ok(!heads.some(h => /^Queue\b/.test(h)), heads.join(' | '));
|
||||
assert.deepEqual(await b.evaluate('window.__urls.filter(u => /queue/i.test(u))'), []);
|
||||
assert.equal(counts.queue ?? 0, 0, 'the server read no queue');
|
||||
});
|
||||
|
||||
test('the Queue card: a refusal for another reason says that reason and keeps Open queue', async t => {
|
||||
const other = new ReadError('queue-refused', 'HEAD is refs/heads/feature; the queue runs only on branch refactor');
|
||||
const { wait, text, has } = await setup(t, { queueError: other });
|
||||
await wait('document.querySelector("[data-org-queue-why]")');
|
||||
assert.equal(await text('[data-org-queue-why]'), 'The queue refused the read. HEAD is refs/heads/feature; the queue runs only on branch refactor');
|
||||
assert.equal(await has('[data-org-queue]'), true);
|
||||
test('SP14: an instances answer with no runs says running was not read on the organization page and the Dashboard, never 0', async t => {
|
||||
const { b, base, wait, text, has } = await setup(t, { scripts: [STRIP] });
|
||||
await wait(`${settled} && document.querySelector('[data-org-dashboard] [data-ws-tile="agents"]')`);
|
||||
const sec = await b.evaluate(`[...document.querySelectorAll('[data-org-dashboard] .ws-sec-h')].find(h => h.querySelector('h2')?.textContent === 'Active agent sessions')?.innerText.replace(/\\s+/g, ' ').trim()`);
|
||||
assert.match(sec, /^Active agent sessions running not read /);
|
||||
assert.doesNotMatch(sec, /none running|\b0 running/);
|
||||
assert.equal(await text('[data-runs-unread]'), 'Not read: the instance read did not say which agents run.');
|
||||
assert.equal(await text('[data-org-dashboard] [data-ws-tile="agents"]'), 'Agents working Not read: the instance read did not say which agents run');
|
||||
assert.equal(await has('[data-org-dashboard] [data-ws-tile="agents"] .ws-big'), false);
|
||||
assert.equal(await text('[data-org-setting="Agents and roles"] .v'), '3 agents · running not read');
|
||||
// The Dashboard: the tile counts no organization whose runs were not read and names it; its row says so.
|
||||
await b.navigate(`${base}/#/dashboard`);
|
||||
await wait(`document.querySelector('[data-ws-tile="agents"]') && document.querySelector('[data-ws-orgrow="acme"]')`);
|
||||
const tile = await text('[data-ws-tile="agents"]');
|
||||
assert.match(tile, /^Agents working Not read Acme Studio \(running not read\)$/);
|
||||
assert.equal(await has('[data-ws-tile="agents"] .ws-big'), false);
|
||||
const row = await text('[data-ws-orgrow="acme"]');
|
||||
assert.match(row, /3 agents \(running not read\)/);
|
||||
assert.doesNotMatch(row, /\b0 running|0 working/);
|
||||
});
|
||||
|
||||
test('a repaint while Stop asks its question keeps the question on screen and Stop disabled', async t => {
|
||||
|
||||
@@ -90,7 +90,7 @@ test('workspace navigation exposes all organizations and remembers project group
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'),true);
|
||||
});
|
||||
|
||||
test('sidebar follows sample 01 and Queue opens from the organization dashboard, not the sidebar', async t => {
|
||||
test('sidebar follows sample 01; the Queue is in neither the sidebar nor the organization dashboard, and #/queue still opens', async t => {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const wait = waitFor(b);
|
||||
@@ -114,9 +114,9 @@ test('sidebar follows sample 01 and Queue opens from the organization dashboard,
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#console-nav a[href]")].filter(a => /\\/(queue|docs)$/.test(a.getAttribute("href"))).length'), 0);
|
||||
// Settings stays pinned to the foot of the sidebar.
|
||||
assert.equal(await b.evaluate('(() => { const nav = document.getElementById("console-nav").getBoundingClientRect(), foot = document.getElementById("sections-foot").getBoundingClientRect(); return foot.bottom >= nav.bottom - 24; })()'), true);
|
||||
const queue = await b.evaluate('document.querySelector("[data-org-dashboard] [data-org-queue]").getAttribute("href")');
|
||||
assert.equal(queue, '#/queue');
|
||||
await b.evaluate('document.querySelector("[data-org-queue]").click()');
|
||||
// SP14 (#1873): the organization dashboard has no Queue card; the Queue page stays at its address.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-org-dashboard] a[href=\'#/queue\'], [data-org-queue]")'), null);
|
||||
await b.evaluate('location.hash = "#/orgs/ddigital/queue"');
|
||||
await wait('location.hash === "#/orgs/ddigital/queue" && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === "Queue")');
|
||||
// Every sidebar view is built: Inbox (U3, workspace-pages-browser.test.mjs), Tasks (U4b,
|
||||
// tasks-board-browser.test.mjs) and Assistant (SP6, assistant-threads-browser.test.mjs), which
|
||||
@@ -152,7 +152,7 @@ test('organization dashboard renders every section from the per-organization rea
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-tbl a.ws-change")].map(a => [a.getAttribute("href"), a.getAttribute("aria-label")])'),
|
||||
['chief', 'copywriter', 'montage-producer'].map(n => ['#/settings/routing', `Change the model for ${n}`]));
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-tbl thead th")].map(th => th.textContent)'), ['Agent', 'Project and task', 'Model', 'Status']);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-org-dashboard]").innerText'), /Queue\s+3 rows, 2 not done\s+Open queue/);
|
||||
assert.doesNotMatch(await b.evaluate('document.querySelector("[data-org-dashboard]").innerText'), /Open queue|\d+ rows?, \d+ not done/);
|
||||
// Settings: one row per setting, each with an icon, a value, a source tag and, where a page exists, Open.
|
||||
const settings = await b.evaluate(`[...document.querySelectorAll('[data-org-settings] > li')].map(li => ({ name: li.dataset.orgSetting, icon: !!li.querySelector('svg'), value: li.querySelector('.v').textContent, tag: li.querySelector('.src-tag').textContent, href: li.querySelector('a.go')?.getAttribute('href') ?? null, danger: li.classList.contains('danger') }))`);
|
||||
assert.deepEqual(settings.map(s => s.name), SETTINGS);
|
||||
@@ -178,7 +178,7 @@ test('organization dashboard empty states say so in one short line', async t =>
|
||||
await waitFor(b)('document.querySelector("[data-org-dashboard] [data-org-settings]")');
|
||||
const text = await b.evaluate('document.querySelector("[data-org-dashboard]").innerText');
|
||||
// Section labels are uppercased by CSS, so innerText carries the uppercase form.
|
||||
for (const line of [/Docs\s+No docs yet/i, /Agents working\s+Not read/, /Done this week\s+Not read/, /Active agent sessions\s+none running/i, /No active agent sessions/, /Queue\s+no rows/i, /Tracker: not set/])
|
||||
for (const line of [/Docs\s+No docs yet/i, /Agents working\s+Not read/, /Done this week\s+Not read/, /Active agent sessions\s+none running/i, /No active agent sessions/, /Tracker: not set/])
|
||||
assert.match(text, line);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-org-settings] > li").length'), SETTINGS.length);
|
||||
});
|
||||
@@ -190,7 +190,7 @@ test('organization dashboard presents sections and honest unavailable controls',
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/organization`);
|
||||
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('[data-org-dashboard]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('dashboard missing')})()`);
|
||||
const text=await b.evaluate('document.querySelector("[data-org-dashboard]").innerText');
|
||||
for(const label of ['Docs','Agents working','Open tasks','Needs you','Done this week','Active agent sessions','Settings','Queue']) assert.match(text,new RegExp(label,'i'));
|
||||
for(const label of ['Docs','Agents working','Open tasks','Needs you','Done this week','Active agent sessions','Settings']) assert.match(text,new RegExp(label,'i'));
|
||||
assert.match(text,/reload organization file/i);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-org-tiles] .ws-big").length'),0);
|
||||
assert.match(text,/bus unavailable, read \d/);
|
||||
@@ -307,7 +307,7 @@ test('keyboard focus follows visual order and survives a data refresh', async t
|
||||
assert.deepEqual([...new Set(wide.map(s => s.region))].filter(r => r !== 'other'), regions);
|
||||
assertVisualOrder(wide.filter(s => s.region !== 'other'), regions, '1440');
|
||||
const keys = wide.map(s => s.key);
|
||||
for (const key of ['org:forge', 'proj:ddigital:spring-launch', 'doc:spring-launch:docs/north-star.md', 'docs-all', 'agents-all', 'queue-open', 'wfg-all', 'set:Identity', 'set:Archive or delete']) assert.ok(keys.includes(key), `Tab never reaches ${key}`);
|
||||
for (const key of ['org:forge', 'proj:ddigital:spring-launch', 'doc:spring-launch:docs/north-star.md', 'docs-all', 'agents-all', 'wfg-all', 'set:Identity', 'set:Archive or delete']) assert.ok(keys.includes(key), `Tab never reaches ${key}`);
|
||||
// The disabled reload is not a tab stop; the restart button is, and Stop bus follows it.
|
||||
assert.ok(!keys.includes('org-reload')); assert.ok(keys.includes('Restart bus'));
|
||||
assert.equal(keys.indexOf('Stop bus'), keys.indexOf('Restart bus') + 1);
|
||||
@@ -319,7 +319,7 @@ test('keyboard focus follows visual order and survives a data refresh', async t
|
||||
await b.viewport(1440, 900);
|
||||
|
||||
// A refresh repaints the dashboard, the sidebar and the restart button; focus stays on the same control.
|
||||
for (const key of ['set:Projects', 'queue-open', 'doc:spring-launch:docs/launch-plan.md']) {
|
||||
for (const key of ['set:Projects', 'agents-all', 'doc:spring-launch:docs/launch-plan.md']) {
|
||||
await wait(`document.querySelector('[data-focus-key="${key}"]')`);
|
||||
await b.evaluate(`window.oldDashboard = document.querySelector('[data-org-dashboard]'); document.querySelector('[data-focus-key="${key}"]').focus(); document.getElementById('refresh').click()`);
|
||||
await wait('document.querySelector("[data-org-dashboard]") && document.querySelector("[data-org-dashboard]") !== window.oldDashboard');
|
||||
|
||||
Reference in new issue
Block a user