Compare commits

..
Author SHA1 Message Date
jason.woltje e08f0fa1f4 test(webui): match the E2E round 4 Queue heading and record-init write
The refused Queue read keeps Queue as its heading, and the owner's
Initialize project records button is a new POST in bus.js. Both older
tests predated those intended changes.
2026-11-09 11:14:08 -06:00
jason.woltje 3581f7548a Merge E2E round 4 fixes (org-e2e4-fix) into refactor
# Conflicts:
#	packages/webui/src/public/chat.js
#	packages/webui/src/public/projects.js
2026-11-08 23:25:02 -06:00
jason.woltje 1b8e7976a6 bus: PRD reads refuse when org project records exist at both roots
readCandidate called readPrdMaterial with the org data root directly, so
prd.submit and the Console PRD draft kept working while project.files refused
the same conflict. It now resolves through recordArgs like the file adapters.
Fixes the P2 from the E2E4 review.
2026-11-08 23:24:19 -06:00
jason.woltje c27ff53649 webui: reread request panels, files and summary on every refresh
The refresh shortcuts (unchanged view, transcript-only change) skipped the request
summary and file rereads, so a request's phase change showed only after a full
repaint. One refreshRequestData helper now runs on every paint path; a quiet reread
keeps the present content until a changed result arrives.

s6b's error matrix waited for the 'Reading' placeholder to go, which a quiet
reread no longer shows, so it could read the previous scenario. It now waits for
the expected text.
2026-11-08 23:22:43 -06:00
jason.woltje 61e4e76520 Fix organization setup records and console regressions 2026-11-08 22:55:55 -06:00
jason.woltje 20d798cd57 Merge live project updates (org-live-updates) into refactor 2026-11-08 22:43:23 -06:00
jason.woltje a8bcf94b8c Scope requirement view state to its request and keep closed panels closed
A refresh that drops the displayed request no longer hands its open
requirement and focus to another request's requirement with the same
local id: the restore key now carries the request id. Keyed disclosures
also remember a closed state, so desktop provenance the user closed
stays closed through a repaint. Adds Sol's two R3 probes as tests.
2026-11-08 22:43:16 -06:00
jason.woltje af29832a05 Keep request selection and requirement state by identity across refreshes
Sol's re-check of c0b2f7d86 found two failures. A refresh that changed only an
unselected request kept the page and so kept the request selector's old closure;
selecting that request then drew its old PRD. Retained handlers now draw from the
newest accepted read for their container.

Requirement disclosures and focus were saved by position, so a requirement
inserted above the focused one handed its open state and focus to a neighbour.
Requirement and provenance details carry a restore key; open state and summary
focus are restored by that key and a key that no longer exists is dropped. The
outer chat capture uses the same helper.

org-live-fix-r3-browser.test.mjs holds Sol's two probes: 0/2 on c0b2f7d86, 2/0
here. Nineteen live-update, chat, project, request and s10-s15 files: 231/0.
2026-11-08 22:23:59 -06:00
jason.woltje c0b2f7d86a Preserve live project views through overlapping refreshes 2026-11-08 21:57:48 -06:00
jason.woltje ac63a187e8 Merge walkthrough fixes and LAN dev bind (org-stage) into refactor
org-walkthrough-fix 60f3b3d78 (Sol APPROVE 0/0/0) and org-lan-bind 95a5f7072 (Sol APPROVE 0/0/1) via org-stage 265f1767c.
2026-11-08 21:56:43 -06:00
jason.woltje 265f1767c0 Merge org-lan-bind into org-stage 2026-11-08 21:27:33 -06:00
jason.woltje 298081dbf3 Keep console views stable across refreshes 2026-11-08 21:24:12 -06:00
jason.woltje 95a5f7072d Document the console LAN flags and console status 2026-11-08 21:13:56 -06:00
jason.woltje 665a5148d4 Open the dev owner Console to allowed LAN names
mosaic console start --dev-local-owner --dev-insecure-bind ADDR
--dev-allow-host NAME[,NAME...] binds 0.0.0.0 or an IPv4 address and
answers only NAME:<bound port> plus the loopback names. Both flags are
refused without the dev owner, the bind without an allowed name, and a
specific ADDR that is not itself a name. Start and console status print
the no-sign-in warning; the state file records bind and allowHosts.

serve.mjs takes devAllowHosts only with the dev owner; SSO, preview and
tester listeners stay loopback. Writes need Origin http://<same Host>.
Chromium sends no Fetch Metadata from a LAN http origin, so for an
allowed name a write with that exact Origin and no Sec-Fetch-Site counts
as same-origin; an explicit header is still judged by the routes.
chat.js builds its operation keys without crypto.randomUUID, which an
insecure context lacks.
2026-11-08 21:13:53 -06:00
jason.woltje 60f3b3d78f Offer New organization in the header menu, picker and empty state
Once organizations existed, Quick Setup was reachable only through
Settings > Organizations > type an ID > New organization. This tree
has no header organization switcher, so the owner of a Console with
organization setup now gets "New organization" in the header avatar
menu, under the Organization picker on Settings > Organization, and
next to the Settings link in the no-organization empty state. Each
opens #/settings/organizations?new=1, which focuses the ID field and
then drops the flag. The form itself is unchanged.

The new browser test covers the menu at 1400 and 390px, the picker
and the empty state. It fails on 6ca7f3b65 and passes here.
2026-11-08 20:58:46 -06:00
jason.woltje 4b40c7f57b Keep project crumbs evenly spaced for short project names
"Projects / work /  / Conversations / init" had no missing label.
Every project crumb link had min-width:44px, so a short name such as
"work" was padded with blank space after its text, and the separators
around it read as an empty crumb. The 44px target width now comes
from an ::after overlay centred on the link, so the layout box fits
the text on every project route.

The new browser test measures text-to-separator gaps and hit width
on the overview, conversations and conversation routes at 1440 and
390px. It fails on 6ca7f3b65 and passes here.
2026-11-08 20:58:40 -06:00
jason.woltje a1491a0163 Give organization Consoles a request read so request status resolves
The organization options in cli.mjs carried no requestRead, so
/api/orgs/<id>/config never offered requests and the project and
conversation pages counted "requests not offered" as a failed read:
"Request status unavailable." on a conversation whose message went
through. The owner now reads request status over the organization's
own bus socket, as the root Console does. Request writes stay off,
per the README Console actions inventory.

The new browser test runs the real cli.mjs serve --dev-local-owner
against a real organization record with only the bus transports
shimmed. It fails on 6ca7f3b65 and passes here.
2026-11-08 20:58:35 -06:00
50 changed files with 1781 additions and 159 deletions

No files matched your search

+10 -2
View File
@@ -302,6 +302,8 @@ scripts/mosaic inbox --read <id>... | --read-all [--business <id>]
scripts/mosaic decide <decision> <option> [--note <text>] [--yes]
scripts/mosaic trail <task|decision> [--json]
scripts/mosaic bus start <business> [--pm | --no-launch] [--dev-local-owner] | stop | status [--json]
scripts/mosaic console start [--dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--detach] [--port auto|N]
scripts/mosaic console stop | status
scripts/mosaic talk <role> <text> [--wait SECONDS] [--business <id>]
scripts/mosaic project create <name> [--repo <repo>] | list [--json]
scripts/mosaic threads [--project <project>] [--json] [--tester]
@@ -320,6 +322,8 @@ scripts/bus-service.sh [--instance NAME] uninstall | status <business>
For local development on a named instance only, `mosaic bus start BUSINESS --dev-local-owner` admits the fixed local-dev Console owner and refuses the default instance. Launches are enabled unless `--no-launch` is supplied; `--pm` can start the business's PM. Launched agents run as this OS user and can reach the loopback Console as owner; use this instance for UI testing only. Start the matching Console with `--dev-local-owner`; the usual Console wrapper, process proof and nonce checks still apply. This mode has no sign-in and is for local development only.
`mosaic console start --dev-local-owner --dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]` opens that dev owner Console to other machines, for UI testing from another device on a trusted LAN. It has no sign-in: anyone who can reach ADDR:PORT acts as the owner. Start, and `mosaic console status` while that Console runs, print `WARNING: dev owner Console listens on ADDR:PORT with no sign-in; anyone who can reach it acts as the owner.` on stderr. ADDR is `0.0.0.0` or an IPv4 address, and a specific address must also be one of the names. NAME is a host name or IPv4 address without a port; give several as one comma-separated value. The Console answers only `NAME:<bound port>` and the loopback names, and takes a write only when Origin is `http://` plus that same Host. A browser on such an http origin sends no Fetch Metadata, so for an allowed NAME a write with that exact Origin and no `Sec-Fetch-Site` counts as same-origin; a `Sec-Fetch-Site` the browser did send is still checked. Both flags are refused without `--dev-local-owner`, and `--dev-insecure-bind` is refused without a `--dev-allow-host`. SSO, preview and tester Consoles stay on loopback. The Console state file records `bind` and `allowHosts`. `mosaic console status` prints the state as JSON (`bind` is `127.0.0.1` for a loopback Console) and never moves a stale state file.
The instance processing switch is separate from per-business launches off/on.
`mosaic halt` durably stops managed processing across every business in the
selected configuration's data root. The bus stays up for reads and human
@@ -1046,7 +1050,7 @@ including `console.sso` (exit 2 if missing). Without `--send` the unit is
read-only. The template is
`packages/webui/systemd/mosaic-webui.service.in`.
For direct local development (not the service renderer), `node packages/webui/src/cli.mjs serve --dev-local-owner --send` uses the owner without sign-in. It requires `MOSAIC_CONFIG` naming a non-default `~/.config/mosaic-NAME/config.json`, listens only on 127.0.0.1, and requires an exact local Host and same-origin writes. It refuses SSO, preview, proof, and tester flags. Use an absolute path to the Console CLI so the bus can verify its process path. Start the named instance bus with `--dev-local-owner` as above for decision and request operations. Anyone able to reach the local listener acts as the owner.
For direct local development (not the service renderer), `node packages/webui/src/cli.mjs serve --dev-local-owner --send` uses the owner without sign-in. It requires `MOSAIC_CONFIG` naming a non-default `~/.config/mosaic-NAME/config.json`, listens on 127.0.0.1 unless `--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]` opens it to the LAN as described for `mosaic console start` above, and requires an exact local or allowed Host and same-origin writes. It refuses SSO, preview, proof, and tester flags. Use an absolute path to the Console CLI so the bus can verify its process path. Start the named instance bus with `--dev-local-owner` as above for decision and request operations. Anyone able to reach the local listener acts as the owner.
Under `--send` the Console's Docs page (row 148) can also save an existing
spec/north-star.md or docs/NAME.md of a project: one local commit authored by the
@@ -1316,7 +1320,11 @@ refuses `project-no-commit` if the layout has no readable HEAD commit. It
exits 0 when valid, 2 for project/record problems (including an undeclared
project), 3 for configuration loading problems and 4 for usage. The optional business project `repo` is a normalized absolute
record-repository path; the default is
`<dataRoot>/projects/<business>/<project>/`. `root` remains the code checkout.
`<dataRoot>/projects/<business>/<project>/`. For a registered organization,
`<dataRoot>` is that organization's data root for both the CLI and its bus.
If an instance-root records repository already exists, initialization and reads
refuse and name both locations for inspection; they never move or copy it.
`root` remains the code checkout.
The package README describes schemas and the shared agent record writer; agent
writes never enter `spec/`, never absorb pending edits and refuse remotes.
+12 -9
View File
@@ -491,6 +491,7 @@ export async function startBroker({
const withTokens = (id, result) => ({ ...result, tokens: limits.report(id) });
// Row 210 (#1707, design D7 step 6) and row 234 (#1740, r6 E2): the run dir and the host enrollment half.
const runRoot = organization ? join(organization.dir, 'secrets', 'run') : null;
const recordArgs = context => ({ ...context, ...projectModule.recordContext({ ...context, dataRoot: instanceDataRoot ?? dataRoot, orgRoot: org ? dataRoot : null }) });
let broker;
processing = createProcessing({ dataRoot, ...discordProcessingHooks(dataRoot), containerStop: createContainerStopper({ dataRoot }),
onTransition: () => broker.reconcileProcessing(),
@@ -650,15 +651,17 @@ export async function startBroker({
workspaceRead: state => requestWorkspace({ dataRoot, state, events: store.all("SELECT kind,actor_role,subject,body FROM events WHERE business=? AND kind IN ('task.created','task.assigned','session.launched') ORDER BY seq LIMIT 10001", state.business) }),
requirementsHost,
requestLifecycle: services => {
const readCandidate = (candidate, business) => {
// The PRD reader goes through the same record resolver as the file adapters, so both roots present refuses here too.
const readCandidate = async (candidate, business) => {
const { request, project, prd_version, records_commit, prd_path, mode_revision } = candidate;
return readPrdMaterial({ business: services.prdBusiness(business), dataRoot, request, project, prd_version, records_commit, prd_path, mode_revision,
const records = recordArgs({ business: services.prdBusiness(business), project });
return readPrdMaterial({ business: records.business, dataRoot: records.dataRoot, request, project, prd_version, records_commit, prd_path, mode_revision,
requirement_ids: candidate.requirement_ids ?? candidate.requirements.map(r => r.id) });
};
const catalog = createRequirementCatalog({ journal: { ...requestJournal, readCandidate, authority: services.prdAuthority, approval: services.prdApproval },
readSpec: ({ business, project }) => projectModule.readSpec({ business: services.prdBusiness(business), project, dataRoot }),
readSpec: ({ business, project }) => projectModule.readSpec(recordArgs({ business: services.prdBusiness(business), project })),
builtinBinding: services.builtinBinding, projectArchived: services.projectArchived, threadProject: services.threadProject });
return (requestLifecycle = createRequestLifecycle({ journal: requestJournal, services: { catalog, readCandidate, readReviewedArtifact: (state, commit, path) => readReviewedArtifact({ repo: workingRepo(dataRoot, state.business, state.project), commit, path }), readVerification: (state, record) => projectModule.readProjectFile({ business: services.prdBusiness(state.business), project: state.project, dataRoot, path: record.path, commit: record.commit }), requestAdmission: state => admission({ business: state.business, project: state.project }), ...(writer ? hostPreparation({ dataRoot, organization: writer, trackers, journal: requestJournal, catalog, admission, work: fn => processing.work(fn), readSpec: ({ business, project }) => projectModule.readSpec({ business: services.prdBusiness(business), project, dataRoot }) }) : {}), ...requestServices, ...services,
return (requestLifecycle = createRequestLifecycle({ journal: requestJournal, services: { catalog, readCandidate, readReviewedArtifact: (state, commit, path) => readReviewedArtifact({ repo: workingRepo(dataRoot, state.business, state.project), commit, path }), readVerification: (state, record) => projectModule.readProjectFile(recordArgs({ business: services.prdBusiness(state.business), project: state.project, path: record.path, commit: record.commit })), requestAdmission: state => admission({ business: state.business, project: state.project }), ...(writer ? hostPreparation({ dataRoot, organization: writer, trackers, journal: requestJournal, catalog, admission, work: fn => processing.work(fn), readSpec: ({ business, project }) => projectModule.readSpec(recordArgs({ business: services.prdBusiness(business), project })) }) : {}), ...requestServices, ...services,
// The new-project executor needs the organization writer; without one a confirmed selection stays at its setup wait.
...(writer ? { bootstrap: ({ binding, state }) => processing.work(() => executeBootstrap({ binding, owner: state.owner, journal: requestJournal, services: {
dataRoot, organization: writer, activate: activateManagedProject, barrier: requestServices.barrier, projectArchived: services.projectArchived, threadProject: services.threadProject,
@@ -717,11 +720,11 @@ export async function startBroker({
GuardError: libraryModule.GuardError,
},
project: {
readSpec: context => projectModule.readSpec({ ...context, dataRoot }),
writeRecord: context => processing.work(() => projectModule.writeRecord({ ...context, dataRoot })),
listFiles: context => projectModule.listProjectFiles({ ...context, dataRoot }),
readFile: context => projectModule.readProjectFile({ ...context, dataRoot }),
writeFile: context => projectModule.writeHumanFile({ ...context, dataRoot }),
readSpec: context => projectModule.readSpec(recordArgs(context)),
writeRecord: context => processing.work(() => projectModule.writeRecord(recordArgs(context))),
listFiles: context => projectModule.listProjectFiles(recordArgs(context)),
readFile: context => projectModule.readProjectFile(recordArgs(context)),
writeFile: context => projectModule.writeHumanFile(recordArgs(context)),
ProjectError: projectModule.ProjectError,
},
// limits and limits.set (organization.set is limits.set) carry each token limit's count; a
@@ -0,0 +1,70 @@
import test from 'node:test';
import { DatabaseSync } from 'node:sqlite';
import assert from 'node:assert/strict';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { bootConfig, loadSystem } from '../../cli/src/config.mjs';
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
import { prdFixture, git } from '../../project/tests/prd-fixture.mjs';
import { createRequestStore } from '../src/request-store.mjs';
async function setup(t, mode = 'autonomous') {
const root = tmp(t, 'activation-'), f = fixture(root, 'demo');
const instanceRoot=join(root,'instance');mkdirSync(instanceRoot);
const source = join(root, 'project'); mkdirSync(join(source, '.mosaic'), { recursive: true });
writeFileSync(join(source, '.mosaic/project.json'), JSON.stringify({ projectVersion: 1, id: 'stack', vars: { 'tracker.project': 1 } }));
git(source, 'init', '-qb', 'main'); git(source, 'add', '-A'); git(source, 'commit', '-qm', 'Factory');
const system = loadSystem({ env: f.env });
const boot = async () => {
const config = bootConfig({ system, businessId: 'demo', env: f.env, warn: () => {} });
const bus = await startBroker({...config,org:{id:'demo'},instanceDataRoot:instanceRoot,instanceConfigDir:join(root,'config')}); t.after(() => bus.close().catch(() => {})); return bus;
};
const bus = await boot(), caps = {};
const db = new DatabaseSync(join(f.dataRoot, 'bus/bus.sqlite'), { readOnly: true }); t.after(() => db.close());
const store = { get: (sql, ...a) => db.prepare(sql).get(...a), all: (sql, ...a) => db.prepare(sql).all(...a) };
const bind = b => {
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
caps.console = b.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'test', iss: 'test', sub: 'owner' } });
caps.reader = b.bindReader({ business: 'demo' });
for (const role of ['pm','cto','coder']) { caps[role] = b.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi' }); b.request(caps[role], { verb: 'role.claim' }); }
};
bind(bus.broker);
const call = (who, verb, args = {}) => bus.broker.request(caps[who], { verb, args });
const sent = call('human', 'message.send', { to: 'pm', body: 'Build a timer.\n' });
let state = await call('pm', 'request.open', { request: sent.request });
state = await call('pm', 'request.propose', { request: sent.request, revision: state.revision, op: 'plan', source_project: 'stack', project: 'timer', name: 'Timer', tracker_project: 2 });
call('human', 'decision.resolve', { id: state.selection.decision, choice: mode });
state = await bus.requestLifecycle.resume('demo', sent.request);
const context = { business: { id: 'demo', human: 'jason', projects: { timer: { root: join(f.dataRoot, 'products/demo/timer/code') } } }, project: 'timer', dataRoot: f.dataRoot };
const p = await prdFixture(t, context);
const args = { request: sent.request, revision: state.revision, op: 'prd1', prd_version: '1.0', records_commit: p.args.records_commit, prd_path: p.args.prd_path, requirement_ids: p.args.requirement_ids };
const submit = () => call('pm', 'prd.submit', args);
const approve = (submission, who = mode === 'guided' ? 'human' : 'cto') => call(who, 'decision.resolve', { id: submission.decision.id, choice: 'yes' });
const activate = submission => bus.requirementCatalog.activate({ request: sent.request, decision: submission.decision.id });
return { ...p, f, bus, caps, call, state, instanceRoot, request: sent.request, args, submit, approve, activate, boot, bind, config: bootConfig({ system, businessId: 'demo', env: f.env, warn: () => {} }), store, journal: createRequestStore(store) };
}
// E2E4 review P2: in an organization runtime the PRD reader resolves records through the same
// conflict guard as project.files, so records at both roots refuse and neither root changes.
test('org PRD submit refuses when records exist at both roots', async t => {
const f = await setup(t, 'guided');
const legacy = join(f.instanceRoot, 'projects/demo/timer');
mkdirSync(legacy, { recursive: true }); writeFileSync(join(legacy, 'keep'), 'legacy record evidence');
await assert.rejects(f.call('human', 'project.files', { project: 'timer' }), { code: 'project-records-conflict' });
const before = git(f.repo, 'rev-parse', 'HEAD');
const decisions = f.store.get("SELECT count(*) n FROM decisions").n;
await assert.rejects(f.submit(), { code: 'project-records-conflict' });
assert.equal(f.store.get("SELECT count(*) n FROM decisions").n, decisions, 'no approval decision is raised');
assert.equal(git(f.repo, 'rev-parse', 'HEAD'), before);
assert.equal(readFileSync(join(legacy, 'keep'), 'utf8'), 'legacy record evidence');
});
test('org Console PRD draft refuses when records exist at both roots', async t => {
const f = await setup(t, 'guided'); await f.submit();
const legacy = join(f.instanceRoot, 'projects/demo/timer');
mkdirSync(legacy, { recursive: true }); writeFileSync(join(legacy, 'keep'), 'legacy');
await assert.rejects(f.call('console', 'request.draft.console', { request: f.request }), { code: 'project-records-conflict' });
assert.equal(readFileSync(join(legacy, 'keep'), 'utf8'), 'legacy');
});
+17 -6
View File
@@ -28,7 +28,7 @@ import { PluginError } from "../../plugins/src/kinds.mjs";
import { CliError } from "./errors.mjs";
import { readNotifyConfig } from "./notify-config.mjs";
export { readNotifyConfig } from "./notify-config.mjs";
import { DEV_OWNER, devOwnerInstance } from "./dev-local-owner.mjs";
import { DEV_OWNER, devOwnerInstance, devLanOptions } from "./dev-local-owner.mjs";
import { bootConfig, loadSystem, orgPaths, socketPath } from "./config.mjs";
import { bootLine, readBootStatus, recordBootOk, recordBootRefused } from "./boot-status.mjs";
import { businessFilePath, configDir } from "../../business/src/index.mjs";
@@ -41,7 +41,7 @@ import { readSessions, stopSession } from "../../seat/src/session.mjs";
import { SeatError } from "../../seat/src/seat.mjs";
import { OrgError, resolveOrg, withOrgLifecycle } from "../../business/src/org-registry.mjs";
import { busStart, busStatus, busStop, busRestart, busRecover } from "./org-bus.mjs";
import { consoleStart, consoleStop } from "./org-console.mjs";
import { consoleStart, consoleStatus, consoleStop } from "./org-console.mjs";
import { usageCommand, USAGE_USAGE } from "./usage.mjs";
import { orgCommand, ORG_USAGE } from "./org.mjs";
@@ -83,8 +83,8 @@ export const USAGE = `usage:
mosaic bus start <id> [--pm | --no-launch] [--dev-local-owner] [--detach]
mosaic bus stop|restart|recover <id>
mosaic bus status <id> [--json]
mosaic console start [--dev-local-owner] [--detach] [--port auto|N]
mosaic console stop
mosaic console start [--dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--detach] [--port auto|N]
mosaic console stop|status
${DEV_USAGE}
${RUN_USAGE}`;
@@ -555,10 +555,21 @@ async function consoleCommand(rest, io, deps) {
io.stdout.write(`${JSON.stringify(await consoleStop(organizationContext(io, deps), { warn: s => io.stderr.write(s) }))}\n`);
return;
}
if (sub === "status") {
if (args.length) throw usage("console status takes no arguments");
io.stdout.write(`${JSON.stringify(consoleStatus(organizationContext(io, deps), { warn: s => io.stderr.write(s) }))}\n`);
return;
}
if (sub !== "start") throw usage();
const parsed = parse(args, { flags: ["--dev-local-owner", "--detach"], values: ["--port"] });
const parsed = parse(args, { flags: ["--dev-local-owner", "--detach"], values: ["--port", "--dev-insecure-bind", "--dev-allow-host"] });
if (parsed.positional.length) throw usage("console start takes no positional arguments");
const receipt = await consoleStart(organizationContext(io, deps), { devLocalOwner: parsed.flags.has("--dev-local-owner"), detach: parsed.flags.has("--detach"), port: parsed.values["--port"] ?? "auto", warn: s => io.stderr.write(s) });
// org-lan-bind: refused here, before any config is read, unless both flags come with
// --dev-local-owner. ADDR is 0.0.0.0 or an IPv4 address; NAME[,NAME...] has no ports.
const devOwner = parsed.flags.has("--dev-local-owner"), bind = parsed.values["--dev-insecure-bind"] ?? null;
const names = Object.hasOwn(parsed.values, "--dev-allow-host") ? [parsed.values["--dev-allow-host"]] : [];
let lan;
try { lan = devLanOptions({ devOwner, bind, names }); } catch (err) { throw usage(err.message); }
const receipt = await consoleStart(organizationContext(io, deps), { devLocalOwner: devOwner, detach: parsed.flags.has("--detach"), port: parsed.values["--port"] ?? "auto", ...(lan ? { bind: lan.bind, allowHosts: lan.allowHosts } : {}), warn: s => io.stderr.write(s) });
io.stdout.write(`${JSON.stringify(receipt)}\n`);
if (!parsed.flags.has("--detach")) {
const code = await receipt.done;
+30
View File
@@ -1,4 +1,5 @@
import { realpathSync } from 'node:fs';
import { isIP } from 'node:net';
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import { configDir } from '../../business/src/business.mjs';
@@ -17,3 +18,32 @@ export function devOwnerInstance(env = process.env) {
catch (err) { if (err.message === refusal) throw err; if (err.code !== 'ENOENT') throw new Error(refusal); }
return match[1];
}
// org-lan-bind: a name for --dev-allow-host is lowercase DNS labels or an IPv4 address, with no
// port; the Console then accepts the Host NAME:<bound port>.
const HOST_LABEL = /^(?!-)[a-z0-9-]{1,63}(?<!-)$/;
export function devHostName(name) {
if (typeof name !== 'string' || !name || name.length > 253) return false;
if (isIP(name) === 4) return true;
return !/^[0-9.]+$/.test(name) && name.split('.').every(label => HOST_LABEL.test(label));
}
// --dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]: the dev owner Console on the LAN with
// no sign-in. `names` holds the raw --dev-allow-host values. Returns null when neither flag is
// given, else { bind, allowHosts }; throws the refusal otherwise.
export function devLanOptions({ devOwner = false, bind = null, names = [] } = {}) {
if (bind === null || bind === undefined) {
if (names.length) throw new Error('--dev-allow-host needs --dev-insecure-bind');
return null;
}
if (!devOwner) throw new Error('--dev-insecure-bind needs --dev-local-owner');
if (typeof bind !== 'string' || isIP(bind) !== 4) throw new Error('--dev-insecure-bind must be 0.0.0.0 or an IPv4 address');
if (!names.length) throw new Error('--dev-insecure-bind needs at least one --dev-allow-host');
const allowHosts = [];
for (const name of names.flatMap(value => typeof value === 'string' ? value.split(',') : [value])) {
if (!devHostName(name)) throw new Error('--dev-allow-host takes host names or IPv4 addresses without a port, comma-separated');
if (!allowHosts.includes(name)) allowHosts.push(name);
}
// The printed URL must pass the Host check, so a specific address is also an allowed name.
if (bind !== '0.0.0.0' && bind !== '127.0.0.1' && !allowHosts.includes(bind)) throw new Error('--dev-insecure-bind ADDR other than 0.0.0.0 must also be a --dev-allow-host name');
return { bind, allowHosts };
}
+34 -6
View File
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
import { listOrgs, resolveOrg } from '../../business/src/org-registry.mjs';
import { cmdlineOf, startTimeOf } from './host.mjs';
import { CliError } from './errors.mjs';
import { devLanOptions } from './dev-local-owner.mjs';
const WEBUI = fileURLToPath(new URL('../../webui/src/cli.mjs', import.meta.url));
const forbidden = new Set([7332, 7340, 7341, 33223, 17450, 17443, 44307]);
@@ -25,8 +26,14 @@ function state(ctx) {
try { data = JSON.parse(readFileSync(fd, 'utf8')); } catch { fail('console state is unreadable'); }
} finally { closeSync(fd); }
if (!Number.isSafeInteger(data?.pid) || data.pid <= 0 || typeof data.startTime !== 'string' || !data.startTime || typeof data.url !== 'string') fail('console state is invalid');
// org-lan-bind: a LAN-exposed Console records where it listens and the names it answers.
if (data.bind !== undefined || data.allowHosts !== undefined) {
try { if (!Array.isArray(data.allowHosts) || data.allowHosts.some(name => typeof name !== 'string' || name.includes(',')) || !devLanOptions({ devOwner: true, bind: data.bind, names: data.allowHosts })) throw new Error(); }
catch { fail('console state is invalid'); }
}
return data;
}
const lanWarning = (bind, port) => `WARNING: dev owner Console listens on ${bind}:${port} with no sign-in; anyone who can reach it acts as the owner.\n`;
function checkDir(ctx) {
mkdirSync(directory(ctx), { recursive: true, mode: 0o700 });
const st = lstatSync(directory(ctx));
@@ -57,9 +64,13 @@ function takeLock(ctx) {
let fd; try { fd = openSync(path, 'wx', 0o600); } catch { fail('console start lock exists'); }
return () => { closeSync(fd); rmSync(path, { force: true }); };
}
export async function consoleStart(ctx, { devLocalOwner = false, detach = false, port = 'auto', warn = s => process.stderr.write(s) } = {}) {
// `bind` and `allowHosts` are --dev-insecure-bind and --dev-allow-host (org-lan-bind): the dev
// owner Console on the LAN with no sign-in. Neither is set for a loopback Console.
export async function consoleStart(ctx, { devLocalOwner = false, detach = false, port = 'auto', bind = null, allowHosts = [], warn = s => process.stderr.write(s) } = {}) {
const value = port === 'auto' ? 0 : Number(port);
if (!(port === 'auto' || (typeof port === 'string' && /^\d+$/.test(port) && value >= 1 && value <= 65535 && !forbidden.has(value)))) fail('invalid or forbidden console port');
let lan;
try { lan = devLanOptions({ devOwner: devLocalOwner, bind, names: allowHosts }); } catch (e) { throw new CliError(e.message, 4); }
const release = takeLock(ctx);
let child = null, done = null;
try {
@@ -68,7 +79,8 @@ export async function consoleStart(ctx, { devLocalOwner = false, detach = false,
const org = (await listOrgs(ctx)).find(x => x.state === 'active');
if (org) await resolveOrg(ctx, org.id);
// The pre-S5 Console still accepts a business preselection.
const args = [WEBUI, 'serve', '--port', String(value), ...(org ? ['--business', org.id] : []), ...(devLocalOwner ? ['--dev-local-owner'] : [])];
const args = [WEBUI, 'serve', '--port', String(value), ...(org ? ['--business', org.id] : []), ...(devLocalOwner ? ['--dev-local-owner'] : []),
...(lan ? ['--dev-insecure-bind', lan.bind, '--dev-allow-host', lan.allowHosts.join(',')] : [])];
const log = join(directory(ctx), `console-${process.pid}-${Date.now()}.log`);
const fd = openSync(log, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
try { child = spawn(process.execPath, args, { env: ctx.env ?? process.env, detached: detach, stdio: ['ignore', fd, fd] }); }
@@ -78,14 +90,18 @@ export async function consoleStart(ctx, { devLocalOwner = false, detach = false,
const deadline = Date.now() + 30000;
while (Date.now() < deadline && !exited) {
const text = readFileSync(log, 'utf8');
const match = /Mosaic Console: (http:\/\/127\.0\.0\.1:(\d+)\/)/.exec(text);
// The URL is loopback, or the specific address a LAN Console binds.
const match = /Mosaic Console: (http:\/\/([0-9.]+):(\d+)\/)/.exec(text);
if (match) {
if (forbidden.has(Number(match[2]))) fail('console selected a forbidden port');
if (match[2] !== '127.0.0.1' && match[2] !== lan?.bind) fail('console printed an unexpected address');
if (forbidden.has(Number(match[3]))) fail('console selected a forbidden port');
const startTime = startTimeOf(child.pid);
if (!startTime) fail('console exited during startup');
writeFileSync(stateFile(ctx), JSON.stringify({ pid: child.pid, startTime, url: match[1] }) + '\n', { mode: 0o600, flag: 'wx' });
writeFileSync(stateFile(ctx), JSON.stringify({ pid: child.pid, startTime, url: match[1], ...(lan ?? {}) }) + '\n', { mode: 0o600, flag: 'wx' });
if (detach) child.unref();
const result = { url: match[1], pid: child.pid };
// The child's stderr goes to the log; the person who started it sees the warning here.
if (lan) warn(lanWarning(lan.bind, match[3]));
const result = { url: match[1], pid: child.pid, ...(lan ?? {}) };
Object.defineProperty(result, 'done', { value: done });
return result;
}
@@ -121,3 +137,15 @@ export async function consoleStop(ctx, { warn = s => process.stderr.write(s) } =
fail('console did not stop');
} finally { release(); }
}
// Read-only: what the state file says and whether that process still runs. It never moves a
// stale state file (start and stop do that under the start lock).
export function consoleStatus(ctx, { warn = s => process.stderr.write(s) } = {}) {
const s = state(ctx);
if (!s) return { running: false };
const running = liveConsole(s);
const bind = s.bind ?? '127.0.0.1';
const port = /:(\d+)\/$/.exec(s.url)?.[1] ?? 'unknown';
// The same warning start printed, for as long as a Console started with the LAN flags runs.
if (running && s.bind) warn(lanWarning(bind, port));
return { running, ...(running ? {} : { stale: true }), pid: s.pid, url: s.url, bind, ...(s.allowHosts ? { allowHosts: s.allowHosts } : {}) };
}
+18 -2
View File
@@ -1,9 +1,12 @@
import { configDir, loadBusiness } from '../../business/src/index.mjs';
import { checkProject, initProject, ProjectError, testerProjectArea } from '../../project/src/index.mjs';
import { checkProject, initProject, ProjectError, testerProjectArea, recordContext } from '../../project/src/index.mjs';
import { loadSystem, rolesDir } from './config.mjs';
import { CliError } from './errors.mjs';
import { shortId } from './format.mjs';
import { parseArgs } from './thread.mjs';
import { basename, join } from 'node:path';
import { lstatSync } from 'node:fs';
import { readRegistry, resolveOrg } from '../../business/src/org-registry.mjs';
import { refuseInsideAgent } from './transport.mjs';
export const PROJECT_USAGE = 'mosaic project init <business> <project> [--tester] | check <business> <project>';
@@ -77,7 +80,20 @@ export async function projectCommand(args, io, deps = {}, callFor) {
try {
const system = deps.system ?? loadSystem({ env: io.env });
const business = loadBusiness(id, { dir: configDir(io.env), rolesDir: rolesDir(io.env) });
const context = { business, project, dataRoot: system.dataRoot };
const dir = configDir(io.env), named = /^mosaic-([a-z][a-z0-9]{1,15})$/.exec(basename(dir));
let orgRoot = null;
let registryPresent = false;
if (named) { try { lstatSync(join(dir, 'org-runtime.json')); registryPresent = true; } catch (e) { if (e.code !== 'ENOENT') throw e; } }
if (named && registryPresent) {
const registry = await readRegistry({ configDir: dir });
if (registry?.orgs?.[id]) {
const org = await resolveOrg({ configDir: dir, dataRoot: system.dataRoot, instance: named[1] }, id);
if (org.state !== 'active') throw new CliError('organization is not active', 3);
orgRoot = org.root;
}
}
if (tester && orgRoot) throw new CliError('organization project init --tester is unavailable', 3);
const context = recordContext({ business, project, dataRoot: system.dataRoot, orgRoot });
if (tester) {
testerProjectArea(context);
await callFor({ positional: [], flags: new Set(['--tester']), values: { '--business': id } })('agents', {});
+84 -3
View File
@@ -1,13 +1,13 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, rmSync } from 'node:fs';
import { get } from 'node:http';
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { get, request } from 'node:http';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { reserveOrg, activateOrg } from '../../business/src/org-registry.mjs';
import { humanEnv } from './helpers.mjs';
import { consoleStart, consoleStop } from '../src/org-console.mjs';
import { consoleStart, consoleStatus, consoleStop } from '../src/org-console.mjs';
const REPO = new URL('../../../', import.meta.url).pathname;
test('detached console reports its live loopback URL and stops', { timeout: 60000 }, async t => {
@@ -49,3 +49,84 @@ test('mosaic console start and stop dispatch through the CLI', { timeout: 60000
assert.equal(stop.status, 0, stop.stderr);
assert.equal(JSON.parse(stop.stdout).stopped, true);
});
// org-lan-bind: --dev-insecure-bind and --dev-allow-host. The Console binds 0.0.0.0 and the test
// reaches it on 127.0.0.1 with an allowed Host name, as a LAN browser would.
const hostGet = (port, host) => new Promise((resolve, reject) => {
request({ host: '127.0.0.1', port, path: '/', headers: { host } }, res => { res.resume(); resolve(res.statusCode); }).on('error', reject).end();
});
async function instance(t) {
const home = mkdtempSync(join(tmpdir(), 's1-'));
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-s1test/config.json') });
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 's1test'], { env, encoding: 'utf8' });
assert.equal(setup.status, 0, setup.stderr);
const ctx = { configDir: join(home, '.config/mosaic-s1test'), dataRoot: join(home, '.mosaic-s1test'), instance: 's1test', env };
await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha');
t.after(async () => { try { await consoleStop(ctx); } catch {} rmSync(home, { recursive: true, force: true }); });
const mosaic = args => spawnSync(join(REPO, 'scripts/mosaic'), args, { env, encoding: 'utf8', timeout: 30000 });
return { ctx, mosaic };
}
test('a LAN console warns, records its bind and answers only its allowed names', { timeout: 60000 }, async t => {
const { ctx, mosaic } = await instance(t);
const warnings = [];
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', bind: '0.0.0.0', allowHosts: ['lan.test', '10.1.10.37'], warn: s => warnings.push(s) });
const url = new URL(result.url), port = url.port;
assert.equal(url.hostname, '127.0.0.1');
assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(Number(port)));
const warning = `WARNING: dev owner Console listens on 0.0.0.0:${port} with no sign-in; anyone who can reach it acts as the owner.\n`;
assert.deepEqual(warnings, [warning]);
assert.equal(result.bind, '0.0.0.0'); assert.deepEqual(result.allowHosts, ['lan.test', '10.1.10.37']);
const saved = JSON.parse(readFileSync(join(ctx.dataRoot, 'console/console.json'), 'utf8'));
assert.deepEqual({ url: saved.url, bind: saved.bind, allowHosts: saved.allowHosts }, { url: result.url, bind: '0.0.0.0', allowHosts: ['lan.test', '10.1.10.37'] });
// The serve process prints the same warning on its own stderr, which the Console log keeps.
const logs = readdirSync(join(ctx.dataRoot, 'console')).filter(x => /^console-\d+-\d+\.log$/.test(x));
assert.equal(logs.length, 1);
assert.ok(readFileSync(join(ctx.dataRoot, 'console', logs[0]), 'utf8').includes(warning), 'serve log has the warning');
assert.equal(await hostGet(port, `lan.test:${port}`), 200);
assert.equal(await hostGet(port, `10.1.10.37:${port}`), 200);
assert.equal(await hostGet(port, `127.0.0.1:${port}`), 200);
assert.equal(await hostGet(port, `other.test:${port}`), 403);
assert.equal(await hostGet(port, `lan.test:${Number(port) + 1}`), 403);
const statusWarnings = [];
const status = consoleStatus(ctx, { warn: s => statusWarnings.push(s) });
assert.deepEqual(status, { running: true, pid: result.pid, url: result.url, bind: '0.0.0.0', allowHosts: ['lan.test', '10.1.10.37'] });
assert.deepEqual(statusWarnings, [warning]);
const cli = mosaic(['console', 'status']);
assert.equal(cli.status, 0, cli.stderr);
assert.deepEqual(JSON.parse(cli.stdout), status);
assert.equal(cli.stderr, warning);
assert.equal((await consoleStop(ctx)).stopped, true);
assert.deepEqual(consoleStatus(ctx), { running: false });
// A state file whose LAN fields do not validate is refused, not trusted.
for (const bad of [{ bind: '0.0.0.0' }, { bind: '0.0.0.0', allowHosts: [] }, { bind: '0.0.0.0', allowHosts: ['a.test,b.test'] }, { bind: 'lan.test', allowHosts: ['lan.test'] }, { allowHosts: ['lan.test'] }]) {
writeFileSync(join(ctx.dataRoot, 'console/console.json'), JSON.stringify({ pid: 2147483647, startTime: '1', url: 'http://127.0.0.1:1/', ...bad }), { mode: 0o600 });
assert.throws(() => consoleStatus(ctx), /console state is invalid/, JSON.stringify(bad));
}
rmSync(join(ctx.dataRoot, 'console/console.json'));
});
test('mosaic console start refuses the LAN flags without the dev owner and allowed names', { timeout: 60000 }, async t => {
const { ctx, mosaic } = await instance(t);
for (const [args, reason] of [
[['--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test'], /--dev-insecure-bind needs --dev-local-owner/],
[['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0'], /needs at least one --dev-allow-host/],
[['--dev-local-owner', '--dev-allow-host', 'lan.test'], /--dev-allow-host needs --dev-insecure-bind/],
[['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test:7350'], /without a port/],
[['--dev-local-owner', '--dev-insecure-bind', '10.1.10.37', '--dev-allow-host', 'lan.test'], /must also be a --dev-allow-host name/],
[['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'a.test', '--dev-allow-host', 'b.test'], /--dev-allow-host needs one value/],
]) {
const r = mosaic(['console', 'start', '--detach', '--port', 'auto', ...args]);
assert.equal(r.status, 4, `${args.join(' ')}: ${r.stderr}`);
assert.match(r.stderr, reason, args.join(' '));
assert.doesNotMatch(r.stderr, /WARNING: dev owner/);
assert.equal(r.stdout, '');
assert.equal(existsSync(join(ctx.dataRoot, 'console')), false, args.join(' '));
}
await assert.rejects(consoleStart(ctx, { detach: true, bind: '0.0.0.0', allowHosts: ['lan.test'] }), /--dev-insecure-bind needs --dev-local-owner/);
assert.equal(existsSync(join(ctx.dataRoot, 'console')), false);
const none = mosaic(['console', 'status']);
assert.equal(none.status, 0, none.stderr);
assert.deepEqual(JSON.parse(none.stdout), { running: false });
assert.equal(mosaic(['console', 'status', '--json']).status, 4);
});
+25 -2
View File
@@ -1,10 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { main, run } from '../src/cli.mjs';
import { fixture, humanEnv, io, tmp } from './helpers.mjs';
import { fixture, humanEnv, io, tmp, orgFixture } from './helpers.mjs';
import { writeJson } from '../../business/tests/helpers.mjs';
test('project init/check use declared business repo and never contact the bus', async t => {
@@ -125,3 +125,26 @@ test('project list --archived includes and marks archived projects', async t =>
assert.deepEqual(calls.at(-1), ['project.list', { includeArchived: true }]);
assert.equal(archived.out.text, `live - ${A.slice(0, 8)}\nshelved archived - ${B.slice(0, 8)}\n`);
});
test('organization project init and check use the organization data root', async t => {
const { f, org } = await orgFixture(t);
const out = io(humanEnv(f.env));
assert.equal(await run(['project', 'init', 'acme', 'stack'], out, {}), 0, out.err.text);
assert.ok(existsSync(join(org.root, 'projects/acme/stack/spec/north-star.md')));
assert.equal(existsSync(join(f.dataRoot, 'projects/acme/stack')), false);
const checked = io(humanEnv(f.env));
assert.equal(await run(['project', 'check', 'acme', 'stack'], checked, {}), 0, checked.err.text);
});
test('organization project init refuses existing instance-root records without changing them', async t => {
const { f, org } = await orgFixture(t);
const old = join(f.dataRoot, 'projects/acme/stack'), expected = join(org.root, 'projects/acme/stack');
mkdirSync(old, { recursive: true });
writeFileSync(join(old, 'keep'), 'preserve');
const out = io(humanEnv(f.env));
assert.notEqual(await run(['project', 'init', 'acme', 'stack'], out, {}), 0);
assert.ok(out.err.text.includes(old)); assert.ok(out.err.text.includes(expected));
assert.equal(readFileSync(join(old, 'keep'), 'utf8'), 'preserve');
assert.equal(existsSync(expected), false);
});
+1
View File
@@ -8,6 +8,7 @@ import { ProjectError, checkedId, documentPath, noLinks, projectRepo, testerProj
import { git, identity, inspectGit, requireClean } from './git.mjs';
import { validateRecord } from './validate.mjs';
export { ProjectError, projectRepo, testerProjectArea, validateRecord };
export { recordContext } from './paths.mjs';
const DIRECTORIES = ['spec', 'docs', 'requirements', 'decisions', 'templates'];
const TEMPLATES = ['project.json', 'requirement.json', 'decision.json', 'document.md', 'north-star.md'];
+16
View File
@@ -31,6 +31,22 @@ const contains = (base, child) => {
const rel = relative(base, child);
return rel === '' || (rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel));
};
// Resolve organization record storage once for CLI, Console and bus. An old instance-root
// repository is never silently hidden by selecting the organization root.
export function recordContext({ business, project, dataRoot, orgRoot = null }) {
if (orgRoot === null) return { business, project, dataRoot };
checkedId(business?.id); checkedId(project); absolute(dataRoot); absolute(orgRoot);
const entry = business.projects?.[project];
if (entry) {
const old = join(dataRoot, 'projects', business.id, project);
const target = join(orgRoot, 'projects', business.id, project);
if (old !== target) {
try { lstatSync(old); throw new ProjectError('project-records-conflict', `instance-root records exist at ${old}; organization records belong at ${target}. Inspect both before continuing`); }
catch (e) { if (e.code !== 'ENOENT') throw e; }
}
}
return { business, project, dataRoot: orgRoot };
}
export function projectRepo({ business, project, dataRoot }, { allowLibrary = false } = {}) {
checkedId(business?.id); checkedId(project);
if (!Object.hasOwn(business?.projects ?? {}, project))
+11
View File
@@ -24,6 +24,17 @@ async function ready(t, custom) {
return { c, repo: initialized.repo, commit: initialized.commit };
}
test('organization records refuse instance-root conflicts even with an explicit repository', t => {
const c = context(t, 'records'), orgRoot = join(c.root, 'org');
const old = join(c.dataRoot, 'projects/acme/stack'), target = join(orgRoot, 'projects/acme/stack');
mkdirSync(old, { recursive: true }); mkdirSync(target, { recursive: true });
writeFileSync(join(old, 'keep'), 'old'); writeFileSync(join(target, 'keep'), 'org');
assert.throws(() => api.recordContext({ ...c, orgRoot }), e =>
e.code === 'project-records-conflict' && e.message.includes(old) && e.message.includes(target));
assert.equal(readFileSync(join(old, 'keep'), 'utf8'), 'old');
assert.equal(readFileSync(join(target, 'keep'), 'utf8'), 'org');
});
test('C2-04: record APIs refuse a library before creating a repository', async t => {
const c = context(t);
c.business.projects = { books: { kind: 'library' }, ...c.business.projects };
+3
View File
@@ -1333,3 +1333,6 @@ state, dead writer lock and inactive socket. It never sends a signal. Retry
`mosaic console start` and `mosaic console stop` move a dead or mismatched
Console state to `console.json.stale-<UTC timestamp>-<suffix>`, report the
move on stderr, and continue. A live matching Console blocks another start.
`mosaic console status` prints the recorded state (pid, URL, `bind`, and
`allowHosts` for a LAN Console) and whether that process still runs. It moves
nothing. `--dev-insecure-bind` and `--dev-allow-host` are in docs/TOOLS.md.
+23 -5
View File
@@ -18,18 +18,19 @@ import { showOrg } from '../../business/src/org-service.mjs';
import { configDir } from '../../business/src/business.mjs';
import { consoleReads, redactor } from './reads.mjs';
import { createDevOwner } from './dev-owner.mjs';
import { devOwnerInstance } from '../../cli/src/dev-local-owner.mjs';
import { devOwnerInstance, devLanOptions } from '../../cli/src/dev-local-owner.mjs';
import { createSso, readSlot } from './sso.mjs';
import { statSync } from 'node:fs';
import { isAbsolute } from 'node:path';
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
const args = process.argv.slice(2);
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso | --dev-local-owner] [--send] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]';
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso | --dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--send] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]';
try {
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
if (args.shift() !== 'serve') throw new Error(usage);
const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {};
let sendOn = false, ssoOn = false, testerSocket = false, devOwner = false;
let sendOn = false, ssoOn = false, testerSocket = false, devOwner = false, devBind = null;
const devNames = [];
while (args.length) {
const flag = args.shift();
// Row 76 (#1570): the one flag without a value.
@@ -63,9 +64,16 @@ try {
else if (flag === '--allow-user') preview.allowUser = value;
else if (flag === '--proof-header') proofFlags.proofHeader = value;
else if (flag === '--proof-file') proofFlags.proofFile = value;
// org-lan-bind: the dev owner on the LAN, no sign-in. Both flags or neither, and only with
// --dev-local-owner (devLanOptions below, before any config is read).
else if (flag === '--dev-insecure-bind') {
if (devBind !== null) throw new Error('--dev-insecure-bind takes one address');
devBind = value;
} else if (flag === '--dev-allow-host') devNames.push(value);
else throw new Error(`unknown option: ${flag}`);
}
if (devOwner && (ssoOn || testerSocket || Object.keys(preview).length || Object.keys(proofFlags).length)) throw new Error('--dev-local-owner cannot be combined with --sso, preview, proof or --tester-socket');
const lan = devLanOptions({ devOwner, bind: devBind, names: devNames });
const instance = devOwner ? devOwnerInstance() : null;
// The local-development console command has no separate --send flag.
if (devOwner) sendOn = true;
@@ -130,13 +138,16 @@ try {
return {
...consoleBus(call, { send: sendOn, ...(session.user === undefined ? {} : { owner }) }),
decisionWrite: session.user === undefined && sendOn ? consoleCall({ dataRoot: root, socket: paths.socket, business: id }) : null,
// The owner reads request status on the selected organization's bus, as on the root Console. Request
// writes stay off: the README "Console actions" inventory marks them unavailable for the organizations release.
requestRead: session.user === undefined ? consoleCall({ dataRoot: root, socket: paths.socket, business: id }) : null,
reads: consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: id, orgRoot: root }),
};
},
}) : null;
let server;
try {
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null, organizations, preselect: options.business ?? null });
server = await startServer({ ...(lan ? { host: lan.bind, devAllowHosts: lan.allowHosts } : {}), board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null, organizations, preselect: options.business ?? null });
} catch (err) { sso?.close(); throw err; }
let testerServer = null;
if (testerSocket) {
@@ -162,8 +173,15 @@ try {
const health = sso?.health() ?? {};
const ssoLines = sso && !devOwner ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send), grant Approve and Deny' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : '';
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
const port = server.address().port;
if (devOwner) console.log(`DEV LOCAL OWNER MODE: no sign-in, every local request acts as the owner (named instance ${instance})`);
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: unix:${testerServer.address()} (binds the tester)` : ''}\nCtrl-C stops this server.`);
// 0.0.0.0 is reachable on loopback too; a specific address only at that address.
const urlHost = !lan || lan.bind === '0.0.0.0' ? '127.0.0.1' : lan.bind;
if (lan) {
console.error(`WARNING: dev owner Console listens on ${lan.bind}:${port} with no sign-in; anyone who can reach it acts as the owner.`);
console.log(`Listening: ${lan.bind}:${port}, allowed Host names ${lan.allowHosts.map(name => `${name}:${port}`).join(', ')}`);
}
console.log(`Mosaic Console: http://${urlHost}:${port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: unix:${testerServer.address()} (binds the tester)` : ''}\nCtrl-C stops this server.`);
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { sso?.close(); server.close(); testerServer?.close(); });
} catch (err) {
console.error(`refused: ${err.message}`);
+28 -2
View File
@@ -21,13 +21,16 @@
// handler returns false for them.
import { open } from 'node:fs/promises';
import { lstatSync } from 'node:fs';
import { checkProject, initProject, projectRepo, recordContext } from '../../project/src/index.mjs';
import { refuseInsideAgent } from '../../cli/src/transport.mjs';
import { join } from 'node:path';
const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const KEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
const MAX_BODY = 64 * 1024;
const ROUTE = /^\/api\/orgs\/([^/]+)\/(admin|setup|delete|archive|restore)(?:\/(queue|bus|project\/([^/]+)))?$/;
const ROUTE = /^\/api\/orgs\/([^/]+)\/(admin|setup|delete|archive|restore)(?:\/(queue|bus|project\/([^/]+)|records\/([^/]+)))?$/;
// The org-template placeholder (packages/business/src/org-service.mjs templateRender).
export const placeholderRoot = (id, project) => `/__mosaic_org__/${id}/projects/${project}`;
// Project fields the server owns. The browser never sees or sends them.
@@ -42,6 +45,7 @@ export function verbFor(step, id, opts = {}) {
if (step === 'project') return `mosaic org project repo init ${id} ${opts.project} ${tail}`;
if (step === 'archive' || step === 'restore') return `mosaic org ${step} ${id} --if-revision ${opts.revision}`;
if (step === 'delete') return `mosaic org delete ${id} --confirm ${id} --if-revision ${opts.revision}`;
if (step === 'records') return `mosaic project init ${id} ${opts.project}`;
if (step === 'bus') return `mosaic bus start ${id} --dev-local-owner --detach`;
throw new Error(`unknown step ${step}`);
}
@@ -428,7 +432,8 @@ async function route(req, res, { path, search, org, admin, principal, write, log
return json(res, 201, { outcome: 'done', step: 'create', receipt: forReceipt(ctx, receipt), verb: verbFor('create', body.id) });
}
if (!org) return json(res, 404, { error: 'not found' });
const [, id, kind, sub, project] = org;
const [, id, kind, sub, repoProject, recordsProject] = org;
const project = repoProject ?? recordsProject;
if (!ID.test(id)) return json(res, 404, { error: 'org-unavailable' });
const service = await need(admin, 'service', res, kind); if (!service) return;
const ctx = await ctxOf(admin, principal);
@@ -479,6 +484,27 @@ async function route(req, res, { path, search, org, admin, principal, write, log
try { receipt = await service.updateOrg(ctx, id, doc, { ifRevision: body.ifRevision }); } catch (e) { return refusal(res, e, { step: 'update', id, unknownOk: true }); }
return json(res, 200, { outcome: 'done', step: 'update', receipt: forReceipt(ctx, receipt), verb: verbFor('update', id, { revision: body.ifRevision }) });
}
if (kind === 'setup' && sub?.startsWith('records/')) {
let name;
try { name = decodeURIComponent(project); } catch { return json(res, 404, { error: 'not found' }); }
if (!ID.test(name)) return json(res, 404, { error: 'not found' });
if (write) { const body = await readBody(req); strictKeys(body, []); }
try {
const shown = await service.showOrg(ctx, id);
if (!Object.hasOwn(shown.doc?.projects ?? {}, name)) return json(res, 404, { error: 'project-unknown', step: 'records' });
const context = recordContext({ business: shown.doc, project: name, dataRoot: ctx.dataRoot, orgRoot: shown.root });
const repo = projectRepo(context);
let exists = true;
try { lstatSync(repo); } catch (e) { if (e.code === 'ENOENT') exists = false; else throw e; }
if (!exists && write) { refuseInsideAgent(); await initProject(context); }
if (!exists && !write) return json(res, 200, { state: 'missing', step: 'records', verb: verbFor('records', id, { project: name }) });
const checked = await checkProject(context);
if (!checked.ok) return json(res, 409, { error: 'readback-failed', step: 'records', message: 'Project records could not be verified; inspect them before retrying.' });
return json(res, 200, { state: 'done', outcome: 'done', step: 'records', verb: verbFor('records', id, { project: name }) });
} catch (e) {
return json(res, 409, { error: e.code ?? 'readback-failed', step: 'records', message: plainMessage(ctx, e.message ?? 'Project records could not be verified.') });
}
}
if (kind === 'setup' && !sub && !write) {
try { return json(res, 200, await readSetup(admin, ctx, id)); } catch (e) { return refusal(res, e, { step: 'setup', id }); }
}
+18 -3
View File
@@ -185,13 +185,14 @@
}
const historyButton = r => `<button type="button" class="btn conv-open" data-history="${esc(key(r))}" aria-label="History for ${esc(r.agent)}">History</button>`;
const openButton = r => `<button type="button" class="session-open" data-open="${esc(key(r))}" aria-controls="inspector" aria-expanded="${selected === key(r)}">${esc(r.agent)}</button>`;
const activityCell = r => relaunchNotice(r) ? esc(relaunchNotice(r)) : activity(r) ? `${esc(activity(r))}${activity(r) === r.lastActivity ? '' : `<span class="source">${esc(r.lastActivity)}</span>`}` : 'unknown';
function cards(rows) {
if (!rows.length) return '<p class="muted">Nothing here.</p>';
return `<ul class="waiting">${rows.map(r => `<li class="wait-item"><div class="wait-head">${openButton(r)} ${badge(r.state)}${connectorStatus(r)}</div><p class="small muted">${esc(r.project)} · ${esc(r.activeProject || 'unknown')}</p><p>${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</p><p class="preview">${esc(relaunchNotice(r) || r.lastError || r.lastAssistantText || 'No assistant text yet.')}</p><div class="actions"><span class="small muted">${esc(relaunchNotice(r) || (activity(r) ? `Last activity ${activity(r)}` : 'No activity'))}</span><span>${historyButton(r)}${seenButton(r)}</span></div></li>`).join('')}</ul>`;
return `<ul class="waiting">${rows.map(r => `<li class="wait-item"><div class="wait-head">${openButton(r)} ${badge(r.state)}${connectorStatus(r)}</div><p class="small muted">${esc(r.project)} · ${esc(r.activeProject || 'unknown')}</p><p>${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</p><p class="preview">${esc(relaunchNotice(r) || r.lastError || r.lastAssistantText || 'No assistant text yet.')}</p><div class="actions"><span class="small muted" data-board-age="${esc(key(r))}">${esc(relaunchNotice(r) || (activity(r) ? `Last activity ${activity(r)}` : 'No activity'))}</span><span>${historyButton(r)}${seenButton(r)}</span></div></li>`).join('')}</ul>`;
}
function table(rows) {
if (!rows.length) return `<div class="empty"><b>No sessions match these filters.</b><p>${data.sessions.length ? 'Clear Hide offline or Hide seen, or pick All projects, to see the rest.' : 'Sessions appear here when the board scan finds an agent session on this machine.'}</p></div>`;
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Session table, scroll horizontally for all columns"><table class="sessions"><thead><tr>${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr data-row="${esc(key(r))}"${selected === key(r) ? ' class="is-selected"' : ''}><td>${openButton(r)}<span class="source">${esc(r.project)}</span>${historyButton(r)}</td><td>${badge(r.state)}${connectorStatus(r)}${r.seen ? '<span class="source">seen</span>' : ''}</td><td class="task">${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</td><td>${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}</td><td>${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}</td><td>${esc(r.model || 'unknown')}<span class="source">${esc(r.provider)}</span></td><td>${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}</td><td>${relaunchNotice(r) ? esc(relaunchNotice(r)) : activity(r) ? `${esc(activity(r))}${activity(r) === r.lastActivity ? '' : `<span class="source">${esc(r.lastActivity)}</span>`}` : 'unknown'}</td></tr>`).join('')}</tbody></table></div>`;
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Session table, scroll horizontally for all columns"><table class="sessions"><thead><tr>${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr data-row="${esc(key(r))}"${selected === key(r) ? ' class="is-selected"' : ''}><td>${openButton(r)}<span class="source">${esc(r.project)}</span>${historyButton(r)}</td><td>${badge(r.state)}${connectorStatus(r)}${r.seen ? '<span class="source">seen</span>' : ''}</td><td class="task">${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</td><td>${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}</td><td>${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}</td><td>${esc(r.model || 'unknown')}<span class="source">${esc(r.provider)}</span></td><td>${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}</td><td data-board-activity="${esc(key(r))}">${activityCell(r)}</td></tr>`).join('')}</tbody></table></div>`;
}
function registered(r) {
const reg = r.registered;
@@ -432,10 +433,24 @@
function accept(value) {
if (out()) return; // a read that was in flight when the page signed out
if (!value || !Array.isArray(value.sessions) || value.sessions.some(r => !r || typeof r.project !== 'string' || typeof r.agent !== 'string')) throw new Error('Invalid board session data');
const canonical = v => Array.isArray(v) ? v.map(canonical) : v && typeof v === 'object'
? Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical(v[k])])) : v;
const material = board => JSON.stringify(canonical({ counts: board.counts, sessions: board.sessions.map(({ageSeconds, scannedAt, ...row}) => row) }));
const unchanged = data && material(data) === material(value);
data = value; failed = false; $('error').hidden = true; $('error').className = 'banner err';
$('board-refused').hidden = true; document.body.classList.remove('board-refused');
for (const id of awaiting.keys()) { const r = row(id); if (r && !pending(r)) awaiting.delete(id); }
render();
if (unchanged) {
for (const r of data.sessions) {
for (const el of document.querySelectorAll('[data-board-age]')) if (el.dataset.boardAge === key(r)) { const text = relaunchNotice(r) || (activity(r) ? `Last activity ${activity(r)}` : 'No activity'); if (el.textContent !== text) el.textContent = text; }
for (const el of document.querySelectorAll('[data-board-activity]')) if (el.dataset.boardActivity === key(r)) { const html = activityCell(r); if (el.innerHTML !== html) el.innerHTML = html; }
}
const selectedRow = row(selected);
const field = [...$('inspection').querySelectorAll('dt')].find(el => /^(Historical )?Last activity$/.test(el.textContent))?.nextElementSibling;
if (selectedRow && field) { const text = !selectedRow.lastActivity ? 'unknown' : activity(selectedRow) === selectedRow.lastActivity ? selectedRow.lastActivity : `${activity(selectedRow)} (${selectedRow.lastActivity})`; if (field.textContent !== text) field.textContent = text; }
$('status').textContent = `Scanned ${window.MosaicLocalTime(data.generatedAt)} · ${paused ? 'auto-refresh paused' : 'refresh every 10s'}`;
fresh();
} else render();
}
// A refusal (403) fails closed: the last data is dropped, not kept or shown as current, and so is
// everything drawn from it: the cards, the table, the inspector, an open conversation and its
+1 -1
View File
@@ -167,7 +167,7 @@
document.addEventListener('mosaic-processing-state', e => {
halted = e.detail.halted;
const cell = $('dashboard-kpis').lastElementChild;
if (cell) { cell.querySelector('strong').textContent = halted === null ? '–' : halted ? 'Stopped' : 'On'; cell.querySelector('span').textContent = halted === null ? 'Not available yet' : 'Instance status'; }
if (cell) { const strong=cell.querySelector('strong'), span=cell.querySelector('span'), value=halted === null ? '–' : halted ? 'Stopped' : 'On', caption=halted === null ? 'Not available yet' : 'Instance status'; if(strong.textContent!==value) strong.textContent=value; if(span.textContent!==caption) span.textContent=caption; }
});
setInterval(age, 1000);
const button = $('avatar-button'), menu = $('avatar-menu');
+6
View File
@@ -54,3 +54,9 @@ table.s1-table tbody th{font-weight:600;text-transform:none;letter-spacing:0;col
/* Role names stay whole; the wrapper scrolls if they need more room. */
table.s1-roles { min-width:480px; }
table.s1-roles td,table.s1-roles th { overflow-wrap:normal; word-break:normal; }
/* Role names remain readable without making the organization panel wider on phones. */
@media (max-width: 699px) {
table.s1-roles { min-width: 0; width: 100%; table-layout: fixed; }
table.s1-roles td, table.s1-roles th { overflow-wrap: anywhere; }
}
+57 -9
View File
@@ -22,6 +22,18 @@
// ---------------------------------------------------------------- reads
// One GET per Q1 read. The last good answer for each read is kept, so a failed read
// can show what was read before, labelled as such.
let spriteRetry = false, spriteVersion = 0;
window.MosaicIcons = {
failed() { spriteRetry = true; },
succeeded() {
if (!spriteRetry) return;
spriteRetry = false; spriteVersion++;
for (const use of document.querySelectorAll('use[href^="/icons.svg"]')) {
const fragment = use.getAttribute('href').split('#')[1];
if (fragment) use.setAttribute('href', `/icons.svg?retry=${spriteVersion}#${fragment}`);
}
},
};
const last = new Map();
class ReadError extends Error { constructor(code, message, status) { super(message); this.code = code; this.status = status; } }
// Row 95 (#1589): every Console fetch takes `redirect: 'manual'`. A redirect comes only from the
@@ -39,11 +51,13 @@
if (out()) throw signedOut();
let res, body;
try { res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?${verb === 'replies' ? 'id' : verb === 'messages' ? 'after' : 'subject'}=${enc(subject)}`}`, { cache: 'no-store', redirect: 'manual' }); }
catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
catch { window.MosaicIcons.failed(); throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
try { body = await res.json(); } catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
try { body = await res.json(); } catch { window.MosaicIcons.failed(); throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
if (signInRequired(res, body)) { signOutSso(); throw signedOut(); }
if (!res.ok) window.MosaicIcons.failed();
if (!res.ok || !body || !Array.isArray(body.rows)) throw new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status);
window.MosaicIcons.succeeded();
return { ...body, rows: body.rows, at: body.at };
}
// Row 54 (#1543): the queue, business and settings reads (src/reads.mjs), GET only. Their
@@ -52,11 +66,13 @@
if (out()) throw signedOut();
const unreachable = () => Object.assign(new ReadError('unreachable', 'The Console server did not answer.', 0), { module: true });
let res, body;
try { res = await fetch(`/api/${path}`, { cache: 'no-store', redirect: 'manual' }); } catch { throw unreachable(); }
try { res = await fetch(`/api/${path}`, { cache: 'no-store', redirect: 'manual' }); } catch { window.MosaicIcons.failed(); throw unreachable(); }
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
try { body = await res.json(); } catch { throw unreachable(); }
if (signInRequired(res, body)) { signOutSso(); throw signedOut(); }
if (!res.ok) window.MosaicIcons.failed();
if (!res.ok || !body || typeof body !== 'object' || Array.isArray(body)) throw Object.assign(new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status), { module: true });
window.MosaicIcons.succeeded();
return body;
}
// A reader for one render. `live` asks the server; otherwise only kept answers are used.
@@ -227,6 +243,8 @@
const blockChip = text => `<span class="tag tag-block">${txt(text)}</span>`;
// An empty state says what would appear and where it comes from.
const empty = (what, from) => `<div class="empty"><b>${what}</b>${from ? `<p>${from}</p>` : ''}</div>`;
// "New organization" everywhere it is offered: the Organizations form with the ID field focused (org-create.js reads ?new=1).
const NEW_ORG = '#/settings/organizations?new=1';
const taskLink = ref => ref ? `<a href="#/tasks/${enc(ref)}">#${esc(String(ref).split('/')[1])}</a>` : '';
// Rows without an actor role come from the human (CLI outside any run, or the signed-in Console)
// or from the broker itself.
@@ -251,7 +269,7 @@
const items = [['/', 'Dashboard', '', 'board'], ['/projects', 'Projects', '', 'inbox'], ['/inbox', 'Inbox', inboxCount?.open > 0 ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/messages', 'Messages', messageCount > 0 ? `<span class="count" aria-label="${messageCount} unread messages">${messageCount}</span>` : '', 'talk'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/docs', 'Docs', '', 'library'], ['/settings', 'Settings', '', 'settings']];
$('sections').innerHTML = items.map(([h, l, extra, icon]) => {
const cur = h === '/' ? (route === '/' || route === '/board') : route.startsWith(h) || (h === '/projects' && route.startsWith('/chat')) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message')));
return `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}><svg class="i" aria-hidden="true">${h === '/projects' ? '<path d="M2 5h6l2 2h8v11H2z"/>' : `<use href="/icons.svg#i-${icon}"/>`}</svg>${l}${extra ? ` ${extra}` : ''}</a>${h === '/projects' ? '<div id="project-tree" aria-label="Project conversations"></div>' : ''}</li>`;
return `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}><svg class="i" aria-hidden="true">${h === '/projects' ? '<path d="M2 5h6l2 2h8v11H2z"/>' : `<use href="/icons.svg${spriteVersion ? `?retry=${spriteVersion}` : ''}#i-${icon}"/>`}</svg>${l}${extra ? ` ${extra}` : ''}</a>${h === '/projects' ? '<div id="project-tree" aria-label="Project conversations"></div>' : ''}</li>`;
}).join('');
window.MosaicProjectTree?.mount?.({ container: $('project-tree'), path: route });
if (focused) [...$('sections').querySelectorAll('a[href]')].find(a => a.getAttribute('href') === focused)?.focus({ preventScroll: true });
@@ -274,6 +292,32 @@
// reply to its own message, so a reply to a Console send stays unread in `mosaic inbox` until
// the human marks it read with `mosaic inbox --read`.
let isTester = false, orgSetup = false;
// Both Docs and project direction use the same owner-only records setup action.
window.MosaicRecords = {
markup(project) {
const org = window.MosaicOrg?.id;
if (!org) return 'This project has no records yet. Initialize its record repository to add documents.';
const command = `mosaic project init ${org} ${project}`;
return `This project has no records yet. ${orgSetup ? `<button type="button" class="btn" data-record-init="${esc(project)}">Initialize project records</button> ` : 'Run '}<code>${txt(command)}</code><span role="status" data-record-receipt></span>`;
},
};
document.addEventListener('click', async e => {
const button = e.target.closest?.('[data-record-init]');
if (!button || button.disabled || !orgSetup || !window.MosaicOrg) return;
button.disabled = true;
const status = button.parentElement.querySelector('[data-record-receipt]');
const url = `/api/orgs/${encodeURIComponent(window.MosaicOrg.id)}/setup/records/${encodeURIComponent(button.dataset.recordInit)}`;
try {
const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
const body = await response.json();
if (!response.ok || body.outcome !== 'done' || body.step !== 'records') throw Error(body.message || 'Initialization was refused. Inspect the project before retrying.');
const readback = await fetch(url, { cache: 'no-store' });
const checked = await readback.json();
if (!readback.ok || checked.state !== 'done') throw Error('Read-back failed. Inspect the project before retrying.');
document.getElementById('refresh')?.click();
} catch (error) { if (status) status.textContent = ` ${error.message || 'Outcome unknown. Inspect the project before retrying.'}`; }
});
let configRead = Promise.resolve();
let canSend = false, chatSend = false, sendRoles = null, sending = false, receipt = null, replies = [];
const REPLIES = 50; // the broker's cap
@@ -579,7 +623,7 @@
let html;
if (organizationError) html = `<div class="banner err" role="alert"><p>${txt(organizationError)}</p>${retry}</div>`;
else if (invalidOrganizations.length) html = `<div class="banner err" role="alert">${invalidOrganizations.map(row => `<p>${txt(row.id)}: invalid — ${txt(row.reason)}</p>`).join('')}${retry}</div>`;
else if (noOrganizations && err?.code === 'org-unavailable') html = empty('No organizations yet.', 'Create one in <a href="#/settings/organizations">Settings &gt; Organizations</a>.');
else if (noOrganizations && err?.code === 'org-unavailable') html = `<div class="empty"><b>No organizations yet.</b><p>Create one in <a href="#/settings/organizations">Settings &gt; Organizations</a>.</p><p><a class="btn" href="${NEW_ORG}">New organization</a></p></div>`;
else if (err && (refusal(err) || !r)) html = `<div class="banner ${refusal(err) ? 'ref' : 'err'}" role="alert"><p>${refusal(err) ? `<b>refused: <code>${txt(err.code)}</code>.</b> ` : ''}${why(err)} Console shows no held roles rather than a guess.</p>${retry}</div>`;
else if (!r) return; // not read yet: the skeleton stays
else {
@@ -708,7 +752,8 @@
async function businessView(get) {
const b = (await get.api('business')).business || {};
const orgs = window.MosaicOrg ? await window.MosaicOrg.list : null;
const picker = orgs ? `<section class="panel" aria-labelledby="org-picker-title"><h2 id="org-picker-title">Organization</h2><label for="org-picker">Current organization</label><select id="org-picker">${orgs.map(org => `<option value="${esc(org.id)}"${org.id === window.MosaicOrg.id ? ' selected' : ''}${org.invalid ? ' disabled' : ''}>${txt(org.label || org.id)}${org.invalid ? ' · Invalid' : org.busHealthy ? '' : ' · Bus unavailable'}</option>`).join('')}</select></section>` : '';
if (orgs) await configRead; // orgSetup decides the "New organization" link
const picker = orgs ? `<section class="panel" aria-labelledby="org-picker-title"><h2 id="org-picker-title">Organization</h2><label for="org-picker">Current organization</label><select id="org-picker">${orgs.map(org => `<option value="${esc(org.id)}"${org.id === window.MosaicOrg.id ? ' selected' : ''}${org.invalid ? ' disabled' : ''}>${txt(org.label || org.id)}${org.invalid ? ' · Invalid' : org.busHealthy ? '' : ' · Bus unavailable'}</option>`).join('')}</select>${orgSetup ? `<p><a href="${NEW_ORG}">New organization</a></p>` : ''}</section>` : '';
const instances = Array.isArray(b.instances) ? b.instances : [], creds = Array.isArray(b.credentials) ? b.credentials : [], actions = Array.isArray(b.actions) ? b.actions : [];
const launch = b.launch || {};
const authKnown = instances.some(i => i.authority);
@@ -1204,7 +1249,7 @@
try { files = (await get.api(`project-files?project=${enc(p)}`)).files; }
catch (err) { if (err.code === 'signed-out' || refusal(err)) throw err; code = err.code; }
files = Array.isArray(files) ? files.filter(f => typeof f?.path === 'string' && DOC_PATH.test(f.path)) : [];
blocks.push(`<section class="panel" aria-labelledby="doc-p-${esc(p)}"><h2 id="doc-p-${esc(p)}">${txt(p)} <span class="count">${files.length}</span></h2>${code === 'project-records-missing' ? '<p>This project has no records yet. Initialize its record repository to add documents.</p>' : code ? `<p class="small warn">These files could not be read. Read again to retry. <code>${txt(code)}</code></p>${retry}` : files.length ? `<ul class="s1-plain">${files.map(f => `<li><a href="#/docs/${enc(p)}/${enc(f.path)}">${txt(f.path)}</a></li>`).join('')}</ul>` : '<p class="muted">No spec or docs files.</p>'}</section>`);
blocks.push(`<section class="panel" aria-labelledby="doc-p-${esc(p)}"><h2 id="doc-p-${esc(p)}">${txt(p)} <span class="count">${files.length}</span></h2>${code === 'project-records-missing' ? `<p>${window.MosaicRecords.markup(p)}</p>` : code ? `<p class="small warn">These files could not be read. Read again to retry. <code>${txt(code)}</code></p>${retry}` : files.length ? `<ul class="s1-plain">${files.map(f => `<li><a href="#/docs/${enc(p)}/${enc(f.path)}">${txt(f.path)}</a></li>`).join('')}</ul>` : '<p class="muted">No spec or docs files.</p>'}</section>`);
}
return `${head('Docs', `${readLine()} The spec and the files under docs/ of each project. ${canEdit ? 'Open one to edit it; a save makes one commit in the project with you as the author.' : 'Saving needs a Console started with <code>--send</code> behind the sign-in proxy; this one only reads.'}`)}
${blocks.join('') || empty('This business declares no projects.', 'A project shows here when the business file names it.')}`;
@@ -1335,7 +1380,7 @@
const refusal = err => err.status === 403 || ['not-configured', 'no-bus-host', 'queue-refused'].includes(err.code);
// After a refusal nothing read before is shown anywhere: not in a view, the palette or the Inbox count.
function forget() { window.MosaicChat?.forget(); last.clear(); inboxCount = null; messageCount = null; }
const failedView = err => `${head(failTitle(err))}<div class="banner ${refusal(err) ? 'ref' : 'err'}" role="alert"><p>${refusal(err) ? `<b>refused: <code>${txt(err.code)}</code>.</b> ` : ''}${why(err, err.code === 'queue-refused')} Console shows nothing rather than a guess. Nothing was written.</p>${retry}</div>${empty('Nothing to show.', refusal(err) ? 'The view stays empty until the problem above is fixed.' : err.module ? 'Read again.' : 'Read again, or come back when the bus answers.')}`;
const failedView = (err, path) => `${head(path === '/queue' ? 'Queue' : failTitle(err))}<div class="banner ${refusal(err) ? 'ref' : 'err'}" role="alert"><p>${refusal(err) ? `<b>refused: <code>${txt(err.code)}</code>.</b> ` : ''}${why(err, err.code === 'queue-refused')} Console shows nothing rather than a guess. Nothing was written.</p>${retry}</div>${empty('Nothing to show.', refusal(err) ? 'The view stays empty until the problem above is fixed.' : err.module ? 'Read again.' : 'Read again, or come back when the bus answers.')}`;
const staleBanner = err => `<div class="banner err s1-stale" role="alert"><p><b>The last read failed.</b> ${why(err)} This is what was read before, ${when(readAt)}. Nothing was changed.</p>${retry}</div>`;
// Freshness line in the command bar for bus pages.
const stamp = iso => { const t = Date.parse(iso); return Number.isFinite(t) ? `${new Date(t).toISOString().slice(11, 19)} UTC` : 'unknown'; };
@@ -1484,7 +1529,7 @@
if (err.code === 'signed-out') return;
const kept = reader(false);
try { if (refusal(err)) throw err; html = await view(kept, path, new URLSearchParams(query), opts, current); readAt = kept.oldest(); html = staleBanner(err) + html; state = 'stale'; }
catch { html = failedView(err) + (path === '/settings' ? appearance() : ''); state = refusal(err) ? 'refused' : 'failed'; }
catch { html = failedView(err, path) + (path === '/settings' ? appearance() : ''); state = refusal(err) ? 'refused' : 'failed'; }
if (refusal(err)) forget();
}
if (!current()) return;
@@ -1657,6 +1702,9 @@
canSend = !isTester && c?.send === true;
chatSend = !isTester && c?.chatSend === true;
orgSetup = !isTester && c?.orgSetup === true;
window.MosaicOrgBusStopped = !!window.MosaicOrg && c?.devOwner === true && orgSetup && c?.chatSend === false;
// The header menu offers "New organization" to the owner of a Console with organizations; nobody else can create one.
if (orgSetup && !$('org-new-link')) { const a = document.createElement('a'); a.id = 'org-new-link'; a.href = NEW_ORG; a.setAttribute('role', 'menuitem'); a.textContent = 'New organization'; document.querySelector('#avatar-menu > a[href="#/settings"]')?.after(a); }
ssoOn = c?.sso === true;
canDecide = !isTester && c?.decide === true;
if (isTester) {
+173 -40
View File
@@ -2,6 +2,15 @@
(() => {
'use strict';
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// crypto.randomUUID exists only in a secure context; a dev owner Console opened by LAN name
// (http://NAME:PORT/) is not one, so build the same v4 form from getRandomValues there.
const opId = () => {
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
const b = crypto.getRandomValues(new Uint8Array(16));
b[6] = (b[6] & 0x0f) | 0x40; b[8] = (b[8] & 0x3f) | 0x80;
const h = [...b].map(x => x.toString(16).padStart(2, '0')).join('');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
};
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
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'));
@@ -76,13 +85,15 @@
});
} catch (err) {
if (err.name === 'AbortError') throw err;
window.MosaicIcons?.failed();
throw new ChatError(value === undefined ? 'unreachable' : 'outcome-unknown');
}
if (res.type === 'opaqueredirect' || res.status === 401) { signOut(); throw new ChatError('signed-out'); }
let data;
try { data = await res.json(); }
catch { throw new ChatError(value === undefined ? 'invalid-response' : 'outcome-unknown'); }
if (!res.ok) throw new ChatError(typeof data?.error === 'string' ? data.error : 'invalid-response', res.status, data?.sent);
if (!res.ok) { window.MosaicIcons?.failed(); throw new ChatError(typeof data?.error === 'string' ? data.error : 'invalid-response', res.status, data?.sent); }
window.MosaicIcons?.succeeded();
if (!data || typeof data !== 'object' || (value === undefined && !path.includes('/operation?') && !Array.isArray(data.rows))) {
throw new ChatError(value === undefined ? 'invalid-response' : 'outcome-unknown');
}
@@ -96,7 +107,7 @@
const heading = text => `<h1 tabindex="-1">${txt(text)}</h1>`;
const selectedInactive = () => { const s = current?.sessions?.find(r => r.id === current.session); return !!s?.state && s.state !== 'active'; };
const disabledSession = pending => disabled(pending || selectedInactive());
const disabled = pending => !current.canSend || out() || pending ? ' disabled' : '';
const disabled = pending => !current.canSend || current.orgArchived || out() || pending ? ' disabled' : '';
const stamp = at => { const date=new Date(at); const projectSurface=window.MosaicProjects && !current?.user && !current?.tester && (!legacyRoute() || current.path?.startsWith('/projects')); return projectSurface && Number.isFinite(date.getTime()) ? `<time datetime="${esc(at)}">${txt(date.toLocaleTimeString([], {hour:'numeric',minute:'2-digit'}))}</time>` : `<time>${txt(at)}</time>`; };
// Per-grant UI hints; all writes still use the caller's bus principal.
const scopeWrites = (project, instance) => !current.user || current.chatGrants.some(g =>
@@ -167,7 +178,7 @@
if (l.error) return `<div class="chat-library"><p role="status">Library status unavailable (${txt(l.error)}).</p></div>`;
const v = l.value, parts = [];
parts.push(SHA.test(v.served ?? '') ? `Library at <code>${txt(v.served.slice(0, 7))}</code>` : 'Library not served yet');
if (typeof v.lastFetch === 'string') parts.push(`fetched ${txt(ago(v.lastFetch))}`);
if (typeof v.lastFetch === 'string') parts.push(`<span data-library-age="${esc(v.lastFetch)}">fetched ${txt(ago(v.lastFetch))}</span>`);
if (SHA.test(v.diverged?.commit ?? '')) parts.push(`<b>library-diverged</b> at <code>${txt(v.diverged.commit.slice(0, 7))}</code>`);
if (CODE.test(v.lastError?.code ?? '')) parts.push(`fetch error <code>${txt(v.lastError.code)}</code>`);
const limits = v.replyLimits;
@@ -196,7 +207,7 @@
}
if (g !== generation || out()) return;
libraries.set(project, NO_LIBRARY.includes(entry.error) ? { hidden: true } : entry);
paint();
paint(false, true);
}
// design (writes): Revert is library.revert {commit}, her act. A refusal shows its code; before
// C2-02's verb exists that is the bus's refusal of the verb.
@@ -412,7 +423,7 @@
if (err.status === 403 || err.status === 404) { s.rows = []; current.members = []; current.projects = []; }
current.projectArchived = current.projects.some(p => p.id === id && p.archived);
}
if (g === generation && !out()) { paint(navigated); void loadLibrary(libraryOf(current), g, signal); }
if (g === generation && !out()) { paint(navigated, true); void loadLibrary(libraryOf(current), g, signal); }
}
async function openRoom(projectId) {
const c = current, id = projectId || c?.id;
@@ -477,18 +488,120 @@
const dialogFocus = dialog?.contains(active) ? { id:active.id, index:[...dialog.querySelectorAll('button,a,input,select,textarea,[tabindex]')].indexOf(active), value:'value' in active ? active.value : null, start:active.selectionStart, end:active.selectionEnd } : null;
const toggles = [...current.container.querySelectorAll('.project-new-conversation,.project-create')].map(e=>e.open);
const content = current.container.querySelector('[data-project-content]');
const projectFocus = content?.contains(active) ? {id:active.id,index:[...content.querySelectorAll('a,button,select,input,textarea,summary,[tabindex]')].indexOf(active)} : null;
const disclosures = content ? [...content.querySelectorAll('details')].map((e,i)=>e.open?i:-1).filter(i=>i>=0) : [];
const { projectFocus, disclosures, closed } = window.MosaicProjects?.captureView ? window.MosaicProjects.captureView(content) : { projectFocus:null, disclosures:[], closed:[] };
// A repaint can replace a log while its own pin is waiting for layout.
// Carry that intent only while the offset still matches the pending pin.
return { tabScroll: current.container.querySelector('.project-tabs')?.scrollLeft, field, dialog:dialog?.id, dialogValues, dialogFocus, toggles, projectFocus, disclosures, detailsOpen: current.container.querySelector('.chat-details')?.open, top: log ? (pageFlow(log) ? document.scrollingElement.scrollTop : log.scrollTop) : undefined, tail: log ? transcriptAtTail(log) : true, settlingPin: !!log && !pageFlow(log) && (pinnedScrolls.get(log) === log.scrollTop || settlingPins.get(log) === log.scrollTop) };
return { tabScroll: current.container.querySelector('.project-tabs')?.scrollLeft, field, dialog:dialog?.id, dialogValues, dialogFocus, toggles, projectFocus, disclosures, closed, detailsOpen: current.container.querySelector('.chat-details')?.open, top: log ? (pageFlow(log) ? document.scrollingElement.scrollTop : log.scrollTop) : undefined, tail: log ? transcriptAtTail(log) : true, settlingPin: !!log && !pageFlow(log) && (pinnedScrolls.get(log) === log.scrollTop || settlingPins.get(log) === log.scrollTop) };
}
let transcriptObserver = null, transcriptResize = null;
function paint(navigated = false) {
let transcriptObserver = null, transcriptResize = null, lastPaint = null;
const visualKey = html => html.replace(/(<textarea\b[^>]*>)[\s\S]*?(<\/textarea>)/g, '$1$2')
.replace(/<span data-library-age="[^"]*">[^<]*<\/span>/g, '<span data-library-age></span>');
function refreshLibraryAge(c) {
const el = c.container.querySelector('[data-library-age]');
const at = libraries.get(libraryOf(c, c.thread))?.value?.lastFetch;
if (!el || typeof at !== 'string') return;
const text = `fetched ${ago(at)}`;
if (el.textContent !== text) el.textContent = text;
if (el.dataset.libraryAge !== at) el.dataset.libraryAge = at;
}
// Refreshes often read exactly the same view. Remember the generated presentation,
// not the mutable DOM (which also contains independently mounted request controls).
function refreshProjectData(c, redesigned, restore) {
if (c.user || c.tester || !window.MosaicProjects) return;
const project = c.projects.find(p => p.id === c.id);
const projectContent = c.container.querySelector('[data-project-content]');
if (redesigned && (c.kind === 'project' || c.kind === 'thread' || c.kind === 'room')) {
const surfaceProject = c.kind === 'thread' ? c.projects.find(p => p.id === c.thread?.project) : project;
if (surfaceProject) void window.MosaicProjects.enhanceSurface(c.container, surfaceProject, c.threads, c.kind === 'thread' ? c.thread : null, c.canSend, form('room-open:' + surfaceProject.id).receipt);
}
if (projectContent && project) void window.MosaicProjects.mountContent({ container: projectContent, project, threads: c.threads, section: c.section, canSend: c.canSend, restore, roomReceipt: form('room-open:' + project.id).receipt });
if (c.kind === 'list') window.MosaicProjects.enhanceList(c.container, c.projects, c.threads);
}
// Request panels, files and the request summary reread on every refresh, including one whose
// visible view is unchanged: a request's phase moves without a new message. A quiet reread
// keeps the present content until a changed result arrives.
function refreshRequestData(c, quiet) {
window.MosaicRequests?.mount?.(c.container, { archiveUnknown: !!c.archiveUnknown, projectArchived: !!c.projectArchived });
const t = c.kind === 'thread' ? c.thread : null;
const repo = c.kind === 'project' ? c.projects.find(p => p.id === c.id)?.repo : libraryOf(c, t);
const cached = knownRequests.get(c.id);
const remembered = c.kind === 'project' && cached && cached.repo === repo ? cached.ids : [];
const selected = t ? requestRows(state(c.key).rows) : new Set();
const ids = !roomOwner() ? [] : t ? [...new Set(state(c.key).rows.filter(m => selected.has(m.id)).map(m => m.request))] : [...remembered].slice(-REQUEST_ROWS);
const files = c.container.querySelector('[data-chat-files]');
if (files && typeof window.MosaicFiles?.mount === 'function') { if (!quiet) files.replaceChildren(); window.MosaicFiles.mount({ container: files, requestIds: ids, key: `thread:${c.id}`, quiet }); }
const summary = c.container.querySelector('[data-chat-summary]');
if (summary && repo && ids.length) {
if (typeof window.MosaicRequests?.projectSummary === 'function') window.MosaicRequests.projectSummary({ container: summary, requestIds: ids, project: repo, quiet });
else summary.textContent = 'Request summary unavailable.';
}
}
// When only the transcript changed, reconcile its keyed children in the
// existing log. In particular, never detach an unchanged message or composer.
function updateTranscript(c, html, next, keep) {
if (!lastPaint || lastPaint.container !== c.container || lastPaint.path !== c.path) return false;
const previous = document.createElement('template'); previous.innerHTML = lastPaint.html;
const oldLog = previous.content.querySelector('.chat-log'), newLog = next.content.querySelector('.chat-log');
const liveLog = c.container.querySelector('.chat-log');
if (!oldLog || !newLog || !liveLog) return false;
const oldItems = [...oldLog.children], newItems = [...newLog.children], liveItems = [...liveLog.children];
oldLog.replaceChildren(); newLog.replaceChildren();
const outside = template => visualKey(template.innerHTML);
if (outside(previous) !== outside(next)) return false;
const key = el => el.dataset.message ? 'message:' + el.dataset.message : el.querySelector('[data-origin-message]') ? 'request:' + el.querySelector('[data-origin-message]').dataset.originMessage : null;
const oldByKey = new Map(oldItems.map(el => [key(el), el]).filter(([id]) => id));
const liveByKey = new Map(liveItems.map(el => [key(el), el]).filter(([id]) => id));
const desired = newItems.map(el => {
const id = key(el), old = id && oldByKey.get(id), live = id && liveByKey.get(id);
return old && live && old.outerHTML === el.outerHTML ? live : el;
});
for (let i = 0; i < desired.length; i++) if (liveLog.children[i] !== desired[i]) liveLog.insertBefore(desired[i], liveLog.children[i] || null);
while (liveLog.children.length > desired.length) liveLog.lastElementChild.remove();
window.MosaicRequests?.mount?.(c.container, { archiveUnknown: !!c.archiveUnknown, projectArchived: !!c.projectArchived });
const s = state(c.key ?? c.id);
if (s.tailRequested || s.follow && ((keep?.tail ?? true) || keep?.settlingPin)) pinTranscript(liveLog);
else if (keep?.top !== undefined) { if (pageFlow(liveLog)) window.scrollTo(0, keep.top); else liveLog.scrollTop = keep.top; }
s.tailRequested = false;
return true;
}
function updateConversations(c, next) {
if (c.kind !== 'project' || c.section !== 'conversations' || !lastPaint || lastPaint.container !== c.container || lastPaint.path !== c.path) return false;
const previous = document.createElement('template'); previous.innerHTML = lastPaint.html;
const oldList = previous.content.querySelector('[data-project-conversations]'), newList = next.content.querySelector('[data-project-conversations]');
const liveList = c.container.querySelector('[data-project-conversations]');
if (!oldList || !newList || !liveList) return false;
const oldItems = [...oldList.children], newItems = [...newList.children], liveItems = [...liveList.children];
oldList.replaceChildren(); newList.replaceChildren();
const normalize = template => {
const heading = template.content.querySelector('.project-heading p');
for (const node of heading?.childNodes || []) if (node.nodeType === Node.TEXT_NODE) node.data = node.data.replace(/\d+ conversations?/g, '# conversations');
for (const count of template.content.querySelectorAll('.project-tabs .project-count')) count.textContent = '#';
return visualKey(template.innerHTML);
};
if (normalize(previous) !== normalize(next)) return false;
const oldById = new Map(oldItems.filter(el => el.dataset.conversationId).map(el => [el.dataset.conversationId, el]));
const liveById = new Map(liveItems.filter(el => el.dataset.conversationId).map(el => [el.dataset.conversationId, el]));
const desired = newItems.map(el => {
const id = el.dataset.conversationId, old = oldById.get(id), live = liveById.get(id);
return old && live && old.outerHTML === el.outerHTML ? live : el;
});
const focused = liveList.contains(document.activeElement) ? document.activeElement : null;
for (let i = 0; i < desired.length; i++) if (liveList.children[i] !== desired[i]) liveList.insertBefore(desired[i], liveList.children[i] || null);
while (liveList.children.length > desired.length) liveList.lastElementChild.remove();
if (focused?.isConnected && document.activeElement !== focused) focused.focus({preventScroll:true});
const count = c.threads.filter(t => !t.shared && t.project === c.id).length;
const heading = c.container.querySelector('.project-heading p');
for (const node of heading?.childNodes || []) if (node.nodeType === Node.TEXT_NODE) { const value = node.data.replace(/\d+ conversations?/g, `${count} ${count === 1 ? 'conversation' : 'conversations'}`); if (node.data !== value) node.data = value; }
const tab = c.container.querySelector('.project-tabs [href$="/conversations"] .project-count');
if (tab && tab.textContent !== String(count)) tab.textContent = String(count);
return true;
}
function paint(navigated = false, read = false) {
if (!current || out()) return;
const keep = capture(), c = current;
const redesigned = (!legacyRoute() || c.path?.startsWith('/projects')) && !!window.MosaicProjects && !c.user && !c.tester;
let html = '<div class="chat-area"><p><a href="#/projects">Projects</a></p>';
if (c.orgArchived) html += `<p role="status">${c.orgArchived === 'room-not-open' ? 'This room is not open.' : 'This organization is archived.'} Sending is unavailable; your draft is kept.</p>`;
if (c.error && !(c.kind === 'room' && c.error === ROOM_LINES['room-not-open'] && redesigned)) html += `<p class="chat-error banner err" role="alert">${txt(c.error)}</p>`;
if (c.archiveUnknown && !c.loading) html += '<p role="status" data-archive-notice>Archive status unavailable. Some changes are unavailable; sending may be refused; refresh to check.</p>';
else if (c.kind === 'thread' && c.projectArchived) html += '<p role="status">This project is archived; its conversation stays readable.</p>';
@@ -517,9 +630,41 @@
} else {
html += heading('Projects') + '<p>Projects and their conversations.</p><div class="chat-projects">' + c.projects.map(p => `<section class="panel"><h2>${link('projects', p.id, p.name)}</h2>${p.repo ? `<p class="muted">Repository: ${txt(p.repo)}</p>` : ''}${threads(c.threads.filter(t => t.project === p.id && !t.shared))}</section>`).join('') + (c.projects.length ? '' : c.readFailed ? '<p>Projects unavailable.</p>' : '<p>No projects yet.</p>') + '</div><h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('project', 'projects');
}
if (!c.canSend) html += '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
if (!c.canSend) html += window.MosaicOrgBusStopped ? '<p class="muted">The bus is stopped. Use Start the bus to send messages.</p>' : '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
html += '</div>';
// Drafts already live in the focused textarea; their changing text is not a
// reason for a background read to replace the control.
const fingerprint = visualKey(html);
if (read && !navigated && lastPaint?.container === c.container && lastPaint.path === c.path && lastPaint.fingerprint === fingerprint && c.container.querySelector('.chat-area')) {
lastPaint.html = html;
refreshLibraryAge(c);
refreshProjectData(c, redesigned, keep);
refreshRequestData(c, true);
updateRoomCompletion();
return;
}
const next = document.createElement('template');
next.innerHTML = html;
if (read && !navigated && (updateTranscript(c, html, next, keep) || updateConversations(c, next))) {
lastPaint = { container: c.container, path: c.path, html, fingerprint };
refreshLibraryAge(c);
refreshProjectData(c, redesigned, keep);
refreshRequestData(c, true);
updateRoomCompletion();
return;
}
// updateTranscript empties the template log while comparing the rest.
next.innerHTML = html;
// Keep existing, unchanged message nodes when a later read adds more messages.
if (!navigated && lastPaint?.container === c.container && lastPaint.path === c.path) {
for (const message of next.content.querySelectorAll('[data-message]')) {
const previous = [...c.container.querySelectorAll('[data-message]')].find(el => el.dataset.message === message.dataset.message);
if (previous && previous.outerHTML === message.outerHTML) message.replaceWith(previous);
}
}
lastPaint = { container: c.container, path: c.path, html, fingerprint };
transcriptObserver?.disconnect(); transcriptResize?.disconnect();
c.container.innerHTML = html + '</div>';
c.container.replaceChildren(next.content);
const observedLog = c.container.querySelector('.project-chat .chat-log');
if (observedLog) {
const observedState = state(c.key ?? c.id);
@@ -533,20 +678,7 @@
if (pageFlow(observedLog)) transcriptResize.observe(c.container);
}
}
window.MosaicRequests?.mount?.(c.container, { archiveUnknown: !!c.archiveUnknown, projectArchived: !!c.projectArchived });
const t = c.kind === 'thread' ? c.thread : null;
const repo = c.kind === 'project' ? c.projects.find(p => p.id === c.id)?.repo : libraryOf(c, t);
const cached = knownRequests.get(c.id);
const remembered = c.kind === 'project' && cached && cached.repo === repo ? cached.ids : [];
const selected = t ? requestRows(state(c.key).rows) : new Set();
const ids = !roomOwner() ? [] : t ? [...new Set(state(c.key).rows.filter(m => selected.has(m.id)).map(m => m.request))] : [...remembered].slice(-REQUEST_ROWS);
const files = c.container.querySelector('[data-chat-files]');
if (files && typeof window.MosaicFiles?.mount === 'function') { files.replaceChildren(); window.MosaicFiles.mount({ container: files, requestIds: ids, key: `thread:${c.id}` }); }
const summary = c.container.querySelector('[data-chat-summary]');
if (summary && repo && ids.length) {
if (typeof window.MosaicRequests?.projectSummary === 'function') window.MosaicRequests.projectSummary({ container: summary, requestIds: ids, project: repo });
else summary.textContent = 'Request summary unavailable.';
}
refreshRequestData(c, false);
const remote = c.container.querySelector('[data-chat-remote]');
const project = c.projects.find(p => p.id === c.id);
if (remote && roomOwner() && typeof window.MosaicProjectRemote?.mount === 'function') { remote.replaceChildren(); window.MosaicProjectRemote.mount({ container: remote, projectId: c.id, repositoryKey: project?.repo ?? null, projectArchived: !!c.projectArchived, preserve: true }); }
@@ -555,12 +687,7 @@
const controls = c.container.querySelector('.project-archive-control');
if (controls) c.container.querySelector('.project-tabs')?.insertAdjacentElement('afterend', controls);
}
if (!c.user && !c.tester && window.MosaicProjects) {
const projectContent = c.container.querySelector('[data-project-content]');
if (redesigned && (c.kind === 'project' || c.kind === 'thread' || c.kind === 'room')) { const surfaceProject = c.kind === 'thread' ? c.projects.find(p=>p.id===c.thread?.project) : project; if(surfaceProject) void window.MosaicProjects.enhanceSurface(c.container, surfaceProject, c.threads, c.kind === 'thread' ? c.thread : null, c.canSend, form('room-open:' + surfaceProject.id).receipt); }
if (projectContent && project) void window.MosaicProjects.mountContent({ container: projectContent, project, threads: c.threads, section: c.section, canSend: c.canSend, restore: !navigated ? keep : null, roomReceipt: form('room-open:' + project.id).receipt });
if (c.kind === 'list') window.MosaicProjects.enhanceList(c.container, c.projects, c.threads);
}
refreshProjectData(c, redesigned, !navigated ? keep : null);
document.title = `${c.container.querySelector('h1')?.textContent ?? 'Chat'} · Mosaic Console`;
if (!navigated && keep?.toggles) [...c.container.querySelectorAll('.project-new-conversation,.project-create')].forEach((e,i)=>{e.open=!!keep.toggles[i];});
const details = c.container.querySelector('.chat-details');
@@ -630,13 +757,14 @@
paint(); return;
}
const old = current;
current = { container, kind, id, loading: true, archiveUnknown: kind === 'thread' && !user && !tester, settings: !!settings, section, path: originalPath, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.legacyDefault : false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id };
current = { container, kind, id, orgArchived: old?.orgArchived ?? false, loading: true, archiveUnknown: kind === 'thread' && !user && !tester, settings: !!settings, section, path: originalPath, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.legacyDefault : false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id };
current.projectArchived = current.projects.some(p => p.id === (kind === 'project' ? id : current.thread?.project) && p.archived);
if (kind === 'thread' && old?.kind === 'thread' && old.id === id && !navigated && old.archiveUnknown === false && old.projects.some(p => p.id === old.thread?.project)) current.archiveUnknown = false;
if (current.projectArchived) current.archiveUnknown = false;
// Keep the previous conversation in place, but immediately withdraw controls whose
// server paths lack archive guards, and the request panel's writes.
const withdraw = () => {
lastPaint = null;
for (const el of container.querySelectorAll('#chat-session-form input, #chat-session-form button, #chat-share-form input, #chat-share-form button, [data-unshare], #chat-cancel, [data-revert]')) el.disabled = true;
window.MosaicRequests?.mount?.(container, { archiveUnknown: !!current.archiveUnknown, projectArchived: !!current.projectArchived });
if (!current.archiveUnknown) return;
@@ -672,6 +800,7 @@
]);
if (g !== generation || out()) return;
current.channelDisabled = false;
current.orgArchived = false;
current.projects = projects.rows;
const own = new Set(listed.rows.map(t => t.id));
current.threads = [...listed.rows, ...shared.rows.filter(t => !own.has(t.id)).map(t => ({ ...t, shared: true }))];
@@ -823,7 +952,7 @@
}
if (project) document.dispatchEvent(new CustomEvent('mosaic-project-context', { detail: { projectId, repositoryKey: project.repo ?? null, thread: kind === 'thread' ? id : null, session: kind === 'thread' ? current.session : null } }));
}
paint(navigated);
paint(navigated, true);
const t = kind === 'thread' ? current.threads.find(t => t.id === id) : null;
if (t) void loadLibrary(libraryOf(current, t), g, signal);
}
@@ -831,6 +960,7 @@
if (g !== generation || out() || err.name === 'AbortError' || err.code === 'signed-out') return;
current.loading = false;
current.error = readError(err);
if (err.code === 'org-archived' || err.code === 'room-not-open') current.orgArchived = err.code;
current.readFailed = true;
if (err.code === 'channel-disabled') current.channelDisabled = true;
// A bad session selector is local to that read. Only a thread-level refusal
@@ -846,7 +976,7 @@
current.projectArchived = current.projects.some(p => p.id === (kind === 'project' ? id : current.thread?.project) && p.archived);
if (current.projects.some(p => p.id === (kind === 'project' ? id : current.thread?.project))) current.archiveUnknown = false;
else if (kind === 'thread' && !user && !tester) current.archiveUnknown = true;
paint(navigated);
paint(navigated, true);
}
}
function refresh(next = false) {
@@ -917,17 +1047,20 @@
// input that scrolls nothing gives no later scroll event to decide it.
settlingPins.set(log, pinned);
const mark = ++settleMark;
requestAnimationFrame(() => requestAnimationFrame(() => {
// Resize delivery can lag the scroll event by several frames. Keep the
// provenance long enough for the observer to restore a following tail.
const settle = frames => frames ? requestAnimationFrame(() => settle(frames - 1)) : (() => {
if (mark !== settleMark) return;
if (settlingPins.get(log) === pinned) settlingPins.delete(log);
if (log.isConnected && scroller.scrollTop === pinned && !transcriptAtTail(log)) s.follow = false;
}));
})();
settle(6);
return;
}
s.follow = transcriptAtTail(log);
}, true);
async function write(action, key, value, path) {
if (!current || !current.canSend || out() || current.channelDisabled) return;
if (!current || !current.canSend || current.orgArchived || out() || current.channelDisabled) return;
const isThread = action === 'send' || action === 'open';
if ((isThread || action === 'createSession') && !threadWrites()) return;
if (action === 'createSession' && current.archiveUnknown) return;
@@ -982,7 +1115,7 @@
} else if (event.target.id === 'chat-session-form') {
const key = 'session:' + current.id, f = form(key);
// Keep an ambiguous create's key and payload; editing requires a deliberate new attempt.
f.op ??= crypto.randomUUID();
f.op ??= opId();
f.payload ??= { name: f.name, op: f.op };
void write('createSession', key, f.payload, `threads/${current.id}/sessions`);
} else if (event.target.id === 'chat-operation-form') {
@@ -1091,7 +1224,7 @@
if (!window.confirm(`Clear context for "${selected.name}"? This ends this session after a safe stop and starts fresh context. History and the old checkout (including unpushed work) are retained. The successor uses a fresh approved checkout; old work is not copied. This can wait for safe stop or capacity.`)) return;
// A lost answer retains this key. Only status reads are allowed afterward.
if (f.op && !['cancelled', 'failed'].includes(f.record?.state)) { await checkClear(c.id, key, f); return; }
f.op = crypto.randomUUID(); f.pending = true; f.receipt = `Operation ${f.op}: requesting clear.`; paint();
f.op = opId(); f.pending = true; f.receipt = `Operation ${f.op}: requesting clear.`; paint();
try {
const r = await request(`threads/${c.id}/clear`, { session: selected.id, revision: selected.revision, confirm: true, op: f.op });
if (out()) return;
+7 -4
View File
@@ -32,7 +32,7 @@
else { a.target = '_blank'; a.rel = 'noopener noreferrer'; }
return a;
}
async function mount({ container, requestIds, key }) {
async function mount({ container, requestIds, key, quiet = false }) {
if (!(container instanceof Element)) return;
const generation = (generations.get(container) || 0) + 1;
generations.set(container, generation);
@@ -41,17 +41,20 @@
const cacheKey = typeof key === 'string' && key.length > 0 && key.length <= 320 ? `key:${key}` : `ids:${JSON.stringify(ids)}`;
const order = ++started;
const previous = !signedOut && !document.body.classList.contains('signed-out') && cache.get(cacheKey);
container.replaceChildren(...(previous ? previous.map(node => node.cloneNode(true)) : [element('p', 'Reading files…')]));
// A quiet reread keeps the present list (and any focus in it) until a changed result arrives.
const keep = quiet && container.childNodes.length > 0;
if (!keep) container.replaceChildren(...(previous ? previous.map(node => node.cloneNode(true)) : [element('p', 'Reading files…')]));
const same = nodes => keep && container.innerHTML === nodes.map(node => node.outerHTML ?? node.textContent).join('');
// A settled read paints and caches only if no later-started mount for this key has already
// committed; otherwise this container shows that newer result. A pending later mount never
// blocks an earlier completion. Callers keep their own fences before calling this.
function commit(nodes) {
if ((committed.get(cacheKey) || 0) > order) {
const newer = cache.get(cacheKey);
if (newer) container.replaceChildren(...newer.map(node => node.cloneNode(true)));
if (newer && !same(newer)) container.replaceChildren(...newer.map(node => node.cloneNode(true)));
return;
}
container.replaceChildren(...nodes);
if (!same(nodes)) container.replaceChildren(...nodes);
if (signedOut || document.body.classList.contains('signed-out')) return;
committed.set(cacheKey, order);
cache.set(cacheKey, nodes.map(node => node.cloneNode(true)));
+7 -2
View File
@@ -309,6 +309,7 @@
{ title: `Initialize project repository: ${project}`, verb: `mosaic org project repo init ${id} ${project} ${remote ? `--remote ${remote}` : '--local-only --accept-local-loss-risk'}` },
{ title: 'Start the bus', verb: `mosaic bus start ${id} --dev-local-owner --detach` },
{ title: `Create project: ${project}`, verb: `mosaic project create ${project} --repo ${project} --business ${id}` },
{ 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('');
@@ -325,6 +326,7 @@
[`/api/orgs/${enc(id)}/setup/project/${enc(project)}`, remote ? { mode: 'remote', remote } : local, 'project'],
[`/api/orgs/${enc(id)}/setup/bus`, {}, 'bus'],
[`/api/orgs/${enc(id)}/chat/projects`, { name: project, repo: project }, 'chat-project'],
[`/api/orgs/${enc(id)}/setup/records/${enc(project)}`, {}, 'records'],
];
for (current = 0; current < requests.length; current++) {
const card = list.children[current]; card.querySelector('.org-pill').textContent = 'In progress';
@@ -343,7 +345,7 @@
card.querySelector('.org-pill').textContent = 'Stopped'; card.querySelector('.org-field-err').textContent = current === 4
? `${e.message} Open the organization and check Projects before creating the project; its outcome may be unknown.`
: `${e.message} Open the organization to inspect and continue; do not repeat a step whose outcome is unknown.`;
if (current === 4) { editorDirty = false; $('org-new-id').value = ''; $('org-quick-open-org').hidden = false; }
if (current >= 4) { editorDirty = false; $('org-new-id').value = ''; $('org-quick-open-org').hidden = false; }
card.classList.add('org-step-warn'); card.focus(); status('Setup stopped.');
await refreshList();
}
@@ -620,9 +622,12 @@
if (!alive) return;
canWrite = (config.orgSetup === true || config.send === true) && config.tester !== true && config.user !== true;
root.querySelectorAll('[data-write]').forEach(el => { el.disabled = !canWrite; });
const params = query(), org = params.get('org'), setup = params.get('step') === 'setup', created = params.get('created') === '1';
// ?new=1 ("New organization" in the header menu, the Organization picker and the empty state): focus the ID field, then drop
// the flag so the same link focuses it again next time. This runs after the page heading took the navigation focus.
if (params.get('new') === '1' && !org) { $('org-new-id').focus(); setQuery(null, null); }
await refreshList();
// Resume: ?org=ID reopens the organization and its setup, read back from the server.
const params = query(), org = params.get('org'), setup = params.get('step') === 'setup', created = params.get('created') === '1';
if (org && ID.test(org)) {
await openExisting(org, { toSetup: setup });
if (created && st.setup?.notifications?.receipt) showReceipt('Organization created', st.setup.notifications);
+7 -1
View File
@@ -12,7 +12,7 @@
? new CustomEvent('mosaic-signed-out', { detail: { signIn: true } })
: new Event('mosaic-signed-out'));
};
let snapshot = null, canWrite = false, busy = false, sequence = 0, timer;
let snapshot = null, canWrite = false, busy = false, sequence = 0, timer, drawn;
async function responseBody(response) {
if (response.type === 'opaqueredirect') { signOut(false); return null; }
const body = await response.json();
@@ -20,6 +20,12 @@
return body;
}
function draw() {
const signature = JSON.stringify([snapshot?.version, snapshot?.generation, snapshot?.halted, snapshot?.stopping, canWrite, busy, out()]);
if (signature === drawn) {
document.dispatchEvent(new CustomEvent('mosaic-processing-state', { detail: { halted: snapshot?.halted ?? null } }));
return;
}
drawn = signature;
const halted = snapshot?.halted === true, valid = snapshot?.version === 1 && UUID.test(snapshot?.generation);
host.dataset.state = halted ? 'halted' : snapshot?.halted === false ? 'running' : 'unknown';
status.textContent = halted ? snapshot.stopping ? 'Processing halted. Stopping workloads.' : 'Processing halted.'
+9 -3
View File
@@ -81,7 +81,9 @@
.room-chip[aria-pressed=true] { background:var(--nav-active); border-color:var(--nav-active-border); color:var(--strong); }
.project-card-icon svg,.project-header-icon svg,.project-tree-row svg { width:55%; height:55%; fill:none; stroke:currentColor; stroke-width:1.7; stroke-linejoin:round; }
.project-tree-row svg { width:16px; height:16px; flex:none; margin-right:6px; }
.breadcrumbs:has(.project-crumb-current) a { display:inline-flex; align-items:center; min-width:44px; min-height:44px; }
.breadcrumbs:has(.project-crumb-current) a { position:relative; display:inline-flex; align-items:center; min-height:44px; }
/* The 44px target width comes from an overlay, not the layout box: a short name such as "work" must not leave blank space that reads as an empty crumb. */
.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%); } }
@@ -255,7 +257,9 @@ body:has(.project-chat) #bus-view, body:has(.project-chat) .chat-area { flex:1 1
.project-chat .chat-audience .room-chip { display:inline-flex; align-items:center; min-height:32px; margin-left:6px; background:var(--nav-active); border-color:var(--nav-active-border); }
.project-chat [data-conversation-request-status]:empty { display:none; }
.chat-conversation.project-chat > .project-header { margin-bottom:0; }
.project-chat [data-conversation-request-status] { min-height:16px; line-height:16px; margin:0; }
.project-work-wait { min-height:1.5em; }
/* Conversation fidelity and single-line tree labels. */
.project-tree-row a { font-size:14px; font-weight:600; }
.project-tree-children a { font-size:14px; font-weight:500; }
@@ -267,7 +271,6 @@ body:has(.project-chat) #bus-view, body:has(.project-chat) .chat-area { flex:1 1
.project-chat .chat-message-meta { opacity:1; font-size:12px; line-height:16px; }
.project-chat .chat-message-meta strong { font-size:13px; font-weight:600; }
.project-chat .chat-body { font-size:14px; }
.project-chat [data-conversation-request-status]:not(:empty) { margin:16px 0 0; }
@media(max-width:699px) { .project-heading p { font-size:14px; } }
.project-tree-children a { padding-left:40px; }
@@ -498,3 +501,6 @@ body:has(.chat-area .project-header):has(.project-chat) .breadcrumbs { margin-bo
}
/* A long Conversation title wraps beside Details instead of squeezing it. */
.project-header[data-project-conversation="true"] .project-actions { flex:none; }
/* 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; } }
+78 -23
View File
@@ -29,6 +29,7 @@
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const storageKey=window.MosaicOrg ? `mosaic-project-tree:${window.MosaicOrg.id}` : 'mosaic-project-tree';
let treeRows=[], treeThreads=[], treeGeneration=0, treeState, treeRead=null, treeReadAt=0;
const treeFocus = new WeakMap();
try { treeState=JSON.parse(localStorage.getItem(storageKey) || '{}'); if (!treeState || Array.isArray(treeState) || typeof treeState !== 'object') treeState={}; } catch { treeState={}; }
async function read(url) {
const response=await fetch(url,{cache:'no-store',redirect:'manual'});
@@ -45,12 +46,13 @@
function drawTree(container, route) {
if(!container?.isConnected || document.body.classList.contains('signed-out')) return;
const current=currentProject(route), threadId=route.match(/\/threads\/([0-9a-f-]{36})/i)?.[1], room=/\/rooms\//.test(route);
container.innerHTML=`<ul class="project-tree-list">${treeRows.filter(p=>!p.archived && UUID.test(p.id)).map(p=>{
const html=`<ul class="project-tree-list">${treeRows.filter(p=>!p.archived && UUID.test(p.id)).map(p=>{
const expanded=Object.hasOwn(treeState,p.id)?treeState[p.id]:p.id===current;
const own=treeThreads.filter(t=>t.project===p.id && !t.shared && UUID.test(t.id)).slice(-5).reverse();
const active=p.id===current && !room && !threadId;
return `<li><div class="project-tree-row${active?' active':''}"><button type="button" data-project-disclosure="${p.id}" aria-expanded="${!!expanded}" aria-controls="project-children-${p.id}" aria-label="${expanded?'Collapse':'Expand'} ${clean(p.name)}">${expanded?'⌄':'›'}</button><a href="${path(p.id,'overview')}" title="${clean(p.name)}"${active?' aria-current="page"':''}>${folder}<span>${clean(p.name)}</span></a></div><ul id="project-children-${p.id}" class="project-tree-children"${expanded?'':' hidden'}><li><a href="${path(p.id,'room')}"${p.id===current && room?' aria-current="page"':''}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 4h18v14H8l-5 3z"/></svg><span>Project room</span></a></li>${own.map(t=>`<li><a href="#/projects/threads/${t.id}"${t.id===threadId?' aria-current="page"':''} title="${clean(t.name)}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 4h18v14H8l-5 3z"/></svg><span>${clean(t.name)}</span><small>Private conversation</small></a></li>`).join('')}<li><a href="${path(p.id,'conversations')}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/></svg><span>View conversations</span></a></li></ul></li>`;
}).join('')}</ul>`;
if (container.dataset.treePaint !== html) { container.innerHTML = html; container.dataset.treePaint = html; }
const ancestor=document.querySelector('#sections > li > a[href="#/projects"]'); if(ancestor) { ancestor.classList.toggle('project-tree-ancestor',!!current); if(current) ancestor.removeAttribute('aria-current'); }
}
async function mountTree({container,path:route}) {
@@ -62,10 +64,22 @@
treeRead=Promise.all([read('/api/chat/projects'),read('/api/chat/threads')]);
}
const [projects,threads]=await treeRead;
if(generation!==treeGeneration || !container.isConnected || !Array.isArray(projects.rows) || !Array.isArray(threads.rows)) return;
if(generation!==treeGeneration || !container.isConnected) return;
if(!Array.isArray(projects.rows) || !Array.isArray(threads.rows)) throw Error('invalid tree');
treeRows=projects.rows; treeThreads=threads.rows; drawTree(container,route);
const saved=treeFocus.get(container);
if(saved && (document.activeElement===document.body || container.contains(document.activeElement))) {
const target=[...container.querySelectorAll('a,button[data-project-disclosure]')].find(el=>saved.href ? el.getAttribute('href')===saved.href : el.dataset.projectDisclosure===saved.disclosure);
target?.focus({preventScroll:true});
}
treeFocus.delete(container);
container.onclick=e=>{const button=e.target.closest('[data-project-disclosure]');if(!button)return;const id=button.dataset.projectDisclosure;treeState[id]=button.getAttribute('aria-expanded')!=='true';try{localStorage.setItem(storageKey,JSON.stringify(treeState));}catch{}drawTree(container,route);container.querySelector(`[data-project-disclosure="${id}"]`)?.focus();};
} catch { treeRead=null; if(generation===treeGeneration) container.replaceChildren(); }
} catch { if(generation===treeGeneration && container.isConnected && !document.body.classList.contains('signed-out')) {
treeRead=null;
const active=document.activeElement;
if(container.contains(active)) treeFocus.set(container, {href:active.getAttribute('href'), disclosure:active.dataset.projectDisclosure});
container.innerHTML='<p role="status">Projects unavailable. Refresh to try again.</p>'; delete container.dataset.treePaint;
} }
}
document.addEventListener('mosaic-signed-out',()=>{treeGeneration++;treeRead=null;treeRows=[];treeThreads=[];document.querySelector('#project-tree')?.replaceChildren();});
const requestId = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/.test(value);
@@ -158,7 +172,7 @@
const requestCode = id => `<code title="${clean(id)}">${clean(UUID.test(id) ? id.slice(0,8) : id)}</code>`;
function card(thread,request,failed) {
const file=request && delivery(request);
return `<article class="project-work"><div class="project-work-head"><div><span class="project-conversation-icon" aria-hidden="true">◌</span><a href="${conversationLink(thread)}"><strong>${clean(thread.name)}</strong></a><p>With ${clean(thread.instance)}${request?` · Request ${requestCode(request.id)}${request.state?.mode?' · '+clean(modeLabel(request.state.mode)):''} · Observed just now`:failed?' · Request status unavailable':' · No active request'}</p></div>${request?chip(request.state):''}</div>${stepper(request?.state)}${request?.state?.wait?.code?`<p>Waiting: ${clean(request.state.wait.code)}</p>`:''}${file?`<p><code>${clean(file.name)}</code> · Reviewed delivery <a download href="/api/requests/${encodeURIComponent(request.id)}/artifact/${encodeURIComponent(file.id)}">Download</a> <a href="${path(request.thread.project,'files')}">Open files</a></p>`:''}<div class="project-work-bottom">${request?.state?.tasks?.length?`<span>Tasks: ${request.state.tasks.filter(v=>/^vikunja:[0-9]+\/[0-9]+$/.test(v)).map(taskLink).join(', ')}</span>`:''}<a class="btn primary" href="${conversationLink(thread)}">Open conversation</a></div></article>`;
return `<article class="project-work"><div class="project-work-head"><div><span class="project-conversation-icon" aria-hidden="true">◌</span><a href="${conversationLink(thread)}"><strong>${clean(thread.name)}</strong></a><p>With ${clean(thread.instance)}${request?` · Request ${requestCode(request.id)}${request.state?.mode?' · '+clean(modeLabel(request.state.mode)):''} · Observed just now`:failed?' · Request status unavailable':' · No active request'}</p></div>${request?chip(request.state):''}</div>${stepper(request?.state)}<p class="project-work-wait">${request?.state?.wait?.code?`Waiting: ${clean(request.state.wait.code)}`:''}</p>${file?`<p><code>${clean(file.name)}</code> · Reviewed delivery <a download href="/api/requests/${encodeURIComponent(request.id)}/artifact/${encodeURIComponent(file.id)}">Download</a> <a href="${path(request.thread.project,'files')}">Open files</a></p>`:''}<div class="project-work-bottom">${request?.state?.tasks?.length?`<span>Tasks: ${request.state.tasks.filter(v=>/^vikunja:[0-9]+\/[0-9]+$/.test(v)).map(taskLink).join(', ')}</span>`:''}<a class="btn primary" href="${conversationLink(thread)}">Open conversation</a></div></article>`;
}
const requestOrder=(a,b)=>(Number(needs(b.state))-Number(needs(a.state))) || (Number(!!b.state && !ended(b.state))-Number(!!a.state && !ended(a.state))) || (Date.parse(b.at)||0)-(Date.parse(a.at)||0);
function docsList(data) { return Array.isArray(data.docs?.files)?data.docs.files.filter(f=>typeof f?.path==='string' && /^(spec\/north-star\.md|docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md)$/.test(f.path)).map(f=>f.path):null; }
@@ -177,7 +191,7 @@
}
function filesView(data,project) {
const f=filesData(data), docs=f.docs;
return `<h2>Reviewed deliveries <span class="project-count">${f.reviewed.length}</span></h2>${f.reviewed.length?f.reviewed.map(({r,file})=>`<div class="project-panel"><span class="project-delivery-icon" aria-hidden="true">▤</span><strong>${clean(file.name)}</strong> <span class="project-chip success">Reviewed</span><p>Request ${clean(r.id)} · from ${clean(r.thread.name)} · Reviewed delivery. Not landed or deployed.</p><div class="project-delivery-actions"><a class="btn" target="_blank" rel="noopener noreferrer" href="/api/requests/${encodeURIComponent(r.id)}/artifact/${encodeURIComponent(file.id)}/text">Open as text</a> <a class="btn" download href="/api/requests/${encodeURIComponent(r.id)}/artifact/${encodeURIComponent(file.id)}">Download</a>${deliveryPr(r)?` <a class="btn" target="_blank" rel="noopener noreferrer" href="${esc(deliveryPr(r))}">Open pull request</a>`:''}</div><p>Open shows plain text in a new tab. Downloaded HTML can be opened locally after saving.</p></div>`).join(''):`<p>${data.disabled?'Files are unavailable.':data.failed?'Reviewed files are unavailable.':data.requests.length?'No reviewed file is available.':'No request files yet. Files appear when a request in this project produces them.'}</p>`}<h2>Draft workspace <span class="project-count">${f.drafts.length}</span></h2><p>Names only. Draft files may change and are not reviewed deliveries.</p><div class="project-panel project-file-list">${f.drafts.length?f.drafts.map(({r,name})=>`<div><span><span aria-hidden="true">▤</span> <code>${clean(name)}</code></span><span>Request ${clean(r.id)} · <span class="project-chip">Draft</span></span></div>`).join(''):data.failed || data.requests.some(r=>r.workspace?.available!==true || !Array.isArray(r.workspace.entries))?'<p>Could not read draft workspace. Try again later.</p>':'<p>No draft files found.</p>'}${data.requests.some(r=>r.workspace?.truncated)?'<p>The draft list was truncated.</p>':''}</div><h2>Project documents ${docs?`<span class="project-count">${docs.length}</span>`:''}</h2><div class="project-panel project-file-list">${!project.repo?'No linked repository.':data.recordsMissing?'This project has no records yet. Initialize its record repository to add documents.':!docs?'Project documents unavailable. Docs could not read this project’s repository.':docs.length?docs.map(name=>`<div><span><span aria-hidden="true">▤</span> <code>${clean(name)}</code></span><a href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent(name)}">Open in Docs</a></div>`).join(''):'No project documents found.'}</div>`;
return `<h2>Reviewed deliveries <span class="project-count">${f.reviewed.length}</span></h2>${f.reviewed.length?f.reviewed.map(({r,file})=>`<div class="project-panel"><span class="project-delivery-icon" aria-hidden="true">▤</span><strong>${clean(file.name)}</strong> <span class="project-chip success">Reviewed</span><p>Request ${clean(r.id)} · from ${clean(r.thread.name)} · Reviewed delivery. Not landed or deployed.</p><div class="project-delivery-actions"><a class="btn" target="_blank" rel="noopener noreferrer" href="/api/requests/${encodeURIComponent(r.id)}/artifact/${encodeURIComponent(file.id)}/text">Open as text</a> <a class="btn" download href="/api/requests/${encodeURIComponent(r.id)}/artifact/${encodeURIComponent(file.id)}">Download</a>${deliveryPr(r)?` <a class="btn" target="_blank" rel="noopener noreferrer" href="${esc(deliveryPr(r))}">Open pull request</a>`:''}</div><p>Open shows plain text in a new tab. Downloaded HTML can be opened locally after saving.</p></div>`).join(''):`<p>${data.disabled?'Files are unavailable.':data.failed?'Reviewed files are unavailable.':data.requests.length?'No reviewed file is available.':'No request files yet. Files appear when a request in this project produces them.'}</p>`}<h2>Draft workspace <span class="project-count">${f.drafts.length}</span></h2><p>Names only. Draft files may change and are not reviewed deliveries.</p><div class="project-panel project-file-list">${f.drafts.length?f.drafts.map(({r,name})=>`<div><span><span aria-hidden="true">▤</span> <code>${clean(name)}</code></span><span>Request ${clean(r.id)} · <span class="project-chip">Draft</span></span></div>`).join(''):data.failed || data.requests.some(r=>r.workspace?.available!==true || !Array.isArray(r.workspace.entries))?'<p>Could not read draft workspace. Try again later.</p>':'<p>No draft files found.</p>'}${data.requests.some(r=>r.workspace?.truncated)?'<p>The draft list was truncated.</p>':''}</div><h2>Project documents ${docs?`<span class="project-count">${docs.length}</span>`:''}</h2><div class="project-panel project-file-list">${!project.repo?'No linked repository.':data.recordsMissing?window.MosaicRecords.markup(project.repo):!docs?'Project documents unavailable. Docs could not read this project’s repository.':docs.length?docs.map(name=>`<div><span><span aria-hidden="true">▤</span> <code>${clean(name)}</code></span><a href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent(name)}">Open in Docs</a></div>`).join(''):'No project documents found.'}</div>`;
}
const expandedPrds = new Set();
function requirementView(data,project,selected) {
@@ -187,7 +201,7 @@
const candidate=d?.candidate, expanded=r && expandedPrds.has(project.id+':'+r.id);
const docPath=candidate?.prd_path, docLink=project.repo && typeof docPath==='string' && /^docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md$/.test(docPath) ? `#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent(docPath)}` : null;
const rows=candidate ? [['PRD version',candidate.prd_version],['Approved snapshot',d.snapshot.id || 'None'],['PRD file',docPath],['PRD blob',candidate.prd_blob],['Records commit',candidate.records_commit],['Confirmed intent commit',candidate.spec_commit],['Mode revision',candidate.mode_revision]].map(([label,value])=>`<div class="project-provenance-row"><span>${label}</span>${label==='PRD file' && docLink?`<a href="${docLink}"><code>${clean(value)}</code></a>`:`<code>${clean(value)}</code>`}</div>`).join('') : '<p>Unavailable until a PRD is read.</p>';
return `${r?`<div class="project-panel project-source">${select || `Request <code>${clean(r.id)}</code>`} · ${chip(r.state)} · from ${clean(r.thread.name)} <a href="${conversationLink(r.thread)}">Open conversation</a></div>`:select}<div class="project-columns"><section><h2>Project direction</h2><h3 id="project-north-star">North star</h3>${north!==null?`<div data-north-star></div><a class="project-text-link" href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent('spec/north-star.md')}">Open north star in Docs</a>`:data.northRecordsMissing?'<p>This project has no records yet. Initialize its record repository to add documents.</p>':data.northMissing?'<p>No north star yet.</p>':project.repo?'<p>North star unavailable. Docs could not read this project’s repository.</p>':`<p>This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet.</p>`}<h3 id="project-prd">${candidate?`PRD ${clean(candidate.prd_version)}`:'PRD'} ${candidate?`<span class="project-chip ${d.snapshot.id?'success':''}">${d.snapshot.id?'Approved':'Draft'}</span>`:''}</h3>${d?`<p>${d.snapshot.id?'Approved snapshot '+clean(d.snapshot.id):'This candidate has no active approved snapshot.'}</p><div class="project-panel project-prd-panel"><div class="project-prd-preview${expanded?' expanded':''}" data-prd></div><button type="button" class="btn project-prd-toggle" aria-expanded="${!!expanded}">${expanded?'Show less':'Show full PRD'}</button></div><h3>Requirements <span class="project-count">${d.requirements.length}</span></h3><div class="project-panel">${d.requirements.map((req,i)=>`<details><summary>${clean(req.id)} · ${clean(req.title)}</summary><p>${clean(req.text)}</p><p>Requirement identity: <code>${clean(candidate.requirements[i]?.path)}</code> · <code>${clean(candidate.requirements[i]?.blob)}</code></p></details>`).join('')}</div>`:`<p>${(data.failed || data.requests.some(x=>x.materialFailed))?'Request or PRD unavailable. Refresh to try again.':'No current PRD candidate or approved snapshot. Waiting for a PRD submission.'}</p>`}${d && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}</section><aside class="project-panel project-provenance"><details${matchMedia('(min-width:700px)').matches?' open':''}><summary>Version and provenance</summary>${rows}${candidate?`<p>Approval and revocation live in the conversation's Request details.</p><a href="${conversationLink(r.thread)}">Open conversation</a>`:''}</details></aside></div>`;
return `${r?`<div class="project-panel project-source">${select || `Request <code>${clean(r.id)}</code>`} · ${chip(r.state)} · from ${clean(r.thread.name)} <a href="${conversationLink(r.thread)}">Open conversation</a></div>`:select}<div class="project-columns"><section><h2>Project direction</h2><h3 id="project-north-star">North star</h3>${north!==null?`<div data-north-star></div><a class="project-text-link" href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent('spec/north-star.md')}">Open north star in Docs</a>`:data.northRecordsMissing?`<p>${window.MosaicRecords.markup(project.repo)}</p>`:data.northMissing?'<p>No north star yet.</p>':project.repo?'<p>North star unavailable. Docs could not read this project’s repository.</p>':`<p>This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet.</p>`}<h3 id="project-prd">${candidate?`PRD ${clean(candidate.prd_version)}`:'PRD'} ${candidate?`<span class="project-chip ${d.snapshot.id?'success':''}">${d.snapshot.id?'Approved':'Draft'}</span>`:''}</h3>${d?`<p>${d.snapshot.id?'Approved snapshot '+clean(d.snapshot.id):'This candidate has no active approved snapshot.'}</p><div class="project-panel project-prd-panel"><div class="project-prd-preview${expanded?' expanded':''}" data-prd></div><button type="button" class="btn project-prd-toggle" aria-expanded="${!!expanded}">${expanded?'Show less':'Show full PRD'}</button></div><h3>Requirements <span class="project-count">${d.requirements.length}</span></h3><div class="project-panel">${d.requirements.map((req,i)=>`<details data-restore-key="req:${clean(r.id)}:${clean(req.id)}"><summary>${clean(req.id)} · ${clean(req.title)}</summary><p>${clean(req.text)}</p><p>Requirement identity: <code>${clean(candidate.requirements[i]?.path)}</code> · <code>${clean(candidate.requirements[i]?.blob)}</code></p></details>`).join('')}</div>`:`<p>${(data.failed || data.requests.some(x=>x.materialFailed))?'Request or PRD unavailable. Refresh to try again.':'No current PRD candidate or approved snapshot. Waiting for a PRD submission.'}</p>`}${d && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}</section><aside class="project-panel project-provenance"><details data-restore-key="provenance"${matchMedia('(min-width:700px)').matches?' open':''}><summary>Version and provenance</summary>${rows}${candidate?`<p>Approval and revocation live in the conversation's Request details.</p><a href="${conversationLink(r.thread)}">Open conversation</a>`:''}</details></aside></div>`;
}
function overview(data,project,canSend,receipt='') {
const active=data.requests.filter(r=>r.state && !ended(r.state)), waiting=active.filter(r=>needs(r.state)), ordered=[...data.requests].sort(requestOrder), first=ordered[0], threads=[...data.threads];
@@ -195,7 +209,7 @@
const f=filesData(data), filesKnown=!data.failed && !!f.docs && data.requests.every(r=>r.workspace?.available===true && Array.isArray(r.workspace.entries) && r.state), count=filesKnown?f.reviewed.length+f.drafts.length+f.docs.length:'–';
const n=typeof data.north?.content==='string'?data.north.content:null, candidate=ordered.find(r=>r.draft)?.draft;
const metrics=[['Requests in progress',data.failed?'–':active.length,data.failed?'Not available yet':active[0]?label(active[0].state):'No active requests',first?conversationLink(first.thread):path(project.id,'conversations')],['Needs your input',data.failed?'–':waiting.length,data.failed?'Not available yet':waiting.length?label(waiting[0].state):'Nothing waiting',waiting[0]?conversationLink(waiting[0].thread):path(project.id,'conversations')],['Conversations',threads.length,'Project conversations',path(project.id,'conversations')],['Files',count,filesKnown?`${f.reviewed.length} reviewed · ${f.drafts.length} draft · ${f.docs.length} docs`:data.recordsMissing?'No project records yet':'Not available yet',path(project.id,'files')]];
return `${waiting.length?`<div class="project-panel project-decision" role="status">${waiting.length} ${waiting.length===1?'decision needs':'decisions need'} your input. <a class="btn primary" href="${conversationLink(waiting[0].thread)}">Review decision</a></div>`:''}<div class="project-metrics">${metrics.map(([title,value,caption,href])=>`<a href="${href}"><span>${title}</span><strong>${value}</strong><small>${clean(caption)}</small></a>`).join('')}<p>Updated just now · Showing requests from recent messages.</p></div><div class="project-columns"><section><div class="project-section-title"><h2>Current work</h2>${canSend&&!project.archived?`<a class="btn" href="${path(project.id,'conversations')}?new=1">${plusIcon}New conversation</a>`:''}</div>${threads.length?threads.map((t,i)=>i===0?card(t,workFor(data,t),data.failed):`<a class="project-row" href="${conversationLink(t)}"><strong>${clean(t.name)}</strong><small>With ${clean(t.instance)} · ${workFor(data,t) ? label(workFor(data,t).state) : data.failed ? 'Status unavailable' : 'No active request'}</small>${workFor(data,t)?chip(workFor(data,t).state):''}</a>`).join(''):'<p>No conversations yet. Start one to begin work.</p>'}<h2>Tasks <span class="project-count">${taskRefs(data).length}</span></h2><div class="project-panel">${taskRefs(data).length?taskRefs(data).slice(0,2).map(ref=>`<div>${taskLink(ref,data)}</div>`).join(''):data.failed?'<p>Request tasks unavailable. Refresh to try again.</p>':'<p>No request tasks available.</p>'}<a href="${path(project.id,'tasks')}">View all project tasks</a></div></section><aside class="project-panel"><h2>Project direction</h2><h3>North star</h3><p>${n!==null?clean(n):data.northRecordsMissing?'This project has no records yet. Initialize its record repository to add documents.':data.northMissing?'No north star yet.':project.repo?'North star unavailable. Docs could not read this project’s repository.':"This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet."}</p><h3>${candidate?'PRD '+clean(candidate.candidate.prd_version):'PRD'} ${candidate?.snapshot.id?'<span class="project-chip success">Approved</span>':''}</h3><p>${candidate?(candidate.snapshot.id?'Approved snapshot '+clean(candidate.snapshot.id):'Draft'):(data.failed || data.requests.some(x=>x.materialFailed)?'PRD unavailable.':'No current PRD available.')}</p>${candidate && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}<a href="${path(project.id,'requirements')}">Open PRD and requirements</a><h3>Files ${filesKnown?`<span class="project-count">${count}</span>`:''}</h3>${[...f.reviewed.map(x=>({name:x.file.name,reviewed:true})),...f.drafts.map(x=>({name:x.name,reviewed:false}))].slice(0,3).map(file=>file.reviewed?`<p><a href="${path(project.id,'files')}"><code>${clean(file.name)}</code> · Reviewed</a></p>`:`<p><code>${clean(file.name)}</code> · Draft</p>`).join('')||(data.failed?'<p>Request files unavailable.</p>':'<p>No request files available.</p>')}<a href="${path(project.id,'files')}">All files</a><h3>Project facts</h3><div class="project-provenance-row"><span>Repository</span><span>${project.repo?clean(project.repo):'Not linked'}</span></div>${roomControls(project,data.room,canSend,receipt)}<div class="project-provenance-row"><span>Mode</span><span>${first?.state?.mode?clean(modeLabel(first.state.mode)):data.failed?'Unavailable':'No active request'}</span></div></aside></div>`;
return `${waiting.length?`<div class="project-panel project-decision" role="status">${waiting.length} ${waiting.length===1?'decision needs':'decisions need'} your input. <a class="btn primary" href="${conversationLink(waiting[0].thread)}">Review decision</a></div>`:''}<div class="project-metrics">${metrics.map(([title,value,caption,href])=>`<a href="${href}"><span>${title}</span><strong>${value}</strong><small>${clean(caption)}</small></a>`).join('')}<p>Updated just now · Showing requests from recent messages.</p></div><div class="project-columns"><section><div class="project-section-title"><h2>Current work</h2>${canSend&&!project.archived?`<a class="btn" href="${path(project.id,'conversations')}?new=1">${plusIcon}New conversation</a>`:''}</div>${threads.length?threads.map((t,i)=>i===0?card(t,workFor(data,t),data.failed):`<a class="project-row" href="${conversationLink(t)}"><strong>${clean(t.name)}</strong><small>With ${clean(t.instance)} · ${workFor(data,t) ? label(workFor(data,t).state) : data.failed ? 'Status unavailable' : 'No active request'}</small>${workFor(data,t)?chip(workFor(data,t).state):''}</a>`).join(''):'<p>No conversations yet. Start one to begin work.</p>'}<h2>Tasks <span class="project-count">${taskRefs(data).length}</span></h2><div class="project-panel">${taskRefs(data).length?taskRefs(data).slice(0,2).map(ref=>`<div>${taskLink(ref,data)}</div>`).join(''):data.failed?'<p>Request tasks unavailable. Refresh to try again.</p>':'<p>No request tasks available.</p>'}<a href="${path(project.id,'tasks')}">View all project tasks</a></div></section><aside class="project-panel"><h2>Project direction</h2><h3>North star</h3><p>${n!==null?clean(n):data.northRecordsMissing?window.MosaicRecords.markup(project.repo):data.northMissing?'No north star yet.':project.repo?'North star unavailable. Docs could not read this project’s repository.':"This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet."}</p><h3>${candidate?'PRD '+clean(candidate.candidate.prd_version):'PRD'} ${candidate?.snapshot.id?'<span class="project-chip success">Approved</span>':''}</h3><p>${candidate?(candidate.snapshot.id?'Approved snapshot '+clean(candidate.snapshot.id):'Draft'):(data.failed || data.requests.some(x=>x.materialFailed)?'PRD unavailable.':'No current PRD available.')}</p>${candidate && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}<a href="${path(project.id,'requirements')}">Open PRD and requirements</a><h3>Files ${filesKnown?`<span class="project-count">${count}</span>`:''}</h3>${[...f.reviewed.map(x=>({name:x.file.name,reviewed:true})),...f.drafts.map(x=>({name:x.name,reviewed:false}))].slice(0,3).map(file=>file.reviewed?`<p><a href="${path(project.id,'files')}"><code>${clean(file.name)}</code> · Reviewed</a></p>`:`<p><code>${clean(file.name)}</code> · Draft</p>`).join('')||(data.failed?'<p>Request files unavailable.</p>':'<p>No request files available.</p>')}<a href="${path(project.id,'files')}">All files</a><h3>Project facts</h3><div class="project-provenance-row"><span>Repository</span><span>${project.repo?clean(project.repo):'Not linked'}</span></div>${roomControls(project,data.room,canSend,receipt)}<div class="project-provenance-row"><span>Mode</span><span>${first?.state?.mode?clean(modeLabel(first.state.mode)):data.failed?'Unavailable':'No active request'}</span></div></aside></div>`;
}
function tasksView(data) {
const refs=taskRefs(data);
@@ -205,16 +219,59 @@
return `<a class="project-file-row" href="#/tasks/${encodeURIComponent(ref)}"><div><strong>${clean(fields?.title || ref)}</strong>${request?`<small>From request ${clean(request.id)} · ${clean(request.thread.name)}</small>`:''}</div><span class="project-chip ${fields?.done===true?'success':''}">${fields?.done===true?'Done':fields?.bucket!==undefined && fields?.bucket!==null?'bucket '+clean(fields.bucket):'Status unavailable'}${assignees?' · '+assignees:''}</span></a>`;
}).join('')}</div>`:`<p>${data.failed?'Request tasks unavailable. Refresh to try again.':'No request tasks available.'}</p>`}`;
}
const contentPaints = new WeakMap(), contentRuns = new WeakMap(), contentModels = new WeakMap();
// Disclosure and focus state is saved by identity (request and requirement id, provenance), so
// a row inserted or moved above it, or another request's requirement with the same local id,
// cannot inherit its state. Keyed disclosures keep a closed state too, so a default-open panel
// the user closed stays closed. Unkeyed details fall back to their position; a key that no
// longer exists is dropped.
const FOCUSABLE='a,button,select,input,textarea,summary,[tabindex]';
const restoreKey=(el,i)=>el.dataset.restoreKey || 'index:'+i;
function captureView(root) {
if(!root) return {disclosures:[],closed:[],projectFocus:null};
const active=document.activeElement, inside=root.contains(active);
const owner=inside && active.matches('summary') ? active.parentElement?.closest('details[data-restore-key]') : null;
return {
disclosures:[...root.querySelectorAll('details')].map((el,i)=>el.open?restoreKey(el,i):null).filter(Boolean),
closed:[...root.querySelectorAll('details[data-restore-key]')].filter(el=>!el.open).map(el=>el.dataset.restoreKey),
projectFocus:inside ? {id:active.id,details:owner?.dataset.restoreKey || null,index:[...root.querySelectorAll(FOCUSABLE)].indexOf(active)} : null
};
}
function restoreView(root,restore) {
const wanted=new Set((restore.disclosures || []).map(String)), closed=new Set((restore.closed || []).map(String));
[...root.querySelectorAll('details')].forEach((el,i)=>{const key=restoreKey(el,i);if(wanted.has(key)) el.open=true;else if(el.dataset.restoreKey && closed.has(key)) el.open=false;});
const f=restore.projectFocus; if(!f) return;
const focus=f.id ? document.getElementById(f.id) : f.details ? [...root.querySelectorAll('details[data-restore-key]')].find(el=>el.dataset.restoreKey===f.details)?.querySelector(':scope > summary') : root.querySelectorAll(FOCUSABLE)[f.index];
focus?.focus({preventScroll:true});
}
async function mountContent({container,project,threads,section,canSend,restore,roomReceipt}) {
if(!container || !project) return;
const generation=epoch; container.textContent='Reading…';
const generation=epoch, run=(contentRuns.get(container) || 0)+1;
contentRuns.set(container,run);
if (!contentPaints.has(container)) container.textContent='Reading…';
const data=await gather(project,threads).catch(()=>null);
if(!container.isConnected || generation!==epoch || document.body.classList.contains('signed-out')) return;
if(!data){container.textContent='Project data unavailable. Refresh to try again.';return;}
if(!container.isConnected || generation!==epoch || contentRuns.get(container)!==run || document.body.classList.contains('signed-out')) return;
if(!data){contentPaints.delete(container);container.textContent='Project data unavailable. Refresh to try again.';return;}
const selected=selectedRequests.get(project.id);
if(selected && !data.requests.some(r=>r.id===selected && r.draft)) selectedRequests.delete(project.id);
const selection=selectedRequests.get(project.id);
const view = section==='requirements'?requirementView(data,project,selection):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
const selectedRequest=data.requests.find(r=>r.id===(selection||data.requests.find(x=>x.draft)?.id));
// The generated presentation and separately mounted Markdown determine visible content.
const signature=JSON.stringify([view, section==='requirements' ? selectedRequest?.draft?.prd : null, section==='requirements' ? data.north?.content : null, data.failed && ['files','tasks'].includes(section)]);
const header=container.closest('.chat-area')?.querySelector('.project-tabs');
if(header){const tasks=header.querySelector(`a[href="${path(project.id,'tasks')}"]`), files=header.querySelector(`a[href="${path(project.id,'files')}"]`);const f=filesData(data);if(tasks && !tasks.querySelector('.project-count') && !data.failed)tasks.insertAdjacentHTML('beforeend',` <span class="project-count">${taskRefs(data).length}</span>`);if(files && !files.querySelector('.project-count') && !data.failed && f.docs && data.requests.every(r=>r.workspace?.available===true && Array.isArray(r.workspace.entries)))files.insertAdjacentHTML('beforeend',` <span class="project-count">${f.reviewed.length+f.drafts.length+f.docs.length}</span>`);}
revealTab(header);
// Retained controls (the request selector) draw from the newest accepted read, even when
// this read changes nothing visible and the DOM is kept.
contentModels.set(container,{data,project,section,canSend,roomReceipt});
if (contentPaints.get(container) === signature) return;
contentPaints.set(container, signature);
// Capture at commit time: the user may have interacted while the read was pending.
const active=document.activeElement;
const liveRestore=contentPaints.has(container) && container.children.length ? captureView(container) : null;
const draw=selected=>{
const {data,project,section,canSend,roomReceipt}=contentModels.get(container);
container.innerHTML=section==='requirements'?requirementView(data,project,selected):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
if(data.failed && ['files','tasks'].includes(section)) container.insertAdjacentHTML('afterbegin','<p role="status">Some request data could not be read; this list may be incomplete.</p>');
const selectedRequest=data.requests.find(r=>r.id===(selected||data.requests.find(x=>x.draft)?.id));
@@ -222,12 +279,10 @@
const north=container.querySelector('[data-north-star]'); if(north && data.north?.content) north.replaceChildren(window.MosaicMarkdown?.render(data.north.content) || document.createTextNode(data.north.content));
container.querySelector('#project-request-select')?.addEventListener('change',e=>{selectedRequests.set(project.id,e.target.value);draw(e.target.value);container.querySelector('#project-request-select')?.focus({preventScroll:true});});
};
const selected=selectedRequests.get(project.id);
if(selected && !data.requests.some(r=>r.id===selected && r.draft)) selectedRequests.delete(project.id);
draw(selectedRequests.get(project.id));
draw(selection);
restore=liveRestore || restore;
if (restore) {
const details=container.querySelectorAll('details'); for(const i of restore.disclosures || []) if(details[i]) details[i].open=true;
if(restore.projectFocus) { const focus=restore.projectFocus.id ? document.getElementById(restore.projectFocus.id) : container.querySelectorAll('a,button,select,input,textarea,summary,[tabindex]')[restore.projectFocus.index]; focus?.focus({preventScroll:true}); }
restoreView(container,restore);
}
const sectionTarget=new URLSearchParams(location.hash.split('?')[1] || '').get('section');
if(section==='requirements' && ['prd','north-star'].includes(sectionTarget)) container.querySelector('#project-'+sectionTarget)?.scrollIntoView({block:'start'});
@@ -235,7 +290,7 @@
async function enhanceList(container,projects,threads) {
for(const project of projects.filter(p=>!p.archived)) {
const slot=container.querySelector(`[data-project-status="${project.id}"]`); if(!slot) continue;
void gather(project,threads).then(data=>{if(!slot.isConnected)return;const current=[...data.requests].sort(requestOrder).find(r=>r.state);slot.innerHTML=`${current?chip(current.state):''}<span class="project-card-status-line">${data.failed?'Status unavailable · ':current?'':'No active requests · '}${ownThreads(project,threads).length} ${ownThreads(project,threads).length === 1 ? 'conversation' : 'conversations'} · Updated just now</span>`;}).catch(()=>{if(slot.isConnected)slot.textContent='Status unavailable';});
void gather(project,threads).then(data=>{if(!slot.isConnected)return;const current=[...data.requests].sort(requestOrder).find(r=>r.state);const html=`${current?chip(current.state):''}<span class="project-card-status-line">${data.failed?'Status unavailable · ':current?'':'No active requests · '}${ownThreads(project,threads).length} ${ownThreads(project,threads).length === 1 ? 'conversation' : 'conversations'} · Updated just now</span>`;if(slot.innerHTML!==html)slot.innerHTML=html;}).catch(()=>{if(slot.isConnected && slot.textContent!=='Status unavailable')slot.textContent='Status unavailable';});
}
}
@@ -248,21 +303,21 @@
if(!data.failed) { setCount('tasks',taskRefs(data).length); if(f.docs && data.requests.every(r=>r.workspace?.available===true && Array.isArray(r.workspace.entries))) setCount('files',f.reviewed.length+f.drafts.length+f.docs.length); }
revealTab(tabs);
const requestStatus=container.querySelector('[data-conversation-request-status]');
if(requestStatus && thread) requestStatus.textContent=data.failed?'Request status unavailable.':'';
if(requestStatus && thread) { const text=data.failed?'Request status unavailable.':''; if(requestStatus.textContent!==text) requestStatus.textContent=text; }
const selected=thread?workFor(data,thread):[...data.requests].sort(requestOrder).find(r=>r.state);
const agent=container.querySelector('[data-project-agent]'); if(agent && thread) agent.textContent=thread.instance;
const context=container.querySelector('[data-project-context]'); if(context && selected?.state) context.textContent=' · '+label(selected.state)+(selected.state.mode?' · '+modeLabel(selected.state.mode):'');
for(const row of container.querySelectorAll('[data-conversation-id]')) { const t=data.threads.find(t=>t.id===row.dataset.conversationId), r=t && workFor(data,t), slot=row.querySelector('[data-conversation-status]'); if(slot) slot.innerHTML=r?chip(r.state):data.failed?'Status unavailable':'No active request'; }
const agent=container.querySelector('[data-project-agent]'); if(agent && thread && agent.textContent!==thread.instance) agent.textContent=thread.instance;
const context=container.querySelector('[data-project-context]'); if(context) { const text=selected?.state?' · '+label(selected.state)+(selected.state.mode?' · '+modeLabel(selected.state.mode):''):''; if(context.textContent!==text) context.textContent=text; }
for(const row of container.querySelectorAll('[data-conversation-id]')) { const t=data.threads.find(t=>t.id===row.dataset.conversationId), r=t && workFor(data,t), slot=row.querySelector('[data-conversation-status]'); if(slot) { const html=r?chip(r.state):data.failed?'Status unavailable':'No active request'; if(slot.innerHTML!==html) slot.innerHTML=html; } }
surfaceData.set(project.id,data);
enrichRequest(container, project.id);
}
function enrichRequest(container, projectId) {
const data=surfaceData.get(projectId); if(!data) return;
for(const intro of container.querySelectorAll('.project-request-intro')) { const id=intro.querySelector('code')?.title, r=data.requests.find(r=>r.id===id && r.state?.revision===Number(intro.dataset.requestRevision)); if(!r) continue; const files=filesData(data), prd=intro.querySelector('[data-work-prd]'), counts=intro.querySelector('[data-work-files]'); if(prd) prd.innerHTML=`PRD ${clean(r.draft?.candidate?.prd_version || '–')} ${r.draft ? `<span class="project-chip ${r.draft.snapshot?.id?'success':''}">${r.draft.snapshot?.id?'Approved':'Draft'}</span>` : ''}`; if(counts) counts.textContent=`Files · ${!data.failed ? files.reviewed.length : '–'} reviewed, ${!data.failed && data.requests.every(x=>x.workspace?.available===true && Array.isArray(x.workspace.entries) && !x.workspace.truncated) ? files.drafts.length+' draft' : '– draft (Draft files unavailable)'}`; }
for(const intro of container.querySelectorAll('.project-request-intro')) { const id=intro.querySelector('code')?.title, r=data.requests.find(r=>r.id===id && r.state?.revision===Number(intro.dataset.requestRevision)); if(!r) continue; const files=filesData(data), prd=intro.querySelector('[data-work-prd]'), counts=intro.querySelector('[data-work-files]'); if(prd) { const html=`PRD ${clean(r.draft?.candidate?.prd_version || '–')} ${r.draft ? `<span class="project-chip ${r.draft.snapshot?.id?'success':''}">${r.draft.snapshot?.id?'Approved':'Draft'}</span>` : ''}`; if(prd.innerHTML!==html) prd.innerHTML=html; } if(counts) { const text=`Files · ${!data.failed ? files.reviewed.length : '–'} reviewed, ${!data.failed && data.requests.every(x=>x.workspace?.available===true && Array.isArray(x.workspace.entries) && !x.workspace.truncated) ? files.drafts.length+' draft' : '– draft (Draft files unavailable)'}`; if(counts.textContent!==text) counts.textContent=text; } }
}
document.addEventListener('click',e=>{
const open=e.target.closest?.('[data-project-dialog]');
if(open){const dialog=document.getElementById(`project-${open.dataset.projectDialog}-dialog`);if(dialog && !dialog.open){dialog.showModal();dialog.querySelector('h2')?.focus();}return;}
if(open){const dialog=document.getElementById(`project-${open.dataset.projectDialog}-dialog`);if(dialog && !dialog.open){open.focus({preventScroll:true});dialog.showModal();dialog.querySelector('h2')?.focus();}return;}
const close=e.target.closest?.('[data-dialog-close]');if(close)close.closest('dialog')?.close();
});
window.MosaicProjectTree ??= Object.freeze({mount:mountTree});
@@ -280,5 +335,5 @@
box.innerHTML=`<a href="#/projects">Projects</a><span aria-hidden="true">/</span><a class="project-crumb-name" href="#/projects/${encodeURIComponent(id)}" title="${clean(name)}"><span>${clean(name)}</span></a>${conversation?`<span aria-hidden="true">/</span><a href="${path(id,'conversations')}">Conversations</a>`:''}<span class="project-crumb-current"><span aria-hidden="true">/</span> <span aria-current="page">${clean(leaf)}</span></span>`;
return true;
}
window.MosaicProjects = Object.freeze({ header, revealTab, path, clean, mountContent, enhanceList, enhanceSurface, enrichRequest, breadcrumb, requestIntro, palette });
window.MosaicProjects = Object.freeze({ header, revealTab, path, clean, mountContent, captureView, enhanceList, enhanceSurface, enrichRequest, breadcrumb, requestIntro, palette });
})();
+7 -5
View File
@@ -283,7 +283,7 @@
(focus && !focus.disabled ? focus : el.querySelector('.request-receipt'))?.focus({ preventScroll: true });
}
}
const signature = p => JSON.stringify([p.state, p.inbox, p.projects, p.material, p.materialError, p.failed, p.stale, p.missing, p.writes, p.archiveUnknown, p.projectArchived, Boolean(p.reading)]);
const signature = p => JSON.stringify(canonical([p.state, p.inbox, p.projects, p.material, p.materialError, p.failed, p.stale, p.missing, p.writes, p.archiveUnknown, p.projectArchived, Boolean(p.reading)]));
// The reads that follow a state: the mode decision the selection waits on and the project picker.
async function side(p) {
@@ -527,7 +527,7 @@
p.writes = cfg.writes;
p.archiveUnknown = archiveUnknown;
p.projectArchived = projectArchived;
if (p.state || p.failed) draw(p);
if ((p.state || p.failed) && (!el.firstElementChild || signature(p) !== p.sig)) draw(p);
if (!p.reading && Date.now() - p.at > STALE) void read(p);
}
pulse();
@@ -537,9 +537,10 @@
}
// The project page supplies known IDs; each is reread through the existing authorized boundary.
async function projectSummary({ container, requestIds, project } = {}) {
// A quiet reread keeps the present summary until its read settles, then replaces it only if changed.
async function projectSummary({ container, requestIds, project, quiet = false } = {}) {
if (!container || out()) return;
container.replaceChildren();
if (!quiet) container.replaceChildren();
const run = {}; summaryRuns.set(container, run); summaries.add(container);
if (!text(project) || !ID.test(project) || !Array.isArray(requestIds)) return;
const ids = [...new Set(requestIds.filter(id => text(id) && ID.test(id)))];
@@ -551,7 +552,8 @@
}));
if (out() || !container.isConnected || summaryRuns.get(container) !== run) return;
const valid = rows.filter(Boolean);
container.innerHTML = valid.length ? `<section class="request-project-summary" aria-label="Requests"><h3>Requests</h3>${valid.map(s => `<p>Request <code>${txt(s.request)}</code> · Phase: ${txt(s.phase)}${s.snapshot ? ' · PRD approved' : ''}</p>`).join('')}</section>` : '<p>Request information unavailable.</p>';
const html = valid.length ? `<section class="request-project-summary" aria-label="Requests"><h3>Requests</h3>${valid.map(s => `<p>Request <code>${txt(s.request)}</code> · Phase: ${txt(s.phase)}${s.snapshot ? ' · PRD approved' : ''}</p>`).join('')}</section>` : '<p>Request information unavailable.</p>';
if (container.innerHTML !== html) container.innerHTML = html;
}
window.MosaicRequests = Object.freeze({ mount, forget, projectSummary });
})();
+22 -5
View File
@@ -18,6 +18,7 @@ import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } fro
import { REQUEST_ROUTE, requestHTTP, requestRefuse } from './request-http.mjs';
import { PROJECT_REMOTE_ROUTE, projectRemoteHTTP } from './project-remote-http.mjs';
import { orgAdminHTTP, defaultOrgAdmin } from './org-admin.mjs';
import { devHostName } from '../../cli/src/dev-local-owner.mjs';
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
export function isLoopback(host) {
@@ -691,9 +692,15 @@ async function libraryStatusRead(res, read, search) {
// `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
// tests; the field table is otherwise the committed packages/tasks/fields.json.
// `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request.
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, userOptions = null, federation = null, organizations = null, preselect = null, routes = [], orgAdmin = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
if (sso?.devOwner && host !== '127.0.0.1') throw new Error('dev local owner must bind 127.0.0.1');
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, userOptions = null, federation = null, organizations = null, preselect = null, routes = [], orgAdmin = null, devAllowHosts = [], log = line => console.log(line), fieldsFile = undefined } = {}) {
// org-lan-bind: the dev owner on the LAN is an explicit opt-in. `devAllowHosts` names the Host
// names (without a port) a remote browser may use; only the dev owner takes them, and only with
// them may it bind a non-loopback IPv4 address. SSO, preview and tester listeners stay loopback.
if (!Array.isArray(devAllowHosts) || !devAllowHosts.every(devHostName)) throw new Error('dev allowed hosts must be host names or IPv4 addresses without a port');
if (devAllowHosts.length && !sso?.devOwner) throw new Error('dev allowed hosts need the dev local owner');
const devLan = Boolean(sso?.devOwner) && devAllowHosts.length > 0;
if (sso?.devOwner && host !== '127.0.0.1' && !(devLan && isIP(host) === 4)) throw new Error(devLan ? 'dev local owner binds an IPv4 address' : 'dev local owner must bind 127.0.0.1');
if (!isLoopback(host) && !devLan) throw new Error('refusing to bind to non-loopback host');
if (host === 'localhost') host = '127.0.0.1';
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
if (tester && sso !== null) throw new Error('tester listener cannot use sso');
@@ -713,7 +720,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
for (const registerRoutes of routes) registerRoutes(registered);
registered.add('GET', '/api/themes', (req, res) => themesHTTP(req, res, { log }), { publicRead: true });
const adminRoute = (req, res, context) => orgAdminHTTP(req, res, { ...context, admin: orgAdmin ??= defaultOrgAdmin(), log });
for (const path of ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin', /^\/api\/orgs\/[^/]+\/(?:admin|setup|delete|archive|restore)(?:\/(?:queue|bus|project\/[^/]+))?$/]) {
for (const path of ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin', /^\/api\/orgs\/[^/]+\/(?:admin|setup|delete|archive|restore)(?:\/(?:queue|bus|project\/[^/]+|records\/[^/]+))?$/]) {
for (const method of ['GET', 'POST']) registered.add(method, path, adminRoute);
}
registered.add('POST', '/api/orgs', adminRoute);
@@ -751,10 +758,20 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// never legitimate, so it is refused before any check reads the Host.
if ((req.headersDistinct.host?.length ?? 0) > 1) return json(res, sso?.devOwner ? 403 : 400, { error: 'repeated Host refused' });
// Preview mode: a request for the public name, exactly, as the proxy forwards it.
const devHost = sso?.devOwner && [`127.0.0.1:${boundPort}`, `localhost:${boundPort}`].includes(req.headers.host);
const loopHost = sso?.devOwner && [`127.0.0.1:${boundPort}`, `localhost:${boundPort}`].includes(req.headers.host);
const lanHost = devLan && !loopHost && devAllowHosts.some(name => `${name}:${boundPort}` === req.headers.host);
const devHost = loopHost || lanHost;
if (sso?.devOwner && !devHost) return json(res, 403, { error: 'non-local Host refused' });
const localOrigin = devHost ? `http://${req.headers.host}` : null;
if (devHost && req.method !== 'GET' && req.method !== 'HEAD' && (req.headersDistinct.origin?.length !== 1 || req.headers.origin !== localOrigin)) return json(res, 403, { error: 'cross-origin request refused' });
// Chromium sends no Sec-Fetch-* to an http origin that is not loopback (not potentially
// trustworthy), so a LAN page's writes arrive with the exact Origin checked above and no Fetch
// Metadata. Only there, and only when the header is absent, the write counts as same-origin for
// the route checks below. A Sec-Fetch-Site the browser did send is left for them to judge.
if (lanHost && req.method !== 'GET' && req.method !== 'HEAD' && !req.headersDistinct['sec-fetch-site']) {
req.headers['sec-fetch-site'] = 'same-origin';
req.headersDistinct['sec-fetch-site'] = ['same-origin'];
}
const named = devHost || (preview !== null ? (req.headers.host ?? '').toLowerCase() === preview.host : sso !== null && (req.headers.host ?? '').toLowerCase() === sso.publicHost);
// The proof precedes every auth-header read, including the wrong-Host fence.
if (proof !== null) {
+3 -2
View File
@@ -18,11 +18,12 @@ process.once('exit', () => { for (const child of live) killGroup(child); });
// Row 95 (#1589): the Board's machine sessions are off by default. Most tests read the board scan,
// so by default every page this browser opens starts with Show machine sessions on, as if the
// person had turned it on before. Pass { machine: false } to see the page as a first visit does.
export async function browser({ machine = true } = {}) {
// `args` adds Chromium flags, for example --host-resolver-rules for a test host name.
export async function browser({ machine = true, args = [] } = {}) {
const profile = await mkdtemp(join(tmpdir(), 'dewey-brand-browser-'));
const child = spawn(process.env.CHROMIUM || '/usr/bin/chromium', [
'--headless', '--no-first-run', '--no-default-browser-check',
'--disable-dev-shm-usage', '--remote-debugging-pipe', `--user-data-dir=${profile}`,
'--disable-dev-shm-usage', '--remote-debugging-pipe', `--user-data-dir=${profile}`, ...args,
], { detached: true, stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] });
live.add(child);
let id = 0, buffer = '', exited = false;
+17 -3
View File
@@ -58,9 +58,9 @@ async function setup(t, suffix = '', initial = '', startRoute = '#/chat') {
const route = async id => { await b.evaluate(`location.hash='#/chat/threads/${id}',true`); await wait(`document.querySelector('.chat-conversation')?.dataset.thread==='${id}'`); };
await wait('document.querySelector(".chat-projects")');
const refresh = async () => {
await wait('!document.querySelector("#refresh").disabled');
await b.evaluate('document.querySelector(".chat-area").dataset.beforeRefresh="yes";document.querySelector("#refresh").click();true');
await wait('!document.querySelector(".chat-area")?.dataset.beforeRefresh');
// A successful read can leave the existing DOM in place. Await the real
// renderer instead of using replacement of the old element as its signal.
await b.evaluate(`MosaicChat.render({path:location.hash.slice(1),container:document.querySelector('#bus-view'),canSend:true,navigated:false})`);
};
return { b, wait, set, click, route, refresh };
}
@@ -552,6 +552,20 @@ test('264e new session keeps its operation key after an unknown response and use
assert.equal(await b.evaluate('location.hash'),`#/chat/threads/${A}/sessions/${B}`);
});
// org-lan-bind: a Console opened by LAN name is not a secure context, so crypto.randomUUID is
// absent there. A new session still gets a random v4 operation key.
test('a new session gets a v4 operation key without crypto.randomUUID', async t => {
const {b,wait,set,click,route}=await setup(t,'','delete Crypto.prototype.randomUUID');
assert.equal(await b.evaluate('typeof crypto.randomUUID'),'undefined');
await route(A); await set('#chat-session-name','new context');
await b.evaluate(`window.__answers.push({body:{id:'${B}'}});window.__sessions['${A}']=[{id:'${A}',name:'default',default:true},{id:'${B}',name:'new context'}];true`);
await click('#chat-session-form button');
await wait(`document.querySelector('.chat-conversation')?.dataset.session==='${B}'`);
const write=await b.evaluate('window.__writes[0]');
assert.equal(write.path, `/api/chat/threads/${A}/sessions`);
assert.match(write.body.op,/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
});
test('264e cancel shows before/after retirement and never starts or replays work', async t => {
const {b,wait,set,click,route}=await setup(t);
await b.evaluate(`window.__sessions['${A}']=[{id:'${A}',name:'default',default:true,state:'active',revision:2}];window.confirm=()=>true;true`);
@@ -191,12 +191,20 @@ test('chat replies: unchanged file at a different library commit and incomplete
const data=await res.json();data.rows[1].check=window.__checkOverride;
return new Response(JSON.stringify(data),{headers:{'content-type':'application/json'}});
};true`);
await wait('!document.querySelector("#refresh").disabled');
await b.evaluate('document.querySelector(".chat-area").dataset.beforeRefresh="yes";document.querySelector("#refresh").click();true');
await wait('!document.querySelector(".chat-area")?.dataset.beforeRefresh && !document.querySelector("#refresh").disabled');
await b.evaluate(`MosaicChat.render({path:location.hash.slice(1),container:document.querySelector('#bus-view'),canSend:true,navigated:false})`);
assert.equal(await b.evaluate('document.querySelectorAll(".chat-changed").length'), 0);
});
test('library age changes without replacing the conversation', async t => {
const { b, wait } = await setup(t);
await b.evaluate(`window.__status.body.lastFetch=new Date(Date.now()-330000).toISOString();MosaicChat.render({path:location.hash.slice(1),container:document.querySelector('#bus-view'),canSend:true,navigated:false})`);
await wait(`window.__statusReads.length>=2`);
await b.evaluate(`(()=>{const field=document.querySelector('#chat-body');field.focus();field.value='draft';field.dispatchEvent(new Event('input',{bubbles:true}));field.setSelectionRange(2,2);window.savedReplyField=field;window.savedReplyMessage=document.querySelector('[data-message]');const now=Date.now();Date.now=()=>now+120000;})()`);
await b.evaluate(`MosaicChat.render({path:location.hash.slice(1),container:document.querySelector('#bus-view'),canSend:true,navigated:false})`);
await wait(`document.querySelector('.chat-library')?.textContent.includes('7 min ago')`);
assert.equal(await b.evaluate(`savedReplyMessage.isConnected && savedReplyField.isConnected && document.activeElement===savedReplyField && savedReplyField.selectionStart===2`), true);
});
// Invalid configuration is a diagnostic, unlike an ordinary project with no library mapping.
test('chat status line: an invalid library mapping remains visible for diagnosis', async t => {
const { b, wait, text } = await setup(t, { before: "window.__status={status:502,body:{error:'library-mapping-invalid'}};" });
+136
View File
@@ -0,0 +1,136 @@
// org-lan-bind: the dev owner Console on the LAN (--dev-insecure-bind ADDR --dev-allow-host NAME).
// "Remote" requests bind 0.0.0.0 on port 0 and connect to 127.0.0.1 with a LAN Host header.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { request } from 'node:http';
import { connect } from 'node:net';
import { fileURLToPath } from 'node:url';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { close } from './fixture.mjs';
process.chdir(fileURLToPath(new URL('../../..', import.meta.url)));
const LAN = '10.1.10.37';
function call(port, { host, method = 'GET', path = '/api/config', origin, site, body } = {}) {
const data = body === undefined ? undefined : JSON.stringify(body);
const headers = { host, ...(origin === undefined ? {} : { origin }), ...(site === undefined ? {} : { 'sec-fetch-site': site }),
...(data === undefined ? {} : { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) }) };
return new Promise((resolve, reject) => {
const req = request({ host: '127.0.0.1', port, path, method, headers }, res => {
let raw = ''; res.on('data', c => raw += c);
res.on('end', () => { let parsed = raw; try { parsed = JSON.parse(raw); } catch {} resolve({ status: res.statusCode, type: res.headers['content-type'] ?? '', body: parsed }); });
});
req.on('error', reject); req.end(data);
});
}
// node:http will not send two Host lines, so the repeated-Host request is written by hand.
function raw(port, text) {
return new Promise((resolve, reject) => {
const socket = connect(port, '127.0.0.1', () => socket.end(text));
let out = ''; socket.on('data', c => out += c); socket.on('end', () => resolve(out)); socket.on('error', reject);
});
}
// A refusal must come before listening; a server that starts anyway is closed and fails the test.
async function refused(options, pattern) {
let server;
try { server = await startServer({ port: 0, log: () => {}, ...options }); }
catch (err) { assert.match(err.message, pattern); return; }
await close(server);
assert.fail(`startServer listened with ${JSON.stringify(Object.keys(options))}`);
}
test('a non-loopback bind is refused without the dev owner and its allowed names', async () => {
const sso = { publicHost: 'console.test', publicOrigin: 'https://console.test', session: () => null };
const preview = { host: 'console.test', header: 'x-auth', user: 'u' };
await refused({ host: '0.0.0.0' }, /refusing to bind to non-loopback host/);
await refused({ host: '0.0.0.0', devAllowHosts: [LAN] }, /dev allowed hosts need the dev local owner/);
await refused({ host: '0.0.0.0', sso: createDevOwner() }, /dev local owner must bind 127\.0\.0\.1/);
await refused({ host: '0.0.0.0', sso: createDevOwner(), devAllowHosts: [] }, /dev local owner must bind 127\.0\.0\.1/);
await refused({ host: 'localhost', sso: createDevOwner(), devAllowHosts: [LAN] }, /binds an IPv4 address/);
for (const name of [`${LAN}:80`, 'LAN.test', 'lan_test', '', '-lan', '1.2.3', 'http://lan.test'])
await refused({ host: '0.0.0.0', sso: createDevOwner(), devAllowHosts: [name] }, /without a port/);
await refused({ host: '0.0.0.0', sso: createDevOwner(), devAllowHosts: 'lan.test' }, /without a port/);
// SSO, preview and tester listeners stay loopback-only, with or without allowed names.
for (const extra of [{ sso }, { preview }, { tester: true }]) {
await refused({ host: '0.0.0.0', ...extra }, /refusing to bind to non-loopback host/);
await refused({ host: '0.0.0.0', devAllowHosts: [LAN], ...extra }, /dev allowed hosts need the dev local owner/);
await refused({ host: LAN, devAllowHosts: [LAN], ...extra }, /dev allowed hosts need the dev local owner/);
}
});
test('an allowed LAN name reads and writes with its own Origin only', async t => {
const sent = [];
const server = await startServer({ host: '0.0.0.0', port: 0, devAllowHosts: [LAN, 'lan.test'], sso: createDevOwner(), board: 'http://127.0.0.1:9',
send: async args => { sent.push(args); return { id: `m${sent.length}` }; }, log: () => {} });
t.after(() => close(server));
const port = server.address().port, host = `${LAN}:${port}`, message = { to: 'chief', body: 'hello' };
assert.equal(server.address().address, '0.0.0.0');
const shell = await call(port, { host, path: '/' });
assert.equal(shell.status, 200); assert.match(shell.type, /text\/html/);
const config = await call(port, { host });
assert.equal(config.status, 200); assert.equal(config.body.devOwner, true); assert.equal(config.body.send, true);
// A browser on a LAN http origin sends Origin but no Sec-Fetch-Site; the exact Origin is enough.
const write = await call(port, { host, method: 'POST', path: '/api/bus/message', origin: `http://${host}`, body: message });
assert.equal(write.status, 200, JSON.stringify(write.body)); assert.equal(write.body.id, 'm1');
const named = await call(port, { host: `lan.test:${port}`, method: 'POST', path: '/api/bus/message', origin: `http://lan.test:${port}`, body: message });
assert.equal(named.status, 200, JSON.stringify(named.body));
// A Fetch Metadata header the browser did send is still judged by the route.
const crossSite = await call(port, { host, method: 'POST', path: '/api/bus/message', origin: `http://${host}`, site: 'cross-site', body: message });
assert.equal(crossSite.status, 403); assert.equal(crossSite.body.error, 'cross-site request refused');
for (const origin of [undefined, `http://127.0.0.1:${port}`, `http://localhost:${port}`, 'http://evil.example', `http://lan.test:${port}`, `https://${host}`, `http://${LAN}:${port + 1}`, 'null']) {
const r = await call(port, { host, method: 'POST', path: '/api/bus/message', origin, site: 'same-origin', body: message });
assert.equal(r.status, 403, String(origin)); assert.equal(r.body.error, 'cross-origin request refused', String(origin));
}
for (const other of ['10.1.10.38', 'evil.example', `evil.example:${port}`, `${LAN}:${port + 1}`, LAN, `LAN.TEST:${port}`, `lan.test.:${port}`, `${LAN}:${port}.`]) {
const r = await call(port, { host: other });
assert.equal(r.status, 403, other); assert.equal(r.body.error, 'non-local Host refused', other);
}
const repeated = await raw(port, `GET /api/config HTTP/1.1\r\nHost: ${host}\r\nHost: ${host}\r\nConnection: close\r\n\r\n`);
assert.match(repeated, /^HTTP\/1\.1 403 /); assert.match(repeated, /repeated Host refused/);
// Loopback on the same server is unchanged: no Fetch Metadata stand-in there.
const loop = `127.0.0.1:${port}`;
assert.equal((await call(port, { host: loop })).status, 200);
const bare = await call(port, { host: loop, method: 'POST', path: '/api/bus/message', origin: `http://${loop}`, body: message });
assert.equal(bare.status, 403); assert.equal(bare.body.error, 'cross-site request refused');
const loopWrite = await call(port, { host: loop, method: 'POST', path: '/api/bus/message', origin: `http://${loop}`, site: 'same-origin', body: message });
assert.equal(loopWrite.status, 200, JSON.stringify(loopWrite.body));
assert.equal(sent.length, 3);
});
test('a loopback dev owner without allowed names refuses a LAN Host', async t => {
const server = await startServer({ port: 0, sso: createDevOwner(), board: 'http://127.0.0.1:9', send: async () => ({ id: 'm1' }), log: () => {} });
t.after(() => close(server));
const port = server.address().port;
assert.equal(server.address().address, '127.0.0.1');
assert.equal((await call(port, { host: `${LAN}:${port}` })).status, 403);
const r = await call(port, { host: `${LAN}:${port}`, method: 'POST', path: '/api/bus/message', origin: `http://${LAN}:${port}`, body: { to: 'chief', body: 'x' } });
assert.equal(r.status, 403); assert.equal(r.body.error, 'non-local Host refused');
});
// The flags are refused before any configuration is read: an empty MOSAIC_CONFIG would otherwise
// fail with the named-instance refusal instead.
test('serve refuses the LAN flags unless they come together with --dev-local-owner', () => {
const run = args => spawnSync(process.execPath, ['packages/webui/src/cli.mjs', 'serve', '--port', '0', ...args], { encoding: 'utf8', env: { ...process.env, MOSAIC_CONFIG: '' }, timeout: 10000 });
for (const [args, reason] of [
[['--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test'], /--dev-insecure-bind needs --dev-local-owner/],
[['--dev-insecure-bind', '0.0.0.0'], /--dev-insecure-bind needs --dev-local-owner/],
[['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0'], /needs at least one --dev-allow-host/],
[['--dev-local-owner', '--dev-allow-host', 'lan.test'], /--dev-allow-host needs --dev-insecure-bind/],
[['--dev-local-owner', '--dev-insecure-bind', 'lan.test', '--dev-allow-host', 'lan.test'], /0\.0\.0\.0 or an IPv4 address/],
[['--dev-local-owner', '--dev-insecure-bind', '::', '--dev-allow-host', 'lan.test'], /0\.0\.0\.0 or an IPv4 address/],
[['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test:7350'], /without a port/],
[['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test,'], /without a port/],
[['--dev-local-owner', '--dev-insecure-bind', LAN, '--dev-allow-host', 'lan.test'], /must also be a --dev-allow-host name/],
[['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test'], /takes one address/],
[['--dev-local-owner', '--sso', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test'], /cannot be combined/],
]) {
const r = run(args);
assert.equal(r.status, 2, args.join(' ')); assert.match(r.stderr, reason, args.join(' '));
assert.doesNotMatch(r.stdout + r.stderr, /Mosaic Console:|WARNING: dev owner/, args.join(' '));
}
// With valid flags the next refusal is the named-instance check, so parsing accepted them.
const valid = run(['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', `lan.test,${LAN}`, '--dev-allow-host', 'other.lan']);
assert.equal(valid.status, 2); assert.match(valid.stderr, /--dev-local-owner requires a named instance/);
});
+2 -1
View File
@@ -99,7 +99,8 @@ test('Messages nav count polls on Tasks and Board and clears on a Board refusal;
const before=localReads.length;
messagesFailure='human-required';
await wait(`!!document.querySelector('#bus-view .banner.ref') && !${badge}`);
assert.match(await b.evaluate('document.querySelector("#bus-view h1").textContent'),/Bus refused the read/);
// A refused Queue read keeps Queue as its heading (E2E round 4); the other pages name the refusal.
assert.match(await b.evaluate('document.querySelector("#bus-view h1").textContent'),path==='queue'?/^Queue$/:/Bus refused the read/);
assert.equal(localReads.length,before,'a refused messages pre-read prevents the local read');
messagesFailure=null;
await wait(`document.querySelector('#bus-view h1')?.textContent===${JSON.stringify(title)} && ${section ? `document.querySelector('#settings-page-title')?.textContent===${JSON.stringify(section)} && ` : ''}${badge}?.textContent==='4'`);
+1 -1
View File
@@ -150,7 +150,7 @@ test('writes keep the origin and CSRF fencing', async t => {
const r = await c.call('POST', '/api/orgs', body, headers);
assert.equal(r.status, status, JSON.stringify(headers));
}
for (const path of ['/api/orgs/acme/setup/queue', '/api/orgs/acme/setup/bus', '/api/orgs/acme/admin']) {
for (const path of ['/api/orgs/acme/setup/queue', '/api/orgs/acme/setup/bus', '/api/orgs/acme/setup/records/work', '/api/orgs/acme/admin']) {
const r = await c.call('POST', path, {}, { 'sec-fetch-site': 'cross-site' });
assert.equal(r.status, 403); assert.equal(r.body.error, 'cross-site request refused');
}
@@ -0,0 +1,44 @@
// org-lan-bind: a browser on another machine opens the dev owner Console by an allowed LAN name
// (http://lan.test:PORT/, mapped to 127.0.0.1 for the test) and creates a project. That origin
// is not a secure context, so Chromium sends no Fetch Metadata and has no crypto.randomUUID.
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 ID = '20000000-0000-4000-8000-000000000002';
test('an allowed LAN name loads the org page and creates a project', { timeout: 45000 }, async t => {
const projects = [], created = [];
const organizations = {
list: async () => [{ id: 'alpha', label: 'alpha', busHealthy: true }],
resolve: async id => id === 'alpha' ? {
busHealthy: true, send: async () => ({}),
reads: { business: async () => ({ business: { id, projects: ['work'] } }), busStatus: async () => ({ live: true }) },
bus: { inbox: async () => [], agents: async () => [], messages: async () => ({ messages: [] }), tasks: async () => [] },
chat: {
projects: async () => projects, threads: async () => [],
createProject: async args => { created.push(args); const row = { id: ID, ...args }; projects.push(row); return row; },
},
} : null,
};
const server = await startServer({ host: '0.0.0.0', port: 0, devAllowHosts: ['lan.test'], sso: createDevOwner(), organizations, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} });
t.after(() => close(server));
const port = server.address().port;
const b = await browser({ machine: false, args: ['--host-resolver-rules=MAP lan.test 127.0.0.1'] }); t.after(() => b.close());
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
const input = (selector, value) => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));e.dispatchEvent(new Event('change',{bubbles:true}));})()`);
const click = selector => b.evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`);
await b.navigate(`http://lan.test:${port}/#/orgs/alpha/projects`);
assert.deepEqual(await b.evaluate('({ host: location.host, secure: isSecureContext, uuid: typeof crypto.randomUUID })'), { host: `lan.test:${port}`, secure: false, uuid: 'undefined' });
await wait('window.MosaicOrg?.id === "alpha" && !!document.querySelector("#chat-repo option[value=work]")');
await click('.project-create summary');
await input('#chat-project-name', 'Work'); await input('#chat-repo', 'work');
await click('#chat-project-form button[type=submit]');
await wait('/\\/projects\\/[0-9a-f-]{36}/i.test(location.hash)');
assert.equal(await b.evaluate('location.hash'), `#/orgs/alpha/projects/${ID}`);
assert.deepEqual(created, [{ name: 'Work', repo: 'work' }]);
assert.equal(await b.evaluate('location.host'), `lan.test:${port}`);
});
@@ -0,0 +1,123 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { fixture } from './fixture.mjs';
import { browser } from './browser.mjs';
import { P, T, stub } from './helpers/projects-review-fixture.mjs';
async function setup(t, path='/projects/'+P, width=1440) {
const f=await fixture(), b=await browser({machine:false});
t.after(async()=>{await b.close();await f.close();});
await b.call('Fetch.enable',{patterns:[{urlPattern:'*'}]});
b.on('Fetch.requestPaused',p=>void b.call(p.request.url.startsWith(f.base+'/')?'Fetch.continueRequest':'Fetch.failRequest',p.request.url.startsWith(f.base+'/')?{requestId:p.requestId}:{requestId:p.requestId,errorReason:'BlockedByClient'}).catch(()=>{}));
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:stub});
await b.viewport(width,900);
await b.navigate(f.base+'/#'+path);
const run=fn=>b.evaluate('('+fn.toString()+')()');
const wait=expr=>b.evaluate('(async()=>{for(let i=0;i<200;i++){if('+expr+')return;await new Promise(r=>setTimeout(r,25))}throw Error('+JSON.stringify(expr)+')})()');
const render=path=>{const canonical=path==='/projects'?'/chat':/^\/projects\/(threads|rooms)\//.test(path)?'/chat'+path.slice('/projects'.length):/^\/projects\/[^/]+$/.test(path)?'/chat/projects/'+path.slice('/projects/'.length):path;return b.evaluate('MosaicChat.render({path:'+JSON.stringify(canonical)+',container:document.querySelector("#bus-view"),canSend:true,navigated:false})');};
await wait('document.querySelector(".project-header")');
await run(async()=>{await new Promise(r=>setTimeout(r,150));});
return {f,b,run,wait,render};
}
test('probe: room recipient completion still works after an unchanged refresh',async t=>{
const {run,render,wait}=await setup(t,'/projects/rooms/'+P);
await wait('document.querySelector("#chat-room-body") && !document.querySelector("#chat-room-body").disabled');
await run(()=>{
window.roomField=document.querySelector('#chat-room-body');roomField.focus();roomField.value='@a';roomField.setSelectionRange(2,2);roomField.dispatchEvent(new Event('input',{bubbles:true}));
});
await wait('!document.querySelector("#chat-room-completion").hidden && !!document.querySelector("[data-room-complete=assistant]")');
await render('/projects/rooms/'+P);
const result=await run(()=>{
const menu=document.querySelector('#chat-room-completion');
const before={menuOpen:!menu.hidden,value:document.querySelector('#chat-room-body').value,same:roomField.isConnected};
document.querySelector('[data-room-complete=assistant]').click();
return {...before,after:document.querySelector('#chat-room-body').value};
});
console.log('ROOM_COMPLETION',JSON.stringify(result));
assert.equal(result.menuOpen,true);assert.equal(result.after,'@assistant ','the preserved completion menu must accept a recipient');
});
test('probe: invisible request revision does not collapse requirement details or drop focus',async t=>{
const {run,render,wait}=await setup(t,'/projects/'+P+'/requirements');
await wait('document.querySelector("[data-project-content] .project-columns section details summary")');
await run(()=>{
window.requirement=document.querySelector('[data-project-content] .project-columns section details');
requirement.open=true;window.requirementSummary=requirement.querySelector('summary');requirementSummary.focus();
window.beforeText=requirement.textContent;
reviewStates['request-1']={...reviewStates['request-1'],revision:2};
const now=Date.now();Date.now=()=>now+11000;
});
await render('/projects/'+P+'/requirements');
await run(async()=>{await new Promise(r=>setTimeout(r,200));});
const result=await run(()=>{
const detail=document.querySelector('[data-project-content] .project-columns section details');
return {oldConnected:requirement.isConnected,open:detail.open,focused:document.activeElement===detail.querySelector('summary'),textSame:detail.textContent===beforeText};
});
console.log('REQUIREMENT_REFRESH',JSON.stringify(result));
assert.equal(result.textSame,true);
assert.equal(result.open&&result.focused,true,'an unchanged requirement must keep its disclosure and focus');
});
test('probe: focused row survives a keyed move',async t=>{
const {run,render}=await setup(t,'/projects/'+P+'/conversations');
await run(()=>{
window.movingRow=document.querySelectorAll('[data-conversation-id]')[2];movingRow.focus();
fixtureThreads.unshift(fixtureThreads.pop());
});
await render('/projects/'+P+'/conversations');
const result=await run(()=>({connected:movingRow.isConnected,focused:document.activeElement===movingRow,active:document.activeElement.tagName,first:document.querySelector('[data-conversation-id]')===movingRow}));
console.log('FOCUSED_MOVE',JSON.stringify(result));
assert.equal(result.connected&&result.first,true);
assert.equal(result.focused,true,'moving a keyed conversation must keep its keyboard focus');
});
test('probe: stale project-content read cannot roll back newer request state',async t=>{
const {run,render,wait}=await setup(t);
await wait('document.querySelector(".project-work .project-chip")?.textContent==="Building"');
await run(()=>{
const now=Date.now();window.clockOffset=11000;Date.now=()=>now+clockOffset;
const old=window.fetch;window.holdOld=true;
window.fetch=async(url,init)=>{
const res=await old(url,init);
if(String(url)==='/api/requests/request-1'&&holdOld){holdOld=false;return new Promise(resolve=>window.releaseOld=()=>resolve(res));}
return res;
};
});
await render('/projects/'+P);
await wait('!!window.releaseOld');
await run(()=>{reviewStates['request-1']={...reviewStates['request-1'],phase:'review',revision:2,mode:'supervised'};clockOffset+=11000;});
await render('/projects/'+P);
await wait('document.querySelector(".project-work .project-chip")?.textContent==="In review"');
await run(async()=>{window.newerText=document.querySelector('[data-project-content]').textContent;releaseOld();await new Promise(r=>setTimeout(r,200));});
const result=await run(()=>({newer:newerText,final:document.querySelector('.project-work .project-chip').textContent,content:document.querySelector('[data-project-content]').textContent,context:document.querySelector('[data-project-context]').textContent}));
console.log('CONTENT_RACE',JSON.stringify(result));
assert.equal(result.final,'In review','old project content must not overwrite the newer request phase');
assert.equal(result.content,result.newer,'no project content may roll back after the old read');
assert.match(result.content,/Supervised/);
});
for (const changed of [false, true]) for (const method of ['click', 'keyboard']) {
test(`room completion ${method} works after ${changed ? 'message-only' : 'unchanged'} refresh`, async t => {
const {b,run,render,wait}=await setup(t,'/projects/rooms/'+P);
await wait('document.querySelector("#chat-room-body") && !document.querySelector("#chat-room-body").disabled');
await run(()=>{const f=document.querySelector('#chat-room-body');f.focus();f.value='@a';f.setSelectionRange(2,2);f.dispatchEvent(new Event('input',{bubbles:true}));});
if(changed) await run(()=>fixtureRoom.push({id:'50000000-0000-4000-8000-000000000004',ids:['50000000-0000-4000-8000-000000000004'],from_role:'assistant',to:['human'],body:'New message',at:'2026-11-06T10:26:00Z'}));
await render('/projects/rooms/'+P);
if(changed) await wait('document.querySelectorAll("[data-message]").length===4');
if(method==='click') await run(()=>document.querySelector('[data-room-complete=assistant]').click());
else await b.key('Enter');
assert.equal(await run(()=>document.querySelector('#chat-room-body').value),'@assistant ');
});
}
test('changed requirement content retains the disclosure and focused summary',async t=>{
const {run,render,wait}=await setup(t,'/projects/'+P+'/requirements');
await wait('document.querySelector("[data-project-content] .project-columns section details summary")');
await run(()=>{const d=document.querySelector('[data-project-content] .project-columns section details');d.open=true;d.querySelector('summary').focus();reviewDrafts['request-1'].requirements[0].text='Updated requirement text';const now=Date.now();Date.now=()=>now+11000;});
await render('/projects/'+P+'/requirements');
await wait('document.querySelector("[data-project-content]")?.textContent.includes("Updated requirement text")');
const result=await run(()=>{const d=document.querySelector('[data-project-content] .project-columns section details');return {open:d.open,focused:document.activeElement===d.querySelector('summary')}});
assert.deepEqual(result,{open:true,focused:true});
});
@@ -0,0 +1,116 @@
// Re-check findings on c0b2f7d86 (Sol, org-live-updates-recheck.md): a refresh that changes only an
// unselected request must still feed the retained selector, and requirement disclosure and focus
// follow the requirement identity when a row is inserted above it.
import test from 'node:test';
import assert from 'node:assert/strict';
import { fixture } from './fixture.mjs';
import { browser } from './browser.mjs';
import { P, T, stub } from './helpers/projects-review-fixture.mjs';
async function setup(t, path='/projects/'+P, width=1440) {
const f=await fixture(), b=await browser({machine:false});
t.after(async()=>{await b.close();await f.close();});
await b.call('Fetch.enable',{patterns:[{urlPattern:'*'}]});
b.on('Fetch.requestPaused',p=>void b.call(p.request.url.startsWith(f.base+'/')?'Fetch.continueRequest':'Fetch.failRequest',p.request.url.startsWith(f.base+'/')?{requestId:p.requestId}:{requestId:p.requestId,errorReason:'BlockedByClient'}).catch(()=>{}));
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:stub});
await b.viewport(width,900);
await b.navigate(f.base+'/#'+path);
const run=fn=>b.evaluate('('+fn.toString()+')()');
const wait=expr=>b.evaluate('(async()=>{for(let i=0;i<200;i++){if('+expr+')return;await new Promise(r=>setTimeout(r,25))}throw Error('+JSON.stringify(expr)+')})()');
const render=path=>{const canonical=path==='/projects'?'/chat':/^\/projects\/(threads|rooms)\//.test(path)?'/chat'+path.slice('/projects'.length):/^\/projects\/[^/]+$/.test(path)?'/chat/projects/'+path.slice('/projects/'.length):path;return b.evaluate('MosaicChat.render({path:'+JSON.stringify(canonical)+',container:document.querySelector("#bus-view"),canSend:true,navigated:false})');};
await wait('document.querySelector(".project-header")');
await run(async()=>{await new Promise(r=>setTimeout(r,150));});
await run(()=>{const before=MosaicProjects;window.MosaicProjects={...before,mountContent:options=>{const pending=before.mountContent(options);window.lastContentProbe=pending;return pending;}}});
return {f,b,run,wait,render};
}
test('refreshing an unselected request updates its next selection',async t=>{
const {run,render,wait}=await setup(t,'/projects/'+P+'/requirements');
await wait('document.querySelector("#project-request-select")?.value==="request-1" && document.querySelector("[data-prd]")?.textContent');
await run(()=>{
window.originalRequestSelect=document.querySelector('#project-request-select');
const beforeSecondFetch=fetch;window.fetch=async(url,init)=>{const response=await beforeSecondFetch(url,init);if(String(url)==='/api/requests/request-2/draft')window.latestSecondPrd=(await response.clone().json()).prd;return response;};
reviewDrafts['request-2'].prd='# UPDATED SECOND REQUEST PRD';
reviewDrafts['request-2'].requirements[0].text='UPDATED SECOND REQUIREMENT';
reviewStates['request-2'].revision++;
reviewStates['request-2'].candidate.prd_version='v5';
reviewStates['request-2'].snapshot='snapshot-request-2-r2';
reviewDrafts['request-2'].snapshot={id:'snapshot-request-2-r2',revision:2};
const now=Date.now();Date.now=()=>now+11000;
});
await render('/projects/'+P+'/requirements');
await run(async()=>await window.lastContentProbe);
const result=await run(()=>{
const field=document.querySelector('#project-request-select');
field.value='request-2';field.dispatchEvent(new Event('change',{bubbles:true}));
return {observedPrd:latestSecondPrd,sameSelect:originalRequestSelect===field,selected:field.value,prd:document.querySelector('[data-prd]').textContent,updatedRequirement:document.querySelector('[data-project-content]').textContent.includes('UPDATED SECOND REQUIREMENT')};
});
assert.match(result.prd,/UPDATED SECOND REQUEST PRD/);
assert.equal(result.updatedRequirement,true);
});
test('prepending a requirement restores focus and disclosure to its surviving identity',async t=>{
const {run,render,wait}=await setup(t,'/projects/'+P+'/requirements');
await wait('document.querySelectorAll("[data-project-content] section details").length===6');
await run(()=>{
const detail=document.querySelectorAll('[data-project-content] section details')[2];detail.open=true;detail.querySelector('summary').focus();
window.oldSummary=detail.querySelector('summary').textContent;
const candidate=reviewStates['request-1'].candidate;
const newRequirement={id:'R0',title:'New first requirement',path:'docs/r0-new.md',blob:'b-new'};
candidate.requirements.unshift(newRequirement);
candidate.prd_version='v4';
reviewStates['request-1'].snapshot='snapshot-request-1-r2';
reviewDrafts['request-1'].snapshot={id:'snapshot-request-1-r2',revision:2};
reviewDrafts['request-1'].requirements.unshift({...newRequirement,text:'New text'});
reviewStates['request-1'].revision++;
const now=Date.now();Date.now=()=>now+11000;
});
await render('/projects/'+P+'/requirements');
await wait('document.querySelectorAll("[data-project-content] section details").length===7');
const result=await run(()=>({wanted:oldSummary,focused:document.activeElement.textContent,open:[...document.querySelectorAll('[data-project-content] section details')].filter(e=>e.open).map(e=>e.querySelector('summary').textContent)}));
assert.equal(result.focused,result.wanted);
assert.deepEqual(result.open,[result.wanted]);
});
// R3 check (Sol, org-live-updates-r3-check.md): requirement state is scoped to its request, and
// a closed keyed disclosure stays closed through a repaint.
async function refresh(run,render) {
await run(()=>{const now=Date.now();Date.now=()=>now+11000;});
await render('/projects/'+P+'/requirements');
await run(async()=>{await window.lastContentProbe;});
}
test('removing the selected request drops its requirement state instead of transferring it to another request with the same local ids',async t=>{
const {run,render,wait}=await setup(t,'/projects/'+P+'/requirements');
await wait('document.querySelector("#project-request-select")?.value==="request-1" && document.querySelectorAll("[data-project-content] section details").length===6');
await run(()=>{
const detail=document.querySelectorAll('[data-project-content] section details')[2];detail.open=true;detail.querySelector('summary').focus();
reviewDrafts['request-2'].prd='# DISTINCT SECOND REQUEST';
reviewStates['request-2'].candidate.requirements[2].title='Second request distinct R3';
reviewDrafts['request-2'].requirements[2].title='Second request distinct R3';
reviewDrafts['request-2'].requirements[2].text='Only the second request owns this requirement';
fixtureMessages.splice(0,fixtureMessages.length,...fixtureMessages.filter(m=>m.request!=='request-1'));
});
await refresh(run,render);
const result=await run(()=>({
prd:document.querySelector('[data-prd]').textContent,
source:document.querySelector('.project-source').textContent,
open:[...document.querySelectorAll('[data-project-content] section details')].filter(el=>el.open).map(el=>el.querySelector('summary').textContent),
focused:document.activeElement.matches('[data-project-content] section details summary')?document.activeElement.textContent:null
}));
assert.match(result.prd,/DISTINCT SECOND REQUEST/);assert.match(result.source,/request-2/);
assert.deepEqual(result.open,[]);assert.equal(result.focused,null);
});
test('closed desktop provenance remains closed through a requirement content change',async t=>{
const {run,render,wait}=await setup(t,'/projects/'+P+'/requirements',1440);
await wait('document.querySelectorAll("[data-project-content] section details").length===6');
await run(()=>{const el=document.querySelector('[data-project-content] aside details');el.open=false;el.querySelector('summary').focus();reviewDrafts['request-1'].requirements[0].text='Isolated provenance repaint';});
await refresh(run,render);
const result=await run(()=>({open:document.querySelector('[data-project-content] aside details').open,focused:document.activeElement===document.querySelector('[data-project-content] aside summary'),requirementsOpen:[...document.querySelectorAll('[data-project-content] section details')].filter(el=>el.open).length}));
assert.equal(result.focused,true);assert.equal(result.requirementsOpen,0);assert.equal(result.open,false);
});
@@ -0,0 +1,95 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setup, P, T } from './helpers/w15-conversations.mjs';
import { fixture } from './fixture.mjs';
import { browser } from './browser.mjs';
// A periodic paint must not replace controls or messages that a person is using.
test('project and conversation stay still over refreshes and retain existing messages', async t => {
const { b, wait } = await setup(t);
await b.evaluate(`(() => {
const original = window.fetch;
window.extraMessages = []; window.extraThreads = [];
window.fetch = async (url, options) => {
const response = await original(url, options);
if (String(url).startsWith('/api/chat/threads') && !String(url).includes('/messages') && !String(url).includes('/sessions') && !String(url).includes('/shares') && !String(url).includes('shared=true') && response.ok) {
const page = await response.json();
return new Response(JSON.stringify({...page, rows: [...page.rows, ...window.extraThreads]}), {headers: {'content-type':'application/json'}});
}
if (!String(url).includes('/threads/') || !String(url).includes('/messages') || !response.ok) return response;
const page = await response.json();
return new Response(JSON.stringify({...page, rows: [...page.rows, ...window.extraMessages]}), {headers: {'content-type':'application/json'}});
};
})()`);
await b.evaluate(`location.hash='#/projects/${P}/conversations'`);
await wait(`document.querySelector('[data-conversation-id]')`);
await b.evaluate(`(() => {
const row = document.querySelector('[data-conversation-id]');
row.focus(); window.savedProject = {node:row, rect:row.getBoundingClientRect().toJSON(), scroll:window.scrollY};
})()`);
await new Promise(resolve => setTimeout(resolve, 21500));
const project = await b.evaluate(`({connected:savedProject.node.isConnected, same:document.querySelector('[data-conversation-id]')===savedProject.node, rect:savedProject.node.getBoundingClientRect().toJSON(), before:savedProject.rect, focused:document.activeElement===savedProject.node, scroll:window.scrollY, beforeScroll:savedProject.scroll})`);
assert.ok(project.connected && project.same, 'project row survives unchanged refreshes');
assert.deepEqual(project.rect, project.before);
assert.equal(project.focused, true);
assert.equal(project.scroll, project.beforeScroll);
await b.evaluate(`extraThreads.push({id:'20000000-0000-4000-8000-000000000004',project:'${P}',name:'New conversation',instance:'alice'}); document.querySelector('#refresh').click()`);
await wait(`document.querySelector('[data-conversation-id="20000000-0000-4000-8000-000000000004"]')`);
assert.equal(await b.evaluate('savedProject.node.isConnected && document.activeElement===savedProject.node'), true, 'new conversation keeps the existing row and focus');
await b.evaluate(`location.hash='#/projects/threads/${T}'`);
await wait(`document.querySelector('[data-message]') && document.querySelector('#chat-body')`);
await b.evaluate(`(() => {
const message = document.querySelector('[data-message]'), field = document.querySelector('#chat-body');
field.value='draft'; field.dispatchEvent(new Event('input',{bubbles:true})); field.focus(); field.setSelectionRange(2,2);
window.savedChat = {node:message, rect:message.getBoundingClientRect().toJSON(), scroll:document.querySelector('.chat-log').scrollTop, field};
})()`);
await new Promise(resolve => setTimeout(resolve, 21500));
const chat = await b.evaluate(`({connected:savedChat.node.isConnected, same:document.querySelector('[data-message]')===savedChat.node, rect:savedChat.node.getBoundingClientRect().toJSON(), before:savedChat.rect, focused:document.activeElement===savedChat.field, caret:savedChat.field.selectionStart, scroll:document.querySelector('.chat-log').scrollTop, beforeScroll:savedChat.scroll})`);
assert.ok(chat.connected && chat.same, 'message survives unchanged refreshes');
assert.deepEqual(chat.rect, chat.before);
assert.equal(chat.focused, true);
assert.equal(chat.caret, 2);
assert.equal(chat.scroll, chat.beforeScroll);
await b.evaluate(`extraMessages.push({id:'40000000-0000-4000-8000-000000000002',from_role:'human',body:'A new message',at:'2026-11-05T12:01:00Z'}); document.querySelector('#refresh').click()`);
await wait(`document.querySelector('[data-message="40000000-0000-4000-8000-000000000002"]')`);
assert.equal(await b.evaluate('savedChat.node.isConnected'), true, 'new message does not replace the old message');
assert.equal(await b.evaluate('savedChat.field.isConnected && document.activeElement===savedChat.field && savedChat.field.selectionStart===2'), true, 'new message keeps the composer and caret');
});
test('unchanged board reads keep the session row and focused reply control', async t => {
const f = await fixture(); t.after(() => f.close());
const b = await browser(); t.after(() => b.close());
await b.navigate(f.base);
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return;await new Promise(r=>setTimeout(r,30));}throw Error('timed out');})()`);
await wait(`document.querySelector('[data-open="proj/agent1"]')`);
await b.evaluate(`document.querySelector('[data-open="proj/agent1"]').click();document.querySelector('#reply').focus();window.boardRow=document.querySelector('tr[data-row="proj/agent1"]');window.boardAgeRow=document.querySelector('tr[data-row="proj/agent2"]');window.boardReply=document.querySelector('#reply')`);
for (let i=0; i<2; i++) {
await b.evaluate(`document.querySelector('#refresh').click()`);
await wait(`!document.querySelector('#refresh').disabled`);
}
assert.equal(await b.evaluate(`boardRow.isConnected && boardReply.isConnected && document.activeElement===boardReply`), true);
await b.evaluate(`(() => { const old=window.fetch; window.boardAge=120; window.fetch=async (url,options) => {
const response=await old(url,options); if(String(url)!=='/api/board' || !response.ok) return response;
const data=await response.json(); for(const row of data.sessions) row.ageSeconds=window.boardAge;
return new Response(JSON.stringify(data),{headers:{'content-type':'application/json'}});
}; document.querySelector('#refresh').click(); })()`);
await wait(`!document.querySelector('#refresh').disabled`);
assert.match(await b.evaluate(`boardAgeRow.textContent`), /2m ago/);
assert.equal(await b.evaluate(`boardRow.isConnected && boardReply.isConnected && document.activeElement===boardReply`), true);
});
test('conversation status has room for a temporary unavailable message', async t => {
const { b, render, wait } = await setup(t);
await render(`/projects/threads/${T}`);
await wait(`document.querySelector('[data-conversation-request-status]')`);
const position = await b.evaluate(`(() => {
const log=document.querySelector('.chat-log'), line=document.querySelector('[data-conversation-request-status]');
line.textContent='';
const before=log.getBoundingClientRect().top, height=line.getBoundingClientRect().height;
line.textContent='Request status unavailable.';
return {before,after:log.getBoundingClientRect().top,height,filledHeight:line.getBoundingClientRect().height};
})()`);
assert.equal(position.after, position.before);
assert.ok(position.height >= position.filledHeight, 'the empty status line reserves its height');
});
@@ -0,0 +1,86 @@
// Walkthrough defect 3 (Jason, 7350): once organizations exist, Quick Setup was reachable only through
// Settings > Organizations > type an ID > New organization. "New organization" is now offered in the header menu,
// in the organization picker and in the no-organization empty state. Each opens that form with the ID field focused.
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 { createOrgAdmin } from '../src/org-admin.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
// An owner Console with organization setup, holding the given organizations (the org-switch-browser fixture).
async function consoleWith(t, ids) {
const makeDoc = id => ({ businessVersion: 1, id, human: 'local-dev-owner', arbiters: { delivery: 'chief', technical: 'chief' }, roles: { chief: { definition: 'chief-of-staff' } }, projects: { work: {} }, appearance: { theme: 'harbor', mode: 'dark' } });
const docs = new Map(ids.map(id => [id, makeDoc(id)]));
const missing = () => { throw Object.assign(Error('not set up'), { code: 'org-unavailable' }); };
const service = {
orgPresets: async () => [{ name: 'fixture', label: 'Test model', description: 'Canned replies', available: { ok: true }, thinking: { levels: ['off'], default: 'off' } }],
templateRender: async id => makeDoc(id), validateOrg: async () => ({ ok: true, errors: [] }),
showOrg: async (ctx, id) => ({ id, doc: docs.get(id), revision: 1, root: `/data/${id}`, principal: ctx.principal }),
notificationShow: missing, queueShow: missing, projectRepoShow: missing,
};
const registry = { listOrgs: async () => [...docs.keys()].map(id => ({ id, state: 'active' })) };
const organizations = {
list: async () => [...docs.keys()].map(id => ({ id, label: id, busHealthy: true })),
resolve: async id => docs.has(id) ? { busHealthy: true, send: async () => ({}), reads: { business: async () => ({ business: { id, projects: ['work'], appearance: docs.get(id).appearance } }), busStatus: async () => ({ live: true }) }, bus: {} } : null,
};
const orgAdmin = createOrgAdmin({ service, registry, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, orgAdmin, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const wait = (expression, label = expression) => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error('Timed out: '+${JSON.stringify(label)}+'\\n'+document.body.innerText.slice(0,800))})()`);
const shown = selector => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});return !!e && getComputedStyle(e).visibility !== 'hidden' && e.getClientRects().length > 0})()`);
// The form is open, untouched, with the ID field focused, and the ?new=1 flag is gone so the link works again.
const formFocused = async (prefix, where) => {
await wait(`document.activeElement?.id === 'org-new-id'`, `${where}: ID field focused`);
assert.equal(await b.evaluate('location.hash'), `${prefix}/settings/organizations`, `${where}: the flag is dropped`);
assert.equal(await b.evaluate(`document.querySelector('#org-new-id').value`), '', `${where}: the form is not prefilled`);
assert.equal(await shown('#org-new-form [data-write]'), true, `${where}: New organization is shown`);
};
return { base: `http://127.0.0.1:${server.address().port}/`, b, wait, shown, formFocused };
}
for (const [width, height] of [[1400, 900], [390, 844]]) {
test(`the header menu offers New organization once organizations exist, at ${width}px`, { timeout: 60000 }, async t => {
const { base, b, wait, shown, formFocused } = await consoleWith(t, ['alpha']);
await b.viewport(width, height);
// From the dashboard, then again from the Organizations page itself.
for (const route of ['#/orgs/alpha/', '#/orgs/alpha/settings/organizations']) {
await b.navigate(`${base}?r=${encodeURIComponent(route)}${route}`);
await wait(`document.querySelector('#avatar-menu a#org-new-link')`, `${route}: menu entry`);
await b.evaluate(`document.querySelector('#avatar-button').click()`);
assert.equal(await shown('#org-new-link'), true, `${route}: the entry is visible in the open menu`);
assert.equal(await b.evaluate(`document.querySelector('#org-new-link').textContent`), 'New organization');
assert.equal(await b.evaluate(`document.querySelector('#org-new-link').getAttribute('role')`), 'menuitem');
await b.evaluate(`document.querySelector('#org-new-link').click()`);
await formFocused('#/orgs/alpha', route);
assert.equal(await b.evaluate(`document.querySelector('#avatar-menu').hidden`), true, `${route}: the menu closes`);
}
});
}
test('the organization picker offers New organization', { timeout: 60000 }, async t => {
const { base, b, wait, formFocused } = await consoleWith(t, ['alpha', 'beta']);
await b.viewport(1400, 900);
await b.navigate(`${base}#/orgs/alpha/settings/business`);
const link = `document.querySelector('section[aria-labelledby="org-picker-title"] a[href="#/settings/organizations?new=1"]')`;
await wait(`document.querySelector('#org-picker') && ${link}`, 'picker link');
assert.equal(await b.evaluate(`${link}.textContent`), 'New organization');
await b.evaluate(`${link}.click()`);
await formFocused('#/orgs/alpha', 'picker');
});
test('the no-organization empty state offers New organization next to its Settings link', { timeout: 60000 }, async t => {
const { base, b, wait, formFocused } = await consoleWith(t, []);
await b.viewport(1400, 900);
await b.navigate(`${base}#/`);
const button = `document.querySelector('#held a.btn[href="#/settings/organizations?new=1"]')`;
await wait(button, 'empty state button');
const held = await b.evaluate(`document.querySelector('#held').textContent`);
assert.match(held, /No organizations yet/);
assert.equal(await b.evaluate(`!!document.querySelector('#held a[href="#/settings/organizations"]')`), true, 'the Settings link stays');
assert.equal(await b.evaluate(`${button}.textContent`), 'New organization');
await b.evaluate(`${button}.click()`);
await formFocused('#', 'empty state');
});
@@ -52,9 +52,9 @@ for (const scenario of ['bus-refused', 'project-refused', 'project-unknown', 'su
await wait('document.querySelector("#org-quick-open") && !document.querySelector("#org-paths").hidden');
if (scenario === 'project-refused' || scenario === 'project-unknown') await b.evaluate(`window.__projectPosts=0;const real=window.fetch;window.fetch=async(url,init={})=>{if(String(url)==='/api/orgs/alpha/chat/projects' && init.method==='POST'){window.__projectPosts++;${scenario === 'project-refused' ? "return new Response(JSON.stringify({error:'not-configured',message:'Project refused',sent:false}),{status:503,headers:{'content-type':'application/json'}});" : "await real(url,init);return new Response(JSON.stringify({error:'outcome-unknown',message:'Outcome unknown',sent:'unknown'}),{status:503,headers:{'content-type':'application/json'}});"}}return real(url,init)}`);
await b.evaluate(`document.querySelector('#org-quick-open').click();document.querySelector('#org-risk-accept').click();document.querySelector('#org-quick-run').click()`);
const finalStep = scenario === 'bus-refused' ? 3 : 4, finalState = scenario === 'success' ? 'Done' : 'Stopped';
const finalStep = scenario === 'bus-refused' ? 3 : scenario === 'success' ? 5 : 4, finalState = scenario === 'success' ? 'Done' : 'Stopped';
await wait(`document.querySelector('[data-quick-step="${finalStep}"] .org-pill')?.textContent === '${finalState}'`);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-quick-step] .org-pill")].map(e=>e.textContent)'), ['Done', 'Done', 'Done', scenario === 'bus-refused' ? 'Stopped' : 'Done', scenario === 'bus-refused' ? 'Not started' : finalState]);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-quick-step] .org-pill")].map(e=>e.textContent)'), ['Done', 'Done', 'Done', scenario === 'bus-refused' ? 'Stopped' : 'Done', scenario === 'bus-refused' ? 'Not started' : finalState, scenario === 'success' ? 'Done' : 'Not started']);
if (scenario === 'bus-refused') {
assert.match(await b.evaluate(`document.querySelector('[data-quick-step="3"]').textContent`), /bus refused/);
assert.equal(await b.evaluate('document.activeElement.dataset.quickStep'), '3');
@@ -63,6 +63,12 @@ for (const scenario of ['bus-refused', 'project-refused', 'project-unknown', 'su
assert.equal(starts, 1);
assert.ok(existsSync(join(dataRoot, 'org-runtime/alpha/checkout/.git')));
assert.ok(existsSync(join(dataRoot, 'org-runtime/alpha/projects/work/.git')));
if (scenario === 'success') {
assert.equal(await b.evaluate(`document.querySelector('[data-quick-step="5"] code').textContent`), 'mosaic project init alpha work');
assert.ok(existsSync(join(dataRoot, 'org-runtime/alpha/projects/alpha/work/spec/north-star.md')));
const readback = await b.evaluate(`fetch('/api/orgs/alpha/setup/records/work').then(r=>r.json())`);
assert.equal(readback.state, 'done'); assert.equal(readback.verb, 'mosaic project init alpha work');
}
if (scenario !== 'bus-refused') {
assert.equal(await b.evaluate(`document.querySelector('[data-quick-step="4"] code').textContent`), 'mosaic project create work --repo work --business alpha');
if (scenario !== 'success') {
+27 -4
View File
@@ -2,13 +2,15 @@
// instance whose fixture and Console are already running (see BUILD.md).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { browser } from './browser.mjs';
const log = join(homedir(), 'orgdev-qconsole.log');
test('Quick Setup starts the real fixture and a browser message gets its canned reply', { timeout: 150000, skip: !existsSync(log) && 'scratch fixture and Console are not running' }, async t => {
for (const scenario of ['fresh', 'recover', 'cli']) test(`Quick Setup real exchange and records: ${scenario}`, { timeout: 150000, skip: !existsSync(log) && 'scratch fixture and Console are not running' }, async t => {
const url = /Mosaic Console: (http:\/\/127\.0\.0\.1:(\d+)\/)/.exec(readFileSync(log, 'utf8'));
assert.ok(url, 'scratch Console advertised a URL');
assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(Number(url[2])), 'Console must use an allowed port');
@@ -31,17 +33,36 @@ test('Quick Setup starts the real fixture and a browser message gets its canned
await click('#org-quick-open');
await wait('document.querySelector("#org-quick") && !document.querySelector("#org-quick").hidden');
for (const selector of ['#org-project', '#org-project-remote', '#org-risk-accept', '#org-quick-run']) assert.equal(await shown(selector), true, selector);
if (scenario !== 'fresh') await b.evaluate(`(() => { const real=window.fetch; let first=true; window.fetch=(url,opts={}) => { if(first && String(url).includes('/setup/records/') && opts.method==='POST') { first=false; return Promise.resolve(new Response(JSON.stringify({error:'test-stop',message:'Stopped for records recovery test'}),{status:409,headers:{'content-type':'application/json'}})); } return real(url,opts); }; })()`);
await click('#org-risk-accept'); await click('#org-quick-run');
await wait(`document.querySelector('[data-quick-step="4"] .org-pill')?.textContent === 'Done' || [...document.querySelectorAll('[data-quick-step] .org-pill')].some(e=>e.textContent==='Stopped')`, 90000);
await wait(`document.querySelector('[data-quick-step="5"] .org-pill')?.textContent === 'Done' || [...document.querySelectorAll('[data-quick-step] .org-pill')].some(e=>e.textContent==='Stopped')`, 90000);
console.log('setup reached a terminal step');
const states = await b.evaluate('[...document.querySelectorAll("[data-quick-step] .org-pill")].map(e=>e.textContent)');
assert.deepEqual(states, ['Done', 'Done', 'Done', 'Done', 'Done'], await b.evaluate('document.querySelector("#org-quick-steps").innerText'));
assert.deepEqual(states, ['Done', 'Done', 'Done', 'Done', 'Done', scenario === 'fresh' ? 'Done' : 'Stopped'], await b.evaluate('document.querySelector("#org-quick-steps").innerText'));
assert.equal(await shown('#org-quick-open-org'), true, 'the finished organization can be opened');
await click('#org-quick-open-org'); console.log('opening organization');
await wait(`window.MosaicOrg?.id === ${JSON.stringify(id)} && document.querySelector('.project-card')?.textContent.includes('Repository: work')`, 30000);
await click('.project-card');
await wait('location.hash.match(/\\/projects\\/[0-9a-f-]{36}/i)');
console.log('project created');
if (scenario === 'recover') {
await wait(`document.querySelector('[data-record-init]')`);
await click('[data-record-init]');
await wait(`!document.querySelector('[data-record-init]') && document.querySelector('[data-project-content]')?.textContent.includes('North star')`, 30000);
} else if (scenario === 'cli') {
const config = readdirSync(join(homedir(), '.config')).find(name => /^mosaic-[a-z][a-z0-9]{1,15}$/.test(name));
assert.ok(config);
const uid = process.getuid();
await promisify(execFile)('/mnt/storage/scratch/mosaic-lead/uidev/org-release/devstack.sh', ['exec', '--', 'scripts/mosaic', 'project', 'init', id, 'work'], { env: {
PATH: '/mnt/storage/scratch/tmp/q2KNlM/node-v26.12.1-linux-x64/bin:/opt/claude-code/bin:/usr/local/bin:/usr/bin:/bin', HOME: homedir(), TMPDIR: process.env.TMPDIR, LANG: 'C.UTF-8', MOSAIC_CONFIG: join(homedir(), '.config', config, 'config.json'), XDG_RUNTIME_DIR: `/run/user/${uid}`, DBUS_SESSION_BUS_ADDRESS: `unix:path=/run/user/${uid}/bus`,
} });
await b.evaluate(`document.querySelector('#refresh').click()`);
}
await wait(`document.querySelector('[data-project-content]') && !document.querySelector('[data-project-content]').textContent.includes('Reading')`);
assert.doesNotMatch(await b.evaluate(`document.querySelector('[data-project-content]').textContent`), /no project records|no records yet/i);
const projectFiles = await b.evaluate(`fetch('/api/orgs/${id}/project-files?project=work').then(async r => ({ status:r.status, body:await r.json() }))`);
assert.equal(projectFiles.status, 200, JSON.stringify(projectFiles));
assert.ok(projectFiles.body.files.some(f => f.path === 'spec/north-star.md'));
const projectRoute = await b.evaluate('location.hash');
await b.evaluate(`location.hash=${JSON.stringify(projectRoute + '/conversations')}`);
await wait('!!document.querySelector("#chat-thread-form")');
@@ -51,4 +72,6 @@ test('Quick Setup starts the real fixture and a browser message gets its canned
await input('#chat-body', 'Reply with fixture word'); await click('#chat-send-form button[type=submit]');
await wait('document.querySelector(".chat-log")?.textContent.includes("PINEAPPLE")', 90000);
assert.match(await b.evaluate('document.querySelector(".chat-log").textContent'), /PINEAPPLE/); console.log('fixture reply received');
await wait(`document.querySelector('[data-conversation-request-status]') && (document.querySelector('.project-tabs a[href$="/tasks"] .project-count') || document.querySelector('[data-conversation-request-status]').textContent)`, 30000);
assert.doesNotMatch(await b.evaluate(`document.querySelector('[data-conversation-request-status]').textContent`), /unavailable/i);
});
@@ -0,0 +1,141 @@
// Walkthrough defect 1 (Jason, 7350 Quick Setup): an organization conversation showed "Request status unavailable."
// although the message went through. The organization options built in cli.mjs carried no request read, so
// /api/orgs/<id>/config never offered requests and the project page counted that as a failed read.
// This runs the real cli.mjs serve --dev-local-owner against a real organization record. Only the bus
// transports cli.mjs builds are replaced (loader hook), so no bus or human proof is involved.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdirSync, readFileSync, renameSync, chmodSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { tmp, fixture } from '../../cli/tests/helpers.mjs';
import { createOrg } from '../../business/src/org-service.mjs';
import { orgPaths } from '../../cli/src/config.mjs';
import { DEV_OWNER } from '../../cli/src/dev-local-owner.mjs';
import { browser } from './browser.mjs';
const P = '10000000-0000-4000-8000-0000000000a1', T = '20000000-0000-4000-8000-0000000000a1', S = '30000000-0000-4000-8000-0000000000a1';
// The correlation id a sent message carries. The org's request intake has no request under it.
const REQUEST = 'c0ffee00-0000-4000-8000-0000000000a1';
async function consoleFor(t) {
const root = tmp(t, 'o-'), f = fixture(root), home = join(root, 'home'), configDir = join(home, '.config', 'mosaic-test');
mkdirSync(join(home, '.config'), { recursive: true }); renameSync(join(root, 'config'), configDir);
chmodSync(configDir, 0o700); chmodSync(join(configDir, 'businesses'), 0o700);
writeFileSync(join(f.dataRoot, 'instance.json'), JSON.stringify({ instanceVersion: 1, name: 'test' }));
const doc = structuredClone(f.doc); doc.id = 'alpha'; doc.human = DEV_OWNER.sub; doc.projects.stack.root = '/__mosaic_org__/alpha/projects/stack';
const org = await createOrg({ configDir, dataRoot: f.dataRoot, instance: 'test', principal: DEV_OWNER }, 'alpha', doc);
const log = join(root, 'calls.jsonl'); writeFileSync(log, '');
const busURL = new URL('../src/bus.mjs', import.meta.url).href, orgBusURL = new URL('../../cli/src/org-bus.mjs', import.meta.url).href;
// Each transport answers from fixed rows and logs (kind, business, socket, verb). Everything else in bus.mjs is real.
const busShim = join(root, 'bus-shim.mjs'), orgBusShim = join(root, 'org-bus-shim.mjs'), hook = join(root, 'hook.mjs');
writeFileSync(busShim, `import { appendFileSync } from 'node:fs';
import { BusReadError } from ${JSON.stringify(busURL)};
export * from ${JSON.stringify(busURL)};
const P=${JSON.stringify(P)}, T=${JSON.stringify(T)}, S=${JSON.stringify(S)}, REQUEST=${JSON.stringify(REQUEST)};
// The bus answers list verbs with bare arrays; the Console wraps them in rows.
const human = (verb, args) => ({
plugins: { plugins: { channel: ['cli', 'webui'] } },
'project.list': [{ id: P, name: 'work', repo: null }],
'thread.list': args?.shared ? [] : [{ id: T, project: P, name: 'init', instance: 'assistant' }],
'session.list': [{ id: S, name: 'Main', default: true, state: 'active', revision: 1 }],
'thread.messages': [
{ id: '40000000-0000-4000-8000-0000000000a1', from_role: 'human', body: 'hello world!', request: REQUEST, at: '2026-11-08T10:00:00Z' },
{ id: '40000000-0000-4000-8000-0000000000a2', from_role: 'assistant', body: 'PINEAPPLE', at: '2026-11-08T10:00:05Z' },
],
}[verb] ?? []);
const record = (kind, o, verb, args) => appendFileSync(${JSON.stringify(log)}, JSON.stringify({ kind, business: o.business ?? null, socket: o.socket ?? null, verb, args }) + '\\n');
export const busCaller = o => async (verb, args) => { record('human', o, verb, args); return human(verb, args); };
export const consoleCall = o => async (verb, args) => {
record('console', o, verb, args);
if (verb === 'request.get.console') throw new BusReadError('request-not-found', 'no request under this id');
throw new BusReadError('unavailable', 'fixture: ' + verb);
};
`);
writeFileSync(orgBusShim, `export * from ${JSON.stringify(orgBusURL)};\nexport const busStatus = async () => ({ healthy: true });\n`);
writeFileSync(hook, `import { registerHooks } from 'node:module';
registerHooks({ resolve(specifier, context, next) {
if (context.parentURL?.endsWith('/webui/src/cli.mjs')) {
if (specifier === './bus.mjs') return { url: ${JSON.stringify(pathToFileURL(busShim).href)}, shortCircuit: true };
if (specifier === '../../cli/src/org-bus.mjs') return { url: ${JSON.stringify(pathToFileURL(orgBusShim).href)}, shortCircuit: true };
}
return next(specifier, context);
} });
`);
const env = { ...f.env, HOME: home, MOSAIC_CONFIG: join(configDir, 'config.json'), NODE_OPTIONS: `--import=${pathToFileURL(hook).href}` };
const child = spawn(process.execPath, [new URL('../src/cli.mjs', import.meta.url).pathname, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--dev-local-owner'], { env, stdio: ['ignore', 'pipe', 'pipe'] });
let out = ''; child.stdout.on('data', c => out += c); child.stderr.on('data', c => out += c);
t.after(() => { if (child.exitCode === null) child.kill('SIGTERM'); });
for (let i = 0; i < 300 && !/Mosaic Console: (http:\/\/127\.0\.0\.1:\d+\/)/.test(out); i++) {
if (child.exitCode !== null) break;
await new Promise(r => setTimeout(r, 50));
}
const base = /Mosaic Console: (http:\/\/127\.0\.0\.1:\d+\/)/.exec(out)?.[1];
assert.ok(base, `the Console did not start:\n${out}`);
const calls = () => readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
// A failed wait reports the Console output and the last transport calls with the page text.
const diagnostics = () => `Console output:\n${out.slice(-1500)}\nLast calls:\n${readFileSync(log, 'utf8').trim().split('\n').slice(-12).join('\n')}`;
return { base, calls, org, diagnostics };
}
async function page(t, base, diagnostics) {
const b = await browser({ machine: false });
t.after(() => b.close());
await b.viewport(1440, 900);
const wait = async (expression, label = expression) => {
for (let i = 0; i < 400; i++) {
try { if (await b.evaluate(`Boolean(${expression})`)) return; } catch { /* navigation */ }
await new Promise(r => setTimeout(r, 50));
}
throw Error(`Timed out: ${label}\n${await b.evaluate('document.body.innerText.slice(0, 2000)').catch(() => 'page unavailable')}\n${diagnostics()}`);
};
return { b, wait };
}
test('an organization offers its request status, and a conversation whose message has no request says nothing is wrong', { timeout: 60000 }, async t => {
const { base, calls, org, diagnostics } = await consoleFor(t);
const { b, wait } = await page(t, base, diagnostics);
await b.navigate(`${base}#/orgs/alpha/projects/threads/${T}`);
await wait(`document.querySelector('.chat-log')?.textContent.includes('PINEAPPLE')`);
const config = await b.evaluate(`fetch('/api/orgs/alpha/config').then(r => r.json())`);
assert.equal(config.requests, true, `the organization config must offer request reads: ${JSON.stringify(config)}`);
// The page settles when the request read answered: the Tasks tab gets its count, or the status line says it failed.
const settled = `document.querySelector('.project-tabs a[href$="/tasks"] .project-count') || document.querySelector('[data-conversation-request-status]')?.textContent`;
await wait(settled, 'request status settled');
assert.equal(await b.evaluate(`document.querySelector('[data-conversation-request-status]').textContent`), '');
assert.doesNotMatch(await b.evaluate(`document.querySelector('main').innerText`), /Request status unavailable|Status unavailable/);
const reads = calls().filter(c => c.kind === 'console');
assert.ok(reads.length > 0, 'the request read reached the console transport');
for (const read of reads) {
assert.equal(read.verb, 'request.get.console');
assert.deepEqual(read.args, { request: REQUEST });
assert.equal(read.business, 'alpha', 'the read goes to the selected organization');
assert.equal(read.socket, orgPaths(org.root).socket, 'the read uses the organization bus socket');
}
});
test('an organization project overview reports no active request instead of unavailable', { timeout: 60000 }, async t => {
const { base, diagnostics } = await consoleFor(t);
const { b, wait } = await page(t, base, diagnostics);
await b.navigate(`${base}#/orgs/alpha/projects`);
await wait(`document.querySelector('[data-project-status="${P}"] .project-card-status-line')`);
assert.match(await b.evaluate(`document.querySelector('[data-project-status="${P}"]').textContent`), /^No active requests · 1 conversation/);
// The overview renders after its reads settle, so the metrics are final once they exist.
await b.navigate(`${base}?v=2#/orgs/alpha/projects/${P}`);
await wait(`document.querySelector('.project-metrics')`, 'project metrics');
const metrics = await b.evaluate(`[...document.querySelectorAll('.project-metrics a')].slice(0, 2).map(a => a.innerText.replace(/\\s+/g, ' ').trim())`);
assert.deepEqual(metrics, ['Requests in progress 0 No active requests', 'Needs your input 0 Nothing waiting']);
assert.doesNotMatch(await b.evaluate(`document.querySelector('main').innerText`), /Status unavailable|Request status unavailable|Request tasks unavailable/);
});
test('organization role instances reflow inside the viewport at 390 pixels', { timeout: 60000 }, async t => {
const { base, diagnostics } = await consoleFor(t);
const { b, wait } = await page(t, base, diagnostics);
await b.viewport(390, 844);
await b.navigate(`${base}#/orgs/alpha/settings/business`);
await wait(`document.querySelector('.s1-roles tbody tr')`, 'role instances');
const box = await b.evaluate(`(()=>{const table=document.querySelector('.s1-roles'),wrap=table.closest('.table-wrap'),r=wrap.getBoundingClientRect();return {right:r.right,tableRight:table.getBoundingClientRect().right,document:document.documentElement.scrollWidth,viewport:innerWidth,overflow:getComputedStyle(wrap).overflowX,client:wrap.clientWidth,scroll:wrap.scrollWidth}})()`);
assert.ok(box.right <= box.viewport && box.document <= box.viewport, JSON.stringify(box));
assert.ok(box.tableRight <= box.viewport, JSON.stringify(box)); assert.ok(box.scroll <= box.client, JSON.stringify(box));
});
@@ -252,3 +252,17 @@ test('organization creation draft survives Stay and is discarded on switch', asy
await b.evaluate(`document.querySelector('[data-discard]').click()`).catch(() => {});
await wait('window.MosaicOrg?.id === "beta"');
});
test('a stopped organization bus has truthful chat guidance', async t => {
const organizations = {
list: async () => [{ id: 'alpha', label: 'Alpha', busHealthy: false }],
resolve: async () => ({ busHealthy: false, reads: { business: async () => ({ business: { id: 'alpha', projects: [] } }) }, bus: {}, chat: { projects: async () => [], threads: async () => [] } }),
};
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, send: async () => ({}), log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/projects`);
await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(document.querySelector('.chat-area')?.innerText.includes('The bus is stopped'))return;await new Promise(r=>setTimeout(r,30))}throw Error('stopped-bus guidance missing')})()`);
const text = await b.evaluate(`document.querySelector('.chat-area').innerText`);
assert.match(text, /bus is stopped.*Start the bus/i);
assert.doesNotMatch(text, /This Console only reads/);
});
@@ -0,0 +1,23 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { w15Fixture } from './w15-fixture.mjs';
test('project tree returns after a failed refresh followed by the same data', async t => {
const { f, b, wait } = await w15Fixture(t);
await b.navigate(f.base + '/#/projects');
await wait(`document.querySelector('.project-tree-list a')`);
const result = await b.evaluate(`(async()=>{
const container=document.querySelector('.project-tree-list').parentElement;
const link=container.querySelector('.project-tree-list a'); link.focus(); const href=link.getAttribute('href');
const originalFetch=window.fetch, originalNow=Date.now;
Date.now=()=>originalNow()+20000;
window.fetch=(url,options)=>String(url)==='/api/chat/threads' ? Promise.reject(Error('offline')) : originalFetch(url,options);
await MosaicProjectTree.mount({container,path:'/projects'});
const unavailable=!!container.querySelector('[role=status],[role=alert]') && container.textContent.includes('Projects unavailable');
window.fetch=originalFetch;
await MosaicProjectTree.mount({container,path:'/projects'});
Date.now=originalNow;
return {unavailable,recovered:!!container.querySelector('.project-tree-list a'),focused:document.activeElement.getAttribute('href')===href};
})()`);
assert.deepEqual(result, {unavailable:true,recovered:true,focused:true});
});
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { fixture } from './fixture.mjs';
import { browser } from './browser.mjs';
import { P, stub } from './helpers/projects-review-fixture.mjs';
test('project work reserves a line for a wait reason', async t => {
const f = await fixture(), b = await browser({machine:false});
t.after(async () => { await b.close(); await f.close(); });
await b.call('Page.addScriptToEvaluateOnNewDocument', {source:stub});
await b.viewport(1440, 1000);
await b.navigate(f.base + '/#/projects/' + P);
await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(document.querySelector('.project-work'))return;await new Promise(r=>setTimeout(r,30))}throw Error('No project work')})()`);
const positions = await b.evaluate(`(()=>{
const line=document.querySelector('.project-work-wait'), bottom=document.querySelector('.project-work-bottom');
if(!line)return null;
const before=bottom.getBoundingClientRect().top, height=line.getBoundingClientRect().height;
line.textContent='Waiting: decision';
return {before,after:bottom.getBoundingClientRect().top,height,filled:line.getBoundingClientRect().height};
})()`);
assert.ok(positions, 'the wait line is present while empty');
assert.equal(positions.after, positions.before);
assert.ok(positions.height >= positions.filled);
});
@@ -39,6 +39,13 @@ async function setup(t, readonly = false) {
await wait(`document.querySelector('#processing-state')?.textContent.includes(${JSON.stringify(readonly ? 'halted' : 'enabled')})`);
return { b, wait };
}
test('unchanged processing reads retain the status text node', async t => {
const { b, wait } = await setup(t);
await b.evaluate(`window.processingText=document.querySelector('#processing-state').firstChild;window.readsBefore=window.processingRequests.length;window.dispatchEvent(new Event('focus'))`);
await wait('window.processingRequests.length >= window.readsBefore + 2');
await b.evaluate(`new Promise(resolve=>setTimeout(resolve,50))`);
assert.equal(await b.evaluate(`processingText.isConnected && document.querySelector('#processing-state').firstChild===processingText`), true);
});
test('processing browser: keyboard Stop, confirmed Resume, unknown outcome and persistent banner', async t => {
const { b, wait } = await setup(t);
await b.evaluate(`document.querySelector('#processing-halt').focus(),true`); await b.key('Enter');
@@ -0,0 +1,46 @@
// Walkthrough defect 2 (Jason, 7350): the conversation breadcrumb read "Projects / work / / Conversations / init".
// No label was missing. Every project crumb link had min-width:44px, so a short project name such as "work" was
// padded with blank space after its text and the run of separators looked like an empty crumb.
// The crumbs must be evenly spaced whatever the name length, and each link keeps its 44px target.
import test from 'node:test';
import assert from 'node:assert/strict';
import { fixture } from './fixture.mjs';
import { browser } from './browser.mjs';
import { P, T, stub } from './helpers/projects-review-fixture.mjs';
const ROUTES = [[`/projects/${P}`, 'Overview'], [`/projects/${P}/conversations`, 'Conversations'], [`/projects/threads/${T}`, 'Build offline timer']];
// For each crumb link: the space from its text to the next visible separator's text (null when the phone layout
// hides what follows), and how many pixels wide its hit area is along the text's centre line.
const MEASURE = `(() => {
const box = document.querySelector('#breadcrumbs');
const text = e => { const n = document.createTreeWalker(e, NodeFilter.SHOW_TEXT).nextNode(), r = document.createRange(); r.selectNodeContents(n); return r.getBoundingClientRect(); };
return [...box.querySelectorAll(':scope > a')].map(a => {
let sep = a.nextElementSibling; if (sep?.classList.contains('project-crumb-current')) sep = sep.firstElementChild;
const t = text(a), next = sep && text(sep), y = t.top + t.height / 2;
let target = 0; for (let x = Math.floor(t.left) - 30; x <= t.right + 30; x++) if (document.elementFromPoint(x, y)?.closest('a') === a) target++;
return { label: a.textContent.trim(), gap: next?.width ? Math.round(next.left - t.right) : null, target };
});
})()`;
for (const [width, height] of [[1440, 900], [390, 844]]) {
test(`project crumbs are evenly spaced for a short project name at ${width}px`, { timeout: 60000 }, async t => {
const f = await fixture(), b = await browser({ machine: false });
t.after(async () => { await b.close(); await f.close(); });
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub + `;fixtureProjects[0].name='work';` });
await b.viewport(width, height);
let n = 0;
for (const [route, leaf] of ROUTES) {
await b.navigate(`${f.base}/?r=${n++}#${route}`);
const ready = `document.querySelector('#breadcrumbs .project-crumb-current [aria-current="page"]')?.textContent === ${JSON.stringify(leaf)}`;
await b.evaluate(`(async () => { for (let i = 0; i < 300; i++) { if (${ready}) return document.fonts.ready; await new Promise(r => setTimeout(r, 30)); } throw Error('Timed out: crumbs for ' + ${JSON.stringify(route)} + ': ' + document.querySelector('#breadcrumbs')?.innerText); })()`);
const crumbs = await b.evaluate(MEASURE);
assert.equal(crumbs[1]?.label, 'work', `${route}: the project crumb names the project`);
const reference = crumbs[0].gap;
for (const crumb of crumbs) {
if (crumb.gap !== null) assert.ok(Math.abs(crumb.gap - reference) <= 1, `${route}: "${crumb.label}" is followed by ${crumb.gap}px, "Projects" by ${reference}px`);
assert.ok(crumb.target >= 44, `${route}: "${crumb.label}" has a ${crumb.target}px target, not 44px`);
}
}
});
}
@@ -13,7 +13,8 @@ test('independent overview read error matrix',async t=>{
async function check(name,s,expected,route=`/projects/${P}`){await t.test(name,async()=>{
await b.evaluate(`window.__reviewScenario=${JSON.stringify(s)};document.dispatchEvent(new CustomEvent('mosaic-project-room-changed'));`);
await render(route);
await wait(`document.querySelector('[data-project-content]') && !document.querySelector('[data-project-content]').textContent.startsWith('Reading')`);
// A reread keeps the earlier paint until its result arrives (live updates), so wait for the expected text, not for 'Reading' to go.
await wait(`document.querySelector('[data-project-content]') && ${expected}.test(document.querySelector('[data-project-content]').textContent)`).catch(()=>{});
const text=await b.evaluate(`document.querySelector('[data-project-content]').textContent`);
assert.match(text,expected);
});}
@@ -72,7 +72,7 @@ test('sidebar child labels truncate and mobile business role names remain whole'
await b.viewport(390,900); await b.evaluate(`window.fetch=((old)=>(url,init)=>String(url)==='/api/business'?Promise.resolve(new Response(JSON.stringify({business:{id:'demo',instances:[{instance:'assistant',definition:'assistant'}]}}),{headers:{'content-type':'application/json'}})):old(url,init))(window.fetch);location.hash='#/settings/business'`);
await wait(`document.querySelector('.s1-roles tbody th')`);
const role=await b.evaluate(`(()=>{const e=document.querySelector('.s1-roles tbody th'),s=getComputedStyle(e),r=e.getBoundingClientRect();return {wrap:s.overflowWrap,word:s.wordBreak,width:r.width,scroll:e.scrollWidth}})()`);
assert.equal(role.wrap,'normal'); assert.equal(role.word,'normal'); assert.ok(role.width>=role.scroll,JSON.stringify(role));
assert.equal(role.wrap,'anywhere'); assert.equal(role.word,'normal'); assert.ok(role.width>=role.scroll,JSON.stringify(role));
});
const staleFixture = `;{const old=window.fetch;window.instancesFail=false;window.fetch=(url,init)=>{const p=String(url),json=(v,status=200)=>Promise.resolve(new Response(JSON.stringify(v),{status,headers:{'content-type':'application/json'}}));if(p==='/api/bus/agents')return json({rows:[{role:'chief',holder_run:'r-old-chief-123456',harness:'pi',at:'2026-01-01T00:00:00Z',state:'idle'}]});if(p==='/api/bus/thread.claims')return json({rows:[{thread:'${T}',holder_run:'r-old-chief-123456',harness:'pi',at:'2026-01-01T00:00:00Z'}]});if(p==='/api/bus/instances')return window.instancesFail?json({error:'unavailable'},503):json({rows:[{instances:{chief:{running:['r-new-chief-654321']}}}]});return old(url,init)}}`;
@@ -105,3 +105,61 @@ test('desktop agent message matches avatar and card geometry',async t=>{
const x=await b.evaluate(`(()=>{const m=document.querySelector('.chat-message-agent'),a=m.querySelector('.chat-avatar'),name=m.querySelector('strong'),bubble=m.querySelector('.chat-body').getBoundingClientRect(),card=document.querySelector('.project-request-item .request-mount').getBoundingClientRect(),s=getComputedStyle(a);return {font:s.fontSize,weight:s.fontWeight,name:name.getBoundingClientRect().left,left:bubble.left,right:bubble.right,width:bubble.width,cardRight:card.right}})()`);
assert.equal(x.font,'14px');assert.equal(x.weight,'700');assert.ok(Math.abs(x.name-296)<1,JSON.stringify(x));assert.ok(Math.abs(x.left-296)<1&&Math.abs(x.width-744)<1&&Math.abs(x.right-x.cardRight)<1,JSON.stringify(x));
});
test('role instances reflow within the mobile panel', async t => {
const {b,wait}=await setup(t,'/settings/business');
await b.viewport(390,844);
await b.evaluate(`window.fetch=((old)=>(url,init)=>String(url)==='/api/business'?Promise.resolve(new Response(JSON.stringify({business:{id:'demo',instances:[{instance:'assistant',definition:'assistant'}]}}),{headers:{'content-type':'application/json'}})):old(url,init))(window.fetch);document.querySelector('#refresh').click()`);
await wait(`document.querySelector('.s1-roles tbody th')`);
const x=await b.evaluate(`(()=>{const t=document.querySelector('.s1-roles'),w=t.closest('.table-wrap'),r=w.getBoundingClientRect();return {right:r.right,tableRight:t.getBoundingClientRect().right,viewport:innerWidth,overflow:getComputedStyle(w).overflowX,scroll:w.scrollWidth,client:w.clientWidth,document:document.documentElement.scrollWidth}})()`);
assert.ok(x.right<=x.viewport,JSON.stringify(x));
assert.match(x.overflow,/auto|scroll/); assert.ok(x.tableRight<=x.viewport && x.scroll<=x.client,JSON.stringify(x)); assert.ok(x.document<=x.viewport,JSON.stringify(x));
});
test('an archived organization read disables a retained conversation draft', async t => {
const {b,wait}=await setup(t,'/projects/threads/'+T);
await wait(`document.querySelector('#chat-body') && !document.querySelector('#chat-body').disabled`);
await b.evaluate(`document.querySelector('#chat-body').value='Keep this draft';document.querySelector('#chat-body').dispatchEvent(new Event('input',{bubbles:true}));window.fetch=((old)=>(url,init)=>String(url)==='/api/chat/projects'?Promise.resolve(new Response(JSON.stringify({error:'org-archived'}),{status:409,headers:{'content-type':'application/json'}})):old(url,init))(window.fetch);document.querySelector('#refresh').click()`);
await wait(`document.querySelector('.chat-error')`);
assert.equal(await b.evaluate(`document.querySelector('#chat-body').disabled`),true);
assert.equal(await b.evaluate(`document.querySelector('#chat-body').value`),'Keep this draft');
assert.match(await b.evaluate(`document.querySelector('.chat-area').innerText`),/organization.*archived/i);
});
test('a room-not-open read disables a retained draft with the room reason', async t => {
const {b,wait}=await setup(t,'/projects/threads/'+T);
await wait(`document.querySelector('#chat-body') && !document.querySelector('#chat-body').disabled`);
await b.evaluate(`document.querySelector('#chat-body').value='Keep this draft';document.querySelector('#chat-body').dispatchEvent(new Event('input',{bubbles:true}));window.fetch=((old)=>(url,init)=>String(url)==='/api/chat/projects'?Promise.resolve(new Response(JSON.stringify({error:'room-not-open'}),{status:409,headers:{'content-type':'application/json'}})):old(url,init))(window.fetch);document.querySelector('#refresh').click()`);
await wait(`document.querySelector('.chat-error')`);
assert.equal(await b.evaluate(`document.querySelector('#chat-body').disabled`),true);
assert.equal(await b.evaluate(`document.querySelector('#chat-body').value`),'Keep this draft');
const text=await b.evaluate(`document.querySelector('.chat-area').innerText`);
assert.match(text,/room.*not open/i); assert.doesNotMatch(text,/organization is archived/i);
});
test('project read error banner does not overlap New project at desktop width', async t => {
const {b,wait}=await setup(t,'/projects',`;reviewFail['/api/chat/projects']=true;`);
await wait(`document.querySelector('.chat-error') && document.querySelector('.project-create summary')`);
const x=await b.evaluate(`(()=>{const a=document.querySelector('.chat-error').getBoundingClientRect(),b=document.querySelector('.project-create summary').getBoundingClientRect();return {overlap:a.left<b.right&&a.right>b.left&&a.top<b.bottom&&a.bottom>b.top}})()`);
assert.equal(x.overlap,false);
});
test('a refused queue read keeps Queue as the page heading', async t => {
const extra=`;{const old=window.fetch;window.fetch=(url,init)=>String(url)==='/api/queue'?Promise.resolve(new Response(JSON.stringify({error:'queue-refused',message:'fixture refusal'}),{status:503,headers:{'content-type':'application/json'}})):old(url,init)}`;
const {b,wait}=await setup(t,'/queue',extra);
await wait(`document.querySelector('#bus-view [role=alert]')`);
assert.equal(await b.evaluate(`document.querySelector('#bus-view h1').textContent`),'Queue');
assert.match(await b.evaluate(`document.querySelector('#bus-view [role=alert]').innerText`),/queue refused the read/i);
});
test('sidebar sprite is requested again after a failed read and recovery', async t => {
const {b,wait}=await setup(t,'/projects');
await wait(`document.querySelector('#sections use[href*="icons.svg"]')`);
const before = await b.evaluate(`document.querySelector('#sections use[href*="icons.svg"]').getAttribute('href')`);
await b.evaluate(`reviewFail['/api/chat/projects']=true;document.querySelector('#refresh').click()`);
await wait(`document.querySelector('.chat-error')`);
await b.evaluate(`reviewFail['/api/chat/projects']=false;document.querySelector('#refresh').click()`);
await wait(`!document.querySelector('.chat-error') && document.querySelector('#sections use[href*="?retry="]')`);
const after = await b.evaluate(`document.querySelector('#sections use[href*="icons.svg"]').getAttribute('href')`);
assert.notEqual(after,before);
});
+3 -2
View File
@@ -60,10 +60,11 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () =
// to the same Settings route) and row 148's spec and docs save. Row 165 (#1662) adds the role
// instance add or change to /api/instances, row 65 the routing table's save to /api/routes,
// and the provider switch writes only the model-provider plugin setting.
// The owner's Initialize project records button (E2E round 4) posts an empty body to the org setup records route.
assert.doesNotMatch(src, /<button[^>]*>[^<]*(Resolve|Decide|Approve)/i);
const writes = read('bus.js').toString().match(/fetch\([^;]*method:\s*['"](POST|PUT|PATCH|DELETE)[^;]*;/g) ?? [];
assert.deepEqual(writes, ["fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store', redirect: 'manual' });", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:harnessSettings.revision,plugins:{harness:selected}}),cache:'no-store',redirect:'manual'});", "fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:settings.revision,plugins:{'model-provider':next}}),cache:'no-store',redirect:'manual'});", "fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]);
assert.equal(read('bus.js').toString().split(/method:/).length - 1, 10);
assert.deepEqual(writes, ["fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });", "fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store', redirect: 'manual' });", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:harnessSettings.revision,plugins:{harness:selected}}),cache:'no-store',redirect:'manual'});", "fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:settings.revision,plugins:{'model-provider':next}}),cache:'no-store',redirect:'manual'});", "fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]);
assert.equal(read('bus.js').toString().split(/method:/).length - 1, 11);
assert.deepEqual([...read('bus.js').toString().matchAll(/decidePost\(([^,]*),/g)].map(m => m[1]), ['url', '`/api/decisions/${enc(a.id)}/confirmation`', '`/api/decisions/${enc(a.id)}/resolve`']);
// Row 155 (#1649): the one fetch(url, ...) posts only to these three routes, each a literal.
// Row 176 (#1674): the tester name form adds a second /api/name caller.
@@ -12,13 +12,26 @@ const remoteScenario = {
const roomScenario = { [`/api/chat/rooms/${ids.project}/messages?limit=100`]: { rows: [], after: null, hasMore: false } };
const input = `const el=document.querySelector('#project-remote-url'); el.value='https://git.example/owner/unsaved'; el.dispatchEvent(new Event('input',{bubbles:true}));`;
const refresh = `document.querySelector('#refresh').click()`;
const replaced = `new Promise((resolve,reject)=>{const deadline=Date.now()+16000;const tick=()=>{if(document.querySelector('#bus-view .chat-area')!==window.__oldPaint && document.querySelector('#bus-view .chat-area'))resolve(true);else if(Date.now()>deadline)reject(Error('No repaint'));else setTimeout(tick,25)};tick()})`;
// Observe the actual chat render promise, including its completed read and paint.
const trackRefresh = `window.__pollRenders=0;{const original=window.MosaicChat;window.MosaicChat={...original,render:async (...args)=>{const result=await original.render(...args);window.__pollRenders++;return result}}}`;
const refreshed = `new Promise((resolve,reject)=>{const deadline=Date.now()+16000;const tick=()=>{if(window.__pollRenders)resolve(true);else if(Date.now()>deadline)reject(Error('No completed refresh'));else setTimeout(tick,25)};tick()})`;
test('remote draft survives a real polling repaint and resets on project change', { timeout: 40000 }, async t => {
test('refresh wait remains pending until the held project read and render finish', { timeout: 30000 }, async t => {
const { f, b, wait } = await w15Fixture(t, { scenario: remoteScenario });
await b.navigate(f.base + '/' + settings); await wait('!!document.querySelector("#project-remote-url")');
await b.evaluate(`${input} window.__oldPaint=document.querySelector('#bus-view .chat-area')`);
await b.evaluate(replaced);
await b.evaluate(`window.__fetch=fetch;window.fetch=async (url,init)=>{const response=await __fetch(url,init);if(String(url)==='/api/chat/projects'){window.__held=true;await new Promise(resolve=>window.__release=resolve)}return response}; ${trackRefresh} ${refresh}; window.__settled=false;void (${refreshed}).then(()=>window.__settled=true);`);
await wait('!!window.__held');
await b.evaluate(`new Promise(resolve=>setTimeout(resolve,200))`);
assert.equal(await b.evaluate('window.__settled'),false);
await b.evaluate('__release()');
await wait('window.__settled');
});
test('remote draft survives a real poll and resets on project change', { timeout: 40000 }, async t => {
const { f, b, wait } = await w15Fixture(t, { scenario: remoteScenario });
await b.navigate(f.base + '/' + settings); await wait('!!document.querySelector("#project-remote-url")');
await b.evaluate(`${input} ${trackRefresh}`);
await b.evaluate(refreshed);
assert.equal(await b.evaluate(`document.querySelector('#project-remote-url')?.value`), 'https://git.example/owner/unsaved');
await b.evaluate(`location.hash='#/projects/${ids.empty}/settings'`); await wait(`document.querySelector('.project-remote')?.textContent.includes('no linked repository')`);
await b.evaluate(`location.hash=${JSON.stringify(settings)}`); await wait(`!!document.querySelector('#project-remote-url')`);
@@ -29,7 +42,7 @@ test('remote pending save and success survive shell refresh', { timeout: 30000 }
const { f, b, wait } = await w15Fixture(t, { scenario: remoteScenario });
await b.navigate(f.base + '/' + settings); await wait('!!document.querySelector("#project-remote-url")');
await b.evaluate(`window.__fetch=fetch; window.fetch=async (url,init={})=>{if(init.method==='POST' && String(url).includes('/api/project-remotes/')) return new Promise(resolve=>window.__finish=()=>resolve(new Response(JSON.stringify({remote:'https://git.example/owner/unsaved'}),{status:200,headers:{'content-type':'application/json'}})));return __fetch(url,init)}; ${input} document.querySelector('.project-remote form').requestSubmit();`);
await wait('!!window.__finish'); await b.evaluate(`window.__oldPaint=document.querySelector('#bus-view .chat-area'); ${refresh}`); await b.evaluate(replaced);
await wait('!!window.__finish'); await b.evaluate(`${trackRefresh} ${refresh}`); await b.evaluate(refreshed);
assert.equal(await b.evaluate(`document.querySelector('#project-remote-url')?.disabled`), true);
await b.evaluate('__finish()'); await wait(`document.querySelector('.project-remote')?.textContent.includes('Configured, not verified')`);
assert.match(await b.evaluate(`document.querySelector('.project-remote').textContent`), /https:\/\/git.example\/owner\/unsaved/);
@@ -40,7 +53,7 @@ test('remote unknown outcome and reconciliation survive shell refresh', { timeou
await b.navigate(f.base + '/' + settings); await wait('!!document.querySelector("#project-remote-url")');
await b.evaluate(`window.__fetch=fetch; window.fetch=async (url,init={})=>{if(init.method==='POST' && String(url).includes('/api/project-remotes/')) throw Error('lost');return __fetch(url,init)}; ${input} document.querySelector('.project-remote form').requestSubmit();`);
await wait(`document.querySelector('.project-remote')?.textContent.includes('Save outcome is unknown')`);
await b.evaluate(`window.__oldPaint=document.querySelector('#bus-view .chat-area'); ${refresh}`); await b.evaluate(replaced);
await b.evaluate(`${trackRefresh} ${refresh}`); await b.evaluate(refreshed);
assert.match(await b.evaluate(`document.querySelector('.project-remote').textContent`), /Save outcome is unknown/);
assert.equal(await b.evaluate(`document.querySelector('.project-remote button[type=submit]').disabled`), true);
await b.evaluate(`document.querySelector('.project-remote button[type=button]').click()`); await wait(`document.querySelector('.project-remote').textContent.includes('Review your entry')`);