diff --git a/packages/webui/src/decision-http.mjs b/packages/webui/src/decision-http.mjs index 8806a53fc..ef5b8fc9c 100644 --- a/packages/webui/src/decision-http.mjs +++ b/packages/webui/src/decision-http.mjs @@ -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,111 @@ 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 the proposal checks in hire-decisions.mjs +// prepare() before it records the decision, then resolves and applies it in #hireConsole. Every +// check up to the consume transaction changes nothing, so those codes say "Nothing was applied." +// (the recorded proposal, if one was recorded, stays open in the inbox). Codes the apply step can +// raise after the decision is consumed say what to check instead. 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 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.' + NONE], + 'definition-missing': [409, 'The agent\'s role definition is missing from the organization file. Check the Agents page.' + NONE], + 'definition-secret-check-required': [503, 'The bus cannot check role text for secrets right now. Ask the owner to check the organization bus.' + NONE], + 'credential-leak': [409, 'The proposal carries a value the credential store holds. Remove it from the role text or the reason.' + NONE], + '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.' + NONE], + 'organization-unknown-instance': [409, 'No agent has that name at this level. Check the name on the Agents page.' + NONE], + 'organization-instance-exists': [409, 'An agent already has that name at this level. Pick another name, or propose a change to that agent.' + NONE], + 'organization-instance-retired': [409, 'That name belonged to a fired agent and cannot be reused. Pick another name.' + NONE], + 'organization-instance-referenced': [409, 'Another part of the organization still names this agent. Remove those references first.' + NONE], + 'instance-kept': [409, 'This agent is kept by the organization and cannot be fired or replaced this way.' + NONE], + 'root-fire-shared': [409, 'This root agent is shared with other organizations, so it cannot be fired from here.' + NONE], + 'decision-stale': [409, 'The role template or the organization changed since it was read. Reload the templates and propose again; if the inbox lists this proposal, answer it there.' + NONE], + 'organization-refused': [409, 'The organization file refused this change. Check the role and route against the organization checks.' + NONE], + 'not-configured': [503, 'This Console\'s bus has no hire support configured. Ask the owner to check the organization bus.' + NONE], + '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.' + NONE], }; +// 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 +247,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 +266,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') { diff --git a/packages/webui/src/organizations.mjs b/packages/webui/src/organizations.mjs index 5a88e2d97..999d8a9c8 100644 --- a/packages/webui/src/organizations.mjs +++ b/packages/webui/src/organizations.mjs @@ -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; } diff --git a/packages/webui/src/public/autonomy.js b/packages/webui/src/public/autonomy.js index ce0d276de..4a4e59415 100644 --- a/packages/webui/src/public/autonomy.js +++ b/packages/webui/src/public/autonomy.js @@ -22,19 +22,22 @@ 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. + 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,{error:error.message,...keep});}} function section(org) { const s=states.get(org); if(!s || s.error)return `

Autonomy

${esc(s?.error??'Autonomy could not be read.')}

`; const v=s.autonomy??{}; - return `

Autonomy

Choose how closely you follow each class of decision. Hard waits, flagged grants and credentials always need a person.

+ // 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 `

Autonomy

Choose how closely you follow each class of decision. Hard waits, flagged grants and credentials always need a person.

${classes.map(k=>``).join('')}

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.

${s.writable?'':'

Only the owner can change autonomy.

'} -

- ${s.writable?`
Review reversals before moving out of the loop

Read the decision trail and its reversals before recording your review.

${classes.map(k=>``).join('')}
`:''}
`; +

${esc(s.status)}

+ ${s.writable?`
Review reversals before moving out of the loop

Read the decision trail and its reversals before recording your review.

${classes.map(k=>``).join('')}
`:''}
`; } function notice(d) { if(!d?.autonomy)return ''; @@ -49,9 +52,14 @@ 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); + 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]'); diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 3c13249da..edb2abb03 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -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. diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js index 475e0bc58..663e1db21 100644 --- a/packages/webui/src/public/chat.js +++ b/packages/webui/src/public/chat.js @@ -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 . + const prose = value => txt(value).replace(/`([^`\n]+)`/g, '$1'); 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 `
The assistant couldn't reply. Try again, or check Agents.
Details: ${txt(m.body.slice('The turn failed: '.length))}
`; const r = renderReply?.(m.body, { check: m.check ?? null, name: m.from_role, changed: changedBy(m.check) }); - if (r?.kind === 'reply') return `
${txt(r.text)}
${r.citations.length ? cites(r.citations) : ''}`; + if (r?.kind === 'reply') return `
${prose(r.text)}
${r.citations.length ? cites(r.citations) : ''}`; if (r?.kind === 'notice') { const v = reverts.get(r.commit) ?? {}; return `
${txt(r.text)} ${current.archiveUnknown || current.projectArchived || window.MosaicOrg ? '' : ``} ${txt(v.receipt ?? '')}
`; } - return `
${txt(m.body)}
`; + return `
${prose(m.body)}
`; } const ago = iso => { const minutes = Math.floor((Date.now() - Date.parse(iso)) / 60000); diff --git a/packages/webui/src/public/org-create.css b/packages/webui/src/public/org-create.css index 8d55f2eb9..661041850 100644 --- a/packages/webui/src/public/org-create.css +++ b/packages/webui/src/public/org-create.css @@ -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)} diff --git a/packages/webui/src/public/org-create.js b/packages/webui/src/public/org-create.js index fdb00aa07..7fd0faedd 100644 --- a/packages/webui/src/public/org-create.js +++ b/packages/webui/src/public/org-create.js @@ -4,6 +4,9 @@ (() => { const $ = id => document.getElementById(id); const txt = v => String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 => `${String(v ?? '').split(' ').map(w => `${txt(w)}`).join(' ')}`; // #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 ${txt(verb)}` : ''; + $('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) => `
  • ${txt(s.title)}

    Not started

    Same as ${txt(s.verb)}

  • `).join(''); + list.innerHTML = steps.map((s, i) => `
  • ${txt(s.title)}

    Not started

    Same as ${cmd(s.verb)}

  • `).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 = `

    ${txt(title)}

    Same as ${txt(body.verb)}

    ${txt(JSON.stringify(body.receipt, null, 2))}
    `; + box.innerHTML = `

    ${txt(title)}

    Same as ${cmd(body.verb)}

    ${txt(JSON.stringify(body.receipt, null, 2))}
    `; 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 `
  • ${i + 1} ${txt(title)}

    ${txt(stateLabel)}
    -

    Same as ${txt(s.verb || '')}

    +

    Same as ${cmd(s.verb || '')}

    ${s.message ? `

    ${txt(s.message)}

    ` : ''} ${key.startsWith('project:') && s.receipt?.check ? `

    ${txt(checkLine(s.receipt))}

    ` : ''} ${s.lastError ? `` : ''} @@ -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 = `

    Organization restored. ${txt(line)}

    ${known && bus.verb ? `

    Same as ${txt(bus.verb)}

    ` : ''}${offer ? `

    ` : ''}`; + box.innerHTML = `

    Organization restored. ${txt(line)}

    ${known && bus.verb ? `

    Same as ${cmd(bus.verb)}

    ` : ''}${offer ? `

    ` : ''}`; 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 = `

    Same as ${txt(r.body.verb || `mosaic org delete ${id} --confirm ${id}`)}

    1. Bus: ${receipt?.bus ? 'Done' : r.body.step === 'bus' ? 'Stopped' : 'Not started'}
    2. Local files: ${receipt?.moves?.length || 0} moved; ${txt(r.body.step || 'deletion')} stopped
    3. Registry: ${receipt?.registry === 'removed' ? 'Done' : 'Not complete'}
    `; + $('org-delete-progress').innerHTML = `

    Same as ${cmd(r.body.verb || `mosaic org delete ${id} --confirm ${id}`)}

    1. Bus: ${receipt?.bus ? 'Done' : r.body.step === 'bus' ? 'Stopped' : 'Not started'}
    2. Local files: ${receipt?.moves?.length || 0} moved; ${txt(r.body.step || 'deletion')} stopped
    3. Registry: ${receipt?.registry === 'removed' ? 'Done' : 'Not complete'}
    `; 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 = `

    Organization deleted

    Same as ${txt(r.body.verb)}

    1. Bus stopped or was already stopped: Done
    2. Local files moved to the deleted folder: Done
    3. Organization removed from the list: Done
    Receipt
    ${txt(JSON.stringify(r.body.receipt, null, 2))}
    `; + box.innerHTML = `

    Organization deleted

    Same as ${cmd(r.body.verb)}

    1. Bus stopped or was already stopped: Done
    2. Local files moved to the deleted folder: Done
    3. Organization removed from the list: Done
    Receipt
    ${txt(JSON.stringify(r.body.receipt, null, 2))}
    `; 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 = `

    Resume with ${txt(`mosaic org delete ${id} --confirm ${id}`)}

    `; } diff --git a/packages/webui/src/public/packs.css b/packages/webui/src/public/packs.css index 919458330..3c992b7a5 100644 --- a/packages/webui/src/public/packs.css +++ b/packages/webui/src/public/packs.css @@ -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} diff --git a/packages/webui/src/public/packs.js b/packages/webui/src/public/packs.js index 3a9777614..cd7f1db3f 100644 --- a/packages/webui/src/public/packs.js +++ b/packages/webui/src/public/packs.js @@ -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 ? `

    Hires

    ` : '

    The pack proposes no hires.

    '; const skills = v.skills?.notEnabled?.length ? `

    Skills this machine has not enabled: ${v.skills.notEnabled.map(n => `${esc(n)}`).join(', ')}

    ` : ''; const chief = v.chief === false ? '

    The organization has no chief, so no line is compared against one.

    ' : ''; - const apply = s.writable ? `` : '

    This Console was started without send. Packs can be previewed, not applied.

    '; + const apply = s.writable ? `` : '

    This Console was started without send. Packs can be previewed, not applied.

    '; return `

    Preview: ${esc(v.pack?.label ?? p.pack)}${v.project ? ` for project ${esc(v.project)}` : ''}

    ${items}${hires}${skills}${chief}${apply}
    `; } function packRow(org, s, entry) { const pack = entry.pack; const project = pack.scope === 'project' ? s.projects === null ? '

    The project list could not be read.

    ' : s.projects.length - ? `` + ? `` : '

    No code project to apply this pack to.

    ' : ''; const canPreview = pack.scope !== 'project' || (s.projects && s.projects.length); return `
  • ${esc(pack.label ?? entry.id)} ${esc(entry.id)} ${esc(pack.version)} · ${esc(entry.origin)} · ${pack.scope === 'project' ? 'project pack' : 'organization pack'} -

    ${esc(pack.description)}

    ${project}${canPreview ? '' : ''}
  • `; +

    ${esc(pack.description)}

    ${project}${canPreview ? `` : ''}`; } function section(org) { const s = states.get(org); - if (!s || s.error) return `

    Starter packs

    ${esc(s?.error ?? 'Starter packs could not be read.')}

    `; + if (!s || s.error) return `

    Starter packs

    ${esc(s?.error ?? 'Starter packs could not be read.')}

    `; const list = s.packs.length ? `` : '

    No starter packs are installed.

    '; - return `

    Starter packs

    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.

    ${list}${previewBlock(s)}

    ${esc(s.status)}

    `; + return `

    Starter packs

    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.

    ${list}${previewBlock(s)}

    ${esc(s.status)}

    `; } 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 }; })(); diff --git a/packages/webui/src/public/panes.css b/packages/webui/src/public/panes.css index d6b863199..f137feca6 100644 --- a/packages/webui/src/public/panes.css +++ b/packages/webui/src/public/panes.css @@ -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} diff --git a/packages/webui/src/public/panes.js b/packages/webui/src/public/panes.js index da71ec7b4..e0cb94a24 100644 --- a/packages/webui/src/public/panes.js +++ b/packages/webui/src/public/panes.js @@ -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; }); diff --git a/packages/webui/src/public/projects.css b/packages/webui/src/public/projects.css index e23587283..e1e8a5e37 100644 --- a/packages/webui/src/public/projects.css +++ b/packages/webui/src/public/projects.css @@ -86,7 +86,14 @@ .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 goes once the strip is scrolled to its end. */ +@property --tabs-fade { syntax:''; inherits:false; initial-value:48px; } +@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); } } +} .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 +343,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 +512,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; } diff --git a/packages/webui/src/public/shell.css b/packages/webui/src/public/shell.css index ef1f90d54..8e042e4df 100644 --- a/packages/webui/src/public/shell.css +++ b/packages/webui/src/public/shell.css @@ -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} diff --git a/packages/webui/src/public/workflows.js b/packages/webui/src/public/workflows.js index 6fa82f5f4..7eb6bcdbe 100644 --- a/packages/webui/src/public/workflows.js +++ b/packages/webui/src/public/workflows.js @@ -43,8 +43,11 @@ 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: '' }; + 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) { s.error = error.message; } states.set(org, s); } @@ -60,8 +63,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 - ? `

    Delete this workflow?

    ` - : `

    `; + ? `

    Delete this workflow?

    ` + : `

    `; return `
  • ${esc(w?.title || name)} ${esc(name)} ${w?.description ? `

    ${esc(w.description)}

    ` : ''}
      ${steps.map(st => stepItem(s, st)).join('')}
    ${actions}
  • `; } @@ -81,7 +84,7 @@ const scopeField = e.isNew ? `` : `

    ${e.scope ? `Project ${esc(e.scope)}` : 'Organization'}, ${esc(e.name)}

    `; - return `

    ${e.isNew ? 'New workflow' : 'Edit workflow'}

    ${scopeField} + return `

    ${e.isNew ? 'New workflow' : 'Edit workflow'}

    ${scopeField} ${e.draft.steps.map((st, i) => stepRow(s, st, i)).join('')} @@ -95,8 +98,8 @@ const names = Object.keys(flows || {}).sort(); return `

    ${esc(label)}

    ${names.length ? `
      ${names.map(n => workflowItem(org, s, scope, n, flows[n])).join('')}
    ` : '

    No workflows.

    '}
    `; }).join(''); - const add = s.data.writable ? (s.editing ? '' : '

    ') : '

    Only the owner, on a Console started with send, can change workflows.

    '; - return `

    Workflows

    A workflow lists the steps a task goes through, the state each step puts it in and the roles that do it.

    ${lists}${add}${editor(s)}

    ${esc(s.status)}

    `; + const add = s.data.writable ? (s.editing ? '' : `

    `) : '

    Only the owner, on a Console started with send, can change workflows.

    '; + return `

    Workflows

    A workflow lists the steps a task goes through, the state each step puts it in and the roles that do it.

    ${lists}${add}${editor(s)}

    ${esc(s.status)}

    `; } function redraw(org) { const el = [...document.querySelectorAll('[data-workflows-org]')].find(n => n.dataset.workflowsOrg === org); @@ -124,10 +127,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 +151,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 +174,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 +185,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; } diff --git a/packages/webui/src/public/workspace-data.js b/packages/webui/src/public/workspace-data.js index 418a18350..576351961 100644 --- a/packages/webui/src/public/workspace-data.js +++ b/packages/webui/src/public/workspace-data.js @@ -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}`; diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index a53c4792a..5a53f3d15 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -52,7 +52,7 @@ } const av = (org, size = '') => ``; 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) => `${shown ? esc(word) : `${esc(word)}`}`; 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 `
      ${rows.map(([ic, name, value, tag, page]) => { @@ -193,6 +193,35 @@ }).join('')}
    `; } + // 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. + function holdLive(root) { + const held = new Map(), a = document.activeElement; + if (!root) return { held, active: null }; + for (const el of root.querySelectorAll('[data-live-key]')) { + if (el.matches('[data-live-hold]') || el.querySelector('[data-live-hold],[data-live-dirty]') || 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 +231,9 @@ const list = Array.isArray(listRead) ? listRead : null; const org = list?.find(o => o.id === id && !o.invalid); if (!org) return `

    Organization unavailable

    ${list ? 'This organization is not on your list.' : 'The organization list could not be read.'}

    `; - 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 +251,7 @@ const bus = restart ? `${restart}` : ` `; const reload = ``; const [bcls, bglyph] = state(org); - const meta = `

    Kind not recordedid ${esc(id)}Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}Restart is a trailed human action; reload arrives with the cross-org update.

    `; + const meta = `

    Kind not recordedid ${esc(id)}Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : org.busStopped ? `bus stopped, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}Restart is a trailed human action; reload arrives with the cross-org update.

    `; const projectName = pid => projectRows?.find(p => p.id === pid)?.name || pid; const docChips = !docRead ? 'Docs unavailable' : docRead.failed ? 'Docs unavailable' : docRead.rows.length ? docRead.rows.slice(0, 6).map(d => `${icon('doc')}${esc(docName(d.path))}${esc(projectName(d.project))}`).join('') : 'No docs yet'; @@ -242,9 +273,16 @@ : threads ? `Conversation${esc(first.thread)}${more}` : 'Conversation unavailable'; return `${esc(agent)}${where}${modelCell(agent, model)}${dot(acls, aglyph, aword, true)}${count > 1 ? ` ${count} sessions` : ''}`; }).join(''); - const sessions = live === null ? '

    Sessions unavailable. The bus did not answer the instance read.

    ' : live.length ? `
    ${sessionRows}
    AgentProject and taskModelStatus
    ` : '

    No active agent sessions

    '; + const sessions = live === null ? (stopped ? '

    Sessions not read: the bus is stopped.

    ' : '

    Sessions unavailable. The bus did not answer the instance read.

    ') : live.length ? `
    ${sessionRows}
    AgentProject and taskModelStatus
    ` : '

    No active agent sessions

    '; 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 ? '' : `

    ${qerr?.code === 'queue-refused' ? `The queue refused the read.${qerr.detail ? ` ${esc(qerr.detail.replace(//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)})` : ''}.`}

    `; + const stoppedPanel = (attr, title) => `

    ${title}

    Not read: the bus is stopped.

    `; return `
    @@ -257,15 +295,18 @@ ${secH('Active agent sessions', live ? (live.length ? `${live.length} running` : 'none running') : '', 'All agents')} ${sessions} ${secH('Queue', queueNote, 'Open queue')} + ${queueWhy}
    ${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) ?? ''}
    +
    + ${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) ?? ''} +
    `; } @@ -514,7 +555,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), `${num(t.doneThisWeek)} 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`)} -
    Model spendNo spend figures yetNo read prices model use today.
    +
    Model spendNo spend figures yetNo model prices are read yet, so today's use has no cost figure.
    ` : '

    No organization answered, so there are no totals yet.

    '; const cards = o.rows.map(r => { @@ -783,6 +824,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(); })(); diff --git a/packages/webui/tests/chat-replies-browser.test.mjs b/packages/webui/tests/chat-replies-browser.test.mjs index f45123278..e88bb7764 100644 --- a/packages/webui/tests/chat-replies-browser.test.mjs +++ b/packages/webui/tests/chat-replies-browser.test.mjs @@ -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, diff --git a/packages/webui/tests/decision-http.test.mjs b/packages/webui/tests/decision-http.test.mjs index 7a2e49322..76bf3de8b 100644 --- a/packages/webui/tests/decision-http.test.mjs +++ b/packages/webui/tests/decision-http.test.mjs @@ -326,12 +326,62 @@ 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);assert.match(r.json().message,/Nothing was applied/); + 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 ['organization-instance-exists','instance-kept','root-fire-shared','unknown-model','harness-refused','definition-rev-conflict','definition-missing','credential-leak','decision-stale','organization-instance-retired','organization-instance-referenced','organization-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/); + } + 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: 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'); diff --git a/packages/webui/tests/e2e6-layout-browser.test.mjs b/packages/webui/tests/e2e6-layout-browser.test.mjs new file mode 100644 index 000000000..87520d0a3 --- /dev/null +++ b/packages/webui/tests/e2e6-layout-browser.test.mjs @@ -0,0 +1,75 @@ +// 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. +// 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'); +}); + +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'); + }); +} diff --git a/packages/webui/tests/org-live-edit-browser.test.mjs b/packages/webui/tests/org-live-edit-browser.test.mjs new file mode 100644 index 000000000..40819f57b --- /dev/null +++ b/packages/webui/tests/org-live-edit-browser.test.mjs @@ -0,0 +1,119 @@ +// 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 }; + 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: 'Web project', 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++; 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++; 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, 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'); +});