fix(webui): E2E round 6, live edits survive refresh, org page layout, decision refusals (#1873) #1961

Merged
jason.woltje merged 3 commits from f6w into refactor 2026-11-14 02:12:30 +00:00
22 changed files with 672 additions and 76 deletions

No files matched your search

+130 -18
View File
@@ -6,6 +6,7 @@
// rechecks everything in the resolve transaction. Nothing here retries.
import { createHash, randomBytes } from 'node:crypto';
import { BusReadError, subjectOk } from './bus.mjs';
import { ID_PATTERN } from '../../organization/src/vocabulary.mjs';
export const DECISION_ROUTE = /^\/api\/decisions\/([^/]*)\/(confirmation|resolve|start|templates)$/;
// The endpoint contract, design section 4. Each refusal is one of these, never a 200.
@@ -42,21 +43,117 @@ const BUS_CODES = {
'decision-closed': 'decision-closed', 'decision-stale': 'decision-stale', 'invalid-choice': 'decision-stale',
'invalid-request': 'invalid-request', 'hire-owner-required':'hire-owner-required','right-required':'right-required','decision-expired':'decision-expired','hire-resolution-required':'hire-resolution-required',
};
// D4 (#1873, P1-2): a hire, fire or change start the bus definitely refused keeps its own code,
// but only for codes the broker raises before it records the decision: the proposal checks in
// hire-decisions.mjs prepare(), which #raiseHire runs before its INSERT. hire.start.console then
// resolves the decision and applies it in #hireConsole, and those steps can raise other codes (and
// some of the same generic ones) after the decision exists or is consumed, so every code not listed
// here stays outcome-unknown. Review P3: an allowlist, not a pass-through of any well-formed code.
// D4 (#1873, P1-2) and E2E6 P1-2: a hire, fire or change start the bus definitely refused keeps its
// own code and a next step. hire.start.console runs prepare() (hire-decisions.mjs) before it records
// the decision, then #hireConsole checks it, consumes it, and applies it. apply() runs check() again
// and the writers, authorityHost and #fireApproved run after the consume, so most codes can come from
// either side of it; for a fire the agent's runs may already be stopped. The code alone does not say
// which, and the inbox cannot tell either: it lists no closed decision (broker inbox verb), so a
// consumed one simply drops out of it. Those codes therefore say part of the change may already be
// applied and send the user to the Agents page (re-check finding 1). Only codes
// that prepare() alone raises say "Nothing was applied.": the route checks (checkRoutes runs in
// prepare(), and in adjusted() only for an adjust, which a start never carries), definition-shared,
// definition-not-hireable and no-chief-of-staff. organization-uncertain, a lost reply and the
// bus server's own request codes (request-timeout and invalid-envelope can fire while the request is
// processing) stay outcome-unknown.
const NONE = ' Nothing was applied.';
const EITHER = ' Part of it may already be applied: check the Agents page before trying again.';
const CHECK = ' Check the Agents page and the inbox before trying again.';
const HIRE_START_REFUSALS = {
'model-provider-unavailable': [409, 'The model proxy does not offer this model, or its catalogue is not ready. Pick a model it offers, or ask the owner to check Settings > Model proxy. Nothing was applied.'],
'model-catalogue-unavailable': [503, 'The model catalogue could not be read, so this model could not be checked. Check Settings > Model proxy, then try again. Nothing was applied.'],
'model-harness-incompatible': [409, 'This model does not run on the chosen harness. Pick another model or harness. Nothing was applied.'],
'thinking-unsupported': [409, 'The chosen harness does not apply this thinking level. Clear Thinking or pick a level it applies. Nothing was applied.'],
'definition-not-hireable': [409, 'This organization does not hire agents from this role template. Pick another template. Nothing was applied.'],
'definition-shared': [409, 'Another agent uses the same role definition, so this change cannot replace it. Nothing was applied.'],
'no-chief-of-staff': [409, 'This organization has no chief of staff, so a hire, fire or change cannot be checked against it. Nothing was applied.'],
'model-provider-unavailable': [409, 'The model proxy does not offer this model, or its catalogue is not ready. Pick a model it offers, or ask the owner to check Settings > Model proxy.' + NONE],
'model-catalogue-unavailable': [503, 'The model catalogue could not be read, so this model could not be checked. Check Settings > Model proxy, then try again.' + NONE],
'model-harness-incompatible': [409, 'This model does not run on the chosen harness. Pick another model or harness.' + NONE],
'thinking-unsupported': [409, 'The chosen harness does not apply this thinking level. Clear Thinking or pick a level it applies.' + NONE],
'unknown-model': [409, 'This model is not one of the organization\'s models. Pick a model listed under Settings > Model proxy.' + NONE],
'harness-off': [409, 'This harness is not switched on for the organization. Pick a harness listed under Settings > Routing, or ask the owner to switch it on.' + NONE],
'harness-refused': [409, 'The harness refused this route. Clear Thinking, or pick another model or harness under Settings > Routing.' + NONE],
'definition-not-hireable': [409, 'This organization does not hire agents from this role template. Pick another template.' + NONE],
'definition-shared': [409, 'Another agent uses the same role definition, so this change cannot replace it.' + NONE],
'definition-rev-conflict': [409, 'The role definition changed since it was read. Reload the page and propose again.' + EITHER],
'definition-missing': [409, 'The agent\'s role definition is missing from the organization file. Check the Agents page.' + EITHER],
'definition-secret-check-required': [503, 'The bus cannot check role text for secrets right now. Ask the owner to check the organization bus.' + EITHER],
'credential-leak': [409, 'The proposal carries a value the credential store holds. Remove it from the role text or the reason.' + EITHER],
'no-chief-of-staff': [409, 'This organization has no chief of staff, so a hire, fire or change cannot be checked against it.' + NONE],
'project-agents-invalid': [409, 'That project is not a code project of this organization. Pick a code project from Projects; a library project has no agents.' + EITHER],
'organization-unknown-instance': [409, 'No agent has that name at this level. Check the name on the Agents page.' + EITHER],
'organization-instance-exists': [409, 'An agent already has that name at this level. Pick another name, or propose a change to that agent.' + EITHER],
'organization-instance-retired': [409, 'That name belonged to a fired agent and cannot be reused. Pick another name.' + EITHER],
'organization-instance-referenced': [409, 'Another part of the organization still names this agent. Remove those references first.' + EITHER],
'instance-kept': [409, 'This agent is kept by the organization and cannot be fired or replaced this way.' + EITHER],
'root-fire-shared': [409, 'This root agent is shared with other organizations, so it cannot be fired from here.' + EITHER],
'decision-stale': [409, 'The role template or the organization changed since it was read. Reload the templates and propose again.' + EITHER],
'organization-refused': [409, 'The organization file refused this change. Check the role and route against the organization checks.' + EITHER],
'not-configured': [503, 'This Console\'s bus has no hire support configured. Ask the owner to check the organization bus.' + EITHER],
'organization-busy': [409, 'The organization is busy with another write.' + CHECK],
'fire-in-progress': [409, 'A fire of this agent is already in progress.' + CHECK],
'instance-fired': [409, 'This agent was fired.' + CHECK],
'copy-unavailable': [409, 'This copy is not available for credentials.' + CHECK],
'copy-refused': [409, 'The copy refused this credential change.' + CHECK],
'copy-stale': [409, 'The copy changed since it was read. Reload the Agents page.' + CHECK],
'root-policy-unreadable': [503, 'The root policy could not be read.' + CHECK],
'credential-definition-unpinned': [409, 'The copy\'s role definition is not pinned, so credentials cannot be bound to it.' + CHECK],
'credential-binding-changed': [409, 'The copy\'s credential binding changed since it was read.' + CHECK],
'credential-role-changed': [409, 'The copy\'s role changed since it was read.' + CHECK],
'credential-unbound': [409, 'The copy has no credential binding to remove.' + CHECK],
'credential-revoking': [409, 'The copy\'s credential is being revoked.' + CHECK],
'credential-scope-exceeded': [409, 'The requested credential scope is wider than the role allows.' + CHECK],
// The fields passed the shape check below, so a malformed answer here is a value the organization
// checks refused: most often a model that is not a launch.models key.
'invalid-request': [400, 'The bus refused a value in this proposal. Pick a model listed under Settings > Model proxy and a role the organization checks accept.' + EITHER],
};
// A reply the endpoint cannot read as a definite answer: no answer, or the bus server's request
// codes. Anything else that reads as a code is a definite refusal.
const NO_ANSWER = new Set(['outcome-unknown', 'invalid-response', 'response-too-large', 'organization-uncertain', 'request-timeout', 'invalid-envelope', 'incomplete-request', 'revision-conflict-revoked']);
const CODE = /^[a-z][a-z-]{0,63}$/;
const definiteStart = err => err instanceof BusReadError && typeof err.code === 'string' && CODE.test(err.code) &&
!NO_ANSWER.has(err.code) && !err.code.startsWith('outcome-unknown') ? err.code : null;
// E2E6 P2-4: the start's own shape check, the structural rules prepare() applies, so a malformed
// proposal names its field and rule before anything leaves for the bus. A binding request keeps the
// bus's own check (its shape lives in copy-bindings.mjs).
const HIRE_KEYS = ['op', 'level', 'project', 'name', 'role', 'credentials', 'personality', 'route', 'reason', 'binding'];
const plain = x => x !== null && typeof x === 'object' && !Array.isArray(x);
const idOk = x => typeof x === 'string' && ID_PATTERN.test(x) && !['human', 'tester', 'user'].includes(x);
const ID_RULE = 'lowercase letters, digits, dot, dash or underscore, starting with a letter or digit, at most 64 characters, and not human, tester or user';
export function hireProblem(hire) {
if (!plain(hire)) return { field: 'hire', rule: 'must be an object' };
const extra = Object.keys(hire).find(k => !HIRE_KEYS.includes(k));
if (extra) return { field: extra, rule: 'is not a hire field' };
if ('binding' in hire) return null;
for (const k of ['op', 'level', 'name', 'reason']) if (!Object.hasOwn(hire, k)) return { field: k, rule: 'is required' };
if (!['hire', 'fire', 'change'].includes(hire.op)) return { field: 'op', rule: 'must be hire, fire or change' };
if (!['org', 'project', 'root'].includes(hire.level)) return { field: 'level', rule: 'must be org, project or root' };
if (!idOk(hire.name)) return { field: 'name', rule: `must be ${ID_RULE}` };
if (typeof hire.reason !== 'string' || !hire.reason.trim()) return { field: 'reason', rule: 'must not be blank' };
if (hire.reason.length > 500) return { field: 'reason', rule: 'must be at most 500 characters' };
if (hire.level === 'project' && !idOk(hire.project)) return { field: 'project', rule: 'must name a code project at project level' };
if (hire.level !== 'project' && 'project' in hire) return { field: 'project', rule: 'is only for project level' };
if (hire.op === 'fire') {
const k = ['role', 'route', 'credentials', 'personality'].find(x => x in hire);
return k ? { field: k, rule: 'is not used by fire' } : null;
}
const route = hire.route;
if (!plain(route)) return { field: 'route', rule: 'must name a harness and a model' };
const rk = Object.keys(route).find(k => !['harness', 'model', 'thinking'].includes(k));
if (rk) return { field: `route.${rk}`, rule: 'is not a route field' };
for (const k of ['harness', 'model']) if (!Object.hasOwn(route, k)) return { field: `route.${k}`, rule: 'is required' };
for (const [k, v] of Object.entries(route)) if (typeof v !== 'string' || !v || v.length > 200) return { field: `route.${k}`, rule: 'must be text of 1 to 200 characters' };
const role = hire.role;
if (!plain(role)) return { field: 'role', rule: 'must name a role template' };
const kinds = ['template', 'definition', 'inline'].filter(k => k in role);
if (kinds.length !== 1) return { field: 'role', rule: 'must name exactly one of a template, a definition or an inline role' };
const ok = { template: ['template', 'sha256'], definition: ['definition', 'rev'], inline: ['inline'] }[kinds[0]];
const rx = Object.keys(role).find(k => !ok.includes(k));
if (rx) return { field: `role.${rx}`, rule: `is not used with role.${kinds[0]}` };
if (kinds[0] === 'template') {
if (!idOk(role.template)) return { field: 'role.template', rule: `must be ${ID_RULE}` };
if (!DIGEST.test(role.sha256 ?? '')) return { field: 'role.sha256', rule: 'must be the template\'s 64-character digest; read the templates again' };
}
if (kinds[0] === 'definition') {
if (!idOk(role.definition)) return { field: 'role.definition', rule: `must be ${ID_RULE}` };
if (!Number.isSafeInteger(role.rev)) return { field: 'role.rev', rule: 'must be a whole number' };
}
return null;
}
// #1873 CB5: the owner's next step once a copy credential binding decision is approved, fixed text
// (design section 6 and its three steps). Nothing in it comes from the decision, the bus or a token.
export const BINDING_NEXT = Object.freeze({
@@ -156,6 +253,14 @@ export async function decisionHTTP(req, res, { path, decide, session, live, conf
if(step==='start' || step==='templates') {
const allowed=step==='start'?['business','hire']:['business'];
if(id!=='hire' || Object.keys(value).length!==allowed.length || Object.keys(value).some(k=>!allowed.includes(k)) || !subjectOk(value.business))return refuse('invalid-request');
if(step==='start') {
const problem=hireProblem(value.hire);
if(problem) {
const label=problem.field[0].toUpperCase()+problem.field.slice(1);
line(`error=invalid-request field=${problem.field}`);
return json(res,400,{error:'invalid-request',field:problem.field,rule:problem.rule,message:`${label} ${problem.rule}. Nothing was sent to the bus.`});
}
}
if(live()!==session)return refuse('sign-in-required');
try {
const result=await decide.call(step==='start'?'hire.start.console':'hire.templates.console',step==='start'?value.hire:{},{subject,business:value.business});
@@ -167,11 +272,18 @@ export async function decisionHTTP(req, res, { path, decide, session, live, conf
? (value.hire.binding.services===null?'unbind':'bind') : null;
return json(res,200,step==='start'?{id:result.id,op:result.op,choice:result.choice,...(change?{next:BINDING_NEXT[change]}:{})}:result);
} catch(err) {
if(step!=='start' || !(err instanceof BusReadError) || !Object.hasOwn(HIRE_START_REFUSALS,err.code))
return refuse(busCode(err,step==='start'?'outcome-unknown':'confirmation-unavailable'));
const [status,message]=HIRE_START_REFUSALS[err.code];
line(`error=${err.code}`);
return json(res,status,{error:err.code,message});
if(step!=='start')return refuse(busCode(err,'confirmation-unavailable'));
const code=definiteStart(err);
if(!code)return refuse('outcome-unknown');
if(Object.hasOwn(HIRE_START_REFUSALS,code)) {
const [status,message]=HIRE_START_REFUSALS[code];
line(`error=${code}`);
return json(res,status,{error:code,message});
}
if(Object.hasOwn(BUS_CODES,code))return refuse(BUS_CODES[code]);
// A definite refusal this page has no wording for keeps its code, with a safe next step.
line(`error=${code}`);
return json(res,409,{error:code,message:'The bus refused this proposal.'+CHECK});
}
}
if (step === 'confirmation') {
+5 -3
View File
@@ -29,9 +29,11 @@ export function organizationConsole({ ctx, listOrgs, showOrg, resolveOrg, orgPat
result.push({ id: entry.id, label: entry.id, invalid: true, reason: publicReason(ctx, error) });
continue;
}
let busHealthy = false;
try { busHealthy = (await busStatus(ctx, entry.id)).healthy === true; } catch { /* offline metadata remains visible */ }
result.push({ id: entry.id, label: entry.id, theme: verified.shown.doc?.appearance?.theme ?? null, busHealthy });
// E2E6 P2-8: a bus with no host recorded (stopped by its owner, or never started) is "stopped",
// not a failure. A host that is recorded but does not answer stays plain unavailable.
let busHealthy = false, busStopped = false;
try { const s = await busStatus(ctx, entry.id); busHealthy = s?.healthy === true; busStopped = !busHealthy && s?.pid == null; } catch { /* offline metadata remains visible */ }
result.push({ id: entry.id, label: entry.id, theme: verified.shown.doc?.appearance?.theme ?? null, busHealthy, ...(busStopped ? { busStopped: true } : {}) });
}
return result;
}
+22 -9
View File
@@ -22,19 +22,25 @@
const r=await fetch(path(org,step),body===undefined?{cache:'no-store'}:{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
const value=await r.json();if(!r.ok)throw Error(guidance[value.error]??`The bus refused: ${value.error??'outcome unknown'}. Refresh before retrying.`);return value;
}
async function load(org) {try{states.set(org,await call(org,''));}catch(error){states.set(org,{error:error.message});}}
// E2E6 P1-1: a poll reloads this state; the last save's receipt and the open review list stay.
// A failed poll keeps the last good read only while the page holds the form (dirty, or a field has
// focus), so the held form is not dropped (review P3). A clean form shows the read error (re-check P3).
const held=org=>{const form=[...document.querySelectorAll('[data-autonomy-org]')].find(n=>n.dataset.autonomyOrg===org)?.querySelector('[data-autonomy-form]');return !!form&&(form.hasAttribute('data-live-dirty')||form.contains(document.activeElement));};
async function load(org) {const prev=states.get(org),keep={status:prev?.status??'',reviewOpen:prev?.reviewOpen===true};try{states.set(org,{...await call(org,''),...keep});}catch(error){states.set(org,prev&&!prev.error&&held(org)?{...prev,...keep}:{error:error.message,...keep});}}
function section(org) {
const s=states.get(org);
if(!s || s.error)return `<section class="panel" data-autonomy><h3>Autonomy</h3><p>${esc(s?.error??'Autonomy could not be read.')}</p></section>`;
const v=s.autonomy??{};
return `<section class="panel" data-autonomy data-autonomy-org="${esc(org)}"><h3>Autonomy</h3><p>Choose how closely you follow each class of decision. Hard waits, flagged grants and credentials always need a person.</p><form data-autonomy-form>
const v=s.autonomy??{},fk=k=>` data-focus-key="${esc(`autonomy-${k}:${org}`)}"`;
// The form carries the revision it was drawn from: a form a poll kept (workspace holdLive) saves
// against that revision, so it cannot overwrite a change it never showed.
return `<section class="panel" data-autonomy data-autonomy-org="${esc(org)}" data-live-key="${esc(`autonomy:${org}`)}"><h3>Autonomy</h3><p>Choose how closely you follow each class of decision. Hard waits, flagged grants and credentials always need a person.</p><form data-autonomy-form data-revision="${esc(JSON.stringify(s.revision??null))}">
<div class="autonomy-fields">${classes.map(k=>`<label>${labels[k]}<select name="${k}"${s.writable?'':' disabled'}>${levels.map(l=>`<option${s.levels[k]===l?' selected':''}>${l}</option>`).join('')}</select></label>`).join('')}</div>
<div class="autonomy-fields"><label>Reverse window (hours)<input type="number" name="reverseWindowHours" min="1" max="168" value="${v.reverseWindowHours??24}"${s.writable?'':' disabled'}></label><label>Authority resolver<input name="authorityResolver" maxlength="64" value="${esc(v.authorityResolver??'')}" autocomplete="off"${s.writable?'':' disabled'}></label></div>
<label class="autonomy-checkbox"><input name="notices" type="checkbox"${v.notices===true?' checked':''}${s.writable?'':' disabled'}> Console notices for decisions near the loop</label>
<label class="autonomy-checkbox"><input name="notices" type="checkbox"${fk('notices')}${v.notices===true?' checked':''}${s.writable?'':' disabled'}> Console notices for decisions near the loop</label>
<p>In the loop, the arbiter resolves and you confirm before work takes effect. Near the loop, work takes effect and you can reverse it within the window. Out of the loop, the trail records it without a notice.</p>
${s.writable?'<button class="btn" type="submit">Save autonomy</button>':'<p>Only the owner can change autonomy.</p>'}
<p data-autonomy-status role="status"></p></form>
${s.writable?`<details><summary>Review reversals before moving out of the loop</summary><p>Read the decision trail and its reversals before recording your review.</p>${classes.map(k=>`<button class="btn" type="button" data-autonomy-review="${k}">Record review: ${labels[k]}</button>`).join('')}</details>`:''}</section>`;
${s.writable?`<p><button class="btn" type="submit"${fk('save')}>Save autonomy</button> <button class="btn" type="button" data-autonomy-discard${fk('discard')}>Discard changes</button></p>`:'<p>Only the owner can change autonomy.</p>'}
<p data-autonomy-status role="status">${esc(s.status)}</p></form>
${s.writable?`<details data-autonomy-review-list${s.reviewOpen?' open':''}><summary${fk('review-list')}>Review reversals before moving out of the loop</summary><p>Read the decision trail and its reversals before recording your review.</p>${classes.map(k=>`<button class="btn" type="button" data-autonomy-review="${k}"${fk(`review-${k}`)}>Record review: ${labels[k]}</button>`).join('')}</details>`:''}</section>`;
}
function notice(d) {
if(!d?.autonomy)return '';
@@ -49,9 +55,16 @@
const autonomy=Object.fromEntries(classes.map(k=>[k,fields.get(k)]));autonomy.notices=fields.has('notices');autonomy.reverseWindowHours=Number(fields.get('reverseWindowHours'));
if(fields.get('authorityResolver'))autonomy.authorityResolver=fields.get('authorityResolver');
const button=form.querySelector('button[type="submit"]'),status=form.querySelector('[data-autonomy-status]');button.disabled=true;
try{const r=await call(org,'',{revision:s.revision,autonomy});s.revision=r.revision;s.autonomy=autonomy;s.levels=Object.fromEntries(classes.map(k=>[k,autonomy[k]]));status.textContent=`Saved. Receipt ${r.transaction}.`;}
catch(error){status.textContent=error.message;}finally{button.disabled=false;}
let base=s.revision;try{if(form.dataset.revision)base=JSON.parse(form.dataset.revision);}catch{/* drawn without one */}
try{const r=await call(org,'',{revision:base,autonomy});s.revision=r.revision;s.autonomy=autonomy;s.levels=Object.fromEntries(classes.map(k=>[k,autonomy[k]]));s.status=`Saved. Receipt ${r.transaction}.`;form.dataset.revision=JSON.stringify(r.revision);delete form.dataset.liveDirty;status.textContent=s.status;}
catch(error){s.status=error.message;status.textContent=s.status;}finally{button.disabled=false;}
});
// An edited form is dirty until it saves: holdLive keeps a dirty form through every poll.
const dirty=e=>{const form=e.target.closest?.('[data-autonomy-form]');if(form)form.dataset.liveDirty='1';};
document.addEventListener('input',dirty);document.addEventListener('change',dirty);
// Review P3: Discard drops the held form and draws the last read in its place.
document.addEventListener('click',e=>{const b=e.target.closest?.('[data-autonomy-discard]');const el=b?.closest('[data-autonomy-org]');if(!el||!states.get(el.dataset.autonomyOrg))return;const org=el.dataset.autonomyOrg;el.outerHTML=section(org);[...document.querySelectorAll('[data-autonomy-org]')].find(n=>n.dataset.autonomyOrg===org)?.querySelector('[data-autonomy-discard]')?.focus();});
document.addEventListener('toggle',e=>{if(!e.target.matches?.('[data-autonomy-review-list]'))return;const s=states.get(e.target.closest('[data-autonomy-org]')?.dataset.autonomyOrg);if(s)s.reviewOpen=e.target.open;},true);
document.addEventListener('click',async e=>{
const button=e.target.closest('[data-autonomy-confirm],[data-autonomy-reverse],[data-autonomy-review]');if(!button)return;
const section=button.closest('[data-autonomy-org]'),org=section.dataset.autonomyOrg,status=section.querySelector('[data-autonomy-status]');
+6
View File
@@ -1743,8 +1743,11 @@
// A route edit after the markup was prepared must win over that pending redraw.
if(path==='/settings/routing' && routeVersion!==routesEditVersion){render(false);return;}
const k = !navigated && (shown === route || samePage) ? keep() : null;
// E2E6 P1-1: an org-page editor in use keeps its node, draft and focus across the repaint.
const heldLive = k ? window.MosaicWorkspace?.holdLive?.($('bus-view')) : null;
unmountEditor();
$('bus-view').innerHTML = html; shown = route;
if (heldLive) window.MosaicWorkspace.graftLive($('bus-view'), heldLive);
syncAppearance(true);
if (path.startsWith('/docs/')) paintDoc();
if (path === '/organization') window.MosaicConsole?.attachBus(document.querySelector('[data-dashboard-bus]'));
@@ -1929,6 +1932,9 @@
};
configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(applyConfig).catch(() => {});
// 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.
document.addEventListener('mosaic-org-changed', e => { if (window.MosaicOrg && e.detail?.org === window.MosaicOrg.id && routeOf().split('?')[0] === '/organization') render(false); });
document.addEventListener('mosaic-config', e => { if (e.detail && typeof e.detail === 'object') { applyConfig(e.detail); if (window.MosaicOrg && routeOf().split('?')[0] === '/organization') render(false); } });
// The cross-organization pages ask for a repaint after a filter, sort or selection change,
// and report the inbox count across organizations for the sidebar.
+5 -2
View File
@@ -15,6 +15,9 @@
const inert = value => String(value ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u202a-\u202e\u2066-\u2069]/g,
c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
const txt = value => esc(inert(value));
// E2E6 P2-15: a message's `inline code` spans show as code, not as raw backticks. The text is escaped
// first; a pair of single backticks on one line then wraps its already-escaped contents in <code>.
const prose = value => txt(value).replace(/`([^`\n]+)`/g, '<code>$1</code>');
const states = new Map(), forms = new Map(), knownRequests = new Map();
const pinnedScrolls = new WeakMap(), settlingPins = new WeakMap();
const pageFlow = log => log.closest('.project-chat') && matchMedia('(max-width: 319px), (max-height: 540px)').matches;
@@ -161,12 +164,12 @@
function messageBody(m) {
if (m.from_role !== 'human' && typeof m.body === 'string' && m.body.startsWith('The turn failed: ')) return `<div class="chat-body">The assistant couldn't reply. Try again, or check Agents.<div class="small">Details: ${txt(m.body.slice('The turn failed: '.length))}</div></div>`;
const r = renderReply?.(m.body, { check: m.check ?? null, name: m.from_role, changed: changedBy(m.check) });
if (r?.kind === 'reply') return `<div class="chat-body">${txt(r.text)}</div>${r.citations.length ? cites(r.citations) : ''}`;
if (r?.kind === 'reply') return `<div class="chat-body">${prose(r.text)}</div>${r.citations.length ? cites(r.citations) : ''}`;
if (r?.kind === 'notice') {
const v = reverts.get(r.commit) ?? {};
return `<div class="chat-body chat-notice"><span>${txt(r.text)}</span> ${current.archiveUnknown || current.projectArchived || window.MosaicOrg ? '' : `<button class="btn" type="button" data-revert="${esc(r.commit)}"${disabled(v.pending)}>Revert</button>`} <span class="chat-revert-receipt" role="status">${txt(v.receipt ?? '')}</span></div>`;
}
return `<div class="chat-body">${txt(m.body)}</div>`;
return `<div class="chat-body">${prose(m.body)}</div>`;
}
const ago = iso => {
const minutes = Math.floor((Date.now() - Date.parse(iso)) / 60000);
+1
View File
@@ -6,6 +6,7 @@
.org-field-err{display:block;font-size:13px;color:var(--danger);margin:4px 0 0}
.org-field-err code,.org-errors code{overflow-wrap:anywhere}
.org-verb code{overflow-wrap:normal;word-break:normal}
.cmd-word{display:inline-block;max-width:100%;overflow-wrap:anywhere}
.org-start-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:24px;align-items:start}
.org-inline{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 8px;align-items:center}
.org-inline label{grid-column:1/-1;font-size:14px;font-weight:600;color:var(--strong)}
+10 -7
View File
@@ -4,6 +4,9 @@
(() => {
const $ = id => document.getElementById(id);
const txt = v => String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
// E2E6 minor: a command shown as "Same as" wraps between words, never inside a flag such as
// --if-revision. Each word is an inline-block; one longer than the line still breaks inside itself.
const cmd = v => `<code>${String(v ?? '').split(' ').map(w => `<span class="cmd-word">${txt(w)}</span>`).join(' ')}</code>`;
// #1873 P1-6: the remote check a repository receipt records, in one plain line.
const CHECKED = { reachable: 'reachable', 'needs-credential': 'needs a credential', unreachable: 'unreachable' };
const checkLine = r => `Remote check: ${CHECKED[r.check.result] ?? 'unknown'}, ${r.check.message}. Origin: ${r.origin ?? 'none'}.`;
@@ -253,7 +256,7 @@
$('org-archive').hidden = mode !== 'edit' || state !== 'active'; $('org-archived').hidden = mode !== 'edit' || state !== 'archived';
$('org-restore-verb').textContent = state === 'archived' ? `Same as mosaic org restore ${id} --if-revision ${revision}` : '';
$('org-editor-title').textContent = mode === 'create' ? `New organization: ${id}` : `Organization: ${id}`;
$('org-editor-verb').innerHTML = verb ? `Same as <code>${txt(verb)}</code>` : '';
$('org-editor-verb').innerHTML = verb ? `Same as ${cmd(verb)}` : '';
$('org-save').textContent = mode === 'create' ? 'Save organization' : 'Save changes';
$('org-errors').hidden = true;
renderForm(); renderJSON(true);
@@ -318,7 +321,7 @@
{ title: 'Initialize project records', verb: `mosaic project init ${id} ${project}` },
];
const list = $('org-quick-steps');
list.innerHTML = steps.map((s, i) => `<li class="panel org-step" data-quick-step="${i}" tabindex="-1"><div class="org-step-head"><h4>${txt(s.title)}</h4><span class="org-pill">Not started</span></div><p class="org-verb">Same as <code>${txt(s.verb)}</code></p><p class="org-field-err" role="alert"></p></li>`).join('');
list.innerHTML = steps.map((s, i) => `<li class="panel org-step" data-quick-step="${i}" tabindex="-1"><div class="org-step-head"><h4>${txt(s.title)}</h4><span class="org-pill">Not started</span></div><p class="org-verb">Same as ${cmd(s.verb)}</p><p class="org-field-err" role="alert"></p></li>`).join('');
const run = $('org-quick-run'); run.disabled = true; $('org-quick-open').disabled = true; $('org-advanced-open').disabled = true; $('org-preset').disabled = true; $('org-thinking').disabled = true; banner('');
let current = 0;
try {
@@ -404,7 +407,7 @@
}
function showReceipt(title, body) {
const box = $('org-receipt');
box.innerHTML = `<h3 id="org-receipt-title">${txt(title)}</h3><p>Same as <code>${txt(body.verb)}</code></p><pre class="org-pre">${txt(JSON.stringify(body.receipt, null, 2))}</pre>`;
box.innerHTML = `<h3 id="org-receipt-title">${txt(title)}</h3><p>Same as ${cmd(body.verb)}</p><pre class="org-pre">${txt(JSON.stringify(body.receipt, null, 2))}</pre>`;
box.hidden = false; status(`${title}. Receipt shown.`);
}
async function refreshList() {
@@ -454,7 +457,7 @@
const c = st.choice.get(key) || {}, remote = c.mode === 'remote';
return `<li class="panel org-step org-step-${cls}" data-step="${txt(key)}" aria-labelledby="org-step-${i}-title">
<div class="org-step-head"><h4 id="org-step-${i}-title"><span class="org-step-n">${i + 1}</span> ${txt(title)}</h4><span class="org-pill ${cls}">${txt(stateLabel)}</span></div>
<p class="org-verb">Same as <code>${txt(s.verb || '')}</code></p>
<p class="org-verb">Same as ${cmd(s.verb || '')}</p>
${s.message ? `<p class="org-help">${txt(s.message)}</p>` : ''}
${key.startsWith('project:') && s.receipt?.check ? `<p class="org-help" data-remote-check>${txt(checkLine(s.receipt))}</p>` : ''}
${s.lastError ? `<p class="org-field-err" role="alert">${txt(s.lastError)}</p>` : ''}
@@ -570,7 +573,7 @@
: `The bus did not start: ${bus.message || 'no reason was given.'}`;
const offer = (!known || bus.outcome !== 'done') && canWrite;
const box = $('org-archive-success');
box.innerHTML = `<p>Organization restored. ${txt(line)}</p>${known && bus.verb ? `<p class="org-verb">Same as <code>${txt(bus.verb)}</code></p>` : ''}${offer ? `<p><button type="button" data-restore-bus="${txt(id)}">Restart bus</button> <span role="status" data-restore-bus-receipt></span></p>` : ''}`;
box.innerHTML = `<p>Organization restored. ${txt(line)}</p>${known && bus.verb ? `<p class="org-verb">Same as ${cmd(bus.verb)}</p>` : ''}${offer ? `<p><button type="button" data-restore-bus="${txt(id)}">Restart bus</button> <span role="status" data-restore-bus-receipt></span></p>` : ''}`;
box.hidden = false; box.focus();
}
// Restart bus for the restored organization, the same route as the dashboard's (console-restart.js).
@@ -620,13 +623,13 @@
if (r.status !== 200) {
$('org-delete-error').textContent = `The outcome is unknown. ${r.body.message || `Deletion stopped (${r.body.error || r.status}).`} Inspect the receipt and resume with the CLI.`;
const receipt = r.body.receipt;
$('org-delete-progress').innerHTML = `<p>Same as <code>${txt(r.body.verb || `mosaic org delete ${id} --confirm ${id}`)}</code></p><ol class="org-steps"><li class="panel org-step">Bus: ${receipt?.bus ? 'Done' : r.body.step === 'bus' ? 'Stopped' : 'Not started'}</li><li class="panel org-step">Local files: ${receipt?.moves?.length || 0} moved; ${txt(r.body.step || 'deletion')} stopped</li><li class="panel org-step">Registry: ${receipt?.registry === 'removed' ? 'Done' : 'Not complete'}</li></ol>`;
$('org-delete-progress').innerHTML = `<p>Same as ${cmd(r.body.verb || `mosaic org delete ${id} --confirm ${id}`)}</p><ol class="org-steps"><li class="panel org-step">Bus: ${receipt?.bus ? 'Done' : r.body.step === 'bus' ? 'Stopped' : 'Not started'}</li><li class="panel org-step">Local files: ${receipt?.moves?.length || 0} moved; ${txt(r.body.step || 'deletion')} stopped</li><li class="panel org-step">Registry: ${receipt?.registry === 'removed' ? 'Done' : 'Not complete'}</li></ol>`;
status('Deletion stopped.'); return;
}
if (!verifiedReply(r, 'delete')) throw Error('unknown deletion outcome');
editorDirty = false; setupDirty.clear(); $('org-editor').hidden = true; $('org-setup').hidden = true; $('org-delete').hidden = true; $('org-archive').hidden = true; $('org-archived').hidden = true;
const box = $('org-delete-success');
box.innerHTML = `<h3>Organization deleted</h3><p>Same as <code>${txt(r.body.verb)}</code></p><ol class="org-steps"><li class="panel org-step org-step-done">Bus stopped or was already stopped: Done</li><li class="panel org-step org-step-done">Local files moved to the deleted folder: Done</li><li class="panel org-step org-step-done">Organization removed from the list: Done</li></ol><details><summary>Receipt</summary><pre class="org-pre">${txt(JSON.stringify(r.body.receipt, null, 2))}</pre></details>`;
box.innerHTML = `<h3>Organization deleted</h3><p>Same as ${cmd(r.body.verb)}</p><ol class="org-steps"><li class="panel org-step org-step-done">Bus stopped or was already stopped: Done</li><li class="panel org-step org-step-done">Local files moved to the deleted folder: Done</li><li class="panel org-step org-step-done">Organization removed from the list: Done</li></ol><details><summary>Receipt</summary><pre class="org-pre">${txt(JSON.stringify(r.body.receipt, null, 2))}</pre></details>`;
const rows = await refreshOrganizations(); box.hidden = false; box.focus(); status('Organization deleted.');
switchIfCurrentRemoved(id, rows);
} catch { $('org-delete-error').textContent = 'The outcome is unknown. Inspect the receipt and resume with the CLI.'; $('org-delete-progress').innerHTML = `<p>Resume with <code>${txt(`mosaic org delete ${id} --confirm ${id}`)}</code></p>`; }
+4
View File
@@ -3,6 +3,10 @@
:is(.pack-row,.pack-hire,.wf-item){border-top:1px solid var(--border);padding:12px 0;overflow-wrap:anywhere}
.pack-preview{border:1px solid var(--border);border-radius:var(--r);padding:12px;margin:12px 0;overflow-wrap:anywhere}
.pack-preview .ws-tbl{overflow-x:auto}
/* E2E6 P2-3: the preview's overflow-wrap:anywhere let the table squeeze words ("Workfl ow"). Cells wrap
at word breaks; only a path, which has none, may break anywhere. */
.pack-preview .ws-tbl :is(td,th){overflow-wrap:normal;word-break:normal}
.pack-preview .ws-tbl td code{overflow-wrap:anywhere}
.pack-nohire,.wf-missing,.wf-note{color:var(--danger)}
.wf-missing{font-size:.9em}
.wf-steps{margin:6px 0;padding-left:22px}.wf-steps li{margin:4px 0}
+19 -6
View File
@@ -39,8 +39,11 @@
if (!r.ok) throw Error(guidance[value?.error] ?? `The bus refused: ${value?.error ?? 'outcome unknown'}.`);
return value;
}
// E2E6 P1-1, P2-2: a poll reloads the list but keeps the open preview, the project picked for each
// pack and the last receipt. A kept preview applies by its digest, so a stale one is refused by the bus.
async function load(org) {
const s = { packs: null, writable: false, error: null, projects: null, preview: null, status: '' };
const prev = states.get(org);
const s = { packs: null, writable: false, error: null, projects: null, preview: prev?.preview ?? null, status: prev?.status ?? '', picks: prev?.picks ?? {} };
try {
const value = await call(`${base(org)}/packs`);
s.packs = Array.isArray(value?.packs) ? value.packs.filter(p => p && typeof p === 'object' && p.pack && typeof p.pack === 'object') : [];
@@ -81,36 +84,42 @@
const hires = Array.isArray(v.hires) && v.hires.length ? `<h4>Hires</h4><ul class="pack-hires">${v.hires.map(hireBlock).join('')}</ul>` : '<p class="note">The pack proposes no hires.</p>';
const skills = v.skills?.notEnabled?.length ? `<p data-pack-skills>Skills this machine has not enabled: ${v.skills.notEnabled.map(n => `<code>${esc(n)}</code>`).join(', ')}</p>` : '';
const chief = v.chief === false ? '<p class="note">The organization has no chief, so no line is compared against one.</p>' : '';
const apply = s.writable ? `<button class="btn" type="button" data-pack-apply>Apply this plan</button>` : '<p class="note">This Console was started without send. Packs can be previewed, not applied.</p>';
const apply = s.writable ? `<button class="btn" type="button" data-pack-apply data-focus-key="pack-apply">Apply this plan</button>` : '<p class="note">This Console was started without send. Packs can be previewed, not applied.</p>';
return `<div class="pack-preview" data-pack-preview="${esc(p.pack)}"><h4>Preview: ${esc(v.pack?.label ?? p.pack)}${v.project ? ` <span class="muted">for project ${esc(v.project)}</span>` : ''}</h4>${items}${hires}${skills}${chief}${apply}</div>`;
}
function packRow(org, s, entry) {
const pack = entry.pack;
const project = pack.scope === 'project'
? s.projects === null ? '<p class="note">The project list could not be read.</p>' : s.projects.length
? `<label>Project<select data-pack-project>${s.projects.map(id => `<option value="${esc(id)}">${esc(id)}</option>`).join('')}</select></label>`
? `<label>Project<select data-pack-project data-focus-key="${esc(`pack-project:${org}:${entry.id}`)}">${s.projects.map(id => `<option value="${esc(id)}"${s.picks[entry.id] === id ? ' selected' : ''}>${esc(id)}</option>`).join('')}</select></label>`
: '<p class="note">No code project to apply this pack to.</p>'
: '';
const canPreview = pack.scope !== 'project' || (s.projects && s.projects.length);
return `<li class="pack-row" data-pack="${esc(entry.id)}"><strong>${esc(pack.label ?? entry.id)}</strong> <span class="muted"><code>${esc(entry.id)}</code> ${esc(pack.version)} · ${esc(entry.origin)} · ${pack.scope === 'project' ? 'project pack' : 'organization pack'}</span>
<p>${esc(pack.description)}</p>${project}${canPreview ? '<button class="btn" type="button" data-pack-preview-button>Preview</button>' : ''}</li>`;
<p>${esc(pack.description)}</p>${project}${canPreview ? `<button class="btn" type="button" data-pack-preview-button data-focus-key="${esc(`pack-preview:${org}:${entry.id}`)}">Preview</button>` : ''}</li>`;
}
function section(org) {
const s = states.get(org);
if (!s || s.error) return `<section class="panel" data-packs data-packs-org="${esc(org)}"><h3>Starter packs</h3><p>${esc(s?.error ?? 'Starter packs could not be read.')}</p></section>`;
if (!s || s.error) return `<section class="panel" data-packs data-packs-org="${esc(org)}" data-live-key="${esc(`packs:${org}`)}"><h3>Starter packs</h3><p>${esc(s?.error ?? 'Starter packs could not be read.')}</p></section>`;
const list = s.packs.length ? `<ul class="pack-list">${s.packs.map(e => packRow(org, s, e)).join('')}</ul>` : '<p class="note">No starter packs are installed.</p>';
return `<section class="panel" data-packs data-packs-org="${esc(org)}"><h3>Starter packs</h3><p>A pack adds roles, a board and workflows to the organization. Preview shows what it would change; apply adds only what is missing, and a hire it proposes is only raised by the chief, as a decision.</p>${list}${previewBlock(s)}<p role="status" data-packs-status>${esc(s.status)}</p></section>`;
return `<section class="panel" data-packs data-packs-org="${esc(org)}" data-live-key="${esc(`packs:${org}`)}"><h3>Starter packs</h3><p>A pack adds roles, a board and workflows to the organization. Preview shows what it would change; apply adds only what is missing, and a hire it proposes is only raised by the chief, as a decision.</p>${list}${previewBlock(s)}<p role="status" data-packs-status>${esc(s.status)}</p></section>`;
}
function redraw(org) {
const el = [...document.querySelectorAll('[data-packs-org]')].find(n => n.dataset.packsOrg === org);
if (el) el.outerHTML = section(org);
}
document.addEventListener('change', e => {
const select = e.target.closest?.('[data-pack-project]');
const s = select && states.get(select.closest('[data-packs-org]')?.dataset.packsOrg);
if (s) s.picks[select.closest('[data-pack]').dataset.pack] = select.value;
});
document.addEventListener('click', async e => {
const button = e.target.closest('[data-pack-preview-button],[data-pack-apply]');
if (!button) return;
const root = button.closest('[data-packs-org]'), org = root?.dataset.packsOrg, s = states.get(org);
if (!s) return;
button.disabled = true;
let applied = false;
try {
if (button.hasAttribute('data-pack-preview-button')) {
const row = button.closest('[data-pack]'), pack = row.dataset.pack, project = row.querySelector('[data-pack-project]')?.value;
@@ -123,10 +132,14 @@
const added = Array.isArray(r.added) && r.added.length ? r.added.join(', ') : 'nothing new';
s.preview = null;
s.status = `Applied. Added ${added}. Proposed hires are recorded for the chief to raise as decisions.`;
applied = true;
}
} catch (error) { s.status = error.message; }
// A preview the page cannot draw says so, instead of leaving the button disabled with nothing shown.
try { redraw(org); } catch { s.preview = null; s.status = 'The preview could not be shown.'; redraw(org); }
// An apply changes the workflows, the board and the roles too: the page and any pane showing this
// organization read every section again. The receipt above is kept in state, so it survives that read.
if (applied) document.dispatchEvent(new CustomEvent('mosaic-org-changed', { detail: { org } }));
});
window.MosaicPacks = { load, section };
})();
+10
View File
@@ -60,6 +60,16 @@ html[data-organizations] .pane-body .project-columns .project-section-title h2,h
html[data-organizations] .pane-body .project-metrics{grid-template-columns:repeat(4,minmax(0,1fr))}
html[data-organizations] .pane-body .project-metrics a:not(:nth-child(4)){border-right:1px solid var(--line)}
}
/* E2E6 P2-9: a dashboard in a pane sizes from the pane, not the window. Half a 1440 window left the
Settings column too narrow for its values (clipped to 1-2 characters): the split stacks below 900px
of pane, and a narrow Settings list puts each value on its own line, as at phone width. */
@container (max-width:899px){
html[data-organizations] .pane-body .ws-split{grid-template-columns:minmax(0,1fr)}
}
@container (max-width:559px){
html[data-organizations] .pane-body .ws-cfg li{grid-template-columns:18px minmax(0,1fr) auto auto}
html[data-organizations] .pane-body .ws-cfg .v{grid-column:2/-1;grid-row:2}
}
/* The side-by-side page has its own bar; the page breadcrumb would name only one pane. */
body:has(#bus-view > .sbs) #breadcrumbs{display:none}
html[data-organizations] .content:has(> #bus-view > .sbs) > .org-cue{margin:8px 14px}
+8 -1
View File
@@ -176,9 +176,11 @@
function paintBody(p, html) {
const { body } = els(p);
if (p.html === html && body.childElementCount) return;
const kept = capture(p, body);
const kept = capture(p, body), live = window.MosaicWorkspace?.holdLive?.(body);
body.innerHTML = html; p.html = html; p.project = null;
rewrite(p, body);
// E2E6 P1-1: an editor in use keeps its node, draft and focus; its ids were rewritten when first painted.
if (live && window.MosaicWorkspace.graftLive(body, live)) kept.focus = null;
restore(p, body, kept);
}
@@ -470,6 +472,11 @@
void orgList(true).then(rows => { if (rows) listCache = rows; });
for (const p of mounted.panes) void drawPane(p, { fresh: true });
}
// E2E6 P2-2: a pane showing the organization a write changed reads it again.
document.addEventListener('mosaic-org-changed', e => {
if (!mounted) return;
for (const p of mounted.panes) if (p.org === e.detail?.org) void drawPane(p, { fresh: true });
});
// The list the tabs draw from follows each fresh read of the organization list.
document.addEventListener('mosaic-org-list', e => { if (Array.isArray(e.detail?.rows)) listCache = e.detail.rows; });
+15 -2
View File
@@ -86,7 +86,17 @@
.breadcrumbs:has(.project-crumb-current) a::after { content:''; position:absolute; top:0; bottom:0; left:50%; width:max(100%,44px); transform:translateX(-50%); }
.chat-area:has(.project-header),.chat-area:has(.project-list-head) { max-width:none; margin-inline:0; }
.project-tabs { scrollbar-width:none; } .project-tabs::-webkit-scrollbar { display:none; }
@media (max-width:699px) { .project-tabs { mask-image:linear-gradient(to right, #000 calc(100% - 22px), transparent 100%); } }
/* E2E6 P2-12: a strip wider than the screen fades out over its last 48px, so a cut tab reads as "more
this way". Where scroll timelines exist the fade runs only while there is more to scroll: a strip that
fits has an inactive timeline and keeps the 0px initial value (review P3), and the fade goes once the
strip is scrolled to its end. Without scroll timelines the 48px fade is static. */
@property --tabs-fade { syntax:'<length>'; inherits:false; initial-value:0px; }
@keyframes project-tabs-cue { 0%, 92% { --tabs-fade:48px; } 100% { --tabs-fade:0px; } }
@media (max-width:699px) {
.project-tabs { mask-image:linear-gradient(to right, #000 calc(100% - var(--tabs-fade)), transparent 100%); }
@supports (animation-timeline: scroll()) { .project-tabs { animation:project-tabs-cue linear both; animation-timeline:scroll(self inline); } }
@supports not (animation-timeline: scroll()) { .project-tabs { --tabs-fade:48px; } }
}
.project-chip { white-space:nowrap; flex:none; }
@media(max-width:699px) { .project-stepper span { white-space:nowrap; font-size:10.5px; padding:4px 1px; }.project-work-head { align-items:flex-start; } }
@@ -336,7 +346,8 @@ body:has(.chat-area .project-header):has(.project-chat) .breadcrumbs { margin-bo
/* Header actions use the specified compact widths while retaining 44px targets. */
.project-primary-action { box-sizing:border-box; flex:none; }
@media (min-width:1200px) {
.project-header:not([data-project-conversation="true"]) { width:calc(100vw - 270px); }
/* E2E6 P2-11: 100vw counts the page scrollbar; max-width keeps the header inside its column. */
.project-header:not([data-project-conversation="true"]) { width:calc(100vw - 270px); max-width:100%; box-sizing:border-box; }
.project-header:not([data-project-conversation="true"]) .project-actions { align-self:flex-start; margin-top:4px; }
.project-primary-action:nth-of-type(1) { width:130px; }
.project-primary-action:nth-of-type(2) { width:88px; }
@@ -504,3 +515,5 @@ body:has(.chat-area .project-header):has(.project-chat) .breadcrumbs { margin-bo
/* Keep the action in flow when a read error occupies the top of the page. */
@media (min-width:700px) { .chat-area:has(.chat-error) .project-create:not([open]) { position:static; margin:12px 0; } }
/* E2E6 P2-15: inline code in a chat bubble keeps the bubble's text colour on a light tint. */
.project-chat .chat-body code { font-size:.92em; padding:0 .3em; border-radius:4px; background:color-mix(in srgb, currentColor 12%, transparent); color:inherit; }
+12
View File
@@ -301,6 +301,11 @@ button:disabled,[aria-disabled=true]{cursor:not-allowed}
#agents-table tbody th,#routes-table tbody th{text-transform:none;font-size:14px;font-weight:600;color:var(--strong)}
@media(max-width:759px){.settings-content .routing-panel{border:0;background:transparent;padding:0}.routing-panel .table-wrap{border:0;overflow:visible}.routing-panel #routes-table tr{display:grid;grid-template-columns:1fr 1fr;gap:0 10px;background:var(--surface)}.routing-panel #routes-table th,.routing-panel #routes-table td:first-of-type,.routing-panel #routes-table .fallback-cell:first-of-type{grid-column:1/-1}.routing-panel #routes-table td:nth-of-type(4){grid-column:1/-1}.routing-panel #routes-table td:nth-of-type(4)::before{content:'Fallback: None'}.routing-panel #routes-table .fallback-open td:nth-of-type(4)::before{content:'Fallback harness'}}
.settings-content label:has(>input[type=checkbox]){display:inline-flex;align-items:center;gap:8px;min-height:44px;padding:0 8px}
/* E2E6 minor: the harness checkboxes sit side by side, each keeping its 44px target, instead of one per
50px row. Save and the receipt keep their own full-width rows. */
.settings-content fieldset.harness-control{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px}
.settings-content fieldset.harness-control>legend{flex-basis:100%}
.settings-content fieldset.harness-control>:is(button,p){flex-basis:100%}
.settings-content :is(.instance-form,.level-form) textarea,.settings-content .assistant-form textarea{display:block;width:100%}
.route-definition{display:none}.settings-spinner{display:inline-block;width:16px;height:16px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;vertical-align:middle;animation:settings-spin .8s linear infinite}@keyframes settings-spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.settings-spinner{animation:none}}
@media(max-width:759px){#routes-table .route-definition{display:inline;float:right;color:var(--muted);font-size:12px;font-weight:400}}
@@ -444,6 +449,9 @@ html[data-organizations] .ws-btn:disabled,html[data-organizations] .ws-busctl [d
html[data-organizations] .ws-btn svg.i{width:15px;height:15px}
/* The live bus restart is console-restart.js's own button; it carries its icon through repaints,
so the icon stays on the button however wide the receipt below it is. */
/* E2E6 P2-13: the disabled pair (a pane, or a Console without the trail) sits in a flex row too. As two
inline-flex boxes on one line, Restart bus took its baseline from its glyph and sat 2.6px above Stop bus. */
html[data-organizations] [data-dashboard-bus]:not(.ws-busctl){display:inline-flex;flex-wrap:wrap;align-items:center;gap:6px}
html[data-organizations] .ws-busctl{display:inline-flex;flex-direction:column;align-items:flex-end}
html[data-organizations] .ws-busctl .bus-restart{display:inline-flex;flex-direction:column;align-items:flex-end;gap:4px}
html[data-organizations] .ws-busctl .bus-buttons{display:inline-flex;flex-wrap:wrap;gap:6px;justify-content:flex-end}
@@ -477,6 +485,10 @@ html[data-organizations] .ws-panel.tight{padding:12px 14px}
html[data-organizations] .ws-split{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(0,1fr);gap:16px;align-items:start}
html[data-organizations] .ws-split{margin-top:22px}
html[data-organizations] .ws-split>section>.ws-sec-h:first-child{margin-top:0}
/* E2E6 P1-3: the long editors (Autonomy, Packs, Workflows) span the page below the split, so neither
column runs screens past the other at 1440. */
html[data-organizations] .ws-wide{display:grid;grid-template-columns:minmax(0,1fr);gap:16px;margin-top:22px}
html[data-organizations] .ws-wide>.panel{margin:0}
/* Organization dashboard (sample 03). */
html[data-organizations] .ws-orghead{display:flex;flex-wrap:wrap;align-items:center;gap:14px;margin-bottom:14px}
+25 -10
View File
@@ -43,9 +43,17 @@
s.data = value;
s.known = new Set([...(value.roles.definitions || []), ...(value.roles.templates || []), 'human']);
}
// E2E6 P1-1: a poll reloads the data but keeps the page's own state: an open editor and its draft,
// a pending delete confirmation and the last receipt.
async function load(org) {
const s = { data: null, known: new Set(), error: null, editing: null, confirm: null, status: '' };
try { await read(org, s); } catch (error) { s.error = error.message; }
const prev = states.get(org);
const s = { data: null, known: new Set(), error: null, editing: prev?.editing ?? null, confirm: prev?.confirm ?? null, status: prev?.status ?? '' };
try { await read(org, s); } catch (error) {
// Review P3: a failed poll while an editor is open keeps the last good read, so the fresh section
// still carries its data-live-key and graftLive puts the held section, editor and focus back.
if (prev?.editing && prev.data) Object.assign(s, { data: prev.data, known: prev.known });
else s.error = error.message;
}
states.set(org, s);
}
const scopes = s => [['', 'Organization', s.data.workflows], ...Object.keys(s.data.projects).sort().map(p => [p, `Project ${p}`, s.data.projects[p]])];
@@ -60,8 +68,8 @@
const steps = Array.isArray(w?.steps) ? w.steps : [];
const confirm = s.confirm && s.confirm.scope === scope && s.confirm.name === name;
const actions = !s.data.writable ? '' : confirm
? `<p>Delete this workflow? <button class="btn" type="button" data-wf-delete-yes>Delete</button> <button class="btn" type="button" data-wf-delete-no>Keep</button></p>`
: `<p><button class="btn" type="button" data-wf-edit>Edit</button> <button class="btn" type="button" data-wf-delete>Delete</button></p>`;
? `<p>Delete this workflow? <button class="btn" type="button" data-wf-delete-yes data-focus-key="${esc(`wf-yes:${scope}:${name}`)}">Delete</button> <button class="btn" type="button" data-wf-delete-no data-focus-key="${esc(`wf-no:${scope}:${name}`)}">Keep</button></p>`
: `<p><button class="btn" type="button" data-wf-edit data-focus-key="${esc(`wf-edit:${scope}:${name}`)}">Edit</button> <button class="btn" type="button" data-wf-delete data-focus-key="${esc(`wf-delete:${scope}:${name}`)}">Delete</button></p>`;
return `<li class="wf-item" data-wf="${esc(name)}" data-wf-scope="${esc(scope)}"><strong data-wf-title>${esc(w?.title || name)}</strong> <span class="muted"><code>${esc(name)}</code></span>
${w?.description ? `<p>${esc(w.description)}</p>` : ''}<ol class="wf-steps">${steps.map(st => stepItem(s, st)).join('')}</ol>${actions}</li>`;
}
@@ -81,7 +89,7 @@
const scopeField = e.isNew
? `<label>Where<select name="scope">${scopes(s).map(([v, l]) => `<option value="${esc(v)}"${e.scope === v ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label><label>Name<input name="name" maxlength="64" value="${esc(e.name)}" autocomplete="off" placeholder="lowercase letters, digits, dashes"></label>`
: `<p>${e.scope ? `Project ${esc(e.scope)}` : 'Organization'}, <code>${esc(e.name)}</code></p>`;
return `<form class="wf-editor" data-wf-editor><h4>${e.isNew ? 'New workflow' : 'Edit workflow'}</h4>${scopeField}
return `<form class="wf-editor" data-wf-editor data-live-hold><h4>${e.isNew ? 'New workflow' : 'Edit workflow'}</h4>${scopeField}
<label>Title<input name="title" maxlength="120" value="${esc(e.draft.title)}" autocomplete="off"></label>
<label>Description<textarea name="description" maxlength="1000" rows="2">${esc(e.draft.description)}</textarea></label>
${e.draft.steps.map((st, i) => stepRow(s, st, i)).join('')}
@@ -95,8 +103,8 @@
const names = Object.keys(flows || {}).sort();
return `<div class="wf-scope" data-wf-scope-list="${esc(scope)}"><h4>${esc(label)}</h4>${names.length ? `<ul class="wf-list">${names.map(n => workflowItem(org, s, scope, n, flows[n])).join('')}</ul>` : '<p class="note">No workflows.</p>'}</div>`;
}).join('');
const add = s.data.writable ? (s.editing ? '' : '<p><button class="btn" type="button" data-wf-new>New workflow</button></p>') : '<p class="note">Only the owner, on a Console started with send, can change workflows.</p>';
return `<section class="panel" data-workflows data-workflows-org="${esc(org)}"><h3>Workflows</h3><p>A workflow lists the steps a task goes through, the state each step puts it in and the roles that do it.</p>${lists}${add}${editor(s)}<p role="status" data-workflows-status>${esc(s.status)}</p></section>`;
const add = s.data.writable ? (s.editing ? '' : `<p><button class="btn" type="button" data-wf-new data-focus-key="${esc(`wf-new:${org}`)}">New workflow</button></p>`) : '<p class="note">Only the owner, on a Console started with send, can change workflows.</p>';
return `<section class="panel" data-workflows data-workflows-org="${esc(org)}" data-live-key="${esc(`workflows:${org}`)}"><h3>Workflows</h3><p>A workflow lists the steps a task goes through, the state each step puts it in and the roles that do it.</p>${lists}${add}${editor(s)}<p role="status" data-workflows-status>${esc(s.status)}</p></section>`;
}
function redraw(org) {
const el = [...document.querySelectorAll('[data-workflows-org]')].find(n => n.dataset.workflowsOrg === org);
@@ -124,10 +132,10 @@
if (!s?.data) return;
const item = button.closest('[data-wf]'), scope = item?.dataset.wfScope ?? '', name = item?.dataset.wf;
if (button.hasAttribute('data-wf-new')) {
s.editing = { isNew: true, scope: '', name: '', draft: { title: '', description: '', steps: [{ title: '', state: 'todo', roles: '' }] } };
s.editing = { isNew: true, revision: s.data.revision, scope: '', name: '', draft: { title: '', description: '', steps: [{ title: '', state: 'todo', roles: '' }] } };
} else if (button.hasAttribute('data-wf-edit')) {
const w = flowsIn(s, scope)?.[name];
s.editing = { isNew: false, scope, name, draft: { title: w?.title ?? '', description: w?.description ?? '', steps: (w?.steps ?? []).map(st => ({ title: st.title ?? '', state: st.state ?? 'todo', roles: (st.roles ?? []).join(', ') })) } };
s.editing = { isNew: false, revision: s.data.revision, scope, name, draft: { title: w?.title ?? '', description: w?.description ?? '', steps: (w?.steps ?? []).map(st => ({ title: st.title ?? '', state: st.state ?? 'todo', roles: (st.roles ?? []).join(', ') })) } };
} else if (button.hasAttribute('data-wf-cancel')) {
s.editing = null;
} else if (button.hasAttribute('data-wf-step-add')) {
@@ -148,7 +156,10 @@
}
redraw(org);
});
// Every keystroke goes back into the draft, so any repaint draws what was typed.
document.addEventListener('input', ev => {
const form = ev.target.closest('[data-wf-editor]'), root = form?.closest('[data-workflows-org]');
if (form && root) { const st = states.get(root.dataset.workflowsOrg); if (st?.editing) capture(root, st); }
const field = ev.target.closest('[data-wf-edit-step] [name="roles"]');
if (!field) return;
const s = states.get(field.closest('[data-workflows-org]')?.dataset.workflowsOrg);
@@ -168,6 +179,9 @@
if (!NAME.test(e.name)) { s.status = NAME_RULE; redraw(org); return; }
// A save is a set: a new workflow under a name the scope already holds would replace it without a word.
if (e.isNew && Object.hasOwn(flowsIn(s, e.scope) ?? {}, e.name)) { s.status = `A workflow named ${e.name} already exists here. Edit it instead.`; redraw(org); return; }
// E2E6 P2-1: a step role that names no current role is named before the bus refuses it generically.
const unknown = e.draft.steps.flatMap((st, i) => st.roles.split(',').map(r => r.trim()).filter(r => r && !s.known.has(r)).map(r => [i + 1, r]));
if (unknown.length) { s.status = `${unknown.map(([i, r]) => `Step ${i}: ${r}`).join('; ')} ${unknown.length === 1 ? 'names' : 'name'} no current role. Use an organization role, a role template or human.`; redraw(org); return; }
const workflow = {
...(e.draft.title.trim() ? { title: e.draft.title.trim() } : {}),
...(e.draft.description.trim() ? { description: e.draft.description.trim() } : {}),
@@ -176,7 +190,8 @@
const button = ev.target.querySelector('button[type="submit"]');
button.disabled = true;
try {
await call(`${base(org)}/workflows/${encodeURIComponent(e.name)}`, 'POST', { revision: s.data.revision, workflow, ...target(s, e.scope) });
// The revision the editor opened on: a change made since then is a revision-conflict, not overwritten.
await call(`${base(org)}/workflows/${encodeURIComponent(e.name)}`, 'POST', { revision: e.revision ?? s.data.revision, workflow, ...target(s, e.scope) });
s.editing = null;
await reload(org, s, `Saved ${e.name}.`);
} catch (error) { s.status = error.message; }
+6 -1
View File
@@ -5,7 +5,12 @@
const read = async path => {
const response = await fetch(path, { cache: 'no-store', redirect: 'manual' });
// The status travels with the failure, so a caller can tell a refusal (403) from a bus that is down.
if (!response.ok || response.type === 'opaqueredirect') { const error = new Error('Read unavailable'); error.status = response.type === 'opaqueredirect' ? 0 : response.status; throw error; }
// E2E6 P2-6: the server's refusal code and its redacted message travel too, so a section can say why.
if (!response.ok || response.type === 'opaqueredirect') {
const error = new Error('Read unavailable'); error.status = response.type === 'opaqueredirect' ? 0 : response.status;
if (response.type !== 'opaqueredirect') try { const body = await response.json(); if (typeof body?.error === 'string') error.code = body.error; if (typeof body?.message === 'string') error.detail = body.message; } catch { /* no JSON body */ }
throw error;
}
return response.json();
};
const orgPath = (id, path) => `/api/orgs/${encodeURIComponent(id)}/${path}`;
+56 -10
View File
@@ -52,7 +52,7 @@
}
const av = (org, size = '') => `<span class="ws-av ws-o${size ? ` ${size}` : ''}" style="${colours(org)}" aria-hidden="true">${esc(initials(org))}</span>`;
const label = org => org.label || org.id;
const state = org => (org.invalid ? ['s-error', '▲', 'invalid'] : org.busHealthy ? ['s-working', '●', 'bus running'] : ['s-offline', '○', 'bus unavailable']);
const state = org => (org.invalid ? ['s-error', '▲', 'invalid'] : org.busHealthy ? ['s-working', '●', 'bus running'] : org.busStopped ? ['s-offline', '○', 'bus stopped'] : ['s-offline', '○', 'bus unavailable']);
// A status mark: a glyph plus its word, shown or read aloud only.
const dot = (cls, glyph, word, shown = false) => `<span class="ws-dot ${cls}" title="${esc(word)}"><b aria-hidden="true">${glyph}</b>${shown ? esc(word) : `<span class="ws-sr">${esc(word)}</span>`}</span>`;
const orgHref = id => `#/orgs/${enc(id)}`;
@@ -183,7 +183,7 @@
['business', 'Organizations', orgCount === null ? unread : plural(orgCount, 'organization'), 'system', 'organizations'],
['flag', 'About', typeof s?.release === 'string' ? `Release ${s.release}` : 'Address, release and notifier', 'system', 'about'],
['people', 'Members and invites', 'Not built yet', 'not built', null],
['restart', 'Bus', `${org.busHealthy ? 'Running' : 'Unavailable'} · restart from this page's header`, 'system', null],
['restart', 'Bus', `${org.busHealthy ? 'Running' : org.busStopped ? 'Stopped' : 'Unavailable'} · restart from this page's header`, 'system', null],
['close', 'Archive or delete', "Archive or delete this organization's file", 'danger', 'organizations'],
];
return `<ul class="ws-cfg one" data-org-settings>${rows.map(([ic, name, value, tag, page]) => {
@@ -193,6 +193,40 @@
}).join('')}</ul>`;
}
// E2E6 P1-1: a poll repaints the whole page, which would drop an editor's draft and its focus. A
// section marked data-live-key that is being edited (it holds [data-live-hold] or [data-live-dirty])
// or holds focus is kept: holdLive takes it before the repaint and graftLive puts that same node, with
// its typed values, caret and focus, in place of its fresh counterpart. Each editor saves against the
// revision it started from, so a kept node cannot overwrite a change it never saw.
const TYPING = 'input:not([type=button],[type=submit],[type=reset],[type=checkbox],[type=radio]),select,textarea';
function holdLive(root) {
const held = new Map(), a = document.activeElement;
if (!root) return { held, active: null };
// Focus alone holds a section only while it sits in a field being typed in. A focused button or link
// is rebuilt with fresh data and gets its focus back through data-focus-key (review P3), so a section
// never stays stale just because its Apply button kept focus.
const typing = a?.matches?.(TYPING) ?? false;
for (const el of root.querySelectorAll('[data-live-key]')) {
if (el.matches('[data-live-hold]') || el.querySelector('[data-live-hold],[data-live-dirty]') || (typing && el.contains(a))) held.set(el.dataset.liveKey, el);
}
const active = a && [...held.values()].some(el => el.contains(a))
? { el: a, caret: typeof a.selectionStart === 'number' ? [a.selectionStart, a.selectionEnd, a.scrollTop] : null } : null;
return { held, active };
}
function graftLive(root, kept) {
if (!root || !kept?.held?.size) return false;
for (const el of root.querySelectorAll('[data-live-key]')) {
const old = kept.held.get(el.dataset.liveKey);
if (old && old !== el) el.replaceWith(old);
}
const a = kept.active;
if (a && a.el.isConnected) {
a.el.focus({ preventScroll: true });
if (a.caret && typeof a.el.setSelectionRange === 'function') try { a.el.setSelectionRange(a.caret[0], a.caret[1]); a.el.scrollTop = a.caret[2]; } catch { /* not a text field */ }
}
return Boolean(a && a.el.isConnected);
}
// A side-by-side pane passes its own organization and { pane: true, idPrefix }: the pane has no bus
// restart (that stays in the organization's full view) and its ids carry the pane's prefix.
async function dashboard(id = window.MosaicOrg.id, opts = {}) {
@@ -202,7 +236,9 @@
const list = Array.isArray(listRead) ? listRead : null;
const org = list?.find(o => o.id === id && !o.invalid);
if (!org) return `<article class="ws ws-dash" data-org-dashboard data-org-unavailable><div class="ws-vh"><h1 tabindex="-1">Organization unavailable</h1><p class="sub">${list ? 'This organization is not on your list.' : 'The organization list could not be read.'}</p></div></article>`;
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), window.MosaicAutonomy?.load(id), window.MosaicPacks?.load(id), window.MosaicWorkflows?.load(id)]);
// E2E6 P2-8: a stopped bus is not read for the bus-backed editors; each says the bus is stopped.
const stopped = !org.busHealthy && org.busStopped === true;
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 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));
@@ -220,7 +256,7 @@
const bus = restart ? `<span class="ws-busctl" data-dashboard-bus>${restart}</span>` : `<span data-dashboard-bus><button type="button" class="ws-btn" data-bus-restart-off data-focus-key="bus-restart-off" disabled title="${esc(offTitle)}">${icon('restart')}Restart bus</button> <button type="button" class="ws-btn" data-bus-stop-off data-focus-key="bus-stop-off" disabled title="${esc(stopTitle)}">Stop bus</button></span>`;
const reload = `<button type="button" class="ws-btn" data-org-reload data-focus-key="org-reload" disabled aria-describedby="${esc(why)}" title="Reload arrives with the cross-org update">${icon('doc')}Reload organization file</button>`;
const [bcls, bglyph] = state(org);
const meta = `<p class="ws-meta"><span>Kind not recorded</span><span>id <code>${esc(id)}</code></span><span>Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}</span><span>${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}</span><span id="${esc(why)}">Restart is a trailed human action; reload arrives with the cross-org update.</span></p>`;
const meta = `<p class="ws-meta"><span>Kind not recorded</span><span>id <code>${esc(id)}</code></span><span>Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}</span><span>${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : org.busStopped ? `bus stopped, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}</span><span id="${esc(why)}">Restart is a trailed human action; reload arrives with the cross-org update.</span></p>`;
const projectName = pid => projectRows?.find(p => p.id === pid)?.name || pid;
const docChips = !docRead ? '<span class="note">Docs unavailable</span>' : docRead.failed ? '<span class="note">Docs unavailable</span>' : docRead.rows.length ? docRead.rows.slice(0, 6).map(d => `<a class="ws-doc" href="#/docs/${enc(d.project)}/${enc(d.path)}" data-focus-key="${esc(`doc:${d.project}:${d.path}`)}" title="${esc(`${d.project}/${d.path}`)}">${icon('doc')}${esc(docName(d.path))}<small>${esc(projectName(d.project))}</small></a>`).join('') : '<span class="note">No docs yet</span>';
@@ -242,9 +278,16 @@
: threads ? `<span class="muted">Conversation</span><span class="t2">${esc(first.thread)}${more}</span>` : '<span class="muted">Conversation unavailable</span>';
return `<tr><td>${esc(agent)}</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 ? '<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 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;
const queueWhy = queue !== null ? '' : `<p class="ws-panel tight note" data-org-queue-why>${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 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>
<nav class="ws-crumb" aria-label="Breadcrumb"><a href="#/settings/organizations">Organizations</a><span aria-hidden="true">›</span><span>${name}</span></nav>
@@ -257,15 +300,18 @@
${secH('Active agent sessions', live ? (live.length ? `${live.length} running` : 'none running') : '', '<a href="#/agents" data-focus-key="agents-all">All agents</a>')}
${sessions}
${secH('Queue', queueNote, '<a href="#/queue" data-org-queue data-focus-key="queue-open">Open queue</a>')}
${queueWhy}
</section>
<section aria-label="Settings">
${secH('Settings', 'every setting, with where its value comes from')}
${settingsList(org, { business, settings, instances, orgCount: list ? list.length : null })}
${window.MosaicAutonomy?.section(id)??''}
${window.MosaicPacks?.section(id) ?? ''}
${window.MosaicWorkflows?.section(id) ?? ''}
</section>
</div>
<div class="ws-wide">
${stopped ? stoppedPanel('data-autonomy', 'Autonomy') : window.MosaicAutonomy?.section(id) ?? ''}
${stopped ? stoppedPanel('data-packs', 'Starter packs') : window.MosaicPacks?.section(id) ?? ''}
${stopped ? stoppedPanel('data-workflows', 'Workflows') : window.MosaicWorkflows?.section(id) ?? ''}
</div>
</article>`;
}
@@ -514,7 +560,7 @@
${tile('sessions', 'Sessions', sessN(t.sessions), `${num(t.sessions?.working)} working`, parts('sessions', x => sessN(x.sessions)), `${num(t.sessions?.waiting)} waiting on you · ${num(t.sessions?.idle)} idle${num(t.sessions?.live) ? ` · ${num(t.sessions.live)} live ${t.sessions.live === 1 ? 'thread' : 'threads'}` : ''}`)}
${tile('tasks', 'Open tasks', num(t.openTasks), '', parts('tasks', x => x.openTasks), `<b>${num(t.doneThisWeek)}</b> done this week`)}
${tile('projects', 'Projects', num(t.projects?.active) + num(t.projects?.archived), `${num(t.projects?.active)} active`, parts('projects', x => num(x.projects?.active) + num(x.projects?.archived)), `${num(t.projects?.archived)} archived`)}
<div class="ws-tile quiet" data-ws-tile="spend"><span class="ws-label">Model spend</span><span class="ws-empty-t">No spend figures yet</span><span class="ws-delta">No read prices model use today.</span></div>
<div class="ws-tile quiet" data-ws-tile="spend"><span class="ws-label">Model spend</span><span class="ws-empty-t">No spend figures yet</span><span class="ws-delta">No model prices are read yet, so today's use has no cost figure.</span></div>
</div>` : '<p class="ws-panel tight note" data-ws-tiles>No organization answered, so there are no totals yet.</p>';
const cards = o.rows.map(r => {
@@ -783,6 +829,6 @@
// U4b: the org mark (colour and initials) for the task board (tasks-board.js).
const orgMark = Object.freeze({ ready: ensureThemes, av, colours, initials });
window.MosaicWorkspace = Object.freeze({ dashboard, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, setGrouping, get pollMs() { return pollMs; } });
window.MosaicWorkspace = Object.freeze({ dashboard, holdLive, graftLive, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, setGrouping, get pollMs() { return pollMs; } });
refresh();
})();
@@ -30,6 +30,8 @@ const ROWS = [
row(3, reply('Unchecked.', [cite('Notes/Eyes.md', [1, 1], HOSTILE)])),
row(4, JSON.stringify({ notice: 'library-write', op: 'write', path: 'Notes/Eyes.md', commit: C4 }), undefined, 'scribe'),
row(5, JSON.stringify({ notice: 'library-write', op: 'delete', path: 'Fake/Claim.md', commit: C4 }), { citations: [] }, 'forger'),
// E2E6 P2-15: a human message with inline code, a hostile span and an unpaired backtick.
row(6, `Approve with \`mosaic project north-star work\`. Not \`${HOSTILE}\`, a lone \` stays.`, undefined, 'human'),
];
const STATUS = { project: 'books', version: 1, branch: 'main', served: C3, lastFetch: 'FIVE-MINUTES-AGO',
lastError: { code: 'library-fetch-failed', at: '2026-10-23T18:00:00.000Z' }, diverged: { commit: C2, at: '2026-10-23T18:00:00.000Z' },
@@ -83,6 +85,10 @@ test('chat replies: free text, cites with badges, the agent label, changed since
const { b, text, message } = await setup(t);
assert.equal(await text(`${message(1)} .chat-body`), 'Plain text answer.');
assert.equal(await text(`${message(2)} .chat-body`), "Mara's eyes are grey.");
// E2E6 P2-15: backtick pairs render as code and their contents stay text.
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll(${JSON.stringify(message(6) + ' .chat-body code')})].map(c => c.textContent)`), ['mosaic project north-star work', HOSTILE]);
assert.equal(await text(`${message(6)} .chat-body`), `Approve with mosaic project north-star work. Not ${HOSTILE}, a lone \` stays.`);
assert.equal(await b.evaluate(`document.querySelectorAll('.chat-log img').length`), 0);
const cites = await b.evaluate(`[...document.querySelectorAll(${JSON.stringify(message(2) + ' .chat-cites > li')})].map(li => ({
badge: li.querySelector('.chat-scope')?.textContent ?? null, ref: (li.querySelector('a') ?? li.querySelector('code'))?.textContent,
href: li.querySelector('a')?.getAttribute('href') ?? null, rel: li.querySelector('a')?.getAttribute('rel') ?? null,
+76 -4
View File
@@ -326,12 +326,84 @@ test('D4 hire start: the other proposal checks that run before the decision inse
assert.doesNotMatch(r.json().message,/\bhire was\b/,'worded for hire, fire and change alike');
}
});
// Review P3: a code the broker can raise after the decision exists or is consumed, or one the bus
// server raises about the request itself, is not a definite refusal of the start.
test('D4 hire start: a code that can follow the decision insert, or a transport code, stays outcome-unknown',async()=>{
for(const code of ['revision-conflict-revoked','request-timeout','invalid-envelope','incomplete-request','instance-kept','organization-instance-exists','organization-unknown-instance','root-fire-shared','fire-in-progress','some-future-code'])
// E2E6 P1-2: every definite refusal of a start keeps its own code and a next step. Only a reply that
// never came, could not be read, or the bus server's request codes stay outcome-unknown.
test('E2E6 hire start: an unknown project, an unknown agent on fire and an unknown harness keep their code',async()=>{
for(const [code,re] of [['project-agents-invalid',/code project/],['organization-unknown-instance',/Agents page/],['harness-off',/Settings > Routing/]]) {
const {r,s}=await startWith(new BusReadError(code));
assert.equal(r.status,409,`${code}: ${r.text}`);
assert.equal(r.json().error,code);assert.match(r.json().message,re);
// harness-off comes only from prepare()'s route check; the other two can also come from apply().
if(code==='harness-off')assert.match(r.json().message,/ Nothing was applied\.$/);
else {assert.match(r.json().message,/ Part of it may already be applied: check the Agents page before trying again\.$/);assert.doesNotMatch(r.json().message,/nothing was applied/i);}
assert.match(s.lines.at(-1),new RegExp(`step=start error=${code}$`));
}
});
test('E2E6 hire start: the other proposal and apply codes keep their code; apply-phase ones do not claim nothing changed',async()=>{
for(const code of ['unknown-model','harness-refused']) {
const {r}=await startWith(new BusReadError(code));
assert.equal(r.json().error,code,`${code}: ${r.text}`);assert.match(r.json().message,/ Nothing was applied\.$/);
}
// Review finding 1: apply() runs check() again and the writers run after the consume, so these
// never claim nothing changed, in any case or clause. The inbox lists no closed decision, so they
// do not send the user there to tell which side of the consume the refusal came from (re-check).
for(const code of ['organization-instance-exists','instance-kept','root-fire-shared','definition-rev-conflict','definition-missing','credential-leak','decision-stale','organization-instance-retired','organization-instance-referenced','organization-refused','definition-secret-check-required','not-configured','invalid-request']) {
const {r}=await startWith(new BusReadError(code));
assert.equal(r.json().error,code,`${code}: ${r.text}`);
assert.doesNotMatch(r.json().message,/nothing was applied/i,`${code} does not claim nothing changed`);
assert.doesNotMatch(r.json().message,/inbox/,`${code} does not point at the inbox, which drops a consumed decision`);
assert.match(r.json().message,/Part of it may already be applied: check the Agents page/);
}
for(const code of ['organization-busy','fire-in-progress','credential-revoking']) {
const {r}=await startWith(new BusReadError(code));
assert.equal(r.status,409);assert.equal(r.json().error,code);
assert.doesNotMatch(r.json().message,/Nothing was applied/);assert.match(r.json().message,/Agents page and the inbox/);
}
const {r}=await startWith(new BusReadError('some-future-code'));
assert.equal(r.status,409);assert.equal(r.json().error,'some-future-code');assert.match(r.json().message,/Agents page and the inbox/);
});
test('E2E6 hire start: an apply-phase decision-stale on a fire does not say nothing was applied',async()=>{
// #hireConsole consumes the decision and stops a project agent's runs before apply() checks again.
const s=setup({resolve:()=>{throw new BusReadError('decision-stale');}});
const r=await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire:{op:'fire',level:'project',project:'site',name:'coder',reason:'done'}}}));
assert.equal(s.calls.length,1);assert.equal(r.status,409,r.text);assert.equal(r.json().error,'decision-stale');
assert.doesNotMatch(r.json().message,/nothing was applied/i);
assert.doesNotMatch(r.json().message,/inbox|answer it there/,'a consumed decision is gone from the inbox');
assert.match(r.json().message,/check the Agents page/);
});
test('E2E6 hire start: invalid-request from the bus says the values, not the shape, were refused',async()=>{
const {r}=await startWith(new BusReadError('invalid-request'));
assert.equal(r.status,400);assert.equal(r.json().error,'invalid-request');assert.match(r.json().message,/Settings > Model proxy/);
});
test('E2E6 hire start: the bus server request codes and organization-uncertain stay outcome-unknown',async()=>{
for(const code of ['revision-conflict-revoked','request-timeout','invalid-envelope','incomplete-request','organization-uncertain'])
refused((await startWith(new BusReadError(code))).r,'outcome-unknown');
});
// E2E6 P2-4: a malformed proposal names its field and the rule, and never reaches the bus.
test('E2E6 hire start: a malformed proposal names the field and the rule before the bus',async()=>{
const cases=[
[{...START_HIRE,name:'Bad Name'},'name',/lowercase/],
[(({route,...h})=>h)(START_HIRE),'route',/harness and a model/],
[{...START_HIRE,route:{harness:'pi'}},'route.model',/required/],
[{...START_HIRE,level:'project'},'project',/code project/],
[{...START_HIRE,project:'north'},'project',/only for project level/],
[{...START_HIRE,reason:' '},'reason',/blank/],
[{...START_HIRE,op:'promote'},'op',/hire, fire or change/],
[{op:'fire',level:'org',name:'scout',reason:'r',route:START_HIRE.route},'route',/not used by fire/],
[{...START_HIRE,role:{template:'coder',sha256:'x'}},'role.sha256',/digest/],
[{...START_HIRE,role:{template:'coder',sha256:DIGEST,definition:'x'}},'role',/exactly one/],
[{...START_HIRE,colour:'blue'},'colour',/not a hire field/],
];
for(const [hire,field,rule] of cases) {
const s=setup({resolve:()=>{throw new Error('must not be called');}});
const r=await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire}}));
assert.equal(r.status,400,`${field}: ${r.text}`);
const body=r.json();
assert.equal(body.error,'invalid-request');assert.equal(body.field,field);assert.match(body.rule,rule);
assert.match(body.message,/Nothing was sent to the bus/);assert.ok(body.message.toLowerCase().startsWith(field.toLowerCase()),body.message);
assert.equal(s.calls.length,0,`${field}: no bus call`);
}
});
test('D4 hire start: a lost or unreadable reply is still outcome-unknown',async()=>{
for(const err of [new BusReadError('outcome-unknown'),new BusReadError('invalid-response'),new BusReadError('response-too-large'),new BusReadError('outcome-unknown-revoked'),new BusReadError('Not A Code'),new BusReadError(''),new Error('socket closed'),Object.assign(new Error('x'),{code:'model-provider-unavailable'})])
refused((await startWith(err)).r,'outcome-unknown');
@@ -0,0 +1,81 @@
// E2E6 P2-11, P2-12, P2-13 (#1873): layout pins measured in a real browser.
// P2-11: a project's header stays inside its column at desktop widths, so its actions are not cut off.
// P2-12: at phone width the project tabs fade at the right edge while more tabs lie past it, and the
// fade is gone once the row is scrolled to its end or when every tab fits.
// P2-13: Restart bus and Stop bus share one top and height, on the organization page and in a pane.
import test from 'node:test';
import assert from 'node:assert/strict';
import { fixture, close } from './fixture.mjs';
import { browser } from './browser.mjs';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { P, stub } from './helpers/projects-review-fixture.mjs';
const until = (b, expression, what) => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,25))}throw Error(${JSON.stringify(what)})})()`);
const frames = b => b.evaluate('new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(()=>r(true))))');
async function project(t, width) {
const f = await fixture(), b = await browser({ machine: false, args: ['--disable-extensions'] });
t.after(async () => { await b.close(); await f.close(); });
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub });
await b.viewport(width, 900);
await b.navigate(`${f.base}/#/projects/${P}`);
await until(b, `document.querySelector('.project-header .project-actions')`, 'project header');
await b.evaluate('new Promise(r=>setTimeout(r,400))');
return b;
}
for (const width of [1440, 1280]) {
test(`E2E6 P2-11: the project header and its actions stay inside the column at ${width}`, { timeout: 60000 }, async t => {
const b = await project(t, width);
const m = JSON.parse(await b.evaluate(`JSON.stringify((()=>{const h=document.querySelector('.project-header'),p=h.parentElement.getBoundingClientRect(),r=h.getBoundingClientRect();return {right:r.right,parent:p.right,actions:[...h.querySelectorAll('.project-actions > *')].map(a=>a.getBoundingClientRect().right),page:[document.documentElement.scrollWidth,innerWidth]}})())`));
assert.ok(m.right <= m.parent + 0.5, `header right ${m.right} inside its column ${m.parent}`);
for (const a of m.actions) assert.ok(a <= m.parent + 0.5, `action right ${a} inside ${m.parent}`);
assert.ok(m.page[0] <= m.page[1], 'no sideways page scroll');
});
}
test('E2E6 P2-12: at 390 the project tabs fade at the edge until scrolled to the end', { timeout: 60000 }, async t => {
const b = await project(t, 390);
const read = () => b.evaluate(`JSON.stringify((()=>{const e=document.querySelector('.project-tabs'),s=getComputedStyle(e);return {mask:s.maskImage||s.webkitMaskImage,fade:s.getPropertyValue('--tabs-fade').trim(),sw:e.scrollWidth,cw:e.clientWidth}})())`).then(JSON.parse);
const start = await read();
assert.ok(start.sw > start.cw, `the tabs overflow (${start.sw} > ${start.cw})`);
assert.match(start.mask, /linear-gradient/, 'a fade mask is set');
assert.equal(start.fade, '48px', 'a 48px fade while more tabs lie past the edge');
await b.evaluate(`{const e=document.querySelector('.project-tabs');e.scrollLeft=e.scrollWidth}true`);
await frames(b);
assert.equal((await read()).fade, '0px', 'no fade once the row is scrolled to its end');
// Review P3: a strip whose tabs all fit has nothing past the edge, so it has no fade.
await b.evaluate(`{const e=document.querySelector('.project-tabs');[...e.children].slice(1).forEach(c=>c.style.display='none');e.scrollLeft=0}true`);
await frames(b);
const fits = await read();
assert.ok(fits.sw <= fits.cw, `the remaining tab fits (${fits.sw} <= ${fits.cw})`);
assert.equal(fits.fade, '0px', 'no fade when nothing is cut off');
});
const org = {
busHealthy: true, decisionWrite: async () => ({}), send: async () => ({}),
requestRead: async () => { throw Object.assign(new Error('not-configured'), { code: 'not-configured' }); },
reads: { business: async () => ({ business: { id: 'acme', projects: [], instances: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }) },
bus: { instances: async () => ({ instances: {} }), agents: async () => [], 'thread.claims': async () => [], tasks: async () => [], inbox: async () => [], messages: async () => ({ messages: [] }) },
chat: { projects: async () => [], threads: async () => [] },
};
for (const [width, route, where] of [[1440, 'orgs/acme/organization', 'the organization page'], [390, 'orgs/acme/organization', 'the organization page'], [1440, 'orgs/acme/side-by-side?a=acme/organization&b=acme/agents', 'a pane'], [390, 'orgs/acme/side-by-side?a=acme/agents&b=acme/organization&show=b', 'a pane']]) {
test(`E2E6 P2-13: Restart bus and Stop bus line up on ${where} at ${width}`, { timeout: 60000 }, async t => {
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {}, organizations: { list: async () => [{ id: 'acme', label: 'Acme', busHealthy: true }], resolve: async () => org } });
t.after(() => close(server));
const b = await browser({ machine: false, args: ['--disable-extensions'] });
t.after(() => b.close());
await b.viewport(width, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/${route}`);
const pair = `[...document.querySelectorAll('[data-bus-restart],[data-bus-restart-off],[data-bus-stop],[data-bus-stop-off]')].filter(e=>e.getClientRects().length)`;
await until(b, `${pair}.length===2`, 'the two bus buttons');
await b.evaluate('new Promise(r=>setTimeout(r,300))');
const [restart, stop] = JSON.parse(await b.evaluate(`JSON.stringify(${pair}.map(e=>{const r=e.getBoundingClientRect();return {text:e.textContent.trim(),top:r.top,height:r.height,left:r.left,right:r.right}}))`));
assert.equal(restart.text, 'Restart bus');
assert.equal(stop.text, 'Stop bus');
assert.ok(Math.abs(restart.top - stop.top) < 0.5, `same top (${restart.top} vs ${stop.top})`);
assert.ok(Math.abs(restart.height - stop.height) < 0.5, `same height (${restart.height} vs ${stop.height})`);
assert.ok(stop.left - restart.right >= 4, 'a gap between them');
});
}
@@ -0,0 +1,164 @@
// E2E6 P1-1, P2-2 (#1873): an editor in use on an organization's page survives the page's refresh. The
// workflow editor's node, typed draft and focus, the autonomy form's unsaved choice and a pack's picked
// project are kept when the page reads every section again. An apply refreshes the other sections.
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 { browser } from './browser.mjs';
import { close } from './fixture.mjs';
const waitFor = (b, expression, what) => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(what)}+': '+document.body.innerText.slice(0,2000))})()`);
const REV = n => String(n).repeat(64);
async function fixture(t) {
const counts = { workflows: 0, autonomy: 0, packs: 0 };
// ctl lets a test fail the next reads or change what the packs read returns.
const ctl = { workflowsFail: false, autonomyFail: false, packLabel: 'Web project' };
const flows = {
revision: REV('a'),
document: {
workflows: { intake: { title: 'Intake', steps: [{ title: 'Sort', state: 'todo', roles: ['human'] }] } },
projects: { site: { workflows: {} }, docs: { workflows: {} } },
definitions: {},
},
templates: ['coder'],
};
const read = async verb => {
if (verb === 'packs.console') { counts.packs++; return { packs: [{ id: 'web-project', origin: 'installed', pack: { label: ctl.packLabel, description: 'A board.', version: '0.2.0', scope: 'project' } }] }; }
if (verb === 'pack.preview.console') return { pack: { id: 'web-project', version: '0.2.0', label: 'Web project', origin: 'installed', scope: 'project' }, project: 'docs', revision: REV('c'), items: [{ item: 'board', name: 'board', target: 'board', path: 'board', mark: 'add' }], hires: [], skills: { notEnabled: [] }, chief: true, plan: {}, digest: REV('d') };
if (verb === 'autonomy.get.console') { counts.autonomy++; if (ctl.autonomyFail) throw Object.assign(new Error('bus-unreachable'), { code: 'bus-unreachable' }); return { revision: REV('e'), autonomy: { merge: 'out of the loop' }, levels: { 'cross-role': 'out of the loop', merge: 'out of the loop', policy: 'out of the loop', authority: 'in the loop' } }; }
throw Object.assign(new Error('not-configured'), { code: 'not-configured' });
};
const write = async (verb, args) => {
if (verb === 'pack.apply.console') return { revision: REV('f'), added: ['board'], pack: { id: args.pack, version: '0.2.0' } };
throw Object.assign(new Error('not-configured'), { code: 'not-configured' });
};
const server = await startServer({
port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {},
organizations: {
list: async () => [{ id: 'acme', label: 'Acme', busHealthy: true }],
resolve: async () => ({
busHealthy: true, decisionWrite: async () => ({}), requestRead: read, requestWrite: read, packsWrite: write,
workflowsRead: async () => { counts.workflows++; if (ctl.workflowsFail) throw Object.assign(new Error('bus-unreachable'), { code: 'bus-unreachable' }); return structuredClone(flows); }, send: async () => ({}),
reads: { business: async () => ({ business: { id: 'acme', projects: [], instances: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }) },
bus: { instances: async () => ({ instances: {} }), agents: async () => [], 'thread.claims': async () => [], tasks: async () => [], inbox: async () => [], messages: async () => ({ messages: [] }) },
chat: { projects: async () => [], threads: async () => [] },
}),
},
});
t.after(() => close(server));
const b = await browser({ machine: false, args: ['--disable-extensions'] });
t.after(() => b.close());
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/acme/organization`);
// poll: wait for the page's own 10-second poll. Otherwise send the event a write on the page sends,
// which repaints /organization the same way.
const refresh = async ({ poll = false } = {}) => {
const n = counts.workflows;
if (!poll) await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-org-changed',{detail:{org:'acme'}}));true`);
for (let i = 0; i < 500 && counts.workflows === n; i++) await new Promise(r => setTimeout(r, 30));
assert.ok(counts.workflows > n, 'the refresh read the workflows again');
await b.evaluate(`new Promise(r=>setTimeout(()=>r(true),200))`);
};
return { b, counts, ctl, refresh };
}
test('E2E6 P1-1: an open workflow editor keeps its node, draft and focus across a refresh', { timeout: 60000 }, async t => {
const { b, refresh } = await fixture(t);
await waitFor(b, `document.querySelector('[data-wf="intake"] [data-wf-edit]')`, 'workflow list');
await b.evaluate(`document.querySelector('[data-wf="intake"] [data-wf-edit]').click();true`);
await waitFor(b, `document.querySelector('[data-wf-editor]')`, 'editor');
await b.evaluate(`{const f=document.querySelector('[data-wf-editor]');window.__editor=f;const i=f.querySelector('input[name=title]');i.value='Intake, revised';i.focus();i.setSelectionRange(3,3);f.querySelector('[data-wf-edit-step="0"] [name=roles]').value='human, coder'}true`);
await refresh({ poll: true });
assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor]')===window.__editor`), true, 'the same editor node');
assert.equal(await b.evaluate(`document.querySelectorAll('[data-wf-editor]').length`), 1);
assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor] input[name=title]').value`), 'Intake, revised');
assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor] [data-wf-edit-step="0"] [name=roles]').value`), 'human, coder');
assert.equal(await b.evaluate(`document.activeElement===document.querySelector('[data-wf-editor] input[name=title]')`), true, 'focus stays in the field');
assert.equal(await b.evaluate(`document.activeElement.selectionStart`), 3, 'the caret stays put');
// A second refresh with focus elsewhere still keeps the open editor: it holds data-live-hold.
await b.evaluate(`document.activeElement.blur();true`);
await refresh();
assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor]')===window.__editor`), true);
assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor] input[name=title]').value`), 'Intake, revised');
// Cancel closes it; the next refresh paints the list fresh.
await b.evaluate(`document.querySelector('[data-wf-cancel]').click();true`);
await waitFor(b, `!document.querySelector('[data-wf-editor]')`, 'cancel');
await refresh();
assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor]')`), null);
});
test('E2E6 P1-1, P2-14: an unsaved autonomy choice, a picked pack project and a focused link survive the poll', { timeout: 60000 }, async t => {
const { b, refresh } = await fixture(t);
await waitFor(b, `document.querySelector('[data-autonomy-form] select[name=merge]')&&document.querySelector('[data-pack-project]')`, 'autonomy and packs');
await b.evaluate(`{const s=document.querySelector('[data-autonomy-form] select[name=merge]');s.value='in the loop';s.dispatchEvent(new Event('change',{bubbles:true}));window.__auto=document.querySelector('[data-autonomy-form]')}true`);
await b.evaluate(`{const s=document.querySelector('[data-pack-project]');s.value='docs';s.dispatchEvent(new Event('change',{bubbles:true}));s.blur()}true`);
// P2-14: a visible Open link outside any editor keeps focus through restore(). (The tester's "Open
// Inbox →" is the legacy dashboard card outside #bus-view, hidden on this page, so it never took focus.)
assert.equal(await b.evaluate(`document.querySelector('[data-focus-key="set:Identity"]').focus();document.activeElement.dataset.focusKey`), 'set:Identity');
await refresh({ poll: true });
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey??document.activeElement.tagName`), 'set:Identity', 'focus stays on the link');
assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form]')===window.__auto`), true, 'the dirty form is kept');
assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form] select[name=merge]').value`), 'in the loop');
assert.equal(await b.evaluate(`document.querySelector('[data-pack-project]').value`), 'docs', 'the picked project is kept');
});
test('E2E6 P2-2: a pack apply re-reads the other sections and keeps its receipt', { timeout: 60000 }, async t => {
const { b, counts } = await fixture(t);
await waitFor(b, `document.querySelector('[data-pack="web-project"] [data-pack-preview-button]')`, 'pack list');
await b.evaluate(`{const s=document.querySelector('[data-pack-project]');s.value='docs';s.dispatchEvent(new Event('change',{bubbles:true}))}true`);
await b.evaluate(`document.querySelector('[data-pack-preview-button]').click();true`);
await waitFor(b, `document.querySelector('[data-pack-apply]')`, 'preview');
const before = { ...counts };
await b.evaluate(`document.querySelector('[data-pack-apply]').click();true`);
await waitFor(b, `/^Applied\\./.test(document.querySelector('[data-packs-status]')?.textContent??'')`, 'apply');
for (let i = 0; i < 300 && counts.workflows === before.workflows; i++) await new Promise(r => setTimeout(r, 30));
assert.ok(counts.workflows > before.workflows, 'workflows read again after the apply');
assert.ok(counts.autonomy > before.autonomy, 'autonomy read again after the apply');
await b.evaluate(`new Promise(r=>setTimeout(()=>r(true),300))`);
assert.match(await b.evaluate(`document.querySelector('[data-packs-status]').textContent`), /^Applied\. Added board\./, 'the receipt survives the re-read');
});
test('E2E6 review P3: a failed read keeps an open editor, Discard drops a held form, a focused button does not hold its section, and a clean form shows a failed read', { timeout: 60000 }, async t => {
const { b, ctl, refresh } = await fixture(t);
await waitFor(b, `document.querySelector('[data-wf="intake"] [data-wf-edit]')&&document.querySelector('[data-autonomy-form] select[name=merge]')&&document.querySelector('[data-pack-preview-button]')`, 'sections');
// A focused Preview button alone no longer holds the packs section: the next poll shows a changed
// pack and focus comes back to the rebuilt button through its data-focus-key.
await b.evaluate(`document.querySelector('[data-pack-preview-button]').focus();true`);
ctl.packLabel = 'Web project, renamed';
await refresh({ poll: true });
await waitFor(b, `/Web project, renamed/.test(document.querySelector('[data-packs-org]').textContent)`, 'the renamed pack');
assert.match(await b.evaluate(`document.activeElement.dataset.focusKey??document.activeElement.tagName`), /^pack-preview:acme:web-project$/, 'focus returns to the rebuilt button');
// One failed workflows read while the editor is open keeps the same editor node, draft and focus.
await b.evaluate(`document.querySelector('[data-wf="intake"] [data-wf-edit]').click();true`);
await waitFor(b, `document.querySelector('[data-wf-editor]')`, 'editor');
await b.evaluate(`{const f=document.querySelector('[data-wf-editor]');window.__editor=f;const i=f.querySelector('input[name=title]');i.value='Typed during an outage';i.focus()}true`);
// A dirty autonomy form survives a failed autonomy read the same way.
await b.evaluate(`{const s=document.querySelector('[data-autonomy-form] select[name=merge]');s.value='in the loop';s.dispatchEvent(new Event('change',{bubbles:true}));window.__auto=document.querySelector('[data-autonomy-form]')}true`);
await b.evaluate(`document.querySelector('[data-wf-editor] input[name=title]').focus();true`);
ctl.workflowsFail = true; ctl.autonomyFail = true;
await refresh({ poll: true });
assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor]')===window.__editor`), true, 'the same editor node after a failed read');
assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor] input[name=title]').value`), 'Typed during an outage');
assert.equal(await b.evaluate(`document.activeElement===document.querySelector('[data-wf-editor] input[name=title]')`), true, 'focus stays in the field');
assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form]')===window.__auto`), true, 'the dirty autonomy form is kept after a failed read');
// Discard draws the last read in place of the held form.
ctl.workflowsFail = false; ctl.autonomyFail = false;
await b.evaluate(`document.querySelector('[data-autonomy-discard]').click();true`);
assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form]')!==window.__auto`), true, 'a fresh form');
assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form] select[name=merge]').value`), 'out of the loop', 'the stored value is back');
assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form]').hasAttribute('data-live-dirty')`), false);
assert.equal(await b.evaluate(`document.activeElement.hasAttribute('data-autonomy-discard')`), true, 'focus stays on Discard');
// Re-check P3: a focused button in a clean form gets its focus back after the poll repaints it.
await refresh({ poll: true });
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey??document.activeElement.tagName`), 'autonomy-discard:acme', 'focus returns to Discard after a poll');
// Re-check P3: a clean form the page does not hold shows a failed read instead of hiding it.
await b.evaluate(`document.activeElement.blur();true`);
ctl.autonomyFail = true;
await refresh({ poll: true });
await waitFor(b, `!document.querySelector('[data-autonomy-form]')&&/bus-unreachable|could not|refused/i.test(document.querySelector('[data-autonomy]')?.textContent??'')`, 'the autonomy read error');
ctl.autonomyFail = false;
await refresh({ poll: true });
await waitFor(b, `document.querySelector('[data-autonomy-form] select[name=merge]')`, 'the form after a good read');
});
@@ -26,13 +26,18 @@ async function accessibleName(b, selector) {
for (const width of [1440,390]) {
test(`S8 project action icons, accessible names and positions at ${width}`, async t => {
const {b,wait} = await setup(t,width);
const expected = width === 1440 ? [[1060,1190],[1202,1290],[1302,1418]] : [[12,132],[140,220],[228,340]];
// E2E6 P2-11 (#1873): at 1440 the actions end 22px inside the content edge. A page tall enough to
// scroll has a scrollbar (sb wide), so the design's x values move left by its width on that route:
// 1418 was 100vw-based and ran under the scrollbar, which clipped Settings.
const design = width === 1440 ? [[1060,1190],[1202,1290],[1302,1418]] : [[12,132],[140,220],[228,340]];
const y = width === 1440 ? [130,174] : [198,242];
for (const route of ['', '/conversations', '/tasks', '/requirements', '/files', '/settings', ...(width === 1440 ? ['/room'] : [])]) {
if (route) {
await b.evaluate(`location.hash=${JSON.stringify(route === '/room' ? '#/projects/rooms/'+P : '#/projects/'+P+route)}`);
await wait(`document.querySelector('.project-header')?.dataset.projectSection===${JSON.stringify(route.slice(1))}`);
}
const sb = width === 1440 ? await b.evaluate('innerWidth-document.documentElement.clientWidth') : 0;
const expected = design.map(x => x.map(v => v - sb));
for (const [i,name] of ['North star','PRD','Settings'].entries()) {
const selector = `.project-header .project-primary-action:nth-of-type(${i+1})`;
const data = await b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)}),r=e.getBoundingClientRect();return {icon:!!e.querySelector('svg[aria-hidden="true"]'),left:r.left,right:r.right,top:r.top,bottom:r.bottom}})()`);
@@ -65,10 +65,13 @@ for (const zoom of [true,false]) test(`S9 project routes reflow at ${zoom ? 'act
});
for(const width of [1440,390]) test(`S9 Overview action and S8 header boxes at ${width}`,async t=>{
const {b}=await setup(t,width);
const data=await b.evaluate(`(()=>{const rect=e=>{const r=e.getBoundingClientRect();return [r.left,r.top,r.right,r.bottom]};return {button:rect(document.querySelector('[data-project-content] .project-section-title .btn')),section:rect(document.querySelector('.project-columns > section')),headers:[...document.querySelectorAll('.project-primary-action')].map(rect)}})()`);
const data=await b.evaluate(`(()=>{const rect=e=>{const r=e.getBoundingClientRect();return [r.left,r.top,r.right,r.bottom]};return {sb:innerWidth-document.documentElement.clientWidth,button:rect(document.querySelector('[data-project-content] .project-section-title .btn')),section:rect(document.querySelector('.project-columns > section')),headers:[...document.querySelectorAll('.project-primary-action')].map(rect)}})()`);
if(process.env.S9_CAPTURE) await b.screenshot(`${process.env.S9_CAPTURE}/${process.env.S9_PHASE}-overview-${width}.png`);
if(process.env.S9_MEASURE) console.log('S9 geometry',width,JSON.stringify(data));
const expected=width===1440?[[1060,130,1190,174],[1202,130,1290,174],[1302,130,1418,174]]:[[12,198,132,242],[140,198,220,242],[228,198,340,242]];
// E2E6 P2-11 (#1873): at 1440 the header actions end 22px inside the content edge, so the design's
// x values move left by the page scrollbar when the page scrolls (1418 was 100vw-based and clipped Settings).
const sb=width===1440?data.sb:0;
const expected=(width===1440?[[1060,130,1190,174],[1202,130,1290,174],[1302,130,1418,174]]:[[12,198,132,242],[140,198,220,242],[228,198,340,242]]).map(([x0,y0,x1,y1])=>[x0-sb,y0,x1-sb,y1]);
for(let i=0;i<3;i++) for(let j=0;j<4;j++) assert.ok(Math.abs(data.headers[i][j]-expected[i][j])<=4,`header ${i}: ${JSON.stringify(data.headers[i])}`);
// The accepted shell reserves a scrollbar and narrows the content; the
// action must align to its section when that puts the target right edge out of reach.